418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
import type { LitElement } from 'lit';
|
|
import {
|
|
afterEach,
|
|
assert,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi,
|
|
type Mock,
|
|
} from 'vitest';
|
|
|
|
import {
|
|
EditorController,
|
|
type EditorNotice,
|
|
} 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' },
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const getUpgradeNotice = (controller: EditorController): EditorNotice | null =>
|
|
controller
|
|
.getNotices()
|
|
.find((notice) => notice.message === localize('editor.upgrade_available')) ?? null;
|
|
|
|
// @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(getUpgradeNotice(controller)).toBeNull();
|
|
|
|
controller.setConfig(createUpgradeableConfig());
|
|
expect(getUpgradeNotice(controller)).toBeTruthy();
|
|
|
|
controller.setConfig({ cameras: [] });
|
|
expect(getUpgradeNotice(controller)).toBeNull();
|
|
});
|
|
|
|
it('should detect an invalid configuration', () => {
|
|
const { controller } = createController();
|
|
expect(controller.isConfigValid()).toBeFalsy();
|
|
|
|
controller.setConfig({ type: 'custom:advanced-camera-card', cameras: [] });
|
|
expect(controller.isConfigValid()).toBeTruthy();
|
|
|
|
controller.setConfig({ type: 'custom:advanced-camera-card', cameras: 'nope' });
|
|
expect(controller.isConfigValid()).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([]);
|
|
});
|
|
|
|
it('should say when the simple editor does not have full coverage', () => {
|
|
const { controller } = createController();
|
|
controller.setConfig({
|
|
cameras: [{ camera_entity: 'camera.office' }],
|
|
view: { dim: true },
|
|
editor: { mode: 'simple' },
|
|
});
|
|
|
|
expect(controller.getNotices()).toEqual([
|
|
{ type: 'info', message: localize('editor.simple_coverage') },
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'the simple editor shows everything set',
|
|
{ cameras: [{ camera_entity: 'camera.office' }], editor: { mode: 'simple' } },
|
|
],
|
|
[
|
|
'the full editor is in use',
|
|
{ cameras: [{ camera_entity: 'camera.office' }], view: { dim: true } },
|
|
],
|
|
])('should have no coverage notice when %s', (_case, config) => {
|
|
const { controller } = createController();
|
|
controller.setConfig(config);
|
|
expect(controller.getNotices()).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
[{ cameras: [{ camera_entity: 'camera.office' }] }, 'simple' as const],
|
|
[{ cameras: [], view: { dim: true } }, 'full' as const],
|
|
[{ cameras: [], editor: { mode: 'full' } }, 'full' as const],
|
|
])('should choose the editor for %j', (config, mode) => {
|
|
const { controller } = createController();
|
|
controller.setConfig(config);
|
|
expect(controller.getEditorMode()).toBe(mode);
|
|
});
|
|
});
|
|
|
|
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());
|
|
|
|
const notice = getUpgradeNotice(controller);
|
|
assert(notice?.button);
|
|
notice.button.callback();
|
|
|
|
const config = getLastConfig(configListener);
|
|
expect(getConfigValue(config, 'elements.0.tap_action.data')).toEqual({
|
|
message: 'Hello',
|
|
});
|
|
expect(getUpgradeNotice(controller)).toBeNull();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|