feat: Add a simple mode to the visual card editor (#2588)
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { forEachFieldRecursively } from '../../../../src/components-lib/editor/form-data';
|
||||
import {
|
||||
forEachFieldRecursively,
|
||||
getFormConfigPaths,
|
||||
} from '../../../../src/components-lib/editor/form-data';
|
||||
import {
|
||||
getCameraSchema,
|
||||
getCameraTriggersSchema,
|
||||
getTriggerEventSchema,
|
||||
} from '../../../../src/components-lib/editor/schema/cameras';
|
||||
import { getDimensionsSectionForms } from '../../../../src/components-lib/editor/schema/dimensions';
|
||||
import { getEditorModeForms } from '../../../../src/components-lib/editor/schema/editor-mode';
|
||||
import { getFolderSchema } from '../../../../src/components-lib/editor/schema/folders';
|
||||
import { getFullEditorForms } from '../../../../src/components-lib/editor/schema/full';
|
||||
import { getImageSectionForms } from '../../../../src/components-lib/editor/schema/image';
|
||||
import { getLiveSectionForms } from '../../../../src/components-lib/editor/schema/live';
|
||||
import { getMediaGallerySectionForms } from '../../../../src/components-lib/editor/schema/media-gallery';
|
||||
@@ -17,13 +22,23 @@ import { getMenuSectionForms } from '../../../../src/components-lib/editor/schem
|
||||
import { getPerformanceSectionForms } from '../../../../src/components-lib/editor/schema/performance';
|
||||
import { getProfilesSectionForms } from '../../../../src/components-lib/editor/schema/profiles';
|
||||
import { getRemoteControlSectionForms } from '../../../../src/components-lib/editor/schema/remote-control';
|
||||
import {
|
||||
getSimpleCameraForms,
|
||||
getSimpleMenuForms,
|
||||
getSimpleTopLevelForms,
|
||||
} from '../../../../src/components-lib/editor/schema/simple';
|
||||
import { getStatusBarSectionForms } from '../../../../src/components-lib/editor/schema/status-bar';
|
||||
import { getTimelineSectionForms } from '../../../../src/components-lib/editor/schema/timeline';
|
||||
import {
|
||||
getViewKeyboardShortcutsSectionForms,
|
||||
getViewSectionForms,
|
||||
} from '../../../../src/components-lib/editor/schema/view';
|
||||
import type { EditorForm } from '../../../../src/components-lib/editor/types';
|
||||
import {
|
||||
findBinding,
|
||||
isComputedFieldBinding,
|
||||
type ConfigPath,
|
||||
type EditorForm,
|
||||
} from '../../../../src/components-lib/editor/types';
|
||||
import { advancedCameraCardConfigSchema } from '../../../../src/config/schema/types';
|
||||
import { PTZ_KEYBOARD_SHORTCUTS } from '../../../../src/config/schema/view';
|
||||
import type { HAFormSelectorSchema } from '../../../../src/ha/types';
|
||||
@@ -128,6 +143,26 @@ const SECTION_FORMS: Record<string, EditorForm[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
// The forms that are not a section of the configuration: the switch between the
|
||||
// editors, and the simple editor, which gathers its fields from across the
|
||||
// configuration and binds each to where its setting is stored. Their names are
|
||||
// the editor's own, so a failure says which set of forms it came from. Every
|
||||
// path they address is one a section covers too, so they add nothing to
|
||||
// direction 2 and everything to direction 1.
|
||||
const UNSECTIONED_FORMS: Record<string, EditorForm[]> = {
|
||||
editor_mode: getEditorModeForms(),
|
||||
simple_top_level: getSimpleTopLevelForms(),
|
||||
simple_menu: getSimpleMenuForms(),
|
||||
|
||||
// The camera form is the array item at index 0, as the array sections above.
|
||||
simple_cameras: getSimpleCameraForms(0),
|
||||
};
|
||||
|
||||
const ALL_FORMS: Record<string, EditorForm[]> = {
|
||||
...SECTION_FORMS,
|
||||
...UNSECTIONED_FORMS,
|
||||
};
|
||||
|
||||
// `z.core.$ZodType` is zod v4's base schema type for introspection: it is what
|
||||
// `.unwrap()` and `ZodArray.element` return, and what every schema (including
|
||||
// the config root) is assignable to. The classic `z.ZodType` is a subtype with
|
||||
@@ -156,7 +191,7 @@ const unwrap = (schema: z.core.$ZodType): z.core.$ZodType => {
|
||||
|
||||
// Resolve a configuration path to its zod leaf schema, or null if the path
|
||||
// does not exist in the configuration schema.
|
||||
const resolvePath = (path: (string | number)[]): z.core.$ZodType | null => {
|
||||
const resolvePath = (path: ConfigPath): z.core.$ZodType | null => {
|
||||
let current: z.core.$ZodType = advancedCameraCardConfigSchema;
|
||||
for (const segment of path) {
|
||||
current = unwrap(current);
|
||||
@@ -204,7 +239,7 @@ const enumValues = (schema: z.core.$ZodType): unknown[] | null => {
|
||||
// True if a config path lies inside a subtree the editor forms do not own
|
||||
// (YAML-only, or rendered by a custom widget), so the completeness walk skips
|
||||
// it.
|
||||
const isPruned = (path: (string | number)[]): boolean => {
|
||||
const isPruned = (path: ConfigPath): boolean => {
|
||||
const key = path.join('.');
|
||||
return [...EDITOR_CUSTOM_WIDGETS, ...EDITOR_EXCLUDED].some(
|
||||
(prefix) => key === prefix || key.startsWith(`${prefix}.`),
|
||||
@@ -216,7 +251,7 @@ const isPruned = (path: (string | number)[]): boolean => {
|
||||
// as leaves), pruning the subtrees the editor does not own.
|
||||
const enumerateConfigLeavesRecursively = (
|
||||
schema: z.core.$ZodType,
|
||||
prefix: (string | number)[] = [],
|
||||
prefix: ConfigPath = [],
|
||||
): string[] => {
|
||||
if (prefix.length && isPruned(prefix)) {
|
||||
return [];
|
||||
@@ -235,62 +270,109 @@ const enumerateConfigLeavesRecursively = (
|
||||
return [prefix.join('.')];
|
||||
};
|
||||
|
||||
// Every configuration path the editor forms currently cover.
|
||||
const coveredPaths = new Set<string>();
|
||||
for (const forms of Object.values(SECTION_FORMS)) {
|
||||
for (const form of forms) {
|
||||
forEachFieldRecursively(form.schema, (path) => {
|
||||
// Array sections carry a numeric index in `basePath` (`['folders', 0]`);
|
||||
// strip it so covered paths match the index-free enumerated leaves.
|
||||
const key = [...form.basePath, ...path]
|
||||
.filter((segment) => typeof segment !== 'number')
|
||||
.join('.');
|
||||
coveredPaths.add(key);
|
||||
});
|
||||
}
|
||||
// Every configuration path the editor forms currently cover. Array sections
|
||||
// carry a numeric index (`['folders', 0]`); strip it so covered paths match the
|
||||
// index-free enumerated leaves.
|
||||
const coveredPaths = new Set(
|
||||
Object.values(ALL_FORMS)
|
||||
.flat()
|
||||
.flatMap(getFormConfigPaths)
|
||||
.map((path) => path.filter((segment) => typeof segment !== 'number').join('.')),
|
||||
);
|
||||
|
||||
// One check per configuration path a field addresses. A field that reads and
|
||||
// writes itself addresses more than one, and its selector deliberately does not
|
||||
// match how the value is stored (a switch for a named mode, a list of names for
|
||||
// one boolean per menu button), so only the existence of its paths is checked.
|
||||
interface FieldCheck {
|
||||
section: string;
|
||||
path: ConfigPath;
|
||||
field: HAFormSelectorSchema;
|
||||
checkSelector: boolean;
|
||||
}
|
||||
|
||||
const getFieldChecks = (): FieldCheck[] => {
|
||||
const checks: FieldCheck[] = [];
|
||||
for (const [section, forms] of Object.entries(ALL_FORMS)) {
|
||||
for (const form of forms) {
|
||||
forEachFieldRecursively(form.schema, (formPath, field) => {
|
||||
const binding = findBinding(form, formPath);
|
||||
const computed = !!binding && isComputedFieldBinding(binding);
|
||||
const paths = !binding
|
||||
? [[...form.basePath, ...formPath]]
|
||||
: isComputedFieldBinding(binding)
|
||||
? binding.configPaths
|
||||
: [binding.configPath];
|
||||
|
||||
checks.push(
|
||||
...paths.map((path) => ({ section, path, field, checkSelector: !computed })),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
return checks;
|
||||
};
|
||||
|
||||
describe('editor schema completeness', () => {
|
||||
// The derivation asks `getFullEditorForms` what the full editor shows, while
|
||||
// this harness walks the sections it is given above. The two must describe
|
||||
// the same editor, or a section could be checked here and invisible to the
|
||||
// derivation, or the reverse.
|
||||
it('should check every form the full editor shows', () => {
|
||||
const walked = new Set(
|
||||
Object.entries(ALL_FORMS)
|
||||
.filter(([section]) => !(section in UNSECTIONED_FORMS))
|
||||
.flatMap(([, forms]) => forms)
|
||||
.flatMap(getFormConfigPaths)
|
||||
.map((path) => path.join('.')),
|
||||
);
|
||||
|
||||
for (const path of getFullEditorForms().flatMap(getFormConfigPaths)) {
|
||||
expect(walked, `${path.join('.')} is shown but not checked`).toContain(
|
||||
path.join('.'),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Direction 1: every editor field path is a real configuration key.
|
||||
describe('every form field resolves to a matching configuration key', () => {
|
||||
for (const [section, forms] of Object.entries(SECTION_FORMS)) {
|
||||
for (const form of forms) {
|
||||
forEachFieldRecursively(form.schema, (path, field) => {
|
||||
const fullPath = [...form.basePath, ...path];
|
||||
const key = fullPath.join('.');
|
||||
for (const { section, path, field, checkSelector } of getFieldChecks()) {
|
||||
const key = path.join('.');
|
||||
|
||||
it(`${section}: ${key}`, () => {
|
||||
const resolved = resolvePath(fullPath);
|
||||
expect(resolved, `path ${key} is not in the config schema`).not.toBeNull();
|
||||
it(`${section}: ${key}`, () => {
|
||||
const resolved = resolvePath(path);
|
||||
expect(resolved, `path ${key} is not in the config schema`).not.toBeNull();
|
||||
|
||||
// Direction 1b: the selector kind matches the zod type where the
|
||||
// type is unambiguous (number/boolean/enum).
|
||||
const kind = selectorKind(field);
|
||||
if (resolved instanceof z.ZodNumber) {
|
||||
expect(kind, `${key} should use a number selector`).toBe('number');
|
||||
} else if (resolved instanceof z.ZodBoolean) {
|
||||
expect(kind, `${key} should use a boolean selector`).toBe('boolean');
|
||||
} else {
|
||||
const values = resolved ? enumValues(resolved) : null;
|
||||
if (values) {
|
||||
expect(kind, `${key} should use a select selector`).toBe('select');
|
||||
// Direction 1c: the dropdown offers every enum value.
|
||||
if ('select' in field.selector) {
|
||||
const options = field.selector.select.options.map((option) =>
|
||||
typeof option === 'object' ? option.value : option,
|
||||
);
|
||||
for (const value of values) {
|
||||
expect(
|
||||
options,
|
||||
`${key} dropdown is missing enum value ${String(value)}`,
|
||||
).toContain(value);
|
||||
}
|
||||
}
|
||||
if (!checkSelector) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Direction 1b: the selector kind matches the zod type where the
|
||||
// type is unambiguous (number/boolean/enum).
|
||||
const kind = selectorKind(field);
|
||||
if (resolved instanceof z.ZodNumber) {
|
||||
expect(kind, `${key} should use a number selector`).toBe('number');
|
||||
} else if (resolved instanceof z.ZodBoolean) {
|
||||
expect(kind, `${key} should use a boolean selector`).toBe('boolean');
|
||||
} else {
|
||||
const values = resolved ? enumValues(resolved) : null;
|
||||
if (values) {
|
||||
expect(kind, `${key} should use a select selector`).toBe('select');
|
||||
// Direction 1c: the dropdown offers every enum value.
|
||||
if ('select' in field.selector) {
|
||||
const options = field.selector.select.options.map((option) =>
|
||||
typeof option === 'object' ? option.value : option,
|
||||
);
|
||||
for (const value of values) {
|
||||
expect(
|
||||
options,
|
||||
`${key} dropdown is missing enum value ${String(value)}`,
|
||||
).toContain(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
computeConfigChanges,
|
||||
computeDisplayedData,
|
||||
} from '../../../../src/components-lib/editor/form-data';
|
||||
import { getEditorModeForms } from '../../../../src/components-lib/editor/schema/editor-mode';
|
||||
import type { RawAdvancedCameraCardConfig } from '../../../../src/config/types';
|
||||
|
||||
const [MODE_FORM] = getEditorModeForms();
|
||||
|
||||
const isFullEditorShown = (config: RawAdvancedCameraCardConfig): unknown =>
|
||||
computeDisplayedData(MODE_FORM, config, {})['mode'];
|
||||
|
||||
const setFullEditorShown = (config: RawAdvancedCameraCardConfig, shown: boolean) =>
|
||||
computeConfigChanges(
|
||||
MODE_FORM,
|
||||
computeDisplayedData(MODE_FORM, config, {}),
|
||||
{ mode: shown },
|
||||
config,
|
||||
{},
|
||||
);
|
||||
|
||||
describe('the editor mode field', () => {
|
||||
it('should be on for a configuration asking for the full editor', () => {
|
||||
expect(isFullEditorShown({ editor: { mode: 'full' } })).toBe(true);
|
||||
});
|
||||
|
||||
it('should be off for a configuration asking for the simple editor', () => {
|
||||
expect(isFullEditorShown({ editor: { mode: 'simple' } })).toBe(false);
|
||||
});
|
||||
|
||||
it('should follow the editor chosen for a configuration that does not ask', () => {
|
||||
expect(isFullEditorShown({ type: 'custom:advanced-camera-card' })).toBe(false);
|
||||
expect(isFullEditorShown({ view: { dim: true } })).toBe(true);
|
||||
});
|
||||
|
||||
it('should ask for the full editor when turned on', () => {
|
||||
expect(setFullEditorShown({}, true)).toEqual([
|
||||
{ path: ['editor', 'mode'], type: 'set', value: 'full' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ask for the simple editor when turned off', () => {
|
||||
expect(setFullEditorShown({ editor: { mode: 'full' } }, false)).toEqual([
|
||||
{ path: ['editor', 'mode'], type: 'set', value: 'simple' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import { assert, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getForms } from '../../../../src/components-lib/editor/schema/registry';
|
||||
import type { ConfigPath } from '../../../../src/components-lib/editor/types';
|
||||
import { isFormFieldSchema } from '../../../../src/ha/types';
|
||||
|
||||
const OPTIONS = { cameras: [], folders: [] };
|
||||
|
||||
// Every section the editor offers, and where its first form edits.
|
||||
const SECTIONS: [string, (string | number)[]][] = [
|
||||
const SECTIONS: [string, ConfigPath][] = [
|
||||
['dimensions', ['dimensions']],
|
||||
['image', ['image']],
|
||||
['live', ['live']],
|
||||
@@ -24,7 +26,7 @@ const SECTIONS: [string, (string | number)[]][] = [
|
||||
describe('getForms', () => {
|
||||
describe('should build the forms of a section', () => {
|
||||
it.each(SECTIONS)('should build the %s section', (name, basePath) => {
|
||||
const forms = getForms({ kind: 'section', name }, OPTIONS);
|
||||
const forms = getForms({ kind: 'full-section', name }, OPTIONS);
|
||||
|
||||
expect(forms.length).toBeGreaterThan(0);
|
||||
expect(forms[0].basePath).toEqual(basePath);
|
||||
@@ -32,7 +34,9 @@ describe('getForms', () => {
|
||||
});
|
||||
|
||||
it('should build no forms for a section it does not know', () => {
|
||||
expect(getForms({ kind: 'section', name: 'nonexistent' }, OPTIONS)).toEqual([]);
|
||||
expect(getForms({ kind: 'full-section', name: 'nonexistent' }, OPTIONS)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +49,7 @@ describe('getForms', () => {
|
||||
|
||||
const getDependencyOptions = (index: number): unknown => {
|
||||
const [form] = getForms(
|
||||
{ kind: 'camera', index },
|
||||
{ kind: 'full-camera', index },
|
||||
{ ...OPTIONS, cameras: CAMERAS },
|
||||
);
|
||||
const dependencies = form.schema.find(
|
||||
@@ -55,7 +59,7 @@ describe('getForms', () => {
|
||||
const cameras = dependencies.schema.find(
|
||||
(field) => 'name' in field && field.name === 'cameras',
|
||||
);
|
||||
assert(cameras && 'selector' in cameras && 'select' in cameras.selector);
|
||||
assert(cameras && isFormFieldSchema(cameras) && 'select' in cameras.selector);
|
||||
return cameras.selector.select?.options;
|
||||
};
|
||||
|
||||
@@ -70,13 +74,23 @@ describe('getForms', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('should build the forms of a list item', () => {
|
||||
// Every request other than a section, which is named rather than being a
|
||||
// request of its own and is covered above. Each edits one place in the
|
||||
// configuration, so each builds a single form.
|
||||
describe('should build the forms of every other request', () => {
|
||||
it.each([
|
||||
[{ kind: 'camera' as const, index: 2 }, ['cameras', 2]],
|
||||
[{ kind: 'folder' as const, index: 1 }, ['folders', 1]],
|
||||
[{ kind: 'camera-triggers' as const, cameraIndex: 2 }, ['cameras', 2, 'triggers']],
|
||||
[{ kind: 'editor-mode' as const }, ['editor']],
|
||||
[{ kind: 'simple-top-level' as const }, []],
|
||||
[{ kind: 'simple-menu' as const }, []],
|
||||
[{ kind: 'simple-camera' as const, index: 3 }, ['cameras', 3]],
|
||||
[{ kind: 'full-camera' as const, index: 2 }, ['cameras', 2]],
|
||||
[{ kind: 'full-folder' as const, index: 1 }, ['folders', 1]],
|
||||
[
|
||||
{ kind: 'camera-event' as const, cameraIndex: 2, eventIndex: 4 },
|
||||
{ kind: 'full-camera-triggers' as const, cameraIndex: 2 },
|
||||
['cameras', 2, 'triggers'],
|
||||
],
|
||||
[
|
||||
{ kind: 'full-camera-event' as const, cameraIndex: 2, eventIndex: 4 },
|
||||
['cameras', 2, 'triggers', 'events', 4],
|
||||
],
|
||||
])('should build the forms for %j', (request, basePath) => {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { assert, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applyConfigChanges,
|
||||
computeConfigChanges,
|
||||
computeDisplayedData,
|
||||
} from '../../../../src/components-lib/editor/form-data';
|
||||
import {
|
||||
getSimpleMenuForms,
|
||||
getSimpleTopLevelForms,
|
||||
} from '../../../../src/components-lib/editor/schema/simple';
|
||||
import {
|
||||
findBinding,
|
||||
isComputedFieldBinding,
|
||||
} from '../../../../src/components-lib/editor/types';
|
||||
import { copyConfig, getConfigValue } from '../../../../src/config/management';
|
||||
import { setProfiles } from '../../../../src/config/profiles/set-profiles';
|
||||
import { menuConfigDefault } from '../../../../src/config/schema/menu';
|
||||
import type { ProfileType } from '../../../../src/config/schema/profiles';
|
||||
import { configDefaults } from '../../../../src/config/schema/types';
|
||||
import type { RawAdvancedCameraCardConfig } from '../../../../src/config/types';
|
||||
|
||||
const [MENU_FORM] = getSimpleMenuForms();
|
||||
const [TOP_LEVEL_FORM] = getSimpleTopLevelForms();
|
||||
|
||||
// The paths the buttons field says it stands for, taken from the field itself
|
||||
// rather than from a walk of the form, so that the two can be compared.
|
||||
const getMenuButtonsConfigPaths = () => {
|
||||
const binding = findBinding(MENU_FORM, ['menu_buttons']);
|
||||
assert(binding && isComputedFieldBinding(binding));
|
||||
return binding.configPaths;
|
||||
};
|
||||
|
||||
// The values a field shows when the configuration does not set it, built the
|
||||
// way the editor builds them so that profiles are taken into account. The
|
||||
// configuration given to `setProfiles` must be a valid one: it does not apply a
|
||||
// profile over a configuration it cannot parse.
|
||||
const createDefaults = (profiles: ProfileType[] = []): RawAdvancedCameraCardConfig => {
|
||||
const defaults = copyConfig(configDefaults);
|
||||
setProfiles(
|
||||
{
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
},
|
||||
defaults,
|
||||
profiles,
|
||||
);
|
||||
return defaults;
|
||||
};
|
||||
|
||||
const getMenuButtons = (
|
||||
config: RawAdvancedCameraCardConfig,
|
||||
defaults: RawAdvancedCameraCardConfig = createDefaults(),
|
||||
): unknown =>
|
||||
getConfigValue(computeDisplayedData(MENU_FORM, config, defaults), 'menu_buttons');
|
||||
|
||||
// Edit the buttons as the form does: the form emits everything it displays,
|
||||
// with the edited field changed.
|
||||
const changeMenuButtons = (
|
||||
config: RawAdvancedCameraCardConfig,
|
||||
buttons: unknown,
|
||||
defaults: RawAdvancedCameraCardConfig = createDefaults(),
|
||||
) => {
|
||||
const displayed = computeDisplayedData(MENU_FORM, config, defaults);
|
||||
return computeConfigChanges(
|
||||
MENU_FORM,
|
||||
displayed,
|
||||
{ ...displayed, menu_buttons: buttons },
|
||||
config,
|
||||
defaults,
|
||||
);
|
||||
};
|
||||
|
||||
describe('the simple editor forms', () => {
|
||||
it('should show the value of the configuration key a field is bound to', () => {
|
||||
expect(
|
||||
computeDisplayedData(
|
||||
MENU_FORM,
|
||||
{ menu: { style: 'outside', position: 'left' } },
|
||||
createDefaults(),
|
||||
),
|
||||
).toMatchObject({ menu_style: 'outside', menu_position: 'left' });
|
||||
|
||||
expect(
|
||||
computeDisplayedData(
|
||||
TOP_LEVEL_FORM,
|
||||
{ view: { default: 'clips' } },
|
||||
createDefaults(),
|
||||
),
|
||||
).toMatchObject({ default_view: 'clips' });
|
||||
});
|
||||
|
||||
it('should write an edit to the configuration key a field is bound to', () => {
|
||||
expect(
|
||||
computeConfigChanges(
|
||||
MENU_FORM,
|
||||
{ menu_style: 'hidden' },
|
||||
{ menu_style: 'outside' },
|
||||
{},
|
||||
createDefaults(),
|
||||
),
|
||||
).toEqual([{ path: ['menu', 'style'], type: 'set', value: 'outside' }]);
|
||||
|
||||
expect(
|
||||
computeConfigChanges(
|
||||
TOP_LEVEL_FORM,
|
||||
{ default_view: 'live' },
|
||||
{ default_view: 'clips' },
|
||||
{},
|
||||
createDefaults(),
|
||||
),
|
||||
).toEqual([{ path: ['view', 'default'], type: 'set', value: 'clips' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the menu buttons field', () => {
|
||||
it('should select the buttons the menu shows by default', () => {
|
||||
// `iris` is shown by default, `clips` is not.
|
||||
expect(getMenuButtons({})).toContain('iris');
|
||||
expect(getMenuButtons({})).not.toContain('clips');
|
||||
});
|
||||
|
||||
it('should select buttons by what the configuration sets, not the defaults', () => {
|
||||
const buttons = getMenuButtons({
|
||||
menu: { buttons: { iris: { enabled: false }, clips: { enabled: true } } },
|
||||
});
|
||||
|
||||
expect(buttons).not.toContain('iris');
|
||||
expect(buttons).toContain('clips');
|
||||
});
|
||||
|
||||
it('should not select a button that a profile hides', () => {
|
||||
// The low performance profile hides `iris` and `timeline`.
|
||||
expect(getMenuButtons({}, createDefaults(['low-performance']))).not.toContain(
|
||||
'iris',
|
||||
);
|
||||
});
|
||||
|
||||
it('should store false for a default-shown button that is turned off', () => {
|
||||
expect(changeMenuButtons({}, ['clips'])).toContainEqual({
|
||||
path: ['menu', 'buttons', 'iris', 'enabled'],
|
||||
type: 'set',
|
||||
value: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should store true for a default-hidden button that is turned on', () => {
|
||||
expect(changeMenuButtons({}, ['clips'])).toContainEqual({
|
||||
path: ['menu', 'buttons', 'clips', 'enabled'],
|
||||
type: 'set',
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove the stored value of a button returned to its default', () => {
|
||||
const config = { menu: { buttons: { clips: { enabled: true } } } };
|
||||
|
||||
expect(changeMenuButtons(config, [])).toContainEqual({
|
||||
path: ['menu', 'buttons', 'clips', 'enabled'],
|
||||
type: 'delete',
|
||||
});
|
||||
});
|
||||
|
||||
it('should change nothing for the buttons that stay as they are', () => {
|
||||
// Only `clips` moves: everything else is already as asked for.
|
||||
const config = { menu: { buttons: { iris: { enabled: false } } } };
|
||||
const buttons = getMenuButtons(config);
|
||||
const wanted = Array.isArray(buttons) ? [...buttons, 'clips'] : ['clips'];
|
||||
|
||||
expect(changeMenuButtons(config, wanted)).toEqual([
|
||||
{ path: ['menu', 'buttons', 'clips', 'enabled'], type: 'set', value: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should treat a value that is not a list of buttons as none selected', () => {
|
||||
expect(changeMenuButtons({}, undefined)).toContainEqual({
|
||||
path: ['menu', 'buttons', 'iris', 'enabled'],
|
||||
type: 'set',
|
||||
value: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should stand for the enabled setting of every menu button the card has', () => {
|
||||
// Taken from the card's own menu defaults rather than from the editor, so
|
||||
// that a button the card gains and the editor does not is a failure.
|
||||
const expected = Object.keys(menuConfigDefault.buttons).map(
|
||||
(button) => `menu.buttons.${button}.enabled`,
|
||||
);
|
||||
|
||||
expect(
|
||||
getMenuButtonsConfigPaths()
|
||||
.map((path) => path.join('.'))
|
||||
.sort(),
|
||||
).toEqual(expected.sort());
|
||||
});
|
||||
|
||||
it('should change only the configuration keys it stands for', () => {
|
||||
const declared = new Set(
|
||||
Object.keys(menuConfigDefault.buttons).map(
|
||||
(button) => `menu.buttons.${button}.enabled`,
|
||||
),
|
||||
);
|
||||
const changes = changeMenuButtons({}, ['clips', 'iris']);
|
||||
|
||||
expect(changes.length).toBeGreaterThan(0);
|
||||
for (const change of changes) {
|
||||
expect(declared).toContain(change.path.join('.'));
|
||||
}
|
||||
});
|
||||
|
||||
it('should leave the rest of the configuration as it was', () => {
|
||||
const config = {
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
menu: {
|
||||
style: 'outside',
|
||||
buttons: { iris: { enabled: false, priority: 7, icon: 'mdi:cow' } },
|
||||
},
|
||||
live: { preload: true },
|
||||
timeline: { window_seconds: 7200 },
|
||||
};
|
||||
|
||||
// Turn `iris` back on, which returns it to its default, and `clips` on,
|
||||
// leaving every other button as it is.
|
||||
const shown = getMenuButtons(config);
|
||||
assert(Array.isArray(shown));
|
||||
const edited = applyConfigChanges(
|
||||
config,
|
||||
changeMenuButtons(config, [...shown, 'iris', 'clips']),
|
||||
);
|
||||
assert(edited);
|
||||
|
||||
expect(edited).toEqual({
|
||||
cameras: [{ camera_entity: 'camera.office' }],
|
||||
menu: {
|
||||
style: 'outside',
|
||||
buttons: {
|
||||
// The button's other settings survive its `enabled` being removed.
|
||||
iris: { priority: 7, icon: 'mdi:cow' },
|
||||
clips: { enabled: true },
|
||||
},
|
||||
},
|
||||
live: { preload: true },
|
||||
timeline: { window_seconds: 7200 },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user