feat: Modernize the visual card editor. (#2586)

This commit is contained in:
Dermot Duffy
2026-07-20 20:44:16 -07:00
committed by GitHub
parent e204ec183f
commit b6e1de5999
146 changed files with 8487 additions and 5992 deletions
@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager.js';
import {
MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA,
MEDIA_PLAYER_SUPPORT_STOP,
MEDIA_PLAYER_SUPPORT_TURN_OFF,
} from '../../src/const';
MediaPlayerManager,
} from '../../src/card-controller/media-player-manager.js';
import type { EntityRegistryManager } from '../../src/ha/registry/entity/types.js';
import type { HomeAssistant } from '../../src/ha/types.js';
import { ViewMediaType } from '../../src/view/item.js';
@@ -0,0 +1,358 @@
import type { LitElement } from 'lit';
import {
afterEach,
assert,
beforeEach,
describe,
expect,
it,
vi,
type Mock,
} from 'vitest';
import { EditorController } from '../../../src/components-lib/editor/controller';
import { getConfigValue } from '../../../src/config/management';
import { configDefaults } from '../../../src/config/schema/types';
import type { RawAdvancedCameraCardConfig } from '../../../src/config/types';
import { sideLoadHomeAssistantElements } from '../../../src/ha/side-load-ha-elements';
import { localize } from '../../../src/localize/localize';
import { isRecord } from '../../../src/utils/basic';
import { createHASS, createLitElement, flushPromises } from '../../test-utils';
vi.mock('../../../src/ha/side-load-ha-elements');
interface ControllerHarness {
controller: EditorController;
host: LitElement;
configListener: Mock;
}
const createController = (): ControllerHarness => {
const host = createLitElement();
const controller = new EditorController(host);
const configListener = vi.fn();
host.addEventListener('config-changed', configListener);
return { controller, host, configListener };
};
const getLastConfig = (configListener: Mock): RawAdvancedCameraCardConfig => {
const event = configListener.mock.lastCall?.[0];
assert(event instanceof CustomEvent);
const config: unknown = event.detail.config;
assert(isRecord(config));
return config;
};
// A configuration the upgrade rules can rewrite (`service_data` was renamed to
// `data`).
const createUpgradeableConfig = (): RawAdvancedCameraCardConfig => ({
cameras: [{ camera_entity: 'camera.office' }],
elements: [
{
type: 'icon',
icon: 'mdi:cow',
tap_action: {
action: 'call-service',
service: 'notify.persistent_notification',
service_data: { message: 'Hello' },
},
},
],
});
// @vitest-environment jsdom
describe('EditorController', () => {
beforeEach(() => {
vi.mocked(sideLoadHomeAssistantElements).mockReset();
vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should register with the host', () => {
const host = createLitElement();
const controller = new EditorController(host);
expect(host.addController).toHaveBeenCalledWith(controller);
controller.hostConnected();
});
describe('should initialize', () => {
it('should side-load Home Assistant elements only once', async () => {
const { controller } = createController();
controller.initialize();
await flushPromises();
controller.initialize();
expect(sideLoadHomeAssistantElements).toHaveBeenCalledTimes(1);
});
it('should log and retry after a failed side-load', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockReturnValue(undefined);
vi.mocked(sideLoadHomeAssistantElements).mockRejectedValue(new Error('fail'));
const { controller } = createController();
controller.initialize();
await vi.waitFor(() => expect(consoleSpy).toHaveBeenCalledWith('fail'));
controller.initialize();
expect(sideLoadHomeAssistantElements).toHaveBeenCalledTimes(2);
});
});
describe('should set the configuration', () => {
it('should store the configuration and request an update', () => {
const { controller, host } = createController();
expect(controller.getConfig()).toBeNull();
const config = { cameras: [] };
controller.setConfig(config);
expect(controller.getConfig()).toBe(config);
expect(host.requestUpdate).toHaveBeenCalled();
});
it('should detect an upgradeable configuration', () => {
const { controller } = createController();
expect(controller.isConfigUpgradeable()).toBeFalsy();
controller.setConfig(createUpgradeableConfig());
expect(controller.isConfigUpgradeable()).toBeTruthy();
controller.setConfig({ cameras: [] });
expect(controller.isConfigUpgradeable()).toBeFalsy();
});
it('should apply profile defaults', () => {
const { controller } = createController();
controller.setConfig({ cameras: [], profiles: ['scrubbing'] });
expect(
getConfigValue(
controller.getFormsInput().defaults,
'media_viewer.controls.timeline.style',
),
).toBe('ribbon');
});
it('should reset profile defaults when profiles become invalid', () => {
const { controller } = createController();
controller.setConfig({ cameras: [], profiles: ['scrubbing'] });
controller.setConfig({ cameras: [], profiles: 42 });
expect(
getConfigValue(
controller.getFormsInput().defaults,
'media_viewer.controls.timeline.style',
),
).toBe(configDefaults.media_viewer.controls.timeline.style);
});
it.each([
[{ cameras: [] }, []],
[{ cameras: [], profiles: ['low-performance'] }, ['warning']],
[{ cameras: [], profiles: 42 }, []],
[{ cameras: [], overrides: [] }, []],
[{ cameras: [], overrides: [{}] }, ['info']],
])('should compute the notices for %j', (config, noticeTypes) => {
const { controller } = createController();
controller.setConfig(config);
expect(controller.getNotices().map((notice) => notice.type)).toEqual(noticeTypes);
});
it('should have notices for the low-performance profile and overrides', () => {
const { controller } = createController();
controller.setConfig({
cameras: [],
profiles: ['low-performance'],
overrides: [{}],
});
expect(controller.getNotices()).toEqual([
{ type: 'warning', message: localize('config.performance.warning') },
{ type: 'info', message: localize('config.overrides.info') },
]);
});
it('should have no notices without a configuration', () => {
const { controller } = createController();
expect(controller.getNotices()).toEqual([]);
});
});
describe('should describe what a section needs', () => {
it('should offer an empty configuration before one is set', () => {
const { controller } = createController();
expect(controller.getFormsInput().config).toEqual({});
});
it('should offer the configuration and its defaults', () => {
const { controller } = createController();
const config = { cameras: [] };
controller.setConfig(config);
const input = controller.getFormsInput();
expect(input.config).toBe(config);
expect(input.defaults).toBe(controller.getDefaults());
});
it('should offer the cameras and folders its selectors need', () => {
const { controller } = createController();
controller.setConfig({
cameras: [{ id: 'one' }, { camera_entity: 'camera.office' }],
folders: [{ id: 'recordings', title: 'Recordings' }],
});
const { options } = controller.getFormsInput();
expect(options.cameras).toEqual([
{ value: 'one', label: 'one' },
{ value: 'camera.office', label: 'Camera #1' },
]);
expect(options.folders).toEqual([{ value: 'recordings', label: 'Recordings' }]);
});
it('should report a list holding entries that are not objects', () => {
const { controller } = createController();
controller.setConfig({ cameras: [{ id: 'one' }, 'junk'], folders: 'junk' });
const { options } = controller.getFormsInput();
expect(options.cameras).toHaveLength(2);
expect(options.folders).toEqual([]);
});
});
describe('should set HASS', () => {
it('should offer the HomeAssistant object it was given', () => {
const { controller } = createController();
const hass = createHASS();
controller.setHASS(hass);
expect(controller.getHASS()).toBe(hass);
});
});
describe('should upgrade', () => {
it('should upgrade an upgradeable configuration', () => {
const { controller, configListener } = createController();
controller.setConfig(createUpgradeableConfig());
controller.upgrade();
const config = getLastConfig(configListener);
expect(getConfigValue(config, 'elements.0.tap_action.data')).toEqual({
message: 'Hello',
});
expect(controller.isConfigUpgradeable()).toBeFalsy();
});
it('should do nothing without a configuration', () => {
const { controller, configListener } = createController();
controller.upgrade();
expect(configListener).not.toHaveBeenCalled();
});
it('should not fire for a configuration that needs no upgrade', () => {
const { controller, configListener } = createController();
controller.setConfig({ cameras: [] });
controller.upgrade();
expect(configListener).not.toHaveBeenCalled();
});
});
describe('should carry out an intent', () => {
it('should apply changes', () => {
const { controller, configListener } = createController();
controller.setConfig({ cameras: [] });
controller.applyIntent({
type: 'changes',
changes: [{ path: ['menu', 'style'], type: 'set', value: 'outside' }],
});
expect(getConfigValue(getLastConfig(configListener), 'menu.style')).toBe(
'outside',
);
});
it('should not fire when the changes leave the configuration unmodified', () => {
const { controller, configListener } = createController();
controller.setConfig({ menu: { style: 'outside' } });
controller.applyIntent({
type: 'changes',
changes: [{ path: ['menu', 'style'], type: 'set', value: 'outside' }],
});
expect(configListener).not.toHaveBeenCalled();
});
it('should add an item to a list', () => {
const { controller, configListener } = createController();
controller.setConfig({ cameras: [{ id: 'one' }] });
controller.applyIntent({
type: 'list-add',
path: ['cameras'],
item: { id: 'two' },
});
expect(getConfigValue(getLastConfig(configListener), 'cameras')).toEqual([
{ id: 'one' },
{ id: 'two' },
]);
});
it('should move an item within a list', () => {
const { controller, configListener } = createController();
controller.setConfig({ cameras: [{ id: 'one' }, { id: 'two' }] });
controller.applyIntent({ type: 'list-move', path: ['cameras'], from: 0, to: 1 });
expect(getConfigValue(getLastConfig(configListener), 'cameras')).toEqual([
{ id: 'two' },
{ id: 'one' },
]);
});
it('should delete an item from a list', () => {
const { controller, configListener } = createController();
controller.setConfig({ cameras: [{ id: 'one' }, { id: 'two' }] });
controller.applyIntent({ type: 'list-delete', path: ['cameras'], index: 0 });
expect(getConfigValue(getLastConfig(configListener), 'cameras')).toEqual([
{ id: 'two' },
]);
});
it('should not fire for a list change that cannot be made', () => {
const { controller, configListener } = createController();
controller.setConfig({ cameras: [{ id: 'one' }] });
controller.applyIntent({ type: 'list-delete', path: ['cameras'], index: 5 });
expect(configListener).not.toHaveBeenCalled();
});
it.each([
[{ type: 'changes' as const, changes: [] }],
[{ type: 'list-add' as const, path: ['cameras'], item: {} }],
[{ type: 'list-move' as const, path: ['cameras'], from: 0, to: 1 }],
[{ type: 'list-delete' as const, path: ['cameras'], index: 0 }],
])('should do nothing for %j without a configuration', (intent) => {
const { controller, configListener } = createController();
controller.applyIntent(intent);
expect(configListener).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { getDocLinkPath, getDocURL } from '../../../src/components-lib/editor/doc-links';
describe('getDocURL', () => {
it('should return the documentation URL for a known path', () => {
expect(getDocURL(['live', 'controls', 'thumbnails'])).toBe(
'https://card.camera/#/configuration/live?id=thumbnails',
);
});
it('should ignore array indices', () => {
expect(getDocURL(['cameras', 0, 'triggers'])).toBe(
'https://card.camera/#/configuration/cameras/README?id=triggers',
);
});
it('should return null for a path without documentation', () => {
expect(getDocURL(['unknown', 'path'])).toBeNull();
});
});
describe('getDocLinkPath', () => {
it('should not link a field', () => {
expect(
getDocLinkPath(['cameras'], { name: 'title', selector: { text: {} } }),
).toBeNull();
});
it('should link a group by its explicit documentation path', () => {
expect(
getDocLinkPath(['cameras', 2], {
type: 'expandable',
docPath: 'cameras.engine',
schema: [],
}),
).toEqual(['cameras', 'engine']);
});
it('should not link a group that has neither a name nor a path', () => {
expect(getDocLinkPath(['cameras'], { type: 'expandable', schema: [] })).toBeNull();
});
it('should order nested container paths into configuration order', () => {
expect(
getDocLinkPath(
['cameras', 2],
{ name: 'dashboard', type: 'expandable', schema: [] },
{ path: ['cast'] },
),
).toEqual(['cameras', 2, 'cast', 'dashboard']);
});
});
@@ -0,0 +1,518 @@
import { describe, expect, it } from 'vitest';
import {
applyConfigChanges,
computeConfigChanges,
computeDisplayedData,
forEachFieldRecursively,
} 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 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('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'],
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' }]);
});
});
});
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import {
computeFormLabel,
getLocalizationKeyForPath,
} from '../../../src/components-lib/editor/form-labels';
describe('getLocalizationKeyForPath', () => {
it('should build a config key without array indices', () => {
expect(getLocalizationKeyForPath(['cameras', 2, 'title'])).toBe(
'config.cameras.title',
);
});
});
describe('computeFormLabel', () => {
it('should prefer an explicit schema label', () => {
expect(
computeFormLabel(['cameras', 2], {
name: 'title',
label: 'Explicit',
selector: { text: {} },
}),
).toBe('Explicit');
});
it('should localize the configuration path', () => {
expect(computeFormLabel(['view'], { name: 'default', selector: { text: {} } })).toBe(
'Default view',
);
});
it('should include container paths provided by the form', () => {
expect(
computeFormLabel(
['cameras', 2],
{ name: 'title', selector: { text: {} } },
{ path: [] },
),
).toBe('Title for this camera (autodetected from entity)');
});
it('should order nested container paths into configuration order', () => {
expect(
computeFormLabel(
['cameras', 2],
{ name: 'dashboard_path', selector: { text: {} } },
{ path: ['dashboard', 'cast'] },
),
).toBe('Dashboard path');
});
it('should use the title for container nodes', () => {
expect(
computeFormLabel(['image'], {
name: 'proxy',
type: 'expandable',
title: 'Proxy',
schema: [],
}),
).toBe('Proxy');
});
it('should fall back to the name for container nodes without a title', () => {
expect(
computeFormLabel(['image'], { name: 'proxy', type: 'expandable', schema: [] }),
).toBe('proxy');
});
it('should return an empty label for a nameless, titleless container', () => {
expect(computeFormLabel(['cameras', 0], { type: 'expandable', schema: [] })).toBe(
'',
);
});
});
@@ -0,0 +1,198 @@
import { html } from 'lit';
import { describe, expect, it, vi } from 'vitest';
import { FormsController } from '../../../src/components-lib/editor/forms-controller';
const OPTIONS = { cameras: [], folders: [] };
const INPUT = { config: {}, defaults: {}, options: OPTIONS };
const MENU = { kind: 'section' as const, name: 'menu' };
const createController = () => {
const onChanges = vi.fn();
return {
onChanges,
controller: new FormsController(onChanges, () => html`doc`),
};
};
describe('FormsController', () => {
it('should have no contexts before it is given a request', () => {
const { controller } = createController();
expect(controller.getContexts()).toEqual([]);
});
it('should build the forms of the requested section', () => {
const { controller } = createController();
controller.setInput(MENU, INPUT);
const contexts = controller.getContexts();
expect(contexts.length).toBeGreaterThan(0);
expect(contexts[0].form.basePath).toEqual(['menu']);
});
it('should build the forms again for a different request', () => {
const { controller } = createController();
controller.setInput(MENU, INPUT);
controller.setInput({ kind: 'section', name: 'timeline' }, INPUT);
expect(controller.getContexts()[0].form.basePath).toEqual(['timeline']);
});
it('should keep the same forms when only the configuration changes', () => {
const { controller } = createController();
controller.setInput(MENU, INPUT);
const before = controller.getContexts()[0].form;
controller.setInput(MENU, { ...INPUT, config: { menu: { style: 'outside' } } });
expect(controller.getContexts()[0].form).toBe(before);
});
it('should keep the same contexts when nothing changed', () => {
const { controller } = createController();
controller.setInput(MENU, INPUT);
const before = controller.getContexts();
controller.setInput(MENU, { ...INPUT });
expect(controller.getContexts()).toBe(before);
});
it('should build the forms again when the values its selectors offer change', () => {
const { controller } = createController();
const request = { kind: 'camera' as const, index: 0 };
controller.setInput(request, INPUT);
const before = controller.getContexts()[0].form;
// A second camera: the first camera's dependency dropdown, which lists the
// cameras other than itself, now has something to offer.
controller.setInput(request, {
...INPUT,
options: {
...OPTIONS,
cameras: [
{ value: 'camera.one', label: 'One' },
{ value: 'camera.other', label: 'Other' },
],
},
});
expect(controller.getContexts()[0].form).not.toBe(before);
});
it('should keep the forms it has when a rebuild produces the same ones', () => {
const { controller } = createController();
const request = { kind: 'camera' as const, index: 0 };
const cameras = [
{ value: 'camera.one', label: 'One' },
{ value: 'camera.two', label: 'Two' },
];
controller.setInput(request, { ...INPUT, options: { ...OPTIONS, cameras } });
const before = controller.getContexts()[0];
// Renaming the camera whose form this is: its own form leaves it out, so
// nothing it shows has changed.
controller.setInput(request, {
...INPUT,
options: {
...OPTIONS,
cameras: [{ ...cameras[0], label: 'Renamed' }, cameras[1]],
},
});
expect(controller.getContexts()[0]).toBe(before);
});
it('should show the configured value of a field', () => {
const { controller } = createController();
controller.setInput(MENU, { ...INPUT, config: { menu: { style: 'outside' } } });
expect(controller.getContexts()[0].displayedData['style']).toBe('outside');
});
it('should name a field', () => {
const { controller } = createController();
controller.setInput(MENU, INPUT);
const label = controller.getContexts()[0].computeLabel({
name: 'style',
selector: { text: {} },
});
expect(label).not.toBe('');
});
it('should link a documented field to its documentation', () => {
const { controller } = createController();
controller.setInput(MENU, INPUT);
expect(
controller.getContexts()[0].computeHelper({
name: 'buttons',
type: 'expandable',
title: '',
schema: [],
}),
).not.toBeNull();
});
it('should not link a field that has no documentation', () => {
const { controller } = createController();
controller.setInput(MENU, INPUT);
expect(
controller.getContexts()[0].computeHelper({
name: 'style',
selector: { text: {} },
}),
).toBeNull();
});
it('should report an edit as changes on absolute paths', () => {
const { controller, onChanges } = createController();
controller.setInput(MENU, INPUT);
controller
.getContexts()[0]
.valueChanged(
new CustomEvent('value-changed', { detail: { value: { style: 'outside' } } }),
);
expect(onChanges).toHaveBeenCalledWith([
{ path: ['menu', 'style'], type: 'set', value: 'outside' },
]);
});
it('should ignore an edit that changes nothing', () => {
const { controller, onChanges } = createController();
controller.setInput(MENU, { ...INPUT, config: { menu: { style: 'outside' } } });
controller
.getContexts()[0]
.valueChanged(
new CustomEvent('value-changed', { detail: { value: { style: 'outside' } } }),
);
expect(onChanges).not.toHaveBeenCalled();
});
it('should ignore an edit reported by a form it no longer has', () => {
const { controller, onChanges } = createController();
controller.setInput(MENU, INPUT);
const contexts = controller.getContexts();
expect(contexts.length).toBeGreaterThan(1);
const last = contexts[contexts.length - 1];
// The dimensions section has a single form, so the last of the menu
// section's forms no longer exists once it is asked for.
controller.setInput({ kind: 'section', name: 'dimensions' }, INPUT);
last.valueChanged(
new CustomEvent('value-changed', { detail: { value: { style: 'outside' } } }),
);
expect(onChanges).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,132 @@
import { html } from 'lit';
import { assert, describe, expect, it, vi, type Mock } from 'vitest';
import { KeyboardShortcutsController } from '../../../src/components-lib/editor/keyboard-shortcuts-controller';
import { configDefaults } from '../../../src/config/schema/types';
import { PTZ_KEYBOARD_SHORTCUTS } from '../../../src/config/schema/view';
import { createLitElement } from '../../test-utils';
const OPTIONS = { cameras: [], folders: [] };
const createController = () => {
const host = createLitElement();
const listener = vi.fn();
host.addEventListener('advanced-camera-card:editor:intent', listener);
return {
host,
listener,
controller: new KeyboardShortcutsController(host, () => html`doc`),
};
};
const getLastIntent = (listener: Mock): unknown => {
const event = listener.mock.lastCall?.[0];
assert(event instanceof CustomEvent);
return event.detail;
};
// @vitest-environment jsdom
describe('KeyboardShortcutsController', () => {
it('should register with the host', () => {
const { host, controller } = createController();
expect(host.addController).toHaveBeenCalledWith(controller);
controller.hostConnected();
});
describe('should resolve the shortcuts', () => {
it('should have every shortcut, in order, before it is given any input', () => {
const { controller } = createController();
expect(Object.keys(controller.getShortcuts())).toEqual([
...PTZ_KEYBOARD_SHORTCUTS,
]);
});
it('should resolve a shortcut the configuration leaves unset to its default', () => {
const { controller } = createController();
controller.setInput({ config: {}, defaults: configDefaults, options: OPTIONS });
expect(controller.getShortcuts()['ptz_left']).toEqual(
configDefaults.view.keyboard_shortcuts.ptz_left,
);
});
it('should resolve a configured shortcut', () => {
const { controller } = createController();
controller.setInput({
config: { view: { keyboard_shortcuts: { ptz_home: { key: 'k', ctrl: true } } } },
defaults: configDefaults,
options: OPTIONS,
});
expect(controller.getShortcuts()['ptz_home']).toEqual({ key: 'k', ctrl: true });
});
});
describe('should report an assignment', () => {
it('should report a shortcut as an intent', () => {
const { controller, listener } = createController();
controller.setInput({ config: {}, defaults: configDefaults, options: OPTIONS });
controller.setShortcut('ptz_home', { key: 'k' });
expect(getLastIntent(listener)).toEqual({
type: 'changes',
changes: [
{
path: ['view', 'keyboard_shortcuts', 'ptz_home'],
type: 'set',
value: { key: 'k' },
},
],
});
});
it('should record an unassignment rather than deleting the shortcut', () => {
const { controller, listener } = createController();
controller.setInput({ config: {}, defaults: configDefaults, options: OPTIONS });
controller.setShortcut('ptz_home', null);
expect(getLastIntent(listener)).toEqual({
type: 'changes',
changes: [
{ path: ['view', 'keyboard_shortcuts', 'ptz_home'], type: 'set', value: null },
],
});
});
});
describe('should offer the panel its own form', () => {
it('should have no forms before it is given any input', () => {
const { controller } = createController();
expect(controller.getContexts()).toEqual([]);
});
it('should build the shortcuts form', () => {
const { controller } = createController();
controller.setInput({ config: {}, defaults: configDefaults, options: OPTIONS });
const contexts = controller.getContexts();
expect(contexts).toHaveLength(1);
expect(contexts[0].form.basePath).toEqual(['view', 'keyboard_shortcuts']);
});
it('should report an edit to that form as an intent', () => {
const { controller, listener } = createController();
controller.setInput({ config: {}, defaults: configDefaults, options: OPTIONS });
controller
.getContexts()[0]
.valueChanged(
new CustomEvent('value-changed', { detail: { value: { enabled: false } } }),
);
expect(getLastIntent(listener)).toEqual({
type: 'changes',
changes: [
{ path: ['view', 'keyboard_shortcuts', 'enabled'], type: 'set', value: false },
],
});
});
});
});
@@ -0,0 +1,233 @@
import { html } from 'lit';
import { assert, describe, expect, it, vi, type Mock } from 'vitest';
import { ListFormsController } from '../../../src/components-lib/editor/list-forms-controller';
import { createLitElement } from '../../test-utils';
const OPTIONS = { cameras: [], folders: [] };
const createController = () => {
const host = createLitElement();
const listener = vi.fn();
host.addEventListener('advanced-camera-card:editor:intent', listener);
return {
host,
listener,
controller: new ListFormsController(host, () => html`doc`),
};
};
const getLastIntent = (listener: Mock): unknown => {
const event = listener.mock.lastCall?.[0];
assert(event instanceof CustomEvent);
return event.detail;
};
// @vitest-environment jsdom
describe('ListFormsController', () => {
it('should register with the host', () => {
const { host, controller } = createController();
expect(host.addController).toHaveBeenCalledWith(controller);
controller.hostConnected();
});
describe('should read a list', () => {
it('should have no items before it is given any input', () => {
const { controller } = createController();
expect(controller.getList(['cameras'])).toEqual([]);
});
it('should read the items at a path', () => {
const { controller } = createController();
controller.setInput({
config: { cameras: [{ id: 'one' }] },
defaults: {},
options: OPTIONS,
});
expect(controller.getList(['cameras'])).toEqual([{ id: 'one' }]);
});
it('should read a nested list', () => {
const { controller } = createController();
controller.setInput({
config: { cameras: [{ triggers: { events: [{ event_type: 'a' }] } }] },
defaults: {},
options: OPTIONS,
});
expect(controller.getList(['cameras', 0, 'triggers', 'events'])).toEqual([
{ event_type: 'a' },
]);
});
it('should report an item that is not an object as an empty one', () => {
const { controller } = createController();
controller.setInput({
config: { cameras: [{ id: 'one' }, 'junk'] },
defaults: {},
options: OPTIONS,
});
expect(controller.getList(['cameras'])).toEqual([{ id: 'one' }, {}]);
});
it('should report no items for a value that is not a list', () => {
const { controller } = createController();
controller.setInput({
config: { cameras: 'junk' },
defaults: {},
options: OPTIONS,
});
expect(controller.getList(['cameras'])).toEqual([]);
});
});
describe('should build the forms of an item', () => {
it('should have no contexts before it is given any input', () => {
const { controller } = createController();
expect(controller.getFormContexts({ kind: 'camera', index: 0 })).toEqual([]);
});
it.each([
[{ kind: 'camera' as const, index: 1 }, ['cameras', 1]],
[{ kind: 'folder' as const, index: 2 }, ['folders', 2]],
[{ kind: 'camera-triggers' as const, cameraIndex: 1 }, ['cameras', 1, 'triggers']],
[
{ kind: 'camera-event' as const, cameraIndex: 1, eventIndex: 3 },
['cameras', 1, 'triggers', 'events', 3],
],
])('should build the forms for %j', (request, basePath) => {
const { controller } = createController();
controller.setInput({ config: {}, defaults: {}, options: OPTIONS });
const contexts = controller.getFormContexts(request);
expect(contexts).toHaveLength(1);
expect(contexts[0].form.basePath).toEqual(basePath);
});
it('should keep the forms of an item across configuration changes', () => {
const { controller } = createController();
const request = { kind: 'camera' as const, index: 0 };
controller.setInput({ config: {}, defaults: {}, options: OPTIONS });
const before = controller.getFormContexts(request)[0].form;
controller.setInput({
config: { cameras: [{ id: 'one' }] },
defaults: {},
options: OPTIONS,
});
expect(controller.getFormContexts(request)[0].form).toBe(before);
});
it('should keep the same contexts when nothing changed', () => {
const { controller } = createController();
const request = { kind: 'camera' as const, index: 0 };
const input = { config: {}, defaults: {}, options: OPTIONS };
controller.setInput(input);
const before = controller.getFormContexts(request);
controller.setInput({ ...input });
expect(controller.getFormContexts(request)).toBe(before);
});
it('should give each request its own contexts', () => {
const { controller, listener } = createController();
controller.setInput({ config: {}, defaults: {}, options: OPTIONS });
const camera = controller.getFormContexts({ kind: 'camera', index: 0 });
const folder = controller.getFormContexts({ kind: 'folder', index: 0 });
expect(camera[0]).not.toBe(folder[0]);
folder[0].valueChanged(
new CustomEvent('value-changed', { detail: { value: { title: 'Clips' } } }),
);
// The edit is attributed to the folder, not to the camera of the same
// index.
expect(getLastIntent(listener)).toEqual({
type: 'changes',
changes: [{ path: ['folders', 0, 'title'], type: 'set', value: 'Clips' }],
});
});
it('should rebuild the forms when the values its selectors offer change', () => {
const { controller } = createController();
const request = { kind: 'camera' as const, index: 0 };
controller.setInput({ config: {}, defaults: {}, options: OPTIONS });
const before = controller.getFormContexts(request)[0].form;
controller.setInput({
config: {},
defaults: {},
options: {
...OPTIONS,
cameras: [
{ value: 'camera.one', label: 'One' },
{ value: 'camera.other', label: 'Other' },
],
},
});
expect(controller.getFormContexts(request)[0].form).not.toBe(before);
});
it('should report an edit as an intent with an absolute path', () => {
const { controller, listener } = createController();
controller.setInput({ config: {}, defaults: {}, options: OPTIONS });
controller
.getFormContexts({ kind: 'camera', index: 2 })[0]
.valueChanged(
new CustomEvent('value-changed', { detail: { value: { id: 'front' } } }),
);
expect(getLastIntent(listener)).toEqual({
type: 'changes',
changes: [{ path: ['cameras', 2, 'id'], type: 'set', value: 'front' }],
});
});
});
describe('should report a list change as an intent', () => {
it('should report an addition', () => {
const { controller, listener } = createController();
controller.addItem(['cameras'], { id: 'one' });
expect(getLastIntent(listener)).toEqual({
type: 'list-add',
path: ['cameras'],
item: { id: 'one' },
});
});
it('should report a move', () => {
const { controller, listener } = createController();
controller.moveItem(['cameras'], 0, 1);
expect(getLastIntent(listener)).toEqual({
type: 'list-move',
path: ['cameras'],
from: 0,
to: 1,
});
});
it('should report a deletion', () => {
const { controller, listener } = createController();
controller.deleteItem(['cameras'], 1);
expect(getLastIntent(listener)).toEqual({
type: 'list-delete',
path: ['cameras'],
index: 1,
});
});
});
});
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { ListPagesController } from '../../../src/components-lib/editor/list-pages-controller';
import { createLitElement } from '../../test-utils';
const createController = () => {
const host = createLitElement();
return { host, controller: new ListPagesController(host) };
};
// @vitest-environment jsdom
describe('ListPagesController', () => {
it('should register with the host', () => {
const { host, controller } = createController();
expect(host.addController).toHaveBeenCalledWith(controller);
controller.hostConnected();
});
it('should start on the list itself', () => {
const { controller } = createController();
expect(controller.getPath()).toEqual([]);
});
it('should open an item and request an update', () => {
const { host, controller } = createController();
controller.open('cameras', 2);
expect(controller.getPath()).toEqual([{ list: 'cameras', index: 2 }]);
expect(host.requestUpdate).toHaveBeenCalledTimes(1);
});
it('should open an item within an open item', () => {
const { controller } = createController();
controller.open('cameras', 2);
controller.open('events', 0);
expect(controller.getPath()).toEqual([
{ list: 'cameras', index: 2 },
{ list: 'events', index: 0 },
]);
});
it('should go back to the item above', () => {
const { controller } = createController();
controller.open('cameras', 2);
controller.open('events', 0);
controller.back();
expect(controller.getPath()).toEqual([{ list: 'cameras', index: 2 }]);
});
it('should go back to the list itself', () => {
const { host, controller } = createController();
controller.open('cameras', 2);
controller.back();
expect(controller.getPath()).toEqual([]);
expect(host.requestUpdate).toHaveBeenCalledTimes(2);
});
it('should ignore going back from the list itself', () => {
const { host, controller } = createController();
controller.back();
expect(controller.getPath()).toEqual([]);
expect(host.requestUpdate).not.toHaveBeenCalled();
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import {
getFormContainerPath,
stripArrayIndices,
} from '../../../src/components-lib/editor/paths';
describe('getFormContainerPath', () => {
it('should reverse the innermost-first segments into configuration order', () => {
expect(getFormContainerPath({ path: ['dashboard', 'cast'] })).toEqual([
'cast',
'dashboard',
]);
});
it('should return an empty path without options', () => {
expect(getFormContainerPath()).toEqual([]);
});
it('should return an empty path without a path in the options', () => {
expect(getFormContainerPath({})).toEqual([]);
});
it('should not modify the passed segments', () => {
const path = ['dashboard', 'cast'];
getFormContainerPath({ path });
expect(path).toEqual(['dashboard', 'cast']);
});
});
describe('stripArrayIndices', () => {
it('should strip numeric segments', () => {
expect(stripArrayIndices(['cameras', 2, 'title'])).toEqual(['cameras', 'title']);
});
it('should strip numeric string segments', () => {
expect(stripArrayIndices(['cameras', '2', 'title'])).toEqual(['cameras', 'title']);
});
it('should leave paths without indices untouched', () => {
expect(stripArrayIndices(['live', 'controls'])).toEqual(['live', 'controls']);
});
});
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import {
createNumberSelector,
createSelectSelector,
} from '../../../../../src/components-lib/editor/schema/common/selectors';
describe('createSelectSelector', () => {
it('should build a single-value dropdown from options', () => {
expect(createSelectSelector(['a', 'b'])).toEqual({
select: {
mode: 'dropdown',
multiple: false,
custom_value: false,
options: ['a', 'b'],
},
});
});
it('should allow multiple values', () => {
expect(createSelectSelector(['a'], { multiple: true }).select.multiple).toBe(true);
});
it('should allow custom values only when no options are given', () => {
expect(createSelectSelector([]).select.custom_value).toBe(true);
expect(createSelectSelector(['a']).select.custom_value).toBe(false);
});
});
describe('createNumberSelector', () => {
it('should default to an input box with a zero minimum', () => {
expect(createNumberSelector()).toEqual({
number: { min: 0, max: undefined, mode: 'box', step: undefined },
});
});
it('should be a slider when a maximum is given', () => {
expect(createNumberSelector({ min: 1, max: 10, step: 2 })).toEqual({
number: { min: 1, max: 10, mode: 'slider', step: 2 },
});
});
});
@@ -0,0 +1,312 @@
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { forEachFieldRecursively } 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 { getFolderSchema } from '../../../../src/components-lib/editor/schema/folders';
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';
import { getMediaViewerSectionForms } from '../../../../src/components-lib/editor/schema/media-viewer';
import { getMenuSectionForms } from '../../../../src/components-lib/editor/schema/menu';
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 { 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 { advancedCameraCardConfigSchema } from '../../../../src/config/schema/types';
import { PTZ_KEYBOARD_SHORTCUTS } from '../../../../src/config/schema/view';
import type { HAFormSelectorSchema } from '../../../../src/ha/types';
// ============================================================================
// Editor <-> Zod Schema Completeness tests
//
// Two directions:
// - Direction 1 (editor -> config): every editor field path is a real config
// key, its selector kind matches the zod type (number/boolean/enum), and its
// dropdown offers every enum value.
// - Direction 2 (config -> editor): every configurable zod leaf appears in an
// editor form, unless explicitly excepted. Catches config fields forgotten in
// the editor.
// ============================================================================
// Configuration subtrees the editor renders with a dedicated widget instead of
// an `ha-form` field, so the field walk cannot see them.
const EDITOR_CUSTOM_WIDGETS = PTZ_KEYBOARD_SHORTCUTS.map(
(name) => `view.keyboard_shortcuts.${name}`,
);
// Configuration subtrees intentionally kept YAML-only.
const EDITOR_EXCLUDED = [
'cameras_global',
'elements',
'automations',
'overrides',
'debug',
'type',
'card_id',
'card_mod',
// Advanced/freeform camera subtrees not surfaced in the editor.
'cameras.jsmpeg',
'cameras.ptz',
'cameras.dimensions.grid',
'cameras.triggers.events.context',
// Free-form CSS variable overrides.
'view.theme.overrides',
// Not surfaced in the editor (the maintainer declined adding it).
'view.render_entities',
// Section-level action handlers (`tap_action` etc.) need a dedicated action
// editor and have never been exposed.
'view.actions',
'image.actions',
'media_gallery.actions',
'live.actions',
'media_viewer.actions',
// Free-form CSS object for the PTZ control styling.
'live.controls.ptz.style',
'media_viewer.controls.ptz.style',
// Advanced folder path parsers/matchers/templating: text-editor only.
'folders.ha.path',
];
// Every section's forms, keyed by section, so the harness walks the full
// field tree of everything rendered via `ha-form`.
const SECTION_FORMS: Record<string, EditorForm[]> = {
profiles: getProfilesSectionForms(),
view: [...getViewSectionForms(), ...getViewKeyboardShortcutsSectionForms()],
image: getImageSectionForms(),
live: getLiveSectionForms(),
media_gallery: getMediaGallerySectionForms(),
media_viewer: getMediaViewerSectionForms(),
menu: getMenuSectionForms(),
status_bar: getStatusBarSectionForms(),
timeline: getTimelineSectionForms(),
dimensions: getDimensionsSectionForms(),
performance: getPerformanceSectionForms(),
remote_control: getRemoteControlSectionForms(),
// Array section: one representative item at index 0 for testing purposes. The
// numeric index is stripped when building covered paths, so this checks the
// item's fields (`folders.type`, ...) against the array element's schema.
folders: [{ basePath: ['folders', 0], schema: getFolderSchema() }],
// Array section split across the camera form, the triggers sub-form, and the
// per-event item form (the triggers group is hand-built to host the events
// list, so its fields live in a separate schema).
cameras: [
{
basePath: ['cameras', 0],
schema: getCameraSchema({
otherCameras: [{ value: 'camera.other', label: 'Other' }],
folders: [{ value: 'folder-1', label: 'Folder' }],
}),
},
{
basePath: ['cameras', 0, 'triggers'],
schema: getCameraTriggersSchema(),
},
{
basePath: ['cameras', 0, 'triggers', 'events', 0],
schema: getTriggerEventSchema(),
},
],
};
// `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
// extra methods, so those introspection results would not assign back to it.
// Unwrap the zod wrappers that carry no structural meaning for path
// navigation (optional/default/nullable/readonly/lazy).
const unwrap = (schema: z.core.$ZodType): z.core.$ZodType => {
let current: z.core.$ZodType = schema;
for (let guard = 0; guard < 20; guard++) {
if (
current instanceof z.ZodOptional ||
current instanceof z.ZodNullable ||
current instanceof z.ZodDefault ||
current instanceof z.ZodReadonly
) {
current = current.unwrap();
} else if (current instanceof z.ZodLazy) {
current = current.unwrap();
} else {
break;
}
}
return current;
};
// 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 => {
let current: z.core.$ZodType = advancedCameraCardConfigSchema;
for (const segment of path) {
current = unwrap(current);
if (current instanceof z.ZodObject) {
const next: z.core.$ZodType | undefined = current.shape[segment];
if (!next) {
return null;
}
current = next;
} else if (current instanceof z.ZodArray) {
current = current.element;
} else {
return null;
}
}
return unwrap(current);
};
// A selector object has exactly one key naming its kind (`{ number: {} }` ->
// 'number', `{ select: {} }` -> 'select'), so its single key is the kind.
const selectorKind = (field: HAFormSelectorSchema): string =>
Object.keys(field.selector)[0];
// Collect the literal values of a zod enum or union-of-literals, or null if the
// schema is not an enumeration.
const enumValues = (schema: z.core.$ZodType): unknown[] | null => {
if (schema instanceof z.ZodEnum) {
return Object.values(schema.enum);
}
if (schema instanceof z.ZodUnion) {
const values: unknown[] = [];
for (const option of schema.options) {
const inner = unwrap(option);
if (inner instanceof z.ZodLiteral) {
values.push(inner.value);
} else {
return null;
}
}
return values;
}
return 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 key = path.join('.');
return [...EDITOR_CUSTOM_WIDGETS, ...EDITOR_EXCLUDED].some(
(prefix) => key === prefix || key.startsWith(`${prefix}.`),
);
};
// Enumerate the configuration schema's leaf field paths (a leaf is anything
// that is not a plain object; arrays are descended into rather than treated
// as leaves), pruning the subtrees the editor does not own.
const enumerateConfigLeavesRecursively = (
schema: z.core.$ZodType,
prefix: (string | number)[] = [],
): string[] => {
if (prefix.length && isPruned(prefix)) {
return [];
}
const current = unwrap(schema);
if (current instanceof z.ZodObject) {
return Object.keys(current.shape).flatMap((key) =>
enumerateConfigLeavesRecursively(current.shape[key], [...prefix, key]),
);
}
if (current instanceof z.ZodArray) {
// Descend into array items without an index: array sections cover the item
// fields once (e.g. `folders.type`, not `folders.0.type`).
return enumerateConfigLeavesRecursively(current.element, prefix);
}
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);
});
}
}
describe('editor schema completeness', () => {
// 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('.');
it(`${section}: ${key}`, () => {
const resolved = resolvePath(fullPath);
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);
}
}
}
}
});
});
}
}
});
// Direction 2: every configurable field (outside the excluded subtrees)
// appears in the editor. This is the forgotten-feature catcher.
describe('every configurable field appears in the editor', () => {
for (const path of enumerateConfigLeavesRecursively(
advancedCameraCardConfigSchema,
)) {
it(path, () => {
expect(
coveredPaths.has(path),
`${path} is a configurable field but is not in any editor form ` +
`(add it to the editor, or to EDITOR_CUSTOM_WIDGETS / EDITOR_EXCLUDED)`,
).toBe(true);
});
}
});
});
@@ -0,0 +1,90 @@
import { assert, describe, expect, it } from 'vitest';
import { getForms } from '../../../../src/components-lib/editor/schema/registry';
const OPTIONS = { cameras: [], folders: [] };
// Every section the editor offers, and where its first form edits.
const SECTIONS: [string, (string | number)[]][] = [
['dimensions', ['dimensions']],
['image', ['image']],
['live', ['live']],
['media_gallery', ['media_gallery', 'controls']],
['media_viewer', ['media_viewer']],
['menu', ['menu']],
['performance', ['performance']],
['profiles', []],
['remote_control', ['remote_control']],
['status_bar', ['status_bar']],
['timeline', ['timeline']],
['view', ['view']],
['view.keyboard_shortcuts', ['view', 'keyboard_shortcuts']],
];
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);
expect(forms.length).toBeGreaterThan(0);
expect(forms[0].basePath).toEqual(basePath);
forms.forEach((form) => expect(form.schema.length).toBeGreaterThan(0));
});
it('should build no forms for a section it does not know', () => {
expect(getForms({ kind: 'section', name: 'nonexistent' }, OPTIONS)).toEqual([]);
});
});
describe('should offer a camera the other cameras, but not itself', () => {
const CAMERAS = [
{ value: 'one', label: 'One' },
{ value: 'two', label: 'Two' },
{ value: 'three', label: 'Three' },
];
const getDependencyOptions = (index: number): unknown => {
const [form] = getForms(
{ kind: 'camera', index },
{ ...OPTIONS, cameras: CAMERAS },
);
const dependencies = form.schema.find(
(field) => 'name' in field && field.name === 'dependencies',
);
assert(dependencies && 'schema' in dependencies);
const cameras = dependencies.schema.find(
(field) => 'name' in field && field.name === 'cameras',
);
assert(cameras && 'selector' in cameras && 'select' in cameras.selector);
return cameras.selector.select?.options;
};
it.each([
[0, ['two', 'three']],
[1, ['one', 'three']],
[2, ['one', 'two']],
])('should leave camera %i out of its own dependencies', (index, expected) => {
expect(getDependencyOptions(index)).toEqual(
CAMERAS.filter((camera) => expected.includes(camera.value)),
);
});
});
describe('should build the forms of a list item', () => {
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: 'camera-event' as const, cameraIndex: 2, eventIndex: 4 },
['cameras', 2, 'triggers', 'events', 4],
],
])('should build the forms for %j', (request, basePath) => {
const forms = getForms(request, OPTIONS);
expect(forms).toHaveLength(1);
expect(forms[0].basePath).toEqual(basePath);
expect(forms[0].schema.length).toBeGreaterThan(0);
});
});
});
@@ -0,0 +1,100 @@
import { html } from 'lit';
import { describe, expect, it, vi } from 'vitest';
import { SectionController } from '../../../src/components-lib/editor/section-controller';
import { createLitElement } from '../../test-utils';
const OPTIONS = { cameras: [], folders: [] };
const INPUT = { config: {}, defaults: {}, options: OPTIONS };
const createController = () => {
const host = createLitElement();
return { host, controller: new SectionController(host, () => html`doc`) };
};
// @vitest-environment jsdom
describe('SectionController', () => {
it('should register with the host', () => {
const { host, controller } = createController();
expect(host.addController).toHaveBeenCalledWith(controller);
controller.hostConnected();
});
it('should have no contexts before it is given a request', () => {
const { controller } = createController();
expect(controller.getContexts()).toEqual([]);
});
it('should build the forms of the requested section', () => {
const { controller } = createController();
controller.setInput({ kind: 'section', name: 'menu' }, INPUT);
const contexts = controller.getContexts();
expect(contexts.length).toBeGreaterThan(0);
expect(contexts[0].form.basePath).toEqual(['menu']);
});
it('should report an edit as an intent', () => {
const { host, controller } = createController();
const listener = vi.fn();
host.addEventListener('advanced-camera-card:editor:intent', listener);
controller.setInput({ kind: 'section', name: 'menu' }, INPUT);
controller
.getContexts()[0]
.valueChanged(
new CustomEvent('value-changed', { detail: { value: { style: 'outside' } } }),
);
const event = listener.mock.lastCall?.[0];
expect(event).toBeInstanceOf(CustomEvent);
if (event instanceof CustomEvent) {
expect(event.detail).toEqual({
type: 'changes',
changes: [{ path: ['menu', 'style'], type: 'set', value: 'outside' }],
});
// The editor is the only thing that listens, and it sits outside this
// section's shadow root.
expect(event.bubbles).toBe(true);
expect(event.composed).toBe(true);
}
});
describe('should track whether it is open', () => {
it('should start closed and never opened', () => {
const { controller } = createController();
expect(controller.isOpen()).toBe(false);
expect(controller.wasEverOpened()).toBe(false);
});
it('should open and request an update', () => {
const { host, controller } = createController();
controller.setOpen(true);
expect(controller.isOpen()).toBe(true);
expect(controller.wasEverOpened()).toBe(true);
expect(host.requestUpdate).toHaveBeenCalledTimes(1);
});
it('should remember having been opened after it closes', () => {
const { controller } = createController();
controller.setOpen(true);
controller.setOpen(false);
expect(controller.isOpen()).toBe(false);
expect(controller.wasEverOpened()).toBe(true);
});
it('should ignore a state it is already in', () => {
const { host, controller } = createController();
controller.setOpen(true);
controller.setOpen(true);
expect(host.requestUpdate).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import {
getEditorCameraTitle,
getEditorFolderTitle,
getEditorTriggerEventTitle,
} from '../../../src/components-lib/editor/titles';
import { createHASS, createStateEntity } from '../../test-utils';
describe('getEditorCameraTitle', () => {
it('should prefer the configured title', () => {
expect(getEditorCameraTitle(0, { title: 'Front', id: 'front' })).toBe('Front');
});
it('should use the camera entity title', () => {
const hass = createHASS({
'camera.front': createStateEntity({
attributes: { friendly_name: 'Front Door' },
}),
});
expect(getEditorCameraTitle(0, { camera_entity: 'camera.front' }, hass)).toBe(
'Front Door',
);
});
it('should use the webrtc_card entity', () => {
expect(getEditorCameraTitle(0, { webrtc_card: { entity: 'camera.front' } })).toBe(
'camera.front',
);
});
it('should prettify the frigate camera name', () => {
expect(getEditorCameraTitle(0, { frigate: { camera_name: 'front_door' } })).toBe(
'Front Door',
);
});
it('should use the camera id', () => {
expect(getEditorCameraTitle(0, { id: 'front' })).toBe('front');
});
it('should fall back to an indexed label', () => {
expect(getEditorCameraTitle(2, {})).toBe('Camera #2');
expect(getEditorCameraTitle(2, 'NOT_AN_OBJECT')).toBe('Camera #2');
});
});
describe('getEditorFolderTitle', () => {
it('should prefer the configured title', () => {
expect(getEditorFolderTitle(0, { title: 'Snapshots', id: 'snaps' })).toBe(
'Snapshots',
);
});
it('should use the folder id', () => {
expect(getEditorFolderTitle(0, { id: 'snaps' })).toBe('snaps');
});
it('should fall back to an indexed label', () => {
expect(getEditorFolderTitle(1, {})).toBe('Folder #1');
expect(getEditorFolderTitle(1, 'NOT_AN_OBJECT')).toBe('Folder #1');
});
});
describe('getEditorTriggerEventTitle', () => {
it('should prefer the event type', () => {
expect(getEditorTriggerEventTitle(0, { event_type: 'motion' })).toBe('motion');
});
it('should fall back to an indexed label', () => {
expect(getEditorTriggerEventTitle(3, {})).toBe('Event #3');
expect(getEditorTriggerEventTitle(3, 'NOT_AN_OBJECT')).toBe('Event #3');
});
});
+18 -31
View File
@@ -4,41 +4,28 @@ import { IconController } from '../../src/components-lib/icon-controller';
import { createHASS, createStateEntity } from '../test-utils';
describe('IconController', () => {
describe('should get custom icon', () => {
it('should return frigate SVG for frigate icon', () => {
expect(new IconController().getCustomIcon({ icon: 'frigate' })).toMatch(
/frigate.svg$/,
);
describe('should get icon name', () => {
it.each(['frigate', 'iris', 'motioneye', 'reolink', 'tplink'])(
'should prefix the bare legacy name %s',
(name) => {
expect(new IconController().getIconName({ icon: name })).toBe(
`advanced-camera-card:${name}`,
);
},
);
it('should leave an iconset-prefixed name untouched', () => {
expect(
new IconController().getIconName({ icon: 'advanced-camera-card:frigate' }),
).toBe('advanced-camera-card:frigate');
});
it('should return motioneye SVG for motioneye icon', () => {
expect(new IconController().getCustomIcon({ icon: 'motioneye' })).toMatch(
/motioneye.svg$/,
);
it('should leave an mdi name untouched', () => {
expect(new IconController().getIconName({ icon: 'mdi:car' })).toBe('mdi:car');
});
it('should return reolink SVG for reolink icon', () => {
expect(new IconController().getCustomIcon({ icon: 'reolink' })).toMatch(
/reolink.svg$/,
);
});
it('should return tplink SVG for tplink icon', () => {
expect(new IconController().getCustomIcon({ icon: 'tplink' })).toMatch(
/tplink.svg$/,
);
});
it('should return iris SVG for iris icon', () => {
expect(new IconController().getCustomIcon({ icon: 'iris' })).toMatch(/iris.svg$/);
});
it('should return null for mdi icon', () => {
expect(new IconController().getCustomIcon({ icon: 'mdi:car' })).toBeNull();
});
it('should return null for undefined icon', () => {
expect(new IconController().getCustomIcon()).toBeNull();
it('should return null for an undefined icon', () => {
expect(new IconController().getIconName()).toBeNull();
});
});
@@ -40,6 +40,35 @@ describe('KeyAssignerController', () => {
});
});
describe('should show a value without reporting it', () => {
it('should adopt the value it is given', () => {
const element = createLitElement();
const controller = new KeyAssignerController(element);
const listener = vi.fn();
element.addEventListener('value-changed', listener);
controller.showValue({ key: 'ArrowLeft' });
expect(controller.getValue()).toEqual({ key: 'ArrowLeft' });
expect(element.requestUpdate).toHaveBeenCalled();
// The value came from outside: reporting it back would have the owner
// store a shortcut the user never assigned.
expect(listener).not.toHaveBeenCalled();
});
it('should do nothing for the value it already has', () => {
const element = createLitElement();
const controller = new KeyAssignerController(element);
controller.showValue({ key: 'ArrowLeft' });
vi.mocked(element.requestUpdate).mockClear();
controller.showValue({ key: 'ArrowLeft' });
expect(element.requestUpdate).not.toHaveBeenCalled();
});
});
describe('should manage assignment state', () => {
it('should not be assigned to start', () => {
const element = createLitElement();
@@ -107,7 +107,7 @@ describe('MenuButtonController', () => {
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
icon: 'iris',
icon: 'advanced-camera-card:iris',
enabled: true,
permanent: true,
priority: 50,
@@ -126,7 +126,7 @@ describe('MenuButtonController', () => {
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
icon: 'iris',
icon: 'advanced-camera-card:iris',
enabled: true,
permanent: true,
priority: 50,
@@ -1,7 +1,7 @@
import { describe, expect, it, onTestFinished, vi } from 'vitest';
import { NotificationPopupController } from '../../../src/components-lib/notification/notification-popup-controller';
import { POP_OUT_ANIMATION_NAME } from '../../../src/const';
import { POP_OUT_ANIMATION_NAME } from '../../../src/utils/animation';
import { createLitElement } from '../../test-utils';
// @vitest-environment jsdom
+112 -7
View File
@@ -1,15 +1,17 @@
import { describe, expect, it } from 'vitest';
import {
addConfigArrayItem,
copyConfig,
createRangedTransform,
deleteConfigArrayItem,
deleteConfigValue,
deleteTransform,
deleteWithOverrides,
getArrayConfigPath,
getConfigValue,
hasConfigUpgradeFailures,
isConfigUpgradeable,
moveConfigArrayItem,
moveConfigValue,
setConfigValue,
upgradeArrayOfObjects,
@@ -88,8 +90,98 @@ describe('general functions', () => {
expect(copy).not.toBe(target);
});
it('should get array config path', () => {
expect(getArrayConfigPath('a.#.b', 10)).toBe('a.[10].b');
describe('addConfigArrayItem', () => {
it('should append an item', () => {
const config = { cameras: [{ id: 'a' }] };
expect(addConfigArrayItem(config, 'cameras', { id: 'b' })).toBe(true);
expect(config.cameras).toEqual([{ id: 'a' }, { id: 'b' }]);
});
it('should append at a segment-array path', () => {
const config = { cameras: [{ triggers: { events: [{ a: 1 }] } }] };
expect(
addConfigArrayItem(config, ['cameras', 0, 'triggers', 'events'], { b: 2 }),
).toBe(true);
expect(config.cameras[0].triggers.events).toEqual([{ a: 1 }, { b: 2 }]);
});
it('should create the array when the configuration has none', () => {
const config = {};
expect(addConfigArrayItem(config, 'cameras', { id: 'a' })).toBe(true);
expect(config).toEqual({ cameras: [{ id: 'a' }] });
});
it('should replace a value that is not an array', () => {
const config = { cameras: 'junk' };
expect(addConfigArrayItem(config, 'cameras', { id: 'a' })).toBe(true);
expect(config).toEqual({ cameras: [{ id: 'a' }] });
});
});
describe('moveConfigArrayItem', () => {
it('should move an item at a segment-array path', () => {
const config = { cameras: [{ triggers: { events: [{ a: 1 }, { b: 2 }] } }] };
expect(
moveConfigArrayItem(config, ['cameras', 0, 'triggers', 'events'], 0, 1),
).toBe(true);
expect(config.cameras[0].triggers.events).toEqual([{ b: 2 }, { a: 1 }]);
});
it('should move an item in place and report a change', () => {
const config = { cameras: [{ id: 'a' }, { id: 'b' }] };
expect(moveConfigArrayItem(config, 'cameras', 0, 1)).toBe(true);
expect(config).toEqual({ cameras: [{ id: 'b' }, { id: 'a' }] });
});
it('should return false when the path is not an array', () => {
const config = { cameras: 'nope' };
expect(moveConfigArrayItem(config, 'cameras', 0, 1)).toBe(false);
expect(config).toEqual({ cameras: 'nope' });
});
it.each([
['same index', 0, 0],
['from out of bounds', 2, 0],
['to out of bounds', 0, 2],
['negative from', -1, 0],
['negative to', 0, -1],
['non-integer from', 0.5, 1],
['non-integer to', 0, Number.NaN],
])(
'should return false and not modify for an impossible move (%s)',
(_name, from, to) => {
const config = { cameras: [{ id: 'a' }, { id: 'b' }] };
expect(moveConfigArrayItem(config, 'cameras', from, to)).toBe(false);
expect(config).toEqual({ cameras: [{ id: 'a' }, { id: 'b' }] });
},
);
});
describe('deleteConfigArrayItem', () => {
it('should delete an item in place and report a change', () => {
const config = { cameras: [{ id: 'a' }, { id: 'b' }] };
expect(deleteConfigArrayItem(config, 'cameras', 0)).toBe(true);
expect(config).toEqual({ cameras: [{ id: 'b' }] });
});
it('should return false when the path is not an array', () => {
const config = { cameras: 'nope' };
expect(deleteConfigArrayItem(config, 'cameras', 0)).toBe(false);
expect(config).toEqual({ cameras: 'nope' });
});
it.each([
['out of bounds', 1],
['negative', -1],
['non-integer', 0.5],
])(
'should return false and not modify for an impossible delete (%s)',
(_name, index) => {
const config = { cameras: [{ id: 'a' }] };
expect(deleteConfigArrayItem(config, 'cameras', index)).toBe(false);
expect(config).toEqual({ cameras: [{ id: 'a' }] });
},
);
});
});
@@ -5220,14 +5312,27 @@ describe('should handle version specific upgrades', () => {
postUpgradeChecks(config);
});
it('should migrate an empty legacy array', () => {
it('should leave an empty array alone', () => {
// An empty list says nothing about which of the two shapes it is, and
// is valid under the new schema. Claiming it would rename the key of a
// user who has simply deleted their last event trigger.
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office', triggers: { events: [] } }],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.cameras[0].triggers).toEqual({ media_events: [] });
postUpgradeChecks(config);
upgradeConfig(config);
expect(config.cameras[0].triggers).toEqual({ events: [] });
});
it('should not report an upgrade for an empty array', () => {
// Otherwise the editor offers an upgrade to a user who has only
// deleted their last event trigger.
expect(
isConfigUpgradeable({
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office', triggers: { events: [] } }],
}),
).toBeFalsy();
});
it('should leave the new object-array shape untouched', () => {
-1
View File
@@ -503,7 +503,6 @@ describe('config defaults', () => {
format: {
'24h': true,
},
pan_mode: 'pan',
show_recordings: true,
style: 'stack',
window_seconds: 3600,
+103
View File
@@ -0,0 +1,103 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { beforeEach, describe, expect, it } from 'vitest';
import {
CUSTOM_ICON_NAMES,
CUSTOM_ICONSET_PREFIX,
registerCustomIconset,
} from '../../src/ha/custom-icons';
// @vitest-environment jsdom
describe('registerCustomIconset', () => {
beforeEach(() => {
delete window.customIcons;
});
it('should register the iconset', () => {
registerCustomIconset();
expect(window.customIcons?.[CUSTOM_ICONSET_PREFIX]).toBeDefined();
});
it('should leave an existing registration in place', () => {
registerCustomIconset();
const helpers = window.customIcons?.[CUSTOM_ICONSET_PREFIX];
registerCustomIconset();
expect(window.customIcons?.[CUSTOM_ICONSET_PREFIX]).toBe(helpers);
});
it('should leave other iconsets untouched', () => {
const other = {
getIcon: async () => ({ path: '' }),
getIconList: async () => [],
};
window.customIcons = { other: other };
registerCustomIconset();
expect(window.customIcons['other']).toBe(other);
expect(window.customIcons[CUSTOM_ICONSET_PREFIX]).toBeDefined();
});
it('should resolve an icon', async () => {
registerCustomIconset();
await expect(
window.customIcons?.[CUSTOM_ICONSET_PREFIX].getIcon('frigate'),
).resolves.toEqual({
path: expect.stringMatching(/^M/),
viewBox: '0 0 24 24',
});
});
it('should resolve an icon with a non-default viewBox', async () => {
registerCustomIconset();
await expect(
window.customIcons?.[CUSTOM_ICONSET_PREFIX].getIcon('tplink'),
).resolves.toEqual({
path: expect.stringMatching(/^M/),
viewBox: '0 0 256 256',
});
});
it('should reject an unknown icon', async () => {
registerCustomIconset();
await expect(
window.customIcons?.[CUSTOM_ICONSET_PREFIX].getIcon('unknown'),
).rejects.toThrow('Unknown icon: advanced-camera-card:unknown');
});
it('should list the icons', async () => {
registerCustomIconset();
await expect(
window.customIcons?.[CUSTOM_ICONSET_PREFIX].getIconList(),
).resolves.toEqual([
{ name: 'frigate' },
{ name: 'iris' },
{ name: 'motioneye' },
{ name: 'reolink' },
{ name: 'tplink' },
]);
});
});
describe('custom icon assets', () => {
it.each(CUSTOM_ICON_NAMES)('should have a single-path SVG asset for %s', (name) => {
const source = readFileSync(
join(process.cwd(), 'src', 'images', 'icons', `${name}.svg`),
'utf-8',
);
const doc = new DOMParser().parseFromString(source, 'image/svg+xml');
expect(doc.querySelectorAll('svg > path')).toHaveLength(1);
expect(doc.querySelector('svg > path')?.getAttribute('d')).toBeTruthy();
expect(doc.querySelector('svg')?.getAttribute('viewBox')).toBeTruthy();
});
});
+4 -4
View File
@@ -244,12 +244,12 @@ describe('localize', () => {
expect(localize('common.advanced_camera_card', '', '')).toBe('Advanced Camera Card');
});
it('should throw for completely missing key', async () => {
it('should return the key for completely missing key', async () => {
const { localize } = await importFresh();
// The first reduce throws (caught), the English fallback also throws
// (uncaught), so this will throw a TypeError.
expect(() => localize('nonexistent.deeply.nested.key')).toThrow();
expect(localize('nonexistent.deeply.nested.key')).toBe(
'nonexistent.deeply.nested.key',
);
});
it('should return translated value for single-segment key', async () => {