Files
advanced-camera-card/tests/components-lib/editor/form-data.test.ts
T

584 lines
18 KiB
TypeScript

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' }]);
});
});
});