import { describe, expect, it } from 'vitest'; import { advancedCameraCardCustomActionsBaseSchema } from '../../src/config/schema/actions/custom/base'; import { actionConfigSchema, statusBarActionConfigSchema, } from '../../src/config/schema/actions/types'; import { automationsSchema } from '../../src/config/schema/automations'; import { cameraConfigSchema } from '../../src/config/schema/cameras'; import { conditionSchema } from '../../src/config/schema/condition-trigger/conditions/types'; import { dimensionsConfigSchema } from '../../src/config/schema/dimensions'; import { customSchema } from '../../src/config/schema/elements/stock/custom'; import { conditionalSchema } from '../../src/config/schema/elements/types'; import { createConfig } from './test-utils'; describe('config defaults', () => { it('should be as expected', () => { expect(createConfig()).toEqual({ cameras: [{}], cameras_global: { always_error_if_entity_unavailable: false, dependencies: { all_cameras: false, cameras: [], }, engine: 'auto', frigate: {}, go2rtc: { metadata_fetch_timeout_seconds: 2, }, image: { mode: 'auto', refresh_seconds: 'auto', }, live_provider: 'auto', motioneye: { images: { directory_pattern: '%Y-%m-%d', file_pattern: '%H-%M-%S', }, movies: { directory_pattern: '%Y-%m-%d', file_pattern: '%H-%M-%S', }, }, proxy: { dynamic: true, live: 'auto', media: 'auto', ssl_ciphers: 'auto', ssl_verification: 'auto', }, ptz: { c2r_delay_between_calls_seconds: 0.2, r2c_delay_between_calls_seconds: 0.5, }, reolink: { media_resolution: 'low', }, triggers: { doorbell: false, entities: [], events: [], media_events: [], motion: false, occupancy: false, reviews: { description: true, severities: ['high'], }, }, }, debug: { logging: false, }, dimensions: { aspect_ratio: [16, 9], aspect_ratio_mode: 'dynamic', height: 'auto', }, elements: [], image: { mode: 'auto', proxy: { dynamic: true, ssl_ciphers: 'auto', ssl_verification: 'auto', enabled: false, }, refresh_seconds: 'auto', zoomable: true, }, live: { auto_mute: ['unselected', 'hidden', 'microphone', 'call'], auto_pause: [], auto_play: ['selected', 'visible'], auto_unmute: ['microphone', 'call'], controls: { builtin: true, call: { button_size: 40, enabled: true, lock: true, ringtone: { type: 'chime', repeat: 0 }, unanswered_timeout_seconds: 60, }, next_previous: { auto_hide: ['call', 'casting'], size: 48, style: 'chevrons', }, ptz: { type: 'buttons', hide_home: false, hide_pan_tilt: false, hide_type: false, hide_zoom: false, mode: 'auto', orientation: 'horizontal', position: 'bottom-right', }, thumbnails: { mode: 'right', show_details: true, show_download_control: false, show_favorite_control: true, show_info_control: true, show_review_control: true, show_timeline_control: false, size: 100, }, timeline: { clustering_threshold: 3, format: { '24h': true, }, mode: 'none', pan_mode: 'pan', show_recordings: true, style: 'ribbon', window_seconds: 3600, }, wheel: true, }, draggable: true, lazy_load: true, lazy_unload: [], microphone: { always_connected: false, audio_processing: { auto_gain_control: 'auto', echo_cancellation: 'auto', noise_suppression: 'auto', }, auto_mute: [], auto_unmute: [], mute_after_microphone_mute_seconds: 60, }, preload: false, show_image_during_load: true, transition_effect: 'slide', zoomable: true, }, media_gallery: { controls: { filter: { mode: 'right', }, thumbnails: { show_details: false, show_download_control: false, show_favorite_control: true, show_info_control: true, show_review_control: true, show_timeline_control: false, size: 100, }, }, }, media_viewer: { auto_mute: ['unselected', 'hidden'], auto_pause: ['unselected', 'hidden'], auto_play: ['selected', 'visible'], auto_unmute: [], controls: { builtin: true, next_previous: { auto_hide: ['casting'], size: 48, style: 'thumbnails', }, ptz: { type: 'buttons', hide_home: false, hide_pan_tilt: false, hide_type: false, hide_zoom: false, mode: 'off', orientation: 'horizontal', position: 'bottom-right', }, thumbnails: { mode: 'right', show_details: true, show_download_control: false, show_favorite_control: true, show_info_control: true, show_review_control: true, show_timeline_control: false, size: 100, }, timeline: { clustering_threshold: 3, format: { '24h': true, }, mode: 'none', pan_mode: 'pan', show_recordings: true, style: 'ribbon', window_seconds: 3600, }, wheel: true, }, draggable: true, lazy_load: true, snapshot_click_plays_clip: true, transition_effect: 'slide', zoomable: true, }, menu: { alignment: 'left', auto_hide: ['call', 'casting'], button_size: 40, buttons: { call: { alignment: 'matching', enabled: true, permanent: false, priority: 50, state_color: true, }, camera_ui: { alignment: 'matching', enabled: true, permanent: false, priority: 50, state_color: true, }, cameras: { alignment: 'matching', enabled: true, permanent: false, priority: 50, state_color: true, }, clips: { alignment: 'matching', enabled: false, permanent: false, priority: 50, state_color: true, }, display_mode: { alignment: 'matching', enabled: true, permanent: false, priority: 50, state_color: true, }, download: { alignment: 'matching', enabled: true, permanent: false, priority: 50, state_color: true, }, expand: { alignment: 'matching', enabled: false, permanent: false, priority: 50, state_color: true, }, folders: { alignment: 'matching', enabled: true, permanent: false, priority: 50, state_color: true, }, fullscreen: { alignment: 'matching', enabled: true, permanent: false, priority: 50, state_color: true, }, gallery: { alignment: 'matching', enabled: true, permanent: false, priority: 50, state_color: true, }, image: { alignment: 'matching', enabled: false, permanent: false, priority: 50, state_color: true, }, info: { alignment: 'matching', enabled: true, permanent: false, priority: 50, state_color: true, }, iris: { alignment: 'matching', enabled: true, permanent: false, priority: 50, state_color: true, }, live: { alignment: 'matching', enabled: true, permanent: false, priority: 50, state_color: true, }, media_player: { alignment: 'matching', enabled: true, permanent: false, priority: 50, state_color: true, }, microphone: { alignment: 'matching', enabled: false, permanent: false, priority: 50, state_color: true, type: 'momentary', }, mute: { alignment: 'matching', enabled: false, permanent: false, priority: 50, state_color: true, }, pip: { alignment: 'matching', enabled: false, permanent: false, priority: 50, state_color: true, }, play: { alignment: 'matching', enabled: false, permanent: false, priority: 50, state_color: true, }, ptz_controls: { alignment: 'matching', enabled: false, permanent: false, priority: 50, state_color: true, }, ptz_home: { alignment: 'matching', enabled: false, permanent: false, priority: 50, state_color: true, }, recordings: { alignment: 'matching', enabled: false, permanent: false, priority: 50, state_color: true, }, reviews: { alignment: 'matching', enabled: false, permanent: false, priority: 50, state_color: true, }, screenshot: { alignment: 'matching', enabled: false, permanent: false, priority: 50, state_color: true, }, set_review: { alignment: 'matching', enabled: true, permanent: false, priority: 50, state_color: true, }, snapshots: { alignment: 'matching', enabled: false, permanent: false, priority: 50, state_color: true, }, substreams: { alignment: 'matching', enabled: true, permanent: false, priority: 50, state_color: true, }, timeline: { alignment: 'matching', enabled: true, permanent: false, priority: 50, state_color: true, }, }, position: 'top', style: 'hidden', }, performance: { features: { animated_progress_indicator: true, card_loading_effects: true, card_loading_indicator: true, media_chunk_size: 50, }, style: { border_radius: true, box_shadow: true, }, }, remote_control: { entities: { camera_priority: 'card', }, }, status_bar: { auto_hide: ['call', 'casting'], height: 40, items: { engine: { enabled: true, permanent: false, priority: 50, }, issues: { enabled: true, permanent: true, priority: 50, }, resolution: { enabled: true, permanent: false, priority: 50, }, severity: { enabled: true, permanent: false, priority: 50, }, technology: { enabled: true, permanent: false, priority: 50, }, title: { enabled: true, permanent: false, priority: 50, }, }, popup_seconds: 3, position: 'bottom', style: 'popup', }, timeline: { clustering_threshold: 3, controls: { thumbnails: { mode: 'right', show_details: true, show_download_control: false, show_favorite_control: true, show_info_control: true, show_review_control: true, show_timeline_control: false, size: 100, }, }, format: { '24h': true, }, show_recordings: true, style: 'stack', window_seconds: 3600, }, type: 'advanced-camera-card', view: { camera_select: 'current', default: 'auto', default_cycle_camera: false, default_reset: { after_interaction: false, entities: [], every_seconds: 0, interaction_mode: 'inactive', }, dim: false, interaction_seconds: 300, keyboard_shortcuts: { enabled: true, ptz_down: { alt: false, ctrl: false, key: 'ArrowDown', meta: false, }, ptz_home: { alt: false, ctrl: false, key: 'h', meta: false, }, ptz_left: { alt: false, ctrl: false, key: 'ArrowLeft', meta: false, }, ptz_right: { alt: false, ctrl: false, key: 'ArrowRight', meta: false, }, ptz_up: { alt: false, ctrl: false, key: 'ArrowUp', meta: false, }, ptz_zoom_in: { alt: false, ctrl: false, key: '+', meta: false, }, ptz_zoom_out: { alt: false, ctrl: false, key: '-', meta: false, }, }, theme: { themes: ['traditional'], }, triggers: { actions: { interaction_mode: 'inactive', trigger: 'update', untrigger: 'none', }, filter_selected_camera: true, show_trigger_status: false, event_hold_seconds: 30, untrigger_delay_seconds: 0, untrigger_force_seconds: 0, }, issues: { interaction_mode: 'all', retry_seconds: 'auto', }, }, }); }); it('should include all stock elements', () => { const stockElements = [ { type: 'icon', icon: 'mdi:dog', entity: 'camera.office', }, { type: 'custom:element', data: 'foo', }, { type: 'image', entity: 'camera.office', image: 'image', camera_image: 'camera_image', camera_view: 'camera_view', state_image: {}, filter: 'filter', state_filter: { on: '/foo' }, aspect_ratio: '16 / 9', }, { type: 'custom:advanced-camera-card-menu-icon', alignment: 'matching', enabled: true, entity: 'camera.kitchen', icon: 'mdi:cat', permanent: false, priority: 50, state_color: true, }, { type: 'custom:advanced-camera-card-menu-state-icon', alignment: 'matching', enabled: true, entity: 'camera.kitchen', icon: 'mdi:sheep', permanent: false, priority: 50, state_color: false, }, { type: 'state-badge', entity: 'sensor.kitchen_dining_multisensor_air_temperature', style: { left: '100px', top: '50px', }, title: 'Temperature', }, { type: 'state-icon', entity: 'light.office_main_lights', icon: 'mdi:lamp', state_color: true, style: { left: '100px', top: '100px', }, }, { type: 'state-label', entity: 'sensor.kitchen_motion_sensor_battery', attribute: 'battery_voltage', prefix: 'Volts', title: 'Battery Voltage', style: { left: '100px', top: '150px', }, }, { type: 'state-label', entity: 'sensor.kitchen_motion_sensor_battery', attribute: 'battery_voltage', prefix: 'Volts: ', title: 'Battery Voltage', style: { backgroundColor: 'black', left: '100px', top: '200px', }, }, { type: 'service-button', title: 'Light on', service: 'homeassistant.turn_on', service_data: { entity: 'light.office_main_lights', }, style: { left: '100px', top: '250px', }, }, { type: 'icon', icon: 'mdi:cow', title: 'Moo', style: { left: '100px', top: '300px', }, }, { type: 'image', entity: 'light.office_main_lights', title: 'Image', state_image: { on: 'https://picsum.photos/id/1003/1181/1772', off: 'https://picsum.photos/id/102/4320/3240', }, state_filter: { on: 'brightness(110%) saturate(1.2)', off: 'brightness(50%) hue-rotate(45deg)', }, style: { left: '100px', top: '350px', height: '50px', width: '100px', }, }, { type: 'conditional', conditions: [ { condition: 'state', entity_id: 'light.office_main_lights', state: 'on', state_not: 'off', }, ], elements: [ { type: 'icon', icon: 'mdi:dog', title: 'Woof', style: { left: '100px', top: '400px', }, }, ], }, ]; expect( createConfig({ elements: stockElements, }).elements, ).toEqual(stockElements); }); it('should include all custom elements', () => { const customElements = [ { type: 'custom:advanced-camera-card-menu-icon', alignment: 'matching', enabled: true, entity: 'light.office_main_lights', icon: 'mdi:car', permanent: false, priority: 50, state_color: true, style: { color: 'white', }, title: 'Vroom', }, { type: 'custom:advanced-camera-card-menu-state-icon', alignment: 'matching', enabled: true, entity: 'light.office_main_lights', icon: 'mdi:chair-rolling', permanent: false, priority: 50, state_color: true, style: { color: 'white', }, title: 'Office lights', }, { type: 'custom:advanced-camera-card-menu-submenu', alignment: 'matching', enabled: true, entity: 'light.office_main_lights', icon: 'mdi:menu', items: [ { enabled: true, entity: 'light.office_main_lights', icon: 'mdi:lightbulb', selected: false, state_color: true, style: { color: 'white', }, tap_action: { action: 'toggle', }, title: 'Lights', }, { enabled: true, icon: 'mdi:google', selected: false, state_color: false, style: { color: 'white', }, tap_action: { action: 'url', url_path: 'https://www.google.com', }, title: 'Google', }, ], permanent: false, priority: 50, state_color: true, style: { color: 'white', }, title: 'Office lights', }, { type: 'custom:advanced-camera-card-menu-submenu-select', alignment: 'matching', enabled: true, entity: 'input_select.kitchen_scene', icon: 'mdi:lamps', options: { 'scene.kitchen_cooking_scene': { enabled: true, icon: 'mdi:chef-hat', selected: false, state_color: true, style: { color: 'white', }, title: 'Cooking time!', }, 'scene.kitchen_tv_scene': { enabled: true, icon: 'mdi:television', selected: false, state_color: true, title: 'TV!', }, }, permanent: false, priority: 50, state_color: true, style: { color: 'white', }, title: 'Kitchen Scene', }, { type: 'custom:advanced-camera-card-conditional', elements: [ { type: 'icon', icon: 'mdi:pig', title: 'Oink', style: { left: '300px', top: '100px', }, }, ], conditions: [ { condition: 'view', views: ['live'], }, ], }, { type: 'custom:advanced-camera-card-status-bar-string', enabled: true, exclusive: false, expand: false, permanent: false, string: 'Intruder alert!', priority: 50, sufficient: false, }, { type: 'custom:advanced-camera-card-status-bar-icon', enabled: true, exclusive: false, expand: false, icon: 'mdi:cow', permanent: false, priority: 50, sufficient: false, }, { type: 'custom:advanced-camera-card-status-bar-image', enabled: true, exclusive: false, expand: false, image: 'https://my.site.com/status.png', permanent: false, priority: 50, sufficient: false, }, ]; expect( createConfig({ elements: customElements, }).elements, ).toEqual(customElements); }); it('should include all conditions', () => { const conditions = [ { condition: 'and', conditions: [{ condition: 'initialized', ever: false }] }, { condition: 'call', call: ['ringing', 'answered'] }, { condition: 'camera', cameras: ['camera.office'] }, { condition: 'display_mode', display_mode: 'single' }, { condition: 'expand', expand: true }, { condition: 'fullscreen', fullscreen: true }, { condition: 'initialized', ever: false }, { condition: 'interaction', interaction: true }, { condition: 'key', alt: false, ctrl: false, key: 'F', meta: false, shift: false, state: 'down', }, { condition: 'media_loaded', media_loaded: true }, { condition: 'microphone', muted: true }, { condition: 'not', conditions: [{ condition: 'initialized', ever: false }] }, { condition: 'numeric_state', entity_id: 'sensor.office_temperature', above: 10, below: 20, }, { condition: 'or', conditions: [{ condition: 'initialized', ever: false }] }, { condition: 'screen', media_query: '(orientation: landscape)' }, { condition: 'state', entity_id: 'climate.office', state: 'heat', state_not: 'off', }, { condition: 'triggered', triggered: ['camera.office'] }, { condition: 'user', users: ['581fca7fdc014b8b894519cc531f9a04'] }, { condition: 'user_agent', user_agent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', user_agent_re: 'Chrome/', companion: true, }, { condition: 'view', views: ['live'] }, ]; const elements = [ { type: 'custom:advanced-camera-card-conditional', elements: [ { type: 'icon', icon: 'mdi:pig', }, ], conditions: conditions, }, ]; expect( createConfig({ elements: elements, }).elements, ).toEqual(elements); }); it('should include all stock actions', () => { const stockActions = [ { action: 'more-info', }, { action: 'toggle', }, { action: 'call-service', service: 'homeassistant.toggle', data: { entity_id: 'light.office_main_lights', }, }, { action: 'navigate', navigation_path: '/lovelace/2', }, { action: 'url', url_path: 'https://www.home-assistant.io/', }, { action: 'none', }, { action: 'fire-dom-event', key: 'value', }, { action: 'perform-action', perform_action: 'homeassistant.toggle', target: { entity_id: 'light.office_main_lights', }, }, ]; expect( createConfig({ live: { actions: { tap_action: stockActions, }, }, }).live.actions?.tap_action, ).toEqual(stockActions); }); it('should include all custom actions', () => { const customActions = [ { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'camera_select', camera: 'camera.front_door', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'camera_ui', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'clip', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'clips', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'default', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'diagnostics', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'display_mode_select', display_mode: 'grid', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'download', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'expand', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'fullscreen', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'image', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'live', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'substream_off', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'substream_on', camera: 'camera.front_door', stream: 'camera.front_door_hd', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'log', message: 'Hello, world!', level: 'debug', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'media_player', media_player: 'media_player.nesthub50be', media_player_action: 'play', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'media_player', media_player: 'media_player.nesthub', media_player_action: 'stop', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'menu_toggle', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'microphone_mute', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'microphone_unmute', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'mute', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'notification', notification: { heading: { text: 'Attention', icon: 'mdi:alert', severity: 'high', }, body: { text: 'Something happened.' }, metadata: [{ text: 'Detail 1', icon: 'mdi:info' }], controls: [ { icon: 'mdi:check', tooltip: 'Acknowledge', dismiss: true, }, { icon: 'mdi:eye', dismiss: false, }, ], }, }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'pause', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'play', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'ptz', ptz_action: 'preset', ptz_preset: 'doorway', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'ptz_controls', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'ptz_digital', absolute: { zoom: 5, pan: { x: 58, y: 14, }, }, }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'ptz_multi', ptz_action: 'left', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'recording', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'recordings', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'screenshot', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'sleep', duration: { h: 1, m: 20, s: 56, ms: 422, }, }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'snapshot', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'snapshots', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'timeline', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'unmute', }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'status_bar', status_bar_action: 'add', items: [ { type: 'custom:advanced-camera-card-status-bar-string', enabled: true, exclusive: false, expand: false, permanent: false, string: 'Intruder alert!', priority: 50, sufficient: false, }, { type: 'custom:advanced-camera-card-status-bar-icon', enabled: true, exclusive: false, expand: false, icon: 'mdi:cow', permanent: false, priority: 50, sufficient: false, }, { type: 'custom:advanced-camera-card-status-bar-image', enabled: true, exclusive: false, expand: false, image: 'https://my.site.com/status.png', permanent: false, priority: 50, sufficient: false, }, ], }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'status_bar', status_bar_action: 'remove', items: [ { type: 'custom:advanced-camera-card-status-bar-string', enabled: true, exclusive: false, expand: false, permanent: false, string: 'Intruder alert!', priority: 50, sufficient: false, }, { type: 'custom:advanced-camera-card-status-bar-icon', enabled: true, exclusive: false, expand: false, icon: 'mdi:cow', permanent: false, priority: 50, sufficient: false, }, { type: 'custom:advanced-camera-card-status-bar-image', enabled: true, exclusive: false, expand: false, image: 'https://my.site.com/status.png', permanent: false, priority: 50, sufficient: false, }, ], }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'status_bar', status_bar_action: 'reset', }, ]; expect( createConfig({ live: { actions: { tap_action: customActions, }, }, }).live.actions?.tap_action, ).toEqual( // Action type will be transformed to fire-dom-event. customActions.map((action) => ({ ...action, action: 'fire-dom-event' })), ); }); }); it('should transform dimensions.aspect_ratio', () => { expect( dimensionsConfigSchema.parse({ aspect_ratio: '16 / 9', }), ).toEqual(expect.objectContaining({ aspect_ratio: [16, 9] })); expect( dimensionsConfigSchema.parse({ aspect_ratio: '16 : 9', }), ).toEqual(expect.objectContaining({ aspect_ratio: [16, 9] })); expect( dimensionsConfigSchema.parse({ aspect_ratio: [16, 9], }), ).toEqual(expect.objectContaining({ aspect_ratio: [16, 9] })); }); describe('should refine user_agent_re conditions', () => { it('should successfully parse valid user_agent_re condition', () => { expect( conditionSchema.parse({ condition: 'user_agent', user_agent_re: 'Chrome/', }), ).toEqual({ condition: 'user_agent', user_agent_re: 'Chrome/', }); }); it('should reject invalid user_agent_re conditions', () => { expect(() => conditionSchema.parse({ condition: 'user_agent', user_agent_re: '[', }), ).toThrow(/Invalid regular expression/); }); }); describe('conditions should accept Home Assistant composite shorthand', () => { it('should expand and/or/not operator shorthand', () => { for (const op of ['and', 'or', 'not'] as const) { expect( conditionSchema.parse({ [op]: [{ condition: 'fullscreen', fullscreen: true }], }), ).toMatchObject({ condition: op, conditions: [{ condition: 'fullscreen', fullscreen: true }], }); } }); it('should expand a condition list to an implicit and', () => { expect( conditionSchema.parse({ condition: [ { condition: 'fullscreen', fullscreen: true }, { condition: 'expand', expand: true }, ], }), ).toMatchObject({ condition: 'and', conditions: [ { condition: 'fullscreen', fullscreen: true }, { condition: 'expand', expand: true }, ], }); }); it('should normalize a single shorthand condition to a list', () => { expect( conditionSchema.parse({ or: { condition: 'fullscreen', fullscreen: true } }), ).toMatchObject({ condition: 'or', conditions: [{ condition: 'fullscreen', fullscreen: true }], }); }); it('should expand nested shorthand and preserve base fields', () => { expect( conditionSchema.parse({ or: [{ and: [{ condition: 'fullscreen', fullscreen: true }] }], enabled: false, }), ).toMatchObject({ condition: 'or', enabled: false, conditions: [ { condition: 'and', conditions: [{ condition: 'fullscreen', fullscreen: true }], }, ], }); }); it('should leave a canonical condition untouched', () => { expect( conditionSchema.parse({ condition: 'fullscreen', fullscreen: true }), ).toMatchObject({ condition: 'fullscreen', fullscreen: true }); }); it('should reject ambiguous or non-record shorthand', () => { expect(conditionSchema.safeParse({ and: [], or: [] }).success).toBe(false); expect(conditionSchema.safeParse('nope').success).toBe(false); }); }); it('should transform action', () => { expect( advancedCameraCardCustomActionsBaseSchema.parse({ action: 'custom:advanced-camera-card-action', }), ).toEqual({ action: 'fire-dom-event', }); }); describe('should convert webrtc card PTZ to Advanced Camera Card PTZ', () => { describe('relative actions', () => { it.each([ ['left' as const], ['right' as const], ['up' as const], ['down' as const], ['zoom_in' as const], ['zoom_out' as const], ])('%s', (action: string) => { expect( cameraConfigSchema.parse({ ptz: { service: 'foo', [`data_${action}`]: { device: '048123', cmd: action, }, }, }), ).toEqual( expect.objectContaining({ ptz: expect.objectContaining({ [`actions_${action}`]: { action: 'perform-action', perform_action: 'foo', data: { device: '048123', cmd: action, }, }, }), }), ); }); }); describe('continuous actions', () => { it.each([ ['left' as const], ['right' as const], ['up' as const], ['down' as const], ['zoom_in' as const], ['zoom_out' as const], ])('%s', (action: string) => { expect( cameraConfigSchema.parse({ ptz: { service: 'foo', [`data_start_${action}`]: { device: '048123', cmd: action, phase: 'start', }, [`data_end_${action}`]: { device: '048123', cmd: action, phase: 'stop', }, }, }), ).toEqual( expect.objectContaining({ ptz: expect.objectContaining({ [`actions_${action}_start`]: { action: 'perform-action', perform_action: 'foo', data: { device: '048123', cmd: action, phase: 'start', }, }, [`actions_${action}_stop`]: { action: 'perform-action', perform_action: 'foo', data: { device: '048123', cmd: action, phase: 'stop', }, }, }), }), ); }); }); it('should parse presets via presets sub-object', () => { expect( cameraConfigSchema.parse({ ptz: { service: 'service_outer', presets: { service: 'service_inner', data_home: { device: '048123', cmd: 'home', }, data_another: { device: '048123', cmd: 'another', }, }, }, }), ).toEqual( expect.objectContaining({ ptz: expect.objectContaining({ presets: { home: { action: 'perform-action', perform_action: 'service_inner', data: { device: '048123', cmd: 'home', }, }, another: { action: 'perform-action', perform_action: 'service_inner', data: { device: '048123', cmd: 'another', }, }, }, }), }), ); }); it('should prioritize actions_left over data_left', () => { const result = cameraConfigSchema.parse({ ptz: { service: 'foo', data_left: { cmd: 'from_data' }, actions_left: { action: 'perform-action', perform_action: 'bar', data: { cmd: 'from_actions' }, }, }, }); expect(result).toEqual( expect.objectContaining({ ptz: expect.objectContaining({ actions_left: { action: 'perform-action', perform_action: 'bar', data: { cmd: 'from_actions' }, }, }), }), ); }); it('should create a home preset from data_home', () => { expect( cameraConfigSchema.parse({ ptz: { service: 'foo', data_home: { device: '048123', cmd: 'home', }, }, }), ).toEqual( expect.objectContaining({ ptz: expect.objectContaining({ presets: { home: { action: 'perform-action', perform_action: 'foo', data: { device: '048123', cmd: 'home', }, }, }, }), }), ); }); it('should not overwrite existing home preset from data_home', () => { expect( cameraConfigSchema.parse({ ptz: { service: 'foo', data_home: { device: '048123', cmd: 'home_data', }, presets: { home: { action: 'perform-action', perform_action: 'bar', data: { cmd: 'home_preset' }, }, }, }, }), ).toEqual( expect.objectContaining({ ptz: expect.objectContaining({ presets: { home: { action: 'perform-action', perform_action: 'bar', data: { cmd: 'home_preset' }, }, }, }), }), ); }); }); describe('should lazy evaluate schemas', () => { it('should parse conditional picture element', () => { expect( conditionalSchema.parse({ type: 'conditional', conditions: [ { condition: 'state', entity_id: 'light.office_main_lights', state: 'on', state_not: 'off', }, ], elements: [ { type: 'icon', icon: 'mdi:dog', title: 'Woof', style: { left: '100px', top: '400px', }, }, ], }), ).toEqual({ conditions: [ { condition: 'state', entity_id: 'light.office_main_lights', state: 'on', state_not: 'off', }, ], elements: [ { icon: 'mdi:dog', style: { left: '100px', top: '400px', }, title: 'Woof', type: 'icon', }, ], type: 'conditional', }); }); it('should parse status bar actions', () => { const input = { action: 'fire-dom-event', advanced_camera_card_action: 'status_bar', status_bar_action: 'reset', items: [ { enabled: true, exclusive: false, expand: false, permanent: false, priority: 50, sufficient: false, type: 'custom:advanced-camera-card-status-bar-string', string: 'Item', }, ], }; expect(statusBarActionConfigSchema.parse(input)).toEqual(input); }); it('should recursively validate if action then/else sequences', () => { const input = { if: [ { condition: 'state', entity_id: 'light.office_main_lights', state: 'on', }, ], then: [ { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_on', }, ], else: [ { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_off', }, ], }; expect(actionConfigSchema.parse(input)).toEqual(input); // The then/else sequences are validated as actions (not accepted verbatim). expect( actionConfigSchema.safeParse({ ...input, then: [{ action: 'not-an-action' }] }) .success, ).toBeFalsy(); }); it('should normalize single if/then/else items to lists', () => { const result = actionConfigSchema.parse({ if: { condition: 'state', entity_id: 'light.office', state: 'on' }, then: { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_on', }, else: { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_off', }, }); expect(result).toMatchObject({ if: [{ condition: 'state', entity_id: 'light.office', state: 'on' }], then: [ { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_on' }, ], else: [ { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_off' }, ], }); }); }); describe('should apply specific custom action schemas, not the generic catch-all', () => { it('should default a log action level to info', () => { // The generic `customActionSchema` (a loose `fire-dom-event` matcher) must // not shadow `logActionConfigSchema`, or the `level` default is lost and the // action crashes at runtime. expect( actionConfigSchema.parse({ action: 'fire-dom-event', advanced_camera_card_action: 'log', message: 'hello', }), ).toMatchObject({ advanced_camera_card_action: 'log', level: 'info' }); }); it('should default a log action level nested in an if branch', () => { const result = actionConfigSchema.parse({ if: { condition: 'state', entity_id: 'light.office', state: 'on' }, then: { action: 'fire-dom-event', advanced_camera_card_action: 'log', message: 'hello', }, }); expect(result).toMatchObject({ then: [{ advanced_camera_card_action: 'log', level: 'info' }], }); }); }); describe('should handle custom advanced camera card elements', () => { it('should reject non-custom element types', () => { const result = customSchema.safeParse({ type: 'foo', }); expect(result.success).toBeFalsy(); }); it('should add custom error on advanced camera card element', () => { const result = customSchema.safeParse({ type: 'custom:advanced-camera-card-foo', }); expect(result.success).toBeFalsy(); if (!result.success) { expect(result.error.issues[0]).toEqual({ code: 'custom', message: 'advanced-camera-card custom elements must match specific schemas', fatal: true, path: ['type'], }); } }); it('should not add custom error on valid entry', () => { const result = customSchema.safeParse({ type: 'custom:foo', }); expect(result.success).toBeTruthy(); }); }); // https://github.com/dermotduffy/advanced-camera-card/issues/1280 it('should not require title controls to specify all options', () => { expect( createConfig({ cameras: [{}], live: { controls: { title: { mode: 'popup-top-left', }, }, }, }), ).toBeTruthy(); }); it('should allow the on-screen call controls overlay to be disabled', () => { const config = createConfig({ cameras: [{}], live: { controls: { call: { enabled: false, }, }, }, }); expect(config.live.controls.call.enabled).toBe(false); }); it('should strip trailing slashes from go2rtc url', () => { const config = createConfig({ cameras: [ { go2rtc: { url: 'https://my-custom-go2rtc//', }, }, ], }); expect(config).toBeTruthy(); expect(config.cameras?.[0].go2rtc.url).toBe('https://my-custom-go2rtc'); }); it('media viewer should not support microphone based conditions', () => { expect(() => createConfig({ cameras: [], media_viewer: { auto_unmute: 'microphone' as const, }, }), ).toThrow(); }); describe('automations should require actions', () => { it('should reject a missing actions key', () => { expect(() => createConfig({ cameras: [{}], automations: [{ triggers: [{ trigger: 'initialized' }], conditions: [] }], }), ).toThrow(); }); }); describe('automations should accept Home Assistant input shorthands', () => { it('should normalize singular keys and single items to lists', () => { const result = automationsSchema.parse([ { trigger: { trigger: 'state', entity_id: 'binary_sensor.door', to: 'on' }, condition: { condition: 'state', entity_id: 'input_boolean.x', state: 'on' }, action: { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_on', }, }, ]); expect(result).toMatchObject([ { triggers: [{ trigger: 'state', entity_id: 'binary_sensor.door', to: 'on' }], conditions: [{ condition: 'state', entity_id: 'input_boolean.x', state: 'on' }], actions: [ { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_on' }, ], }, ]); }); 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([ { triggers: [{ trigger: 'initialized' }], trigger: { trigger: 'state', entity_id: 'x', to: 'on' }, actions: [ { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_on' }, ], }, ]); expect(result[0].triggers).toEqual([{ trigger: 'initialized', ever: false }]); }); it('should reject a non-object automation', () => { expect(automationsSchema.safeParse(['not-an-object']).success).toBe(false); }); });