import { describe, expect, it } from 'vitest'; import { applyConfigChanges, computeConfigChanges, computeDisplayedData, forEachFieldRecursively, getFormConfigPaths, } from '../../../src/components-lib/editor/form-data'; import type { ConfigChange, EditorForm } from '../../../src/components-lib/editor/types'; import { getConfigValue } from '../../../src/config/management'; import type { RawAdvancedCameraCardConfig } from '../../../src/config/types'; import type { HAFormSchema } from '../../../src/ha/types'; const createForm = (schema: HAFormSchema[] = SCHEMA): EditorForm => ({ basePath: ['live'], schema, }); const createConfig = (live: unknown) => ({ live }); const SCHEMA: HAFormSchema[] = [ { name: 'title', selector: { text: {} } }, { name: 'preload', selector: { boolean: {} } }, { name: 'controls', type: 'expandable', schema: [ { name: 'wheel', selector: { boolean: {} } }, { name: 'size', selector: { number: { min: 0 } } }, { name: 'thumbnails', type: 'expandable', schema: [{ name: 'mode', selector: { select: { options: ['none'] } } }], }, ], }, { name: 'modes', selector: { select: { options: ['a', 'b'], multiple: true } } }, ]; // Defaults mirror the configuration shape; the form displays these where the // configuration leaves a field unset. const DEFAULTS = { preload: false, controls: { wheel: true }, }; describe('forEachFieldRecursively', () => { it('should not add a nameless grouping to the path', () => { const visited: string[][] = []; forEachFieldRecursively( [ { type: 'expandable', schema: [{ name: 'style', selector: { text: {} } }], }, ], (path) => visited.push(path), ); expect(visited).toEqual([['style']]); }); it('should not add a grid to the path', () => { const visited: string[][] = []; forEachFieldRecursively( [ { type: 'grid', schema: [ { name: 'aspect_ratio_mode', selector: { text: {} } }, { name: 'aspect_ratio', selector: { text: {} } }, ], }, ], (path) => visited.push(path), ); expect(visited).toEqual([['aspect_ratio_mode'], ['aspect_ratio']]); }); it('should visit every leaf field with its relative path', () => { const visited: [string[], string][] = []; forEachFieldRecursively(SCHEMA, (path, field) => visited.push([path, field.name])); expect(visited).toEqual([ [['title'], 'title'], [['preload'], 'preload'], [['controls', 'wheel'], 'wheel'], [['controls', 'size'], 'size'], [['controls', 'thumbnails', 'mode'], 'mode'], [['modes'], 'modes'], ]); }); }); describe('getFormConfigPaths', () => { it('should place an unbound field beneath the base path', () => { expect(getFormConfigPaths(createForm())).toEqual([ ['live', 'title'], ['live', 'preload'], ['live', 'controls', 'wheel'], ['live', 'controls', 'size'], ['live', 'controls', 'thumbnails', 'mode'], ['live', 'modes'], ]); }); it('should use the path a binding names', () => { expect( getFormConfigPaths({ basePath: [], schema: [{ name: 'menu_style', selector: { text: {} } }], bindings: [{ formPath: ['menu_style'], configPath: ['menu', 'style'] }], }), ).toEqual([['menu', 'style']]); }); it('should use every path a field that reads and writes itself declares', () => { expect( getFormConfigPaths({ basePath: [], schema: [{ name: 'buttons', selector: { select: { options: [] } } }], bindings: [ { formPath: ['buttons'], configPaths: [ ['menu', 'buttons', 'cameras', 'enabled'], ['menu', 'buttons', 'timeline', 'enabled'], ], read: () => [], write: () => [], }, ], }), ).toEqual([ ['menu', 'buttons', 'cameras', 'enabled'], ['menu', 'buttons', 'timeline', 'enabled'], ]); }); }); describe('computeDisplayedData', () => { it('should fill in defaults for absent fields', () => { expect( computeDisplayedData(createForm(), createConfig({}), createConfig(DEFAULTS)), ).toEqual({ preload: false, controls: { wheel: true }, }); }); it('should not override configured values with defaults', () => { expect( computeDisplayedData( createForm(), createConfig({ preload: true, controls: { wheel: false } }), createConfig(DEFAULTS), ), ).toEqual({ preload: true, controls: { wheel: false }, }); }); it('should only fill in fields that have a default', () => { // `title` and `modes` have no default in DEFAULTS, so they stay absent. expect( computeDisplayedData(createForm(), createConfig({}), createConfig(DEFAULTS)), ).not.toHaveProperty('title'); }); it('should preserve unknown keys', () => { expect( computeDisplayedData( createForm(), createConfig({ unknown_key: 42 }), createConfig(DEFAULTS), ), ).toEqual({ unknown_key: 42, preload: false, controls: { wheel: true }, }); }); it('should treat a non-object configuration as empty', () => { expect( computeDisplayedData( createForm(), createConfig('NOT_AN_OBJECT'), createConfig(DEFAULTS), ), ).toEqual({ preload: false, controls: { wheel: true }, }); }); it('should tolerate absent defaults', () => { expect( computeDisplayedData(createForm(), createConfig({ title: 'Front' }), {}), ).toEqual({ title: 'Front', }); }); it('should not modify the raw configuration', () => { const raw = { title: 'Front' }; computeDisplayedData(createForm(), createConfig(raw), createConfig(DEFAULTS)); expect(raw).toEqual({ title: 'Front' }); }); it('should copy non-primitive defaults rather than share them', () => { const defaults = { modes: ['a'] }; const schema: HAFormSchema[] = [ { name: 'modes', selector: { select: { options: ['a', 'b'], multiple: true } } }, ]; const data = computeDisplayedData( createForm(schema), createConfig({}), createConfig(defaults), ); expect(data.modes).toEqual(['a']); expect(data.modes).not.toBe(defaults.modes); }); }); describe('computeConfigChanges', () => { it('should return no changes for a non-object emission', () => { expect(computeConfigChanges(createForm(), {}, 'NOT_AN_OBJECT', {})).toEqual([]); }); it('should return no changes when nothing changed', () => { const displayed = computeDisplayedData( createForm(), createConfig({ title: 'Front' }), createConfig(DEFAULTS), ); expect(computeConfigChanges(createForm(), displayed, displayed, {})).toEqual([]); }); it('should never report an untouched display default', () => { const displayed = computeDisplayedData( createForm(), createConfig({}), createConfig(DEFAULTS), ); expect(computeConfigChanges(createForm(), displayed, { ...displayed }, {})).toEqual( [], ); }); it('should report a changed value', () => { const displayed = computeDisplayedData( createForm(), createConfig({}), createConfig(DEFAULTS), ); expect( computeConfigChanges( createForm(), displayed, { ...displayed, title: 'Front' }, {}, ), ).toEqual([{ path: ['live', 'title'], type: 'set', value: 'Front' }]); }); it('should report a changed nested value', () => { const displayed = computeDisplayedData( createForm(), createConfig({}), createConfig(DEFAULTS), ); expect( computeConfigChanges( createForm(), displayed, { preload: false, controls: { wheel: false } }, {}, ), ).toEqual([{ path: ['live', 'controls', 'wheel'], type: 'set', value: false }]); }); it('should report a value set back to its default as a change', () => { // Diffing against the displayed baseline: when the stored value is // non-default, returning it to the default value is a real change (this is // how a user pins a default-equal value). const displayed = computeDisplayedData( createForm(), createConfig({ controls: { wheel: false } }), createConfig(DEFAULTS), ); const emitted = { ...displayed, controls: { wheel: true } }; expect(computeConfigChanges(createForm(), displayed, emitted, {})).toEqual([ { path: ['live', 'controls', 'wheel'], type: 'set', value: true }, ]); }); it('should trim string values', () => { expect(computeConfigChanges(createForm(), {}, { title: ' Front ' }, {})).toEqual([ { path: ['live', 'title'], type: 'set', value: 'Front' }, ]); }); it('should not report a value that only differs by whitespace', () => { expect( computeConfigChanges(createForm(), { title: 'Front' }, { title: ' Front ' }, {}), ).toEqual([]); }); it('should request deletion for an emptied string', () => { expect( computeConfigChanges(createForm(), { title: 'Front' }, { title: '' }, {}), ).toEqual([{ path: ['live', 'title'], type: 'delete' }]); }); it('should request deletion for a cleared value', () => { expect( computeConfigChanges( createForm(), { controls: { size: 5 } }, { controls: { size: undefined } }, {}, ), ).toEqual([{ path: ['live', 'controls', 'size'], type: 'delete' }]); }); it('should set a zero value rather than delete it', () => { expect( computeConfigChanges(createForm(), {}, { controls: { size: 0 } }, {}), ).toEqual([{ path: ['live', 'controls', 'size'], type: 'set', value: 0 }]); }); it('should compare multi-select arrays by value', () => { expect( computeConfigChanges(createForm(), { modes: ['a'] }, { modes: ['a'] }, {}), ).toEqual([]); expect( computeConfigChanges(createForm(), { modes: ['a'] }, { modes: ['a', 'b'] }, {}), ).toEqual([{ path: ['live', 'modes'], type: 'set', value: ['a', 'b'] }]); }); it('should ignore emitted keys that are not in the schema', () => { expect(computeConfigChanges(createForm(), {}, { unknown_key: 42 }, {})).toEqual([]); }); }); describe('applyConfigChanges', () => { it('should return null without changes', () => { expect(applyConfigChanges({ title: 'Front' }, [])).toBeNull(); }); it('should set values', () => { expect( applyConfigChanges({}, [{ path: ['title'], type: 'set', value: 'Front' }]), ).toEqual({ title: 'Front' }); }); it('should create intermediate objects for absent branches', () => { expect( applyConfigChanges({}, [ { path: ['image', 'proxy', 'dynamic'], type: 'set', value: true }, ]), ).toEqual({ image: { proxy: { dynamic: true } } }); }); it('should delete values', () => { expect( applyConfigChanges({ image: { preload: true } }, [ { path: ['image', 'preload'], type: 'delete' }, ]), ).toEqual({ image: {} }); }); it('should leave unknown keys untouched', () => { expect( applyConfigChanges({ image: { unknown_key: 42 } }, [ { path: ['image', 'title'], type: 'set', value: 'Front' }, ]), ).toEqual({ image: { unknown_key: 42, title: 'Front' } }); }); it('should return null when the changes leave the configuration unmodified', () => { expect( applyConfigChanges({}, [{ path: ['image', 'preload'], type: 'delete' }]), ).toBeNull(); }); it('should not modify the given configuration', () => { const config = { image: { preload: true } }; applyConfigChanges(config, [ { path: ['image', 'preload'], type: 'set', value: false }, ]); expect(config).toEqual({ image: { preload: true } }); }); it('should copy the values it sets rather than share them', () => { const value = { mode: 'none' }; const result = applyConfigChanges({}, [ { path: ['image', 'thumbnails'], type: 'set', value }, ]); expect(getConfigValue(result ?? {}, 'image.thumbnails')).toEqual(value); expect(getConfigValue(result ?? {}, 'image.thumbnails')).not.toBe(value); }); }); describe('field bindings', () => { // A form gathering fields from across the configuration: the field is shown // in the form under its own name, but stored somewhere else entirely. const BOUND_FORM: EditorForm = { basePath: [], schema: [{ name: 'menu_style', selector: { text: {} } }], bindings: [{ formPath: ['menu_style'], configPath: ['menu', 'style'] }], }; it('should display the value from the bound path', () => { expect(computeDisplayedData(BOUND_FORM, { menu: { style: 'outside' } }, {})).toEqual( { menu: { style: 'outside' }, menu_style: 'outside' }, ); }); it('should display nothing for a bound path with no value or default', () => { expect(computeDisplayedData(BOUND_FORM, {}, {})).toEqual({ menu_style: undefined }); }); it('should display a bound path configured to null as null', () => { // As an unbound field would: only an absent value falls back to a default. expect( computeDisplayedData( BOUND_FORM, { menu: { style: null } }, { menu: { style: 'hidden' } }, ), ).toMatchObject({ menu_style: null }); }); it('should bind nothing for a path that names no field', () => { const form: EditorForm = { basePath: ['live'], schema: [ { name: 'controls', type: 'expandable', schema: [{ name: 'mode', selector: { text: {} } }], }, ], // A group, not a field: its fields are bound individually or not at all. bindings: [{ formPath: ['controls'], configPath: ['elsewhere'] }], }; expect(computeConfigChanges(form, {}, { controls: { mode: 'above' } }, {})).toEqual([ { path: ['live', 'controls', 'mode'], type: 'set', value: 'above' }, ]); }); it('should display the default of the bound path', () => { expect(computeDisplayedData(BOUND_FORM, {}, { menu: { style: 'hidden' } })).toEqual({ menu_style: 'hidden', }); }); it('should write an edit to the bound path', () => { expect( computeConfigChanges( BOUND_FORM, { menu_style: 'hidden' }, { menu_style: 'overlay' }, {}, ), ).toEqual([{ path: ['menu', 'style'], type: 'set', value: 'overlay' }]); }); it('should delete the bound path for an emptied field', () => { expect( computeConfigChanges(BOUND_FORM, { menu_style: 'hidden' }, { menu_style: '' }, {}), ).toEqual([{ path: ['menu', 'style'], type: 'delete' }]); }); describe('with a field that reads and writes itself', () => { // One field standing for many configuration keys: a list of which menu // buttons are on, where each button stores its own `enabled`. This is what // a computed binding exists for: the field's shape in the form and its // shape in the configuration have nothing to do with each other. const BUTTONS = ['cameras', 'fullscreen', 'timeline']; const isEnabled = ( config: RawAdvancedCameraCardConfig, defaults: RawAdvancedCameraCardConfig, button: string, ): boolean => (getConfigValue(config, ['menu', 'buttons', button, 'enabled']) ?? getConfigValue(defaults, ['menu', 'buttons', button, 'enabled'])) !== false; const BUTTONS_FORM: EditorForm = { basePath: [], schema: [ { name: 'buttons', selector: { select: { options: BUTTONS, multiple: true } }, }, ], bindings: [ { formPath: ['buttons'], configPaths: BUTTONS.map((button) => ['menu', 'buttons', button, 'enabled']), read: (config, defaults) => BUTTONS.filter((button) => isEnabled(config, defaults, button)), write: (value, config, defaults) => { const enabled = Array.isArray(value) ? value : []; return BUTTONS.flatMap((button): ConfigChange[] => { const path = ['menu', 'buttons', button, 'enabled']; const wanted = enabled.includes(button); if (wanted === isEnabled(config, defaults, button)) { return []; } // Returning to the default deletes the key rather than writing a // value the user never set. const isDefault = wanted === (getConfigValue(defaults, path) !== false); return isDefault ? [{ path, type: 'delete' }] : [{ path, type: 'set', value: wanted }]; }); }, }, ], }; const DEFAULTS = { menu: { buttons: { cameras: { enabled: true }, fullscreen: { enabled: false }, timeline: { enabled: true }, }, }, }; it('should display the buttons that are on', () => { expect( computeDisplayedData( BUTTONS_FORM, { menu: { buttons: { timeline: { enabled: false } } } }, DEFAULTS, ), ).toMatchObject({ buttons: ['cameras'] }); }); it('should write only the buttons whose state actually changed', () => { const config = {}; const displayed = computeDisplayedData(BUTTONS_FORM, config, DEFAULTS); // Turn `fullscreen` on (it defaults off) and `cameras` off (defaults on). expect( computeConfigChanges( BUTTONS_FORM, displayed, { buttons: ['fullscreen', 'timeline'] }, config, DEFAULTS, ), ).toEqual([ { path: ['menu', 'buttons', 'cameras', 'enabled'], type: 'set', value: false }, { path: ['menu', 'buttons', 'fullscreen', 'enabled'], type: 'set', value: true, }, ]); }); it('should delete a button returned to its default rather than writing it', () => { const config = { menu: { buttons: { cameras: { enabled: false } } } }; const displayed = computeDisplayedData(BUTTONS_FORM, config, DEFAULTS); expect( computeConfigChanges( BUTTONS_FORM, displayed, { buttons: ['cameras', 'timeline'] }, config, DEFAULTS, ), ).toEqual([{ path: ['menu', 'buttons', 'cameras', 'enabled'], type: 'delete' }]); }); }); });