Initial version of status bar.

This commit is contained in:
Dermot Duffy
2024-08-16 20:08:21 -07:00
parent db9303c60d
commit e503f0e429
77 changed files with 3072 additions and 1061 deletions
-1
View File
@@ -11,7 +11,6 @@ import {
QueryClassifier,
QueryResultClassifier,
} from '../../src/camera-manager/manager';
import { CameraManagerStore } from '../../src/camera-manager/store';
import {
CameraEndpoint,
CameraEndpoints,
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { StatusBarAction } from '../../../../src/card-controller/actions/actions/status-bar';
import { createCardAPI } from '../../../test-utils';
describe('should handle status bar action', () => {
it('reset', async () => {
const api = createCardAPI();
const action = new StatusBarAction(
{},
{
action: 'fire-dom-event',
frigate_card_action: 'status_bar',
status_bar_action: 'reset',
},
);
await action.execute(api);
expect(api.getStatusBarItemManager().removeAllDynamicStatusBarItems).toBeCalled();
});
it('add', async () => {
const api = createCardAPI();
const item = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item',
};
const action = new StatusBarAction(
{},
{
action: 'fire-dom-event',
frigate_card_action: 'status_bar',
status_bar_action: 'add',
items: [item],
},
);
await action.execute(api);
expect(api.getStatusBarItemManager().addDynamicStatusBarItem).toBeCalledWith(item);
});
it('remove', async () => {
const api = createCardAPI();
const item = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item',
};
const action = new StatusBarAction(
{},
{
action: 'fire-dom-event',
frigate_card_action: 'status_bar',
status_bar_action: 'remove',
items: [item],
},
);
await action.execute(api);
expect(api.getStatusBarItemManager().removeDynamicStatusBarItem).toBeCalledWith(
item,
);
});
});
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { CameraSelectAction } from '../../../src/card-controller/actions/actions/camera-select';
import { CameraUIAction } from '../../../src/card-controller/actions/actions/camera-ui';
import { DefaultAction } from '../../../src/card-controller/actions/actions/default';
@@ -21,6 +21,7 @@ import { PTZDigitalAction } from '../../../src/card-controller/actions/actions/p
import { PTZMultiAction } from '../../../src/card-controller/actions/actions/ptz-multi';
import { ScreenshotAction } from '../../../src/card-controller/actions/actions/screenshot';
import { SleepAction } from '../../../src/card-controller/actions/actions/sleep';
import { StatusBarAction } from '../../../src/card-controller/actions/actions/status-bar';
import { SubstreamOffAction } from '../../../src/card-controller/actions/actions/substream-off';
import { SubstreamOnAction } from '../../../src/card-controller/actions/actions/substream-on';
import { SubstreamSelectAction } from '../../../src/card-controller/actions/actions/substream-select';
@@ -125,6 +126,10 @@ describe('ActionFactory', () => {
[{ frigate_card_action: 'snapshots' as const }, ViewAction],
[{ frigate_card_action: 'timeline' as const }, ViewAction],
[{ frigate_card_action: 'unmute' as const }, UnmuteAction],
[
{ frigate_card_action: 'status_bar' as const, status_bar_action: 'reset' },
StatusBarAction,
],
])(
'frigate_card_action: $frigate_card_action',
(action: Partial<FrigateCardCustomAction>, classObject: object) => {
@@ -306,28 +306,6 @@ describe('getOverriddenConfig', () => {
}),
).toEqual({});
});
// it('with empty value and object', () => {
// const manager = new ConditionsManager(createCardAPI());
// manager.setState({ fullscreen: true });
// expect(
// getOverriddenConfig(manager, config, {
// configOverrides: [
// {
// delete: [''],
// conditions: [
// {
// condition: 'fullscreen' as const,
// fullscreen: true,
// },
// ],
// },
// ],
// emptyKeyReplaces: true,
// }),
// ).toEqual({});
// });
});
describe('should validate schema', () => {
@@ -538,6 +516,11 @@ describe('ConditionsManager', () => {
state: 'on',
},
],
actions: [
{
action: 'fire-dom-event',
},
],
},
],
});
+8
View File
@@ -29,6 +29,7 @@ import { ViewManager } from '../../src/card-controller/view/view-manager';
import { FrigateCardEditor } from '../../src/editor';
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager';
vi.mock('../../src/camera-manager/manager');
vi.mock('../../src/card-controller/actions/actions-manager');
@@ -50,6 +51,7 @@ vi.mock('../../src/card-controller/media-player-manager');
vi.mock('../../src/card-controller/message-manager');
vi.mock('../../src/card-controller/microphone-manager');
vi.mock('../../src/card-controller/query-string-manager');
vi.mock('../../src/card-controller/status-bar-item-manager');
vi.mock('../../src/card-controller/style-manager');
vi.mock('../../src/card-controller/triggers-manager');
vi.mock('../../src/card-controller/view/view-manager');
@@ -247,6 +249,12 @@ describe('CardController', () => {
);
});
it('getStatusBarItemManager', () => {
expect(createController().getStatusBarItemManager()).toBe(
vi.mocked(StatusBarItemManager).mock.instances[0],
);
});
it('getStyleManager', () => {
expect(createController().getStyleManager()).toBe(
vi.mocked(StyleManager).mock.instances[0],
@@ -0,0 +1,243 @@
import { describe, expect, it, vi } from 'vitest';
import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager';
import { StatusBarString } from '../../src/config/types';
import { MediaQueriesResults } from '../../src/view/media-queries-results';
import {
TestViewMedia,
createCameraManager,
createCardAPI,
createStore,
createView,
} from '../test-utils';
describe('StatusBarItemManager', () => {
const testItem: StatusBarString = {
type: 'custom:frigate-card-status-bar-string' as const,
string: 'test',
};
it('should add', () => {
const manager = new StatusBarItemManager(createCardAPI());
manager.addDynamicStatusBarItem(testItem);
manager.addDynamicStatusBarItem(testItem);
expect(manager.calculateItems()).toContain(testItem);
expect(manager.calculateItems().find((item) => item === testItem).length === 1);
});
it('should remove', () => {
const manager = new StatusBarItemManager(createCardAPI());
manager.addDynamicStatusBarItem(testItem);
manager.removeDynamicStatusBarItem({ ...testItem });
expect(manager.calculateItems()).not.toContain(testItem);
manager.removeDynamicStatusBarItem({ ...testItem, string: 'not-present' });
expect(manager.calculateItems()).not.toContain({
...testItem,
string: 'not-present',
});
});
it('should remove all', () => {
const manager = new StatusBarItemManager(createCardAPI());
manager.addDynamicStatusBarItem(testItem);
manager.removeAllDynamicStatusBarItems();
expect(manager.calculateItems()).not.toContain(testItem);
});
describe('should have standard status bar items', () => {
describe('should have title', () => {
describe('live', () => {
it('with metadata', () => {
const manager = new StatusBarItemManager(createCardAPI());
const store = createStore([
{
cameraID: 'camera-1',
},
]);
const cameraManager = createCameraManager(store);
vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({
title: 'Camera Title',
icon: 'mdi:camera',
});
expect(
manager.calculateItems({
cameraManager: cameraManager,
view: createView({ view: 'live', camera: 'camera-1' }),
}),
).toContainEqual({
type: 'custom:frigate-card-status-bar-string' as const,
string: 'Camera Title',
expand: true,
sufficient: true,
});
});
it('without metadata', () => {
const manager = new StatusBarItemManager(createCardAPI());
const cameraManager = createCameraManager();
expect(
manager.calculateItems({
cameraManager: cameraManager,
view: createView({ view: 'live', camera: 'MISSING-CAMERA' }),
}),
).not.toContainEqual(expect.objectContaining({ sufficient: true }));
});
});
describe('media', () => {
it('with a title', () => {
const manager = new StatusBarItemManager(createCardAPI());
const cameraManager = createCameraManager();
const media = [new TestViewMedia({ title: 'Media Title' })];
const queryResults = new MediaQueriesResults({
results: media,
});
const view = createView({
view: 'media',
queryResults: queryResults,
});
expect(
manager.calculateItems({
cameraManager: cameraManager,
view: view,
}),
).toContainEqual({
type: 'custom:frigate-card-status-bar-string' as const,
string: 'Media Title',
expand: true,
sufficient: true,
});
});
it('without a title', () => {
const manager = new StatusBarItemManager(createCardAPI());
const cameraManager = createCameraManager();
const media = [new TestViewMedia()];
const queryResults = new MediaQueriesResults({
results: media,
});
const view = createView({
view: 'media',
queryResults: queryResults,
});
expect(
manager.calculateItems({
cameraManager: cameraManager,
view: view,
}),
).not.toContainEqual(expect.objectContaining({ sufficient: true }));
});
});
});
describe('should have resolution', () => {
it.each([
['1080p landscape', '1080p', 1920, 1080],
['1080p portrait', '1080p', 1080, 1920],
['1080p approximate', '1080p', 1922, 1082],
['720p landscape', '720p', 1280, 720],
['720p portrait', '720p', 720, 1280],
['720p approximate', '720p', 1282, 722],
['VGA landscape', 'VGA', 640, 480],
['VGA portrait', 'VGA', 480, 640],
['VGA approximate', 'VGA', 642, 482],
['4K landscape', '4K', 3840, 2160],
['4K portrait', '4K', 2160, 3840],
['4K approximate', '4K', 3842, 2162],
['480p landscape', '480p', 720, 480],
['480p portrait', '480p', 480, 720],
['480p approximate', '480p', 722, 482],
['576p landscape', '576p', 720, 576],
['576p portrait', '576p', 576, 720],
['576p approximate', '576p', 722, 578],
['8K landscape', '8K', 7680, 4320],
['8K portrait', '8K', 4320, 7680],
['8K approximate', '8K', 7682, 4322],
['random', '123x456', 123, 456],
])(
'%s',
(_testName: string, expectedName: string, width: number, height: number) => {
const manager = new StatusBarItemManager(createCardAPI());
expect(
manager.calculateItems({
mediaLoadedInfo: { width, height },
}),
).toContainEqual({
type: 'custom:frigate-card-status-bar-string' as const,
string: expectedName,
});
},
);
});
describe('should have technology', () => {
it('webrtc', () => {
const manager = new StatusBarItemManager(createCardAPI());
expect(
manager.calculateItems({
mediaLoadedInfo: { width: 640, height: 480, technology: ['webrtc'] },
}),
).toContainEqual({
type: 'custom:frigate-card-status-bar-icon' as const,
icon: 'mdi:webrtc',
});
});
it('non-webrtc', () => {
const manager = new StatusBarItemManager(createCardAPI());
expect(
manager.calculateItems({
mediaLoadedInfo: { width: 640, height: 480, technology: ['hls'] },
}),
).toContainEqual({
type: 'custom:frigate-card-status-bar-string' as const,
string: 'HLS',
});
});
});
it('should have engine', () => {
const manager = new StatusBarItemManager(createCardAPI());
const store = createStore([
{
cameraID: 'camera-1',
},
]);
const cameraManager = createCameraManager(store);
vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({
title: 'Camera Title',
icon: 'mdi:camera',
engineLogo: 'IMAGE_LOGO',
});
expect(
manager.calculateItems({
cameraManager: cameraManager,
view: createView({ view: 'live', camera: 'camera-1' }),
}),
).toContainEqual({
type: 'custom:frigate-card-status-bar-image' as const,
image: 'IMAGE_LOGO',
});
});
});
});
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { getTechnologyForVideoRTC } from '../../../../src/components-lib/live/utils/get-technology-for-video-rtc';
import { VideoRTC } from '../../../../src/components/live/go2rtc/video-rtc';
import { createLitElement } from '../../../test-utils';
// @vitest-environment jsdom
describe('getTechnologyForVideoRTC', () => {
it('webrtc', () => {
const element = createLitElement() as unknown as VideoRTC;
element.pc = {} as unknown as RTCPeerConnection;
expect(getTechnologyForVideoRTC(element)).toEqual(['webrtc']);
});
it('mse', () => {
const element = createLitElement() as unknown as VideoRTC;
element.mseCodecs = 'mp4a';
expect(getTechnologyForVideoRTC(element)).toEqual(['mse', 'hls']);
});
it('other', () => {
const element = createLitElement() as unknown as VideoRTC;
expect(getTechnologyForVideoRTC(element)).toBeUndefined();
});
});
+12 -25
View File
@@ -1,11 +1,11 @@
import { HASSDomEvent, handleActionConfig } from '@dermotduffy/custom-card-helpers';
import { handleActionConfig } from '@dermotduffy/custom-card-helpers';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FRIGATE_ICON_SVG_PATH } from '../../src/camera-manager/frigate/icon';
import { MenuController } from '../../src/components-lib/menu-controller';
import { ActionsConfig, MenuConfig, menuConfigSchema } from '../../src/config/types';
import { MenuConfig, menuConfigSchema } from '../../src/config/types';
import { StateParameters } from '../../src/types';
import { refreshDynamicStateParameters } from '../../src/utils/ha';
import { createHASS, createLitElement } from '../test-utils';
import { createInteractionEvent, createHASS, createLitElement } from '../test-utils';
vi.mock('@dermotduffy/custom-card-helpers');
vi.mock('../../src/utils/ha');
@@ -14,18 +14,6 @@ const createMenuConfig = (config: unknown): MenuConfig => {
return menuConfigSchema.parse(config);
};
const createEvent = (
action: string,
config?: ActionsConfig,
): HASSDomEvent<{ action: string; config?: ActionsConfig }> => {
return new CustomEvent<{ action: string; config?: ActionsConfig }>('@action', {
detail: {
action: action,
config: config,
},
});
};
// @vitest-environment jsdom
describe('MenuController', () => {
const action = {
@@ -362,7 +350,7 @@ describe('MenuController', () => {
describe('should handle actions', () => {
it('should bail without config', () => {
const controller = new MenuController(createLitElement());
controller.actionHandler(createEvent('tap'));
controller.actionHandler(createInteractionEvent('tap'));
expect(vi.mocked(handleActionConfig)).not.toBeCalled();
});
@@ -373,7 +361,7 @@ describe('MenuController', () => {
const controller = new MenuController(host);
controller.actionHandler(createEvent('tap'), tapActionConfig);
controller.actionHandler(createInteractionEvent('tap'), tapActionConfig);
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: { action: [action], config: tapActionConfig },
@@ -389,7 +377,7 @@ describe('MenuController', () => {
const controller = new MenuController(host);
controller.actionHandler(createEvent('tap', tapActionConfig));
controller.actionHandler(createInteractionEvent('tap', tapActionConfig));
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: { action: [action], config: tapActionConfig },
@@ -404,7 +392,7 @@ describe('MenuController', () => {
const controller = new MenuController(host);
controller.actionHandler(createEvent('tap'), tapActionConfigMulti);
controller.actionHandler(createInteractionEvent('tap'), tapActionConfigMulti);
expect(handler).toBeCalledWith(
expect.objectContaining({
@@ -426,7 +414,7 @@ describe('MenuController', () => {
controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(createEvent('tap'), tapActionConfig);
controller.actionHandler(createInteractionEvent('tap'), tapActionConfig);
expect(controller.isExpanded()).toBeFalsy();
});
@@ -442,7 +430,7 @@ describe('MenuController', () => {
controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(createEvent('end_tap'), {
controller.actionHandler(createInteractionEvent('end_tap'), {
end_tap_action: action,
});
expect(controller.isExpanded()).toBeFalsy();
@@ -462,7 +450,7 @@ describe('MenuController', () => {
controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(createEvent('start_tap'), {
controller.actionHandler(createInteractionEvent('start_tap'), {
start_tap_action: action,
end_tap_action: action,
});
@@ -481,7 +469,7 @@ describe('MenuController', () => {
controller.setExpanded(false);
expect(controller.isExpanded()).toBeFalsy();
controller.actionHandler(createEvent('tap'), {
controller.actionHandler(createInteractionEvent('tap'), {
camera_entity: 'foo',
tap_action: menuToggleAction,
});
@@ -490,7 +478,6 @@ describe('MenuController', () => {
it('when no action is actually taken', () => {
const host = createLitElement();
const hass = createHASS();
const controller = new MenuController(host);
controller.setMenuConfig(
createMenuConfig({
@@ -501,7 +488,7 @@ describe('MenuController', () => {
controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(createEvent('end_tap'), tapActionConfig);
controller.actionHandler(createInteractionEvent('end_tap'), tapActionConfig);
expect(controller.isExpanded()).toBeTruthy();
});
});
@@ -0,0 +1,330 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { StatusBarController } from '../../src/components-lib/status-bar-controller';
import { StatusBarConfig, statusBarConfigSchema } from '../../src/config/types';
import { setOrRemoveAttribute } from '../../src/utils/basic';
import { createInteractionEvent, createLitElement } from '../test-utils';
const createConfig = (config?: unknown): StatusBarConfig => {
return statusBarConfigSchema.parse(config);
};
// @vitest-environment jsdom
describe('StatusBarController', () => {
describe('should set config', () => {
it('should set config', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
controller.setConfig(
createConfig({
position: 'top',
style: 'hover',
height: 50,
}),
);
expect(host.style.getPropertyValue('--frigate-card-status-bar-height')).toBe(
'50px',
);
expect(host.getAttribute('data-style')).toBe('hover');
expect(host.getAttribute('data-position')).toBe('top');
expect(host.requestUpdate).toHaveBeenCalled();
});
it('should not hide when not in popup style', () => {
const host = createLitElement();
setOrRemoveAttribute(host, true, 'hide');
const controller = new StatusBarController(host);
controller.setConfig(
createConfig({
position: 'top',
style: 'hover',
height: 50,
}),
);
expect(host.getAttribute('hide')).toBe(null);
});
it('should not show when in popup style', () => {
const host = createLitElement();
setOrRemoveAttribute(host, true, 'hide');
const controller = new StatusBarController(host);
controller.setConfig(
createConfig({
position: 'top',
style: 'popup',
height: 50,
}),
);
expect(host.getAttribute('hide')).not.toBe(null);
});
});
describe('should set items', () => {
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
it('set/get basic items', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
const items = [
{
type: 'custom:frigate-card-status-bar-string',
string: 'Test',
},
];
controller.setItems(items);
expect(controller.getRenderItems()).toEqual(items);
});
it('should order items', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
const item1 = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 1',
priority: 40,
};
const item2 = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 2',
priority: 10,
};
const item3 = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 3',
priority: 60,
};
const item4 = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 4',
priority: undefined,
};
controller.setItems([item1, item2, item3, item4]);
expect(controller.getRenderItems()).toEqual([item3, item4, item1, item2]);
});
it('should treat exclusive items exclusively', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
const item1 = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 1',
priority: 100,
};
const exclusiveItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 2',
priority: 1,
exclusive: true,
};
controller.setItems([item1, exclusiveItem]);
expect(controller.getRenderItems()).toEqual([exclusiveItem]);
});
describe('should recognize sufficient items', () => {
it('with sufficient item', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
const insufficientItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 1',
sufficient: false,
};
const sufficientItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 2',
sufficient: true,
};
controller.setItems([insufficientItem, sufficientItem]);
expect(controller.getRenderItems()).toEqual([insufficientItem, sufficientItem]);
expect(controller.shouldRender()).toBeTruthy();
});
it('without sufficient item', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
const insufficientItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 1',
sufficient: false,
};
const sufficientItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 2',
sufficient: false,
};
controller.setItems([insufficientItem, sufficientItem]);
expect(controller.getRenderItems()).toEqual([insufficientItem, sufficientItem]);
expect(controller.shouldRender()).toBeFalsy();
});
});
describe('should deal with popup styles correctly', () => {
it('should show from empty to sufficient', () => {
const host = createLitElement();
setOrRemoveAttribute(host, true, 'hide');
const controller = new StatusBarController(host);
controller.setConfig(
createConfig({
style: 'popup',
}),
);
const sufficientItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 1',
priority: 100,
sufficient: true,
};
controller.setItems([sufficientItem]);
expect(host.getAttribute('hide')).toBe(null);
});
it('should not show from empty to insufficient', () => {
const host = createLitElement();
setOrRemoveAttribute(host, true, 'hide');
const controller = new StatusBarController(host);
controller.setConfig(
createConfig({
style: 'popup',
}),
);
const insufficientItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 1',
priority: 100,
sufficient: false,
};
controller.setItems([insufficientItem]);
expect(host.getAttribute('hide')).not.toBeNull();
});
it('should show from sufficient to different sufficient', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
controller.setConfig(
createConfig({
style: 'popup',
}),
);
const sufficientString = {
type: 'custom:frigate-card-status-bar-string',
string: 'String',
priority: 100,
sufficient: true,
};
const sufficientIcon = {
type: 'custom:frigate-card-status-bar-icon',
icon: 'Icon',
priority: 100,
sufficient: true,
};
const sufficientImage = {
type: 'custom:frigate-card-status-bar-image',
image: 'Image',
priority: 100,
sufficient: true,
};
controller.setItems([sufficientString]);
// Emulate the popup being hidden.
setOrRemoveAttribute(host, true, 'hide');
controller.setItems([sufficientIcon]);
expect(host.getAttribute('hide')).toBe(null);
// Emulate the popup being hidden.
setOrRemoveAttribute(host, true, 'hide');
controller.setItems([sufficientImage]);
expect(host.getAttribute('hide')).toBe(null);
});
it('should hide popup after expiry', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
controller.setConfig(
createConfig({
style: 'popup',
}),
);
const sufficientItem = {
type: 'custom:frigate-card-status-bar-string',
string: 'Item 1',
priority: 100,
sufficient: true,
};
controller.setItems([sufficientItem]);
expect(host.getAttribute('hide')).toBe(null);
vi.advanceTimersByTime(1000);
expect(host.getAttribute('hide')).toBe(null);
vi.advanceTimersByTime(2000);
expect(host.getAttribute('hide')).not.toBe(null);
});
});
});
describe('should handle actions', () => {
it('should bail without action', () => {
const host = createLitElement();
const handler = vi.fn();
host.addEventListener('frigate-card:action:execution-request', handler);
const controller = new StatusBarController(host);
controller.actionHandler(createInteractionEvent('tap'));
expect(handler).not.toBeCalled();
});
it('should request action execution', () => {
const host = createLitElement();
const handler = vi.fn();
host.addEventListener('frigate-card:action:execution-request', handler);
const controller = new StatusBarController(host);
const action = {
action: 'fire-dom-event' as const,
};
const tapActionConfig = {
tap_action: action,
};
controller.actionHandler(createInteractionEvent('tap'), tapActionConfig);
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: { action: [action], config: tapActionConfig },
}),
);
});
});
});
+111 -1
View File
@@ -25,7 +25,6 @@ import {
frigateCardConfigSchema,
} from '../../src/config/types';
import { getParseErrorPaths } from '../../src/utils/zod';
import { update } from 'lodash-es';
describe('general functions', () => {
it('should set value', () => {
@@ -3193,5 +3192,116 @@ describe('should handle version specific upgrades', () => {
});
postUpgradeChecks(config);
});
describe('title controls to status bar', () => {
it('when mode is none', () => {
const config = {
type: 'custom:frigate-card',
cameras: [{}],
live: {
controls: {
title: {
mode: 'none',
},
},
},
media_viewer: {
controls: {
title: {
mode: 'none',
},
},
},
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:frigate-card',
cameras: [{}],
live: { controls: {} },
media_viewer: { controls: {} },
status_bar: {
style: 'none',
},
});
postUpgradeChecks(config);
});
describe('when mode is invalid type', () => {
it.each([[{ mode: { should_not_be: 'an object' } }], ['sideways']])(
'%s',
(mode: unknown) => {
const config = {
type: 'custom:frigate-card',
cameras: [{}],
live: {
controls: {
title: {
mode,
},
},
},
media_viewer: {
controls: {
title: {
mode,
},
},
},
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:frigate-card',
cameras: [{}],
live: { controls: {} },
media_viewer: { controls: {} },
});
postUpgradeChecks(config);
},
);
});
describe.each([['bottom'], ['top']])('on the %s', (position: string) => {
it.each([
[`popup-${position}-left` as const],
[`popup-${position}-right` as const],
])('%s', (mode: string) => {
const config = {
type: 'custom:frigate-card',
cameras: [{}],
live: {
controls: {
title: {
mode,
},
},
},
media_viewer: {
controls: {
title: {
mode,
},
},
},
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:frigate-card',
cameras: [{}],
live: { controls: {} },
media_viewer: { controls: {} },
status_bar: {
position,
},
});
postUpgradeChecks(config);
});
});
});
});
});
@@ -13,7 +13,6 @@ it('low performance profile', () => {
'live.controls.thumbnails.show_favorite_control': false,
'live.controls.thumbnails.show_timeline_control': false,
'live.controls.timeline.show_recordings': false,
'live.controls.title.mode': 'none',
'live.draggable': false,
'live.lazy_unload': 'all',
'live.show_image_during_load': false,
@@ -32,7 +31,6 @@ it('low performance profile', () => {
'media_viewer.controls.thumbnails.show_favorite_control': false,
'media_viewer.controls.thumbnails.show_timeline_control': false,
'media_viewer.controls.timeline.show_recordings': false,
'media_viewer.controls.title.mode': 'none',
'media_viewer.draggable': false,
'media_viewer.snapshot_click_plays_clip': false,
'media_viewer.transition_effect': 'none',
@@ -44,6 +42,7 @@ it('low performance profile', () => {
'performance.features.media_chunk_size': 10,
'performance.style.border_radius': false,
'performance.style.box_shadow': false,
'status_bar.style': 'none',
'timeline.controls.thumbnails.mode': 'none',
'timeline.controls.thumbnails.show_details': false,
'timeline.controls.thumbnails.show_download_control': false,
+79 -1
View File
@@ -5,6 +5,7 @@ import {
customSchema,
dimensionsConfigSchema,
frigateCardCustomActionsBaseSchema,
frigateCardCustomActionSchema,
} from '../../src/config/types';
import { createConfig } from '../test-utils';
@@ -273,6 +274,30 @@ describe('config defaults', () => {
box_shadow: true,
},
},
status_bar: {
height: 46,
items: {
engine: {
enabled: true,
priority: 50,
},
resolution: {
enabled: true,
priority: 50,
},
technology: {
enabled: true,
priority: 50,
},
title: {
enabled: true,
priority: 50,
},
},
popup_seconds: 3,
position: 'bottom',
style: 'popup',
},
timeline: {
clustering_threshold: 3,
controls: {
@@ -507,7 +532,7 @@ describe('should convert webrtc card PTZ to Frigate card PTZ', () => {
});
});
describe('should lazy evaluate', () => {
describe('should lazy evaluate schemas', () => {
it('conditional picture element', () => {
expect(
conditionalSchema.parse({
@@ -555,6 +580,21 @@ describe('should lazy evaluate', () => {
type: 'conditional',
});
});
it('status bar actions', () => {
const input = {
action: 'fire-dom-event',
frigate_card_action: 'status_bar',
status_bar_action: 'reset',
items: [
{
type: 'custom:frigate-card-status-bar-string',
string: 'Item',
},
],
};
expect(frigateCardCustomActionSchema.parse(input)).toEqual(input);
});
});
describe('should handle custom frigate elements', () => {
@@ -621,3 +661,41 @@ it('media viewer should not support microphone based conditions', () => {
}),
).toThrowError();
});
describe('automations should require at least one action', () => {
it('no action', () => {
expect(() =>
createConfig({
cameras: [{}],
automations: [{ conditions: [] }],
}),
).toThrowError(/Automations must include at least one action/);
});
it('empty actions', () => {
expect(() =>
createConfig({
cameras: [{}],
automations: [{ conditions: [], actions: [], actions_not: [] }],
}),
).toThrowError(/Automations must include at least one action/);
});
it('at least one action', () => {
expect(() =>
createConfig({
cameras: [{}],
automations: [
{
conditions: [],
actions: [
{
action: 'fire-dom-event',
},
],
},
],
}),
).not.toThrowError();
});
});
+22 -3
View File
@@ -1,4 +1,8 @@
import { CurrentUser, HomeAssistant } from '@dermotduffy/custom-card-helpers';
import {
CurrentUser,
HASSDomEvent,
HomeAssistant,
} from '@dermotduffy/custom-card-helpers';
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
import { LitElement } from 'lit';
import { expect, vi } from 'vitest';
@@ -15,28 +19,31 @@ import {
CameraManagerMediaCapabilities,
} from '../src/camera-manager/types';
import { ActionsManager } from '../src/card-controller/actions/actions-manager';
import { DefaultManager } from '../src/card-controller/default-manager';
import { AutomationsManager } from '../src/card-controller/automations-manager';
import { CameraURLManager } from '../src/card-controller/camera-url-manager';
import { CardElementManager } from '../src/card-controller/card-element-manager';
import { ConditionsManager } from '../src/card-controller/conditions-manager';
import { ConfigManager } from '../src/card-controller/config/config-manager';
import { CardController } from '../src/card-controller/controller';
import { DefaultManager } from '../src/card-controller/default-manager';
import { DownloadManager } from '../src/card-controller/download-manager';
import { ExpandManager } from '../src/card-controller/expand-manager';
import { FullscreenManager } from '../src/card-controller/fullscreen-manager';
import { HASSManager } from '../src/card-controller/hass-manager';
import { InitializationManager } from '../src/card-controller/initialization-manager';
import { InteractionManager } from '../src/card-controller/interaction-manager';
import { KeyboardStateManager } from '../src/card-controller/keyboard-state-manager';
import { MediaLoadedInfoManager } from '../src/card-controller/media-info-manager';
import { MediaPlayerManager } from '../src/card-controller/media-player-manager';
import { MessageManager } from '../src/card-controller/message-manager';
import { MicrophoneManager } from '../src/card-controller/microphone-manager';
import { QueryStringManager } from '../src/card-controller/query-string-manager';
import { StatusBarItemManager } from '../src/card-controller/status-bar-item-manager';
import { StyleManager } from '../src/card-controller/style-manager';
import { TriggersManager } from '../src/card-controller/triggers-manager';
import { ViewManager } from '../src/card-controller/view/view-manager';
import {
ActionsConfig,
CameraConfig,
FrigateCardCondition,
FrigateCardConfig,
@@ -55,7 +62,6 @@ import { Entity } from '../src/utils/ha/entity-registry/types';
import { ViewMedia, ViewMediaType } from '../src/view/media';
import { MediaQueriesResults } from '../src/view/media-queries-results';
import { View, ViewParameters } from '../src/view/view';
import { KeyboardStateManager } from '../src/card-controller/keyboard-state-manager';
export const createAction = (
action: Record<string, unknown>,
@@ -450,6 +456,7 @@ export const createCardAPI = (): CardController => {
api.getMessageManager.mockReturnValue(mock<MessageManager>());
api.getMicrophoneManager.mockReturnValue(mock<MicrophoneManager>());
api.getQueryStringManager.mockReturnValue(mock<QueryStringManager>());
api.getStatusBarItemManager.mockReturnValue(mock<StatusBarItemManager>());
api.getStyleManager.mockReturnValue(mock<StyleManager>());
api.getTriggersManager.mockReturnValue(mock<TriggersManager>());
api.getViewManager.mockReturnValue(mock<ViewManager>());
@@ -473,3 +480,15 @@ export const callHASubscribeMessageHandler = (
export const flushPromises = async (): Promise<void> => {
await new Promise(process.nextTick);
};
export const createInteractionEvent = (
action: string,
config?: ActionsConfig,
): HASSDomEvent<{ action: string; config?: ActionsConfig }> => {
return new CustomEvent<{ action: string; config?: ActionsConfig }>('@action', {
detail: {
action: action,
config: config,
},
});
};