diff --git a/src/config/management.ts b/src/config/management.ts index df6aea47..96910efa 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -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: `) 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']; + delete data[key]; + return true; + } + + const triggers = conditions + .flatMap(flattenConditionLeaves) + .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[key] = ongoing; } else { - data['triggers'] = conditions - .flatMap(flattenConditionLeaves) - .map(rewriteConditionAsTrigger); - const ongoing = dropTriggerOnlyConditions(conditions); - if (ongoing.length) { - data['conditions'] = 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)) { - delete data[key]; + if (REQUIRED_ACTION_PROPERTIES.includes(key)) { + data[key] = []; + } else { + delete data[key]; + } modified = true; continue; } @@ -1555,7 +1638,11 @@ const removeMicrophoneActionsTransform = (data: unknown): boolean => { } } - modified = removeMicrophoneActionsTransform(data[key]) || modified; + // 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; }; diff --git a/src/config/schema/common/preprocess-to-array.ts b/src/config/schema/common/preprocess-to-array.ts index d96bb4b8..c56a5f1c 100644 --- a/src/config/schema/common/preprocess-to-array.ts +++ b/src/config/schema/common/preprocess-to-array.ts @@ -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 = (arraySchema: T) => - z.preprocess( - (value) => (value === undefined || Array.isArray(value) ? value : [value]), - arraySchema, - ); + z.preprocess((value) => (value === undefined ? value : arrayify(value)), arraySchema); diff --git a/src/config/schema/condition-trigger/conditions/composite.ts b/src/config/schema/condition-trigger/conditions/composite.ts new file mode 100644 index 00000000..4f2d8768 --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/composite.ts @@ -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: , 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: + * , 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 }; +}; diff --git a/src/config/schema/condition-trigger/conditions/types.ts b/src/config/schema/condition-trigger/conditions/types.ts index 5234be2e..4e479de3 100644 --- a/src/config/schema/condition-trigger/conditions/types.ts +++ b/src/config/schema/condition-trigger/conditions/types.ts @@ -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 = conditionBaseSchema.extend conditions: preprocessToArray(z.lazy(() => conditionSchema).array()), }); -// Expand Home Assistant's composite shorthand to the canonical `{condition: -// , 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([ diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index 1612fa4a..d5913119 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -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 = { diff --git a/tests/config/schema/common/preprocess-to-array.test.ts b/tests/config/schema/common/preprocess-to-array.test.ts new file mode 100644 index 00000000..3a190761 --- /dev/null +++ b/tests/config/schema/common/preprocess-to-array.test.ts @@ -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({}); + }); +}); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index f09d00ca..e858ea0f 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -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([ {