feat: Modernize the visual card editor. (#2586)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -503,7 +503,6 @@ describe('config defaults', () => {
|
||||
format: {
|
||||
'24h': true,
|
||||
},
|
||||
pan_mode: 'pan',
|
||||
show_recordings: true,
|
||||
style: 'stack',
|
||||
window_seconds: 3600,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user