fix: Stop the automatic upgrade from discarding valid configuration (#2692)
- Closes #2689
This commit is contained in:
+137
-50
@@ -24,6 +24,7 @@ import {
|
||||
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
|
||||
CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS,
|
||||
} from './const';
|
||||
import { getCompositeConditionsKey } from './schema/condition-trigger/conditions/composite';
|
||||
import type {
|
||||
RawAdvancedCameraCardConfig,
|
||||
RawAdvancedCameraCardConfigArray,
|
||||
@@ -350,7 +351,9 @@ export const upgradeArrayOfObjects = function (
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively upgrade an object.
|
||||
* Recursively upgrade an object. The entries recorded under
|
||||
* {@link CONF_UPGRADE_FAILURE} are the user's own configuration, kept for them
|
||||
* to migrate by hand, so this never descends into them.
|
||||
* @param transform A transform applied to each object recursively.
|
||||
* @param getObject A function to get the object to be upgraded.
|
||||
* @returns An upgrade function.
|
||||
@@ -374,6 +377,9 @@ export const upgradeObjectRecursively = (
|
||||
});
|
||||
} else {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key === CONF_UPGRADE_FAILURE) {
|
||||
return;
|
||||
}
|
||||
result = recurse(data[key] as RawAdvancedCameraCardConfig) || result;
|
||||
});
|
||||
}
|
||||
@@ -508,23 +514,36 @@ const conditionToConditionsTransform = (data: unknown): boolean => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const isCompositeCondition = (condition: unknown): boolean => {
|
||||
if (!isRecord(condition)) {
|
||||
return false;
|
||||
}
|
||||
const kind = condition['condition'];
|
||||
return typeof kind === 'string' && ['or', 'and', 'not'].includes(kind);
|
||||
};
|
||||
const isCompositeCondition = (condition: unknown): boolean =>
|
||||
getCompositeConditionsKey(condition) !== null;
|
||||
|
||||
// Triggers are a flat OR list with no composites, so a composite condition is
|
||||
// reduced to its leaf conditions for the trigger list (the composite itself is
|
||||
// retained on the `conditions:` side).
|
||||
const flattenConditionLeaves = (condition: unknown): unknown[] => {
|
||||
if (!isCompositeCondition(condition) || !isRecord(condition)) {
|
||||
const key = getCompositeConditionsKey(condition);
|
||||
if (!key || !isRecord(condition)) {
|
||||
return [condition];
|
||||
}
|
||||
const inner = condition['conditions'];
|
||||
return Array.isArray(inner) ? inner.flatMap(flattenConditionLeaves) : [];
|
||||
return arrayify(condition[key]).flatMap(flattenConditionLeaves);
|
||||
};
|
||||
|
||||
// Whether promoting this condition to a trigger would lose something, so the
|
||||
// automation keeps the condition. `user` and `user_agent` have no trigger at
|
||||
// all; a `state` condition holding both `state` and `state_not` loses one,
|
||||
// since a state trigger carries `to` or `not_to`, never both.
|
||||
const mustRetainCondition = (condition: unknown): boolean => {
|
||||
if (!isRecord(condition)) {
|
||||
return false;
|
||||
}
|
||||
const kind = condition['condition'];
|
||||
if (kind === 'user' || kind === 'user_agent') {
|
||||
return true;
|
||||
}
|
||||
if (kind === 'state' || kind === undefined) {
|
||||
return condition['state'] !== undefined && condition['state_not'] !== undefined;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// A condition that fired on a *change* rather than describing an ongoing state
|
||||
@@ -556,20 +575,27 @@ const isTriggerOnlyCondition = (condition: unknown): boolean => {
|
||||
};
|
||||
|
||||
// Drop trigger-only conditions from a retained `conditions:` list, recursing
|
||||
// into composites and discarding any that become empty.
|
||||
// into composites and discarding any the recursion empties.
|
||||
const dropTriggerOnlyConditions = (conditions: unknown[]): unknown[] => {
|
||||
const kept: unknown[] = [];
|
||||
for (const condition of conditions) {
|
||||
if (
|
||||
isCompositeCondition(condition) &&
|
||||
isRecord(condition) &&
|
||||
Array.isArray(condition['conditions'])
|
||||
) {
|
||||
const inner = dropTriggerOnlyConditions(condition['conditions']);
|
||||
if (inner.length) {
|
||||
kept.push({ ...condition, conditions: inner });
|
||||
const key = getCompositeConditionsKey(condition);
|
||||
if (key && isRecord(condition)) {
|
||||
const children = arrayify(condition[key]);
|
||||
const inner = dropTriggerOnlyConditions(children);
|
||||
// An empty `or` never matches and an empty `and` always does, so a
|
||||
// composite the user wrote empty is kept; only one the recursion emptied
|
||||
// is dropped.
|
||||
if (inner.length || !children.length) {
|
||||
// `arrayify` has already normalised `children`, so rebuilding a
|
||||
// composite the recursion left alone would rewrite Home Assistant's
|
||||
// single-condition spelling (`or: <condition>`) into a list the user
|
||||
// never wrote.
|
||||
kept.push(isEqual(inner, children) ? condition : { ...condition, [key]: inner });
|
||||
}
|
||||
} else if (!isTriggerOnlyCondition(condition)) {
|
||||
continue;
|
||||
}
|
||||
if (!isTriggerOnlyCondition(condition)) {
|
||||
kept.push(condition);
|
||||
}
|
||||
}
|
||||
@@ -598,15 +624,28 @@ const rewriteConditionAsTrigger = (condition: unknown): unknown => {
|
||||
// picture-element state form -- the only condition that may omit `condition`.
|
||||
if (kind === 'state' || kind === undefined) {
|
||||
const entityId = condition['entity_id'] ?? condition['entity'];
|
||||
const matchFields = mustRetainCondition(condition)
|
||||
? {}
|
||||
: {
|
||||
...(condition['state'] !== undefined && { to: condition['state'] }),
|
||||
...(condition['state_not'] !== undefined && {
|
||||
not_to: condition['state_not'],
|
||||
}),
|
||||
};
|
||||
return {
|
||||
trigger: 'state',
|
||||
...withoutKeys('condition', 'entity', 'entity_id', 'state', 'state_not'),
|
||||
...(entityId !== undefined && { entity_id: entityId }),
|
||||
...(condition['state'] !== undefined && { to: condition['state'] }),
|
||||
...(condition['state_not'] !== undefined && { not_to: condition['state_not'] }),
|
||||
...matchFields,
|
||||
};
|
||||
}
|
||||
|
||||
// `user` and `user_agent` are fixed for the session, so no trigger matches
|
||||
// them.
|
||||
if (kind === 'user' || kind === 'user_agent') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A `call` condition describes a phase; as a trigger it is the arrival at
|
||||
// that phase.
|
||||
if (kind === 'call') {
|
||||
@@ -624,38 +663,67 @@ const rewriteConditionAsTrigger = (condition: unknown): unknown => {
|
||||
return { trigger: kind, ...withoutKeys('condition') };
|
||||
};
|
||||
|
||||
// Home Assistant accepts the singular `trigger`/`condition`/`action` keys as
|
||||
// aliases for the plural ones (the schema renames them), and a single item in
|
||||
// place of a list. Migration reads whichever spelling the automation was written
|
||||
// in so that it can write back under the same key.
|
||||
const getAutomationList = (
|
||||
automation: RawAdvancedCameraCardConfig,
|
||||
plural: string,
|
||||
singular: string,
|
||||
): { key: string; items: unknown[] } => {
|
||||
// Mirroring the schema's rename, the singular key is honoured only when the
|
||||
// plural is absent; an automation with neither is written under the plural.
|
||||
const key = singular in automation && !(plural in automation) ? singular : plural;
|
||||
return { key, items: arrayify(automation[key]) };
|
||||
};
|
||||
|
||||
const hasAutomationTriggers = (automation: RawAdvancedCameraCardConfig): boolean =>
|
||||
'triggers' in automation || 'trigger' in automation;
|
||||
|
||||
/**
|
||||
* Promote an automation's `conditions:` into HA-native `triggers:`.
|
||||
*
|
||||
* A single simple condition becomes one trigger and the `conditions:` block is
|
||||
* dropped. Multiple conditions (or a composite) become one trigger per leaf,
|
||||
* while the original `conditions:` are retained as an ongoing predicate
|
||||
* (dual-list) -- minus any trigger-only forms, which would no longer be valid
|
||||
* conditions. Idempotent: an automation that already has `triggers:` is left
|
||||
* A single condition that its trigger fully expresses becomes that trigger, and
|
||||
* the `conditions:` block is dropped. Everything else -- multiple conditions, a
|
||||
* composite, or a condition the trigger cannot express -- becomes one trigger
|
||||
* per leaf, and the original `conditions:` are retained as an ongoing predicate
|
||||
* (dual-list) minus any trigger-only forms, which would no longer be valid
|
||||
* conditions. Idempotent: an automation that already has triggers is left
|
||||
* untouched.
|
||||
*/
|
||||
const promoteConditionsToTriggersTransform = (data: unknown): boolean => {
|
||||
if (!isRecord(data) || 'triggers' in data) {
|
||||
if (!isRecord(data) || hasAutomationTriggers(data)) {
|
||||
return false;
|
||||
}
|
||||
const conditions = data['conditions'];
|
||||
if (!Array.isArray(conditions) || !conditions.length) {
|
||||
const { key, items: conditions } = getAutomationList(data, 'conditions', 'condition');
|
||||
if (!conditions.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (conditions.length === 1 && !isCompositeCondition(conditions[0])) {
|
||||
if (
|
||||
conditions.length === 1 &&
|
||||
!isCompositeCondition(conditions[0]) &&
|
||||
!mustRetainCondition(conditions[0])
|
||||
) {
|
||||
data['triggers'] = [rewriteConditionAsTrigger(conditions[0])];
|
||||
delete data['conditions'];
|
||||
} else {
|
||||
data['triggers'] = conditions
|
||||
delete data[key];
|
||||
return true;
|
||||
}
|
||||
|
||||
const triggers = conditions
|
||||
.flatMap(flattenConditionLeaves)
|
||||
.map(rewriteConditionAsTrigger);
|
||||
.map(rewriteConditionAsTrigger)
|
||||
.filter((trigger) => trigger !== null);
|
||||
|
||||
// Conditions that produce no trigger never change after startup, so
|
||||
// evaluating them once at startup is faithful.
|
||||
data['triggers'] = triggers.length ? triggers : [{ trigger: 'initialized' }];
|
||||
const ongoing = dropTriggerOnlyConditions(conditions);
|
||||
if (ongoing.length) {
|
||||
data['conditions'] = ongoing;
|
||||
data[key] = ongoing;
|
||||
} else {
|
||||
delete data['conditions'];
|
||||
}
|
||||
delete data[key];
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -803,9 +871,13 @@ const hasRisingEdgeOnlyCondition = (conditions: unknown[]): boolean =>
|
||||
const convertActionsNotAutomation = (
|
||||
automation: RawAdvancedCameraCardConfig,
|
||||
): 'converted' | 'failed' => {
|
||||
const conditions = automation['conditions'];
|
||||
const { key: conditionsKey, items: conditions } = getAutomationList(
|
||||
automation,
|
||||
'conditions',
|
||||
'condition',
|
||||
);
|
||||
|
||||
if (!Array.isArray(conditions) || !conditions.length) {
|
||||
if (!conditions.length) {
|
||||
// No conditions -- `actions_not` could never have run; it is simply dropped.
|
||||
delete automation['actions_not'];
|
||||
return 'converted';
|
||||
@@ -816,25 +888,29 @@ const convertActionsNotAutomation = (
|
||||
}
|
||||
|
||||
const actionsNot = automation['actions_not'];
|
||||
const actions = Array.isArray(automation['actions']) ? automation['actions'] : [];
|
||||
const { key: actionsKey, items: actions } = getAutomationList(
|
||||
automation,
|
||||
'actions',
|
||||
'action',
|
||||
);
|
||||
|
||||
automation['triggers'] = synthesizeAnyChangeTriggers(conditions);
|
||||
delete automation['actions_not'];
|
||||
|
||||
// `conditions` move *into* the `if` below; they must not also remain as a
|
||||
// The conditions move *into* the `if` below; they must not also remain as a
|
||||
// top-level ongoing condition, which would block the automation (and so the
|
||||
// `else` branch) whenever they fail -- exactly the case `else` exists to handle.
|
||||
delete automation['conditions'];
|
||||
delete automation[conditionsKey];
|
||||
|
||||
// The `if` tests only the ongoing predicates; dropping the trigger-only
|
||||
// conditions can leave nothing, in which case `else` could never run.
|
||||
const ongoing = dropTriggerOnlyConditions(conditions);
|
||||
if (!ongoing.length) {
|
||||
automation['actions'] = actions;
|
||||
automation[actionsKey] = actions;
|
||||
return 'converted';
|
||||
}
|
||||
|
||||
automation['actions'] = [
|
||||
automation[actionsKey] = [
|
||||
{
|
||||
if: ongoing,
|
||||
then: actions,
|
||||
@@ -1511,6 +1587,10 @@ const ACTION_PROPERTIES = [
|
||||
'then',
|
||||
];
|
||||
|
||||
// The action properties that are required wherever they appear, so removing
|
||||
// their last action must leave an empty list.
|
||||
const REQUIRED_ACTION_PROPERTIES = ['actions', 'then'];
|
||||
|
||||
const isRemovedMicrophoneAction = (data: unknown): boolean =>
|
||||
isRecord(data) &&
|
||||
(data['action'] === 'fire-dom-event' ||
|
||||
@@ -1526,10 +1606,9 @@ const isRemovedMicrophoneAction = (data: unknown): boolean =>
|
||||
* merely resembles an action -- the `data` of a `perform-action`, for
|
||||
* instance -- is left as the user wrote it.
|
||||
*
|
||||
* A property holding a single such action is deleted, since every one of those
|
||||
* is optional. A list keeps its property even when it empties: some are
|
||||
* required (`automations[].actions`, an `if` action's `then`) and an empty one
|
||||
* is valid everywhere.
|
||||
* A property that empties is left as an empty list where it is required
|
||||
* (`automations[].actions`, an `if` action's `then`), and deleted elsewhere. An
|
||||
* empty list is valid wherever one is accepted.
|
||||
*/
|
||||
const removeMicrophoneActionsTransform = (data: unknown): boolean => {
|
||||
// Arrays are records too, so their entries are walked by the loop below.
|
||||
@@ -1542,7 +1621,11 @@ const removeMicrophoneActionsTransform = (data: unknown): boolean => {
|
||||
if (ACTION_PROPERTIES.includes(key)) {
|
||||
const value = data[key];
|
||||
if (isRemovedMicrophoneAction(value)) {
|
||||
if (REQUIRED_ACTION_PROPERTIES.includes(key)) {
|
||||
data[key] = [];
|
||||
} else {
|
||||
delete data[key];
|
||||
}
|
||||
modified = true;
|
||||
continue;
|
||||
}
|
||||
@@ -1555,8 +1638,12 @@ const removeMicrophoneActionsTransform = (data: unknown): boolean => {
|
||||
}
|
||||
}
|
||||
|
||||
// The entries recorded under `__UPGRADE_FAILURE__` are kept as the user
|
||||
// wrote them.
|
||||
if (key !== CONF_UPGRADE_FAILURE) {
|
||||
modified = removeMicrophoneActionsTransform(data[key]) || modified;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Accept a single item where a list is expected and normalise it to a list,
|
||||
// mirroring Home Assistant's `cv.ensure_list`. The schema output is always the
|
||||
// list, so the rest of the code only ever sees the canonical form.
|
||||
import { arrayify } from '../../../utils/basic';
|
||||
|
||||
// Normalise a single item to an array like Home Assistant's `cv.ensure_list` (a
|
||||
// key written with no value is a list of nothing). The schema output is always
|
||||
// the list, so the rest of the code only ever sees the canonical form. An
|
||||
// absent value is passed through so that an optional field stays absent rather
|
||||
// than becoming an empty list.
|
||||
//
|
||||
// For a list of strings use `stringOrArray` instead, which keeps the falsy
|
||||
// values `arrayify` drops.
|
||||
export const preprocessToArray = <T extends z.ZodTypeAny>(arraySchema: T) =>
|
||||
z.preprocess(
|
||||
(value) => (value === undefined || Array.isArray(value) ? value : [value]),
|
||||
arraySchema,
|
||||
);
|
||||
z.preprocess((value) => (value === undefined ? value : arrayify(value)), arraySchema);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { isRecord } from '../../../../utils/basic';
|
||||
|
||||
const COMPOSITE_CONDITION_OPERATORS = ['and', 'or', 'not'] as const;
|
||||
|
||||
/**
|
||||
* Recognize a composite condition in any of the three formats that Home
|
||||
* Assistant accepts:
|
||||
* - `{condition: <op>, conditions: [...]}` -- the canonical form.
|
||||
* - `{and|or|not: [...]}` -- the boolean operator is the key.
|
||||
* - `{condition: [...]}` -- a list under the discriminator is an implicit AND.
|
||||
*
|
||||
* @param value The value to inspect.
|
||||
* @returns The key the inner conditions are held under, which differs between
|
||||
* the spellings, or `null` if the value is not a composite condition.
|
||||
*/
|
||||
export const getCompositeConditionsKey = (value: unknown): string | null => {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(value.condition)) {
|
||||
return 'condition';
|
||||
}
|
||||
if ('condition' in value) {
|
||||
return COMPOSITE_CONDITION_OPERATORS.some((op) => op === value.condition)
|
||||
? 'conditions'
|
||||
: null;
|
||||
}
|
||||
const present = COMPOSITE_CONDITION_OPERATORS.filter((op) => op in value);
|
||||
return present.length === 1 ? present[0] : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Expand Home Assistant's composite shorthand to the canonical `{condition:
|
||||
* <op>, conditions: [...]}` form.
|
||||
*
|
||||
* @param value The value to expand.
|
||||
* @returns The canonical form, or the value untouched if it is not shorthand.
|
||||
*/
|
||||
export const expandCompositeShorthand = (value: unknown): unknown => {
|
||||
const key = getCompositeConditionsKey(value);
|
||||
if (!key || !isRecord(value) || key === 'conditions') {
|
||||
return value;
|
||||
}
|
||||
const { [key]: conditions, ...rest } = value;
|
||||
// A list under the discriminator is an implicit `and`; every other shorthand
|
||||
// names its own operator.
|
||||
return { ...rest, condition: key === 'condition' ? 'and' : key, conditions };
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isRecord } from '../../../../utils/basic';
|
||||
import { preprocessToArray } from '../../common/preprocess-to-array';
|
||||
import { conditionBaseSchema } from './base';
|
||||
import { expandCompositeShorthand } from './composite';
|
||||
import { callConditionSchema } from './custom/call';
|
||||
import { cameraConditionSchema } from './custom/camera';
|
||||
import { displayModeConditionSchema } from './custom/display-mode';
|
||||
@@ -56,32 +56,6 @@ const notConditionSchema: z.ZodSchema<NotCondition> = conditionBaseSchema.extend
|
||||
conditions: preprocessToArray(z.lazy(() => conditionSchema).array()),
|
||||
});
|
||||
|
||||
// Expand Home Assistant's composite shorthand to the canonical `{condition:
|
||||
// <op>, conditions: [...]}` form:
|
||||
// - `{and|or|not: [...]}` -- the boolean operator is the key.
|
||||
// - `condition: [...]` -- a list under the discriminator is an implicit AND.
|
||||
// Conservative: anything already canonical, ambiguous, or unrecognised
|
||||
// passes through for the union (or its error) to handle.
|
||||
const expandCompositeShorthand = (value: unknown): unknown => {
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value.condition)) {
|
||||
const { condition: conditions, ...rest } = value;
|
||||
return { ...rest, condition: 'and', conditions };
|
||||
}
|
||||
if ('condition' in value) {
|
||||
return value;
|
||||
}
|
||||
const present = (['and', 'or', 'not'] as const).filter((op) => op in value);
|
||||
const op = present.length === 1 ? present[0] : undefined;
|
||||
if (op === undefined) {
|
||||
return value;
|
||||
}
|
||||
const { [op]: conditions, ...rest } = value;
|
||||
return { ...rest, condition: op, conditions };
|
||||
};
|
||||
|
||||
// The raw union of all condition members. `conditionSchema` wraps this with the
|
||||
// shorthand preprocess; this is exported only for schema introspection.
|
||||
export const conditionUnion = z.union([
|
||||
|
||||
@@ -4004,6 +4004,30 @@ describe('should handle version specific upgrades', () => {
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should empty a required actions property rather than delete it', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card' as const,
|
||||
cameras: [{}],
|
||||
automations: [
|
||||
{
|
||||
triggers: [{ trigger: 'initialized' as const }],
|
||||
actions: {
|
||||
action: 'custom:advanced-camera-card-action' as const,
|
||||
advanced_camera_card_action: 'microphone_connect' as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
|
||||
expect(config.automations[0]).toEqual({
|
||||
triggers: [{ trigger: 'initialized' }],
|
||||
actions: [],
|
||||
});
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should splice an action array and keep the order of the rest', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card' as const,
|
||||
@@ -4899,6 +4923,245 @@ describe('should handle version specific upgrades', () => {
|
||||
});
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should retain a state condition holding both state and state_not', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
automations: [
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
condition: 'state',
|
||||
entity: 'binary_sensor.door',
|
||||
state: 'on',
|
||||
state_not: 'unavailable',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{ action: 'fire-dom-event', advanced_camera_card_action: 'live' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.automations[0]).toEqual({
|
||||
conditions: [
|
||||
{
|
||||
condition: 'state',
|
||||
entity: 'binary_sensor.door',
|
||||
state: 'on',
|
||||
state_not: 'unavailable',
|
||||
},
|
||||
],
|
||||
triggers: [{ trigger: 'state', entity_id: 'binary_sensor.door' }],
|
||||
actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }],
|
||||
});
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should retain a user condition rather than promote it to a trigger', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
automations: [
|
||||
{
|
||||
conditions: [
|
||||
{ condition: 'user', users: ['abc'] },
|
||||
{ condition: 'fullscreen', fullscreen: true },
|
||||
],
|
||||
actions: [
|
||||
{ action: 'fire-dom-event', advanced_camera_card_action: 'live' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.automations[0]).toEqual({
|
||||
conditions: [
|
||||
{ condition: 'user', users: ['abc'] },
|
||||
{ condition: 'fullscreen', fullscreen: true },
|
||||
],
|
||||
triggers: [{ trigger: 'fullscreen', fullscreen: true }],
|
||||
actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }],
|
||||
});
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should retain a lone user condition and trigger on initialization', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
automations: [
|
||||
{
|
||||
conditions: [{ condition: 'user', users: ['abc'] }],
|
||||
actions: [
|
||||
{ action: 'fire-dom-event', advanced_camera_card_action: 'live' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.automations[0]).toEqual({
|
||||
conditions: [{ condition: 'user', users: ['abc'] }],
|
||||
triggers: [{ trigger: 'initialized' }],
|
||||
actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }],
|
||||
});
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should promote a malformed lone condition for the schema to reject', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
automations: [
|
||||
{
|
||||
conditions: ['nonsense'],
|
||||
actions: [
|
||||
{ action: 'fire-dom-event', advanced_camera_card_action: 'live' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.automations[0]).toEqual({
|
||||
triggers: ['nonsense'],
|
||||
actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should trigger on initialization when no condition yields a trigger', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
automations: [
|
||||
{
|
||||
conditions: [
|
||||
{ condition: 'user', users: ['abc'] },
|
||||
{ condition: 'user_agent', user_agent: 'Chrome' },
|
||||
],
|
||||
actions: [
|
||||
{ action: 'fire-dom-event', advanced_camera_card_action: 'live' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.automations[0]).toEqual({
|
||||
conditions: [
|
||||
{ condition: 'user', users: ['abc'] },
|
||||
{ condition: 'user_agent', user_agent: 'Chrome' },
|
||||
],
|
||||
triggers: [{ trigger: 'initialized' }],
|
||||
actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }],
|
||||
});
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
});
|
||||
|
||||
describe("automation singular keys and Home Assistant's single-item forms", () => {
|
||||
const live = { action: 'fire-dom-event', advanced_camera_card_action: 'live' };
|
||||
const automate = (
|
||||
automation: RawAdvancedCameraCardConfig,
|
||||
): RawAdvancedCameraCardConfig & {
|
||||
automations: RawAdvancedCameraCardConfig[];
|
||||
} => ({
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
automations: [automation],
|
||||
});
|
||||
|
||||
it('should leave an automation using the singular trigger key untouched', () => {
|
||||
const config = automate({
|
||||
trigger: [{ trigger: 'initialized' }],
|
||||
conditions: [{ condition: 'view', views: ['live'] }],
|
||||
actions: [live],
|
||||
});
|
||||
expect(upgradeConfig(config)).toBeFalsy();
|
||||
expect(config.automations[0]).toEqual({
|
||||
trigger: [{ trigger: 'initialized' }],
|
||||
conditions: [{ condition: 'view', views: ['live'] }],
|
||||
actions: [live],
|
||||
});
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should promote a singular condition key and retain the ongoing conditions under it', () => {
|
||||
const config = automate({
|
||||
condition: [{ condition: 'camera' }, { condition: 'view', views: ['live'] }],
|
||||
actions: [live],
|
||||
});
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.automations[0]).toEqual({
|
||||
condition: [{ condition: 'view', views: ['live'] }],
|
||||
triggers: [{ trigger: 'camera' }, { trigger: 'view', views: ['live'] }],
|
||||
actions: [live],
|
||||
});
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should promote a single condition standing in for the conditions list', () => {
|
||||
const config = automate({
|
||||
conditions: { condition: 'view', views: ['live'] },
|
||||
actions: [live],
|
||||
});
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.automations[0]).toEqual({
|
||||
triggers: [{ trigger: 'view', views: ['live'] }],
|
||||
actions: [live],
|
||||
});
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should convert actions_not when the conditions are a single condition', () => {
|
||||
const config = automate({
|
||||
conditions: {
|
||||
condition: 'state',
|
||||
entity_id: 'binary_sensor.door',
|
||||
state: 'on',
|
||||
},
|
||||
actions: [live],
|
||||
actions_not: [
|
||||
{ action: 'fire-dom-event', advanced_camera_card_action: 'clips' },
|
||||
],
|
||||
});
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.automations[0]).toEqual({
|
||||
triggers: [{ trigger: 'state', entity_id: 'binary_sensor.door' }],
|
||||
actions: [
|
||||
{
|
||||
if: [{ condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }],
|
||||
then: [live],
|
||||
else: [{ action: 'fire-dom-event', advanced_camera_card_action: 'clips' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should convert actions_not under the singular action key', () => {
|
||||
const config = automate({
|
||||
conditions: [
|
||||
{ condition: 'state', entity_id: 'binary_sensor.door', state: 'on' },
|
||||
],
|
||||
action: [live],
|
||||
actions_not: [
|
||||
{ action: 'fire-dom-event', advanced_camera_card_action: 'clips' },
|
||||
],
|
||||
});
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.automations[0]).toEqual({
|
||||
triggers: [{ trigger: 'state', entity_id: 'binary_sensor.door' }],
|
||||
action: [
|
||||
{
|
||||
if: [{ condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }],
|
||||
then: [live],
|
||||
else: [{ action: 'fire-dom-event', advanced_camera_card_action: 'clips' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
});
|
||||
|
||||
describe('automation actions_not -> if/then/else', () => {
|
||||
@@ -5049,6 +5312,22 @@ describe('should handle version specific upgrades', () => {
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should keep a retired microphone action in a recorded upgrade failure', () => {
|
||||
const failing = {
|
||||
conditions: [{ condition: 'template', value_template: '{{ true }}' }],
|
||||
actions: [
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'microphone_connect',
|
||||
},
|
||||
],
|
||||
actions_not: elseActions,
|
||||
};
|
||||
const config = automate({ ...failing });
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config['__UPGRADE_FAILURE__']).toEqual({ automations: [failing] });
|
||||
});
|
||||
|
||||
it('should record a screen condition as an upgrade failure, untouched', () => {
|
||||
const failing = {
|
||||
conditions: [{ condition: 'screen', media_query: '(orientation: landscape)' }],
|
||||
@@ -5478,6 +5757,230 @@ describe('should handle version specific upgrades', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('composite condition shorthand', () => {
|
||||
it('should keep a conditional element whose shorthand conditions are all retained', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
elements: [
|
||||
{
|
||||
type: 'custom:advanced-camera-card-conditional',
|
||||
conditions: [
|
||||
{
|
||||
or: [
|
||||
{ condition: 'state', state: 'off', entity: 'input_boolean.door' },
|
||||
{ condition: 'call', call: 'answered' },
|
||||
],
|
||||
},
|
||||
],
|
||||
elements: [{ type: 'icon', icon: 'mdi:cow' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeFalsy();
|
||||
expect(config.elements).toEqual([
|
||||
{
|
||||
type: 'custom:advanced-camera-card-conditional',
|
||||
conditions: [
|
||||
{
|
||||
or: [
|
||||
{ condition: 'state', state: 'off', entity: 'input_boolean.door' },
|
||||
{ condition: 'call', call: 'answered' },
|
||||
],
|
||||
},
|
||||
],
|
||||
elements: [{ type: 'icon', icon: 'mdi:cow' }],
|
||||
},
|
||||
]);
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should prune trigger-only leaves from an operator shorthand composite', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
overrides: [
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
or: [{ condition: 'camera' }, { condition: 'view', views: ['live'] }],
|
||||
},
|
||||
],
|
||||
merge: { menu: { style: 'none' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.overrides).toEqual([
|
||||
{
|
||||
conditions: [{ or: [{ condition: 'view', views: ['live'] }] }],
|
||||
merge: { menu: { style: 'none' } },
|
||||
},
|
||||
]);
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should prune trigger-only leaves from an implicit and composite', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
overrides: [
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
condition: [
|
||||
{ condition: 'camera' },
|
||||
{ condition: 'view', views: ['live'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
merge: { menu: { style: 'none' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.overrides).toEqual([
|
||||
{
|
||||
conditions: [{ condition: [{ condition: 'view', views: ['live'] }] }],
|
||||
merge: { menu: { style: 'none' } },
|
||||
},
|
||||
]);
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should promote the leaves of a shorthand composite to triggers', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
automations: [
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
or: [{ condition: 'camera' }, { condition: 'view', views: ['live'] }],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{ action: 'fire-dom-event', advanced_camera_card_action: 'live' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.automations[0]).toEqual({
|
||||
conditions: [{ or: [{ condition: 'view', views: ['live'] }] }],
|
||||
triggers: [{ trigger: 'camera' }, { trigger: 'view', views: ['live'] }],
|
||||
actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }],
|
||||
});
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should keep a composite the user wrote empty', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
elements: [
|
||||
{
|
||||
type: 'custom:advanced-camera-card-conditional',
|
||||
conditions: [{ condition: [] }],
|
||||
elements: [{ type: 'icon', icon: 'mdi:cow' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeFalsy();
|
||||
expect(config.elements).toEqual([
|
||||
{
|
||||
type: 'custom:advanced-camera-card-conditional',
|
||||
conditions: [{ condition: [] }],
|
||||
elements: [{ type: 'icon', icon: 'mdi:cow' }],
|
||||
},
|
||||
]);
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should keep an untouched single-condition composite as it was written', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
overrides: [
|
||||
{
|
||||
conditions: [{ or: { condition: 'view', views: ['live'] } }],
|
||||
merge: { menu: { style: 'none' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeFalsy();
|
||||
expect(config.overrides).toEqual([
|
||||
{
|
||||
conditions: [{ or: { condition: 'view', views: ['live'] } }],
|
||||
merge: { menu: { style: 'none' } },
|
||||
},
|
||||
]);
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should prune a trigger-only leaf from a single-condition composite', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
overrides: [
|
||||
{
|
||||
conditions: [{ or: { condition: 'camera' } }],
|
||||
merge: { menu: { style: 'none' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.overrides).toEqual([]);
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should promote the leaf of a single-condition composite to a trigger', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
automations: [
|
||||
{
|
||||
conditions: [{ or: { condition: 'view', views: ['live'] } }],
|
||||
actions: [
|
||||
{ action: 'fire-dom-event', advanced_camera_card_action: 'live' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.automations[0]).toEqual({
|
||||
conditions: [{ or: { condition: 'view', views: ['live'] } }],
|
||||
triggers: [{ trigger: 'view', views: ['live'] }],
|
||||
actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }],
|
||||
});
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
|
||||
it('should drop a bare picture-element state condition with no state match', () => {
|
||||
const config = {
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
overrides: [
|
||||
{
|
||||
conditions: [
|
||||
{ entity: 'binary_sensor.door' },
|
||||
{ condition: 'view', views: ['live'] },
|
||||
],
|
||||
merge: { menu: { style: 'none' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config.overrides).toEqual([
|
||||
{
|
||||
conditions: [{ condition: 'view', views: ['live'] }],
|
||||
merge: { menu: { style: 'none' } },
|
||||
},
|
||||
]);
|
||||
postUpgradeChecks(config);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger template paths -> top-level trigger.*', () => {
|
||||
it('should rewrite every legacy trigger path in an automation action', () => {
|
||||
const config = {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { preprocessToArray } from '../../../../src/config/schema/common/preprocess-to-array';
|
||||
|
||||
const schema = z.object({
|
||||
items: preprocessToArray(z.object({ name: z.string() }).array()).optional(),
|
||||
});
|
||||
|
||||
describe('preprocessToArray', () => {
|
||||
it('should keep a list as it is', () => {
|
||||
expect(schema.parse({ items: [{ name: 'office' }] })).toEqual({
|
||||
items: [{ name: 'office' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalise a single item to a list', () => {
|
||||
expect(schema.parse({ items: { name: 'office' } })).toEqual({
|
||||
items: [{ name: 'office' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should read a key written with no value as a list of nothing', () => {
|
||||
expect(schema.parse({ items: null })).toEqual({ items: [] });
|
||||
});
|
||||
|
||||
it('should leave an absent optional field absent', () => {
|
||||
expect(schema.parse({})).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1935,6 +1935,20 @@ describe('automations should accept Home Assistant input shorthands', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should accept a conditions key written with no value', () => {
|
||||
const result = automationsSchema.parse([
|
||||
{
|
||||
triggers: [{ trigger: 'initialized' }],
|
||||
conditions: null,
|
||||
actions: [
|
||||
{ action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_on' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toMatchObject([{ conditions: [] }]);
|
||||
});
|
||||
|
||||
it('should keep the plural key when both singular and plural are given', () => {
|
||||
const result = automationsSchema.parse([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user