feat: Add hardened error handling and retries (#2451)
- Closes #1830 - Closes #2099
This commit is contained in:
committed by
dermotduffy
parent
4bc787e2b7
commit
47bcce93d3
+52
-52
@@ -7,9 +7,9 @@ import { CardController } from '../../../src/card-controller/controller';
|
||||
import { ViewItemManager } from '../../../src/card-controller/view/item-manager';
|
||||
import { ViewManagerEpoch } from '../../../src/card-controller/view/types';
|
||||
import {
|
||||
MediaDetailsController,
|
||||
MediaNotificationController,
|
||||
NotificationControlsContext,
|
||||
} from '../../../src/components-lib/media/details-controller';
|
||||
} from '../../../src/components-lib/media/notification-controller';
|
||||
import { NotificationControl } from '../../../src/config/schema/actions/types';
|
||||
import { formatDateAndTime } from '../../../src/utils/basic';
|
||||
import { downloadMedia, navigateToTimeline } from '../../../src/utils/media-actions';
|
||||
@@ -34,7 +34,7 @@ async function executeControlAction(
|
||||
await action?.execute(api);
|
||||
}
|
||||
|
||||
describe('MediaDetailsController', () => {
|
||||
describe('MediaNotificationController', () => {
|
||||
describe('should set heading', () => {
|
||||
it('should set heading on event with what, tags and score', () => {
|
||||
const item = new TestViewMedia({
|
||||
@@ -43,7 +43,7 @@ describe('MediaDetailsController', () => {
|
||||
score: 0.5,
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
expect(controller.getHeading()?.text).toBe('Person, Car: Tag1, Tag2 50.00%');
|
||||
});
|
||||
@@ -53,7 +53,7 @@ describe('MediaDetailsController', () => {
|
||||
tags: ['tag1', 'tag2'],
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
expect(controller.getHeading()?.text).toBe('Tag1, Tag2');
|
||||
});
|
||||
@@ -63,7 +63,7 @@ describe('MediaDetailsController', () => {
|
||||
what: ['person', 'car'],
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
expect(controller.getHeading()?.text).toBe('Person, Car');
|
||||
});
|
||||
@@ -76,7 +76,7 @@ describe('MediaDetailsController', () => {
|
||||
score: null,
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
expect(controller.getHeading()).toBeNull();
|
||||
});
|
||||
@@ -92,7 +92,7 @@ describe('MediaDetailsController', () => {
|
||||
mediaType: ViewMediaType.Recording,
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(cameraManager, item);
|
||||
expect(controller.getHeading()?.text).toBe('Camera Title');
|
||||
});
|
||||
@@ -102,7 +102,7 @@ describe('MediaDetailsController', () => {
|
||||
mediaType: ViewMediaType.Recording,
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
expect(controller.getHeading()).toBeNull();
|
||||
});
|
||||
@@ -110,7 +110,7 @@ describe('MediaDetailsController', () => {
|
||||
it('should set no heading on folder', () => {
|
||||
const item = new ViewFolder(createFolder(), []);
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
expect(controller.getHeading()).toBeNull();
|
||||
});
|
||||
@@ -124,9 +124,9 @@ describe('MediaDetailsController', () => {
|
||||
where: ['where1', 'where2'],
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
expect(controller.getDetails()).toContainEqual({
|
||||
expect(controller.getMetadata()).toContainEqual({
|
||||
text: 'Test Event',
|
||||
icon: 'mdi:rename',
|
||||
tooltip: 'Title',
|
||||
@@ -138,9 +138,9 @@ describe('MediaDetailsController', () => {
|
||||
title: 'Test Event',
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
expect(controller.getDetails()).toEqual([
|
||||
expect(controller.getMetadata()).toEqual([
|
||||
{
|
||||
text: 'Test Event',
|
||||
},
|
||||
@@ -153,9 +153,9 @@ describe('MediaDetailsController', () => {
|
||||
startTime: new Date('2025-05-22T21:12:00Z'),
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
expect(controller.getDetails()).not.toContainEqual(
|
||||
expect(controller.getMetadata()).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
text: 'Test Event',
|
||||
}),
|
||||
@@ -169,11 +169,11 @@ describe('MediaDetailsController', () => {
|
||||
startTime,
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
|
||||
// Use formatDateAndTime to generate expected value (formats in local time with seconds)
|
||||
expect(controller.getDetails()).toContainEqual({
|
||||
expect(controller.getMetadata()).toContainEqual({
|
||||
text: formatDateAndTime(startTime, true),
|
||||
tooltip: 'Start',
|
||||
icon: 'mdi:calendar-clock-outline',
|
||||
@@ -187,9 +187,9 @@ describe('MediaDetailsController', () => {
|
||||
endTime: new Date('2025-05-18T17:04:00Z'),
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
expect(controller.getDetails()).toContainEqual({
|
||||
expect(controller.getMetadata()).toContainEqual({
|
||||
text: '1m 0s',
|
||||
tooltip: 'Duration',
|
||||
icon: 'mdi:clock-outline',
|
||||
@@ -203,10 +203,10 @@ describe('MediaDetailsController', () => {
|
||||
inProgress: true,
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
expect(controller.getDetails()).toContainEqual({
|
||||
text: 'In Progress',
|
||||
expect(controller.getMetadata()).toContainEqual({
|
||||
text: 'In progress...',
|
||||
tooltip: 'Duration',
|
||||
icon: 'mdi:clock-outline',
|
||||
});
|
||||
@@ -219,10 +219,10 @@ describe('MediaDetailsController', () => {
|
||||
inProgress: true,
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
expect(controller.getDetails()).toContainEqual({
|
||||
text: '1m 0s In Progress',
|
||||
expect(controller.getMetadata()).toContainEqual({
|
||||
text: '1m 0s In progress...',
|
||||
tooltip: 'Duration',
|
||||
icon: 'mdi:clock-outline',
|
||||
});
|
||||
@@ -240,9 +240,9 @@ describe('MediaDetailsController', () => {
|
||||
cameraID: 'camera_1',
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(cameraManager, item);
|
||||
expect(controller.getDetails()).toContainEqual({
|
||||
expect(controller.getMetadata()).toContainEqual({
|
||||
text: 'Camera Title',
|
||||
tooltip: 'Camera',
|
||||
icon: 'mdi:cctv',
|
||||
@@ -255,9 +255,9 @@ describe('MediaDetailsController', () => {
|
||||
where: ['where1', 'where2'],
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
expect(controller.getDetails()).toContainEqual({
|
||||
expect(controller.getMetadata()).toContainEqual({
|
||||
text: 'Where1, Where2',
|
||||
tooltip: 'Where',
|
||||
icon: 'mdi:map-marker-outline',
|
||||
@@ -270,9 +270,9 @@ describe('MediaDetailsController', () => {
|
||||
tags: ['tag1', 'tag2'],
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
expect(controller.getDetails()).toContainEqual({
|
||||
expect(controller.getMetadata()).toContainEqual({
|
||||
text: 'Tag1, Tag2',
|
||||
tooltip: 'Tag',
|
||||
icon: 'mdi:tag',
|
||||
@@ -283,11 +283,11 @@ describe('MediaDetailsController', () => {
|
||||
const item = new TestViewMedia();
|
||||
const seekTime = new Date('2025-05-20T07:14:57Z');
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item, seekTime);
|
||||
|
||||
// Use format() to generate expected value (formats in local time)
|
||||
expect(controller.getDetails()).toContainEqual({
|
||||
expect(controller.getMetadata()).toContainEqual({
|
||||
text: format(seekTime, 'HH:mm:ss'),
|
||||
tooltip: 'Seek',
|
||||
icon: 'mdi:clock-fast',
|
||||
@@ -300,7 +300,7 @@ describe('MediaDetailsController', () => {
|
||||
severity: 'high',
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
const heading = controller.getHeading();
|
||||
expect(heading?.text).toBe('Review Title');
|
||||
@@ -316,7 +316,7 @@ describe('MediaDetailsController', () => {
|
||||
severity: null,
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
const heading = controller.getHeading();
|
||||
expect(heading?.text).toBe('Review Title');
|
||||
@@ -329,16 +329,16 @@ describe('MediaDetailsController', () => {
|
||||
title: null,
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
expect(controller.getHeading()).toBeNull();
|
||||
});
|
||||
|
||||
it('should calculate with null item', () => {
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, undefined);
|
||||
expect(controller.getHeading()).toBeNull();
|
||||
expect(controller.getDetails()).toEqual([]);
|
||||
expect(controller.getMetadata()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -354,25 +354,25 @@ describe('MediaDetailsController', () => {
|
||||
description: 'Test Description',
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
|
||||
const notification = controller.getNotification();
|
||||
expect(notification.heading?.text).toBe('Person');
|
||||
expect(notification.details).toContainEqual({
|
||||
expect(notification.metadata).toContainEqual({
|
||||
text: 'Test Title',
|
||||
});
|
||||
expect(notification.text).toBe('Test Description');
|
||||
expect(notification.body).toEqual({ text: 'Test Description' });
|
||||
});
|
||||
|
||||
it('should get notification without media', () => {
|
||||
const item = new ViewFolder(createFolder(), []);
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
|
||||
const notification = controller.getNotification();
|
||||
expect(notification.text).toBeUndefined();
|
||||
expect(notification.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should get notification with null description', () => {
|
||||
@@ -380,11 +380,11 @@ describe('MediaDetailsController', () => {
|
||||
description: null,
|
||||
});
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
|
||||
const notification = controller.getNotification();
|
||||
expect(notification.text).toBeUndefined();
|
||||
expect(notification.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should get notification with controls', async () => {
|
||||
@@ -408,7 +408,7 @@ describe('MediaDetailsController', () => {
|
||||
viewManagerEpoch: viewManagerEpoch,
|
||||
};
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
|
||||
const notification = controller.getNotification(context);
|
||||
@@ -469,7 +469,7 @@ describe('MediaDetailsController', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
|
||||
const notification = controller.getNotification(context);
|
||||
@@ -494,7 +494,7 @@ describe('MediaDetailsController', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
|
||||
const notification = controller.getNotification(context);
|
||||
@@ -507,7 +507,7 @@ describe('MediaDetailsController', () => {
|
||||
});
|
||||
const context = {};
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
const controller = new MediaNotificationController();
|
||||
controller.calculate(null, item);
|
||||
|
||||
const notification = controller.getNotification(context);
|
||||
@@ -515,11 +515,11 @@ describe('MediaDetailsController', () => {
|
||||
});
|
||||
|
||||
it('should get empty controls when item is null', () => {
|
||||
const controller = new MediaDetailsController();
|
||||
const ctrl = new MediaNotificationController();
|
||||
// Directly call protected method via casting to test the null item branch.
|
||||
// Use cast to unknown first to avoid any-related lint errors.
|
||||
const controls = (
|
||||
controller as unknown as {
|
||||
ctrl as unknown as {
|
||||
_getControls: (context: NotificationControlsContext) => NotificationControl[];
|
||||
}
|
||||
)._getControls({});
|
||||
@@ -1,157 +0,0 @@
|
||||
import yaml from 'js-yaml';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MessageController } from '../../../src/components-lib/message/controller';
|
||||
import { Link } from '../../../src/config/schema/common/link';
|
||||
import { TROUBLESHOOTING_URL } from '../../../src/const';
|
||||
import { localize } from '../../../src/localize/localize';
|
||||
import { Message, MessageType } from '../../../src/types';
|
||||
|
||||
describe('MessageController', () => {
|
||||
describe('should return the correct message string', () => {
|
||||
it('should return simple message string', () => {
|
||||
const controller = new MessageController();
|
||||
const message: Message = {
|
||||
message: 'Message',
|
||||
type: 'info',
|
||||
};
|
||||
expect(controller.getMessageString(message)).toBe('Message');
|
||||
});
|
||||
|
||||
it('should embed simple string context', () => {
|
||||
const controller = new MessageController();
|
||||
const message: Message = {
|
||||
message: 'Message',
|
||||
context: 'Context',
|
||||
type: 'info',
|
||||
};
|
||||
expect(controller.getMessageString(message)).toBe('Message: Context');
|
||||
});
|
||||
});
|
||||
|
||||
describe('should return the correct icon', () => {
|
||||
describe('when icon is specified', () => {
|
||||
it.each([['info' as const], ['error' as const], ['connection' as const]])(
|
||||
'%s',
|
||||
(type: MessageType) => {
|
||||
const controller = new MessageController();
|
||||
const message: Message = {
|
||||
message: 'Message',
|
||||
icon: 'mdi:car',
|
||||
type,
|
||||
};
|
||||
expect(controller.getIcon(message)).toBe('mdi:car');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('when type is an error', () => {
|
||||
const controller = new MessageController();
|
||||
const message: Message = {
|
||||
message: 'Message',
|
||||
type: 'error',
|
||||
};
|
||||
expect(controller.getIcon(message)).toBe('mdi:alert-circle');
|
||||
});
|
||||
|
||||
it('by default', () => {
|
||||
const controller = new MessageController();
|
||||
const message: Message = {
|
||||
message: 'Message',
|
||||
};
|
||||
expect(controller.getIcon(message)).toBe('mdi:information-outline');
|
||||
});
|
||||
});
|
||||
|
||||
describe('should show troubleshooting link', () => {
|
||||
it('should show for errors', () => {
|
||||
const controller = new MessageController();
|
||||
const message: Message = { message: 'Error message', type: 'error' };
|
||||
expect(controller.getLink(message)).toEqual({
|
||||
url: TROUBLESHOOTING_URL,
|
||||
title: localize('error.troubleshooting'),
|
||||
});
|
||||
});
|
||||
|
||||
describe('should not show for other types', () => {
|
||||
it.each([['info' as const], ['connection' as const]])(
|
||||
'%s',
|
||||
(type: MessageType) => {
|
||||
const controller = new MessageController();
|
||||
const message: Message = {
|
||||
message: 'Message',
|
||||
icon: 'mdi:car',
|
||||
type,
|
||||
};
|
||||
expect(controller.getLink(message)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('should show correct URL', () => {
|
||||
it('by default', () => {
|
||||
const controller = new MessageController();
|
||||
const message: Message = { message: 'Error message', type: 'error' };
|
||||
expect(controller.getLink(message)?.url).toBe(TROUBLESHOOTING_URL);
|
||||
});
|
||||
|
||||
it('when specified', () => {
|
||||
const controller = new MessageController();
|
||||
const url: Link = {
|
||||
url: 'link',
|
||||
title: 'title',
|
||||
};
|
||||
const message: Message = {
|
||||
message: 'Error message',
|
||||
type: 'error',
|
||||
link: url,
|
||||
};
|
||||
expect(controller.getLink(message)).toBe(url);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should get context strings', () => {
|
||||
it('for no context', () => {
|
||||
const controller = new MessageController();
|
||||
const message: Message = {
|
||||
message: 'Message',
|
||||
type: 'info',
|
||||
};
|
||||
expect(controller.getContextStrings(message)).toEqual([]);
|
||||
});
|
||||
|
||||
it('for simple string', () => {
|
||||
const controller = new MessageController();
|
||||
const message: Message = {
|
||||
message: 'Message',
|
||||
context: 'Context',
|
||||
type: 'info',
|
||||
};
|
||||
expect(controller.getContextStrings(message)).toEqual(['Context']);
|
||||
});
|
||||
|
||||
it('for object', () => {
|
||||
const controller = new MessageController();
|
||||
const obj = { one: 1, two: 2 };
|
||||
const message: Message = {
|
||||
message: 'Message',
|
||||
context: obj,
|
||||
type: 'info',
|
||||
};
|
||||
expect(controller.getContextStrings(message)).toEqual([yaml.dump(obj)]);
|
||||
});
|
||||
|
||||
it('for array', () => {
|
||||
const controller = new MessageController();
|
||||
const array = ['one', 'two'];
|
||||
const message: Message = {
|
||||
message: 'Message',
|
||||
context: array,
|
||||
type: 'info',
|
||||
};
|
||||
expect(controller.getContextStrings(message)).toEqual(
|
||||
array.map((item) => yaml.dump(item)),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { dispatchAdvancedCameraCardErrorEvent } from '../../../src/components-lib/message/dispatch';
|
||||
import { AdvancedCameraCardError } from '../../../src/types';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
it('should ignore non-error', () => {
|
||||
const element = document.createElement('div');
|
||||
const handler = vi.fn();
|
||||
element.addEventListener('advanced-camera-card:message', handler);
|
||||
|
||||
dispatchAdvancedCameraCardErrorEvent(element, 'NOT_ADVANCED_CAMERA_CARD_EVENT');
|
||||
|
||||
expect(handler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should dispatch error', () => {
|
||||
const element = document.createElement('div');
|
||||
const handler = vi.fn();
|
||||
element.addEventListener('advanced-camera-card:message', handler);
|
||||
|
||||
dispatchAdvancedCameraCardErrorEvent(element, new Error('ERROR'));
|
||||
|
||||
expect(handler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: expect.objectContaining({
|
||||
message: 'ERROR',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch error with context', () => {
|
||||
const element = document.createElement('div');
|
||||
const handler = vi.fn();
|
||||
element.addEventListener('advanced-camera-card:message', handler);
|
||||
|
||||
dispatchAdvancedCameraCardErrorEvent(
|
||||
element,
|
||||
new AdvancedCameraCardError('ERROR', 'CONTEXT'),
|
||||
);
|
||||
|
||||
expect(handler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: expect.objectContaining({
|
||||
message: 'ERROR',
|
||||
context: 'CONTEXT',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { dispatchActionExecutionRequest } from '../../../src/card-controller/actions/utils/execution-request';
|
||||
import { handleControlAction } from '../../../src/components-lib/notification/action';
|
||||
import { NotificationControl } from '../../../src/config/schema/actions/types';
|
||||
import {
|
||||
getActionConfigGivenAction,
|
||||
stopEventFromActivatingCardWideActions,
|
||||
} from '../../../src/utils/action';
|
||||
|
||||
vi.mock('../../../src/card-controller/actions/utils/execution-request.js');
|
||||
vi.mock('../../../src/utils/action.js');
|
||||
|
||||
describe('handleControlAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const createControl = (
|
||||
overrides?: Partial<NotificationControl>,
|
||||
): NotificationControl => ({
|
||||
dismiss: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('should stop the event from activating card-wide actions', () => {
|
||||
const ev = new CustomEvent('action', { detail: { action: 'tap' } });
|
||||
const host = document.createElement('div');
|
||||
handleControlAction(ev, createControl(), host);
|
||||
|
||||
expect(stopEventFromActivatingCardWideActions).toBeCalledWith(ev);
|
||||
});
|
||||
|
||||
it('should dispatch action when getActionConfigGivenAction returns an action', () => {
|
||||
const action = { action: 'navigate' as const, navigation_path: '/foo' };
|
||||
vi.mocked(getActionConfigGivenAction).mockReturnValue(action);
|
||||
|
||||
const ev = new CustomEvent('action', { detail: { action: 'tap' } });
|
||||
const control = createControl({ actions: { tap_action: action } });
|
||||
const host = document.createElement('div');
|
||||
|
||||
handleControlAction(ev, control, host);
|
||||
|
||||
expect(dispatchActionExecutionRequest).toBeCalledWith(host, {
|
||||
actions: [action],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not dispatch when getActionConfigGivenAction returns null', () => {
|
||||
vi.mocked(getActionConfigGivenAction).mockReturnValue(null);
|
||||
|
||||
const ev = new CustomEvent('action', { detail: { action: 'tap' } });
|
||||
const host = document.createElement('div');
|
||||
|
||||
handleControlAction(ev, createControl(), host);
|
||||
|
||||
expect(dispatchActionExecutionRequest).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should call onDismiss when dismiss is not false', () => {
|
||||
vi.mocked(getActionConfigGivenAction).mockReturnValue(null);
|
||||
|
||||
const ev = new CustomEvent('action', { detail: { action: 'tap' } });
|
||||
const host = document.createElement('div');
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
handleControlAction(ev, createControl({ dismiss: true }), host, onDismiss);
|
||||
|
||||
expect(onDismiss).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not call onDismiss when dismiss is false', () => {
|
||||
vi.mocked(getActionConfigGivenAction).mockReturnValue(null);
|
||||
|
||||
const ev = new CustomEvent('action', { detail: { action: 'tap' } });
|
||||
const host = document.createElement('div');
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
handleControlAction(ev, createControl({ dismiss: false }), host, onDismiss);
|
||||
|
||||
expect(onDismiss).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not call onDismiss when no onDismiss is provided', () => {
|
||||
vi.mocked(getActionConfigGivenAction).mockReturnValue(null);
|
||||
|
||||
const ev = new CustomEvent('action', { detail: { action: 'tap' } });
|
||||
const host = document.createElement('div');
|
||||
|
||||
// Should not throw when onDismiss is undefined
|
||||
expect(() => handleControlAction(ev, createControl(), host)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dataToContext } from '../../../src/components-lib/notification/data-to-context';
|
||||
|
||||
describe('dataToContext', () => {
|
||||
it('should return an array of string items unchanged when input is an array of strings', () => {
|
||||
expect(dataToContext(['line one', 'line two'])).toEqual(['line one', 'line two']);
|
||||
});
|
||||
|
||||
it('should YAML-dump object items when input is an array containing objects', () => {
|
||||
const result = dataToContext([{ key: 'value' }]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain('key: value');
|
||||
});
|
||||
|
||||
it('should handle a mixed array of strings and objects', () => {
|
||||
const result = dataToContext(['plain string', { foo: 'bar' }]);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe('plain string');
|
||||
expect(result[1]).toContain('foo: bar');
|
||||
});
|
||||
|
||||
it('should return a single YAML-dumped string for a plain object', () => {
|
||||
const result = dataToContext({ error: 'something went wrong', code: 42 });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain('error: something went wrong');
|
||||
expect(result[0]).toContain('code: 42');
|
||||
});
|
||||
|
||||
it('should return an empty array for an empty array input', () => {
|
||||
expect(dataToContext([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { assert, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createNotificationFromError,
|
||||
createNotificationFromText,
|
||||
} from '../../../src/components-lib/notification/factory';
|
||||
import { AdvancedCameraCardError } from '../../../src/types';
|
||||
|
||||
describe('createNotificationFromText', () => {
|
||||
it('should create a notification with body text', () => {
|
||||
const notification = createNotificationFromText('Something failed');
|
||||
expect(notification.body?.text).toBe('Something failed');
|
||||
});
|
||||
|
||||
it('should add the default error icon to body when no heading and no icon provided', () => {
|
||||
const notification = createNotificationFromText('oops');
|
||||
expect(notification.body?.icon).toBe('mdi:alert');
|
||||
});
|
||||
|
||||
it('should add a custom icon to body when specified', () => {
|
||||
const notification = createNotificationFromText('oops', { icon: 'mdi:wifi-off' });
|
||||
expect(notification.body?.icon).toBe('mdi:wifi-off');
|
||||
});
|
||||
|
||||
it('should include the heading when provided', () => {
|
||||
const notification = createNotificationFromText('oops', {
|
||||
heading: { text: 'Error heading' },
|
||||
});
|
||||
expect(notification.heading?.text).toBe('Error heading');
|
||||
});
|
||||
|
||||
it('should omit icon from body when heading is provided and no icon is specified', () => {
|
||||
const notification = createNotificationFromText('oops', {
|
||||
heading: { text: 'Error heading' },
|
||||
});
|
||||
expect(notification.body?.icon).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should add icon to body when heading is provided but icon is also specified', () => {
|
||||
const notification = createNotificationFromText('oops', {
|
||||
heading: { text: 'Error heading' },
|
||||
icon: 'mdi:alert-circle',
|
||||
});
|
||||
expect(notification.body?.icon).toBe('mdi:alert-circle');
|
||||
});
|
||||
|
||||
it('should include metadata when provided', () => {
|
||||
const notification = createNotificationFromText('oops', {
|
||||
metadata: [{ text: 'meta line' }],
|
||||
});
|
||||
expect(notification.metadata).toEqual([{ text: 'meta line' }]);
|
||||
});
|
||||
|
||||
it('should omit metadata when not provided', () => {
|
||||
const notification = createNotificationFromText('oops');
|
||||
expect(notification.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include link when provided', () => {
|
||||
const notification = createNotificationFromText('oops', {
|
||||
link: { url: 'https://example.com', title: 'Docs' },
|
||||
});
|
||||
expect(notification.link?.url).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('should include context when provided', () => {
|
||||
const notification = createNotificationFromText('oops', {
|
||||
context: { detail: 'extra info' },
|
||||
});
|
||||
expect(notification.context).toBeDefined();
|
||||
expect(notification.context?.join(' ')).toContain('detail: extra info');
|
||||
});
|
||||
|
||||
it('should omit context when not provided', () => {
|
||||
const notification = createNotificationFromText('oops');
|
||||
expect(notification.context).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include in_progress when true', () => {
|
||||
const notification = createNotificationFromText('oops', { in_progress: true });
|
||||
expect(notification.in_progress).toBe(true);
|
||||
});
|
||||
|
||||
it('should include in_progress when false', () => {
|
||||
const notification = createNotificationFromText('oops', { in_progress: false });
|
||||
expect(notification.in_progress).toBe(false);
|
||||
});
|
||||
|
||||
it('should omit in_progress when not provided', () => {
|
||||
const notification = createNotificationFromText('oops');
|
||||
expect(notification.in_progress).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNotificationFromError', () => {
|
||||
it('should create a notification from an Error object', () => {
|
||||
const notification = createNotificationFromError(new Error('something failed'));
|
||||
assert(notification);
|
||||
expect(notification.body?.text).toBe('something failed');
|
||||
});
|
||||
|
||||
it('should create a notification from a string error', () => {
|
||||
const notification = createNotificationFromError('plain string error');
|
||||
assert(notification);
|
||||
expect(notification.body?.text).toBe('plain string error');
|
||||
});
|
||||
|
||||
it('should create a notification from a non-error object', () => {
|
||||
const notification = createNotificationFromError({ code: 404 });
|
||||
assert(notification);
|
||||
expect(notification.body?.text).toBe('{"code":404}');
|
||||
});
|
||||
|
||||
it('should merge the default error icon into heading when heading is provided', () => {
|
||||
const notification = createNotificationFromError(new Error('failed'), {
|
||||
heading: { text: 'Init Error' },
|
||||
});
|
||||
assert(notification);
|
||||
expect(notification.heading?.text).toBe('Init Error');
|
||||
expect(notification.heading?.icon).toBe('mdi:alert');
|
||||
expect(notification.heading?.severity).toBe('high');
|
||||
});
|
||||
|
||||
it('should preserve custom heading properties alongside defaults', () => {
|
||||
const notification = createNotificationFromError(new Error('failed'), {
|
||||
heading: { text: 'My Heading', icon: 'mdi:custom', severity: 'medium' },
|
||||
});
|
||||
assert(notification);
|
||||
// Spread order: { icon: DEFAULT, severity: 'high', ...options.heading }
|
||||
// so custom values win
|
||||
expect(notification.heading?.icon).toBe('mdi:custom');
|
||||
expect(notification.heading?.severity).toBe('medium');
|
||||
});
|
||||
|
||||
it('should use context from AdvancedCameraCardError when no explicit context is provided', () => {
|
||||
const error = new AdvancedCameraCardError('boom', { reason: 'network' });
|
||||
const notification = createNotificationFromError(error);
|
||||
assert(notification);
|
||||
expect(notification.context).toBeDefined();
|
||||
expect(notification.context?.join(' ')).toContain('reason: network');
|
||||
});
|
||||
|
||||
it('should use explicit context option over AdvancedCameraCardError context', () => {
|
||||
const error = new AdvancedCameraCardError('boom', { reason: 'network' });
|
||||
const notification = createNotificationFromError(error, {
|
||||
context: { override: 'explicit' },
|
||||
});
|
||||
assert(notification);
|
||||
expect(notification.context?.join(' ')).toContain('override: explicit');
|
||||
expect(notification.context?.join(' ')).not.toContain('reason: network');
|
||||
});
|
||||
|
||||
it('should not include context when AdvancedCameraCardError has a non-object context', () => {
|
||||
const error = new AdvancedCameraCardError('boom', 'not an object');
|
||||
const notification = createNotificationFromError(error);
|
||||
assert(notification);
|
||||
expect(notification.context).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not include context when AdvancedCameraCardError context is null', () => {
|
||||
const error = new AdvancedCameraCardError('boom', null);
|
||||
const notification = createNotificationFromError(error);
|
||||
assert(notification);
|
||||
expect(notification.context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { summarizeNotification } from '../../../src/components-lib/notification/summarize';
|
||||
import { Notification } from '../../../src/config/schema/actions/types';
|
||||
|
||||
describe('summarizeNotification', () => {
|
||||
it('should return the body text when present', () => {
|
||||
const notification: Notification = { body: { text: 'Body text' } };
|
||||
expect(summarizeNotification(notification)).toBe('Body text');
|
||||
});
|
||||
|
||||
it('should return the heading text when body is absent', () => {
|
||||
const notification: Notification = {
|
||||
heading: { text: 'Heading text' },
|
||||
};
|
||||
expect(summarizeNotification(notification)).toBe('Heading text');
|
||||
});
|
||||
|
||||
it('should return the body text even when heading is also present', () => {
|
||||
const notification: Notification = {
|
||||
heading: { text: 'Heading text' },
|
||||
body: { text: 'Body text' },
|
||||
};
|
||||
expect(summarizeNotification(notification)).toBe('Body text');
|
||||
});
|
||||
|
||||
it('should return null when neither body nor heading has text', () => {
|
||||
const notification: Notification = {};
|
||||
expect(summarizeNotification(notification)).toBeNull();
|
||||
});
|
||||
|
||||
it('should append metadata text in brackets', () => {
|
||||
const notification: Notification = {
|
||||
body: { text: 'Media not loading' },
|
||||
metadata: [{ text: 'camera.office' }, { text: 'camera.garden' }],
|
||||
};
|
||||
expect(summarizeNotification(notification)).toBe(
|
||||
'Media not loading [camera.office, camera.garden]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not append brackets when metadata is empty', () => {
|
||||
const notification: Notification = {
|
||||
body: { text: 'Body text' },
|
||||
metadata: [],
|
||||
};
|
||||
expect(summarizeNotification(notification)).toBe('Body text');
|
||||
});
|
||||
});
|
||||
@@ -267,6 +267,105 @@ describe('StatusBarController', () => {
|
||||
expect(host.getAttribute('hide')).toBe(null);
|
||||
});
|
||||
|
||||
it('should not start popup timer when permanent items are present', () => {
|
||||
const host = createLitElement();
|
||||
setOrRemoveAttribute(host, true, 'hide');
|
||||
|
||||
const controller = new StatusBarController(host);
|
||||
controller.setConfig(
|
||||
createConfig({
|
||||
style: 'popup',
|
||||
}),
|
||||
);
|
||||
|
||||
const permanentItem = {
|
||||
type: 'custom:advanced-camera-card-status-bar-icon' as const,
|
||||
icon: 'mdi:alert',
|
||||
sufficient: true,
|
||||
permanent: true,
|
||||
};
|
||||
|
||||
controller.setItems([permanentItem]);
|
||||
expect(host.getAttribute('hide')).toBe(null);
|
||||
|
||||
// Timer should not hide the bar.
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(host.getAttribute('hide')).toBe(null);
|
||||
});
|
||||
|
||||
it('should start popup timer when permanent items are removed', () => {
|
||||
const host = createLitElement();
|
||||
|
||||
const controller = new StatusBarController(host);
|
||||
controller.setConfig(
|
||||
createConfig({
|
||||
style: 'popup',
|
||||
}),
|
||||
);
|
||||
|
||||
const permanentItem = {
|
||||
type: 'custom:advanced-camera-card-status-bar-icon' as const,
|
||||
icon: 'mdi:alert',
|
||||
sufficient: true,
|
||||
permanent: true,
|
||||
};
|
||||
const nonPermanentItem = {
|
||||
type: 'custom:advanced-camera-card-status-bar-string' as const,
|
||||
string: 'Title',
|
||||
sufficient: true,
|
||||
};
|
||||
|
||||
// Start with permanent item — bar stays visible.
|
||||
controller.setItems([permanentItem, nonPermanentItem]);
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(host.getAttribute('hide')).toBe(null);
|
||||
|
||||
// Remove permanent item — popup timer starts.
|
||||
controller.setItems([nonPermanentItem]);
|
||||
expect(host.getAttribute('hide')).toBe(null);
|
||||
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(host.getAttribute('hide')).not.toBe(null);
|
||||
});
|
||||
|
||||
it('should start popup timer when permanent item is removed without changing sufficient items', () => {
|
||||
const host = createLitElement();
|
||||
|
||||
const controller = new StatusBarController(host);
|
||||
controller.setConfig(
|
||||
createConfig({
|
||||
style: 'popup',
|
||||
}),
|
||||
);
|
||||
|
||||
const sufficientItem = {
|
||||
type: 'custom:advanced-camera-card-status-bar-string' as const,
|
||||
string: 'Title',
|
||||
sufficient: true,
|
||||
};
|
||||
// A permanent item that is NOT sufficient — removing it does not
|
||||
// change the sufficient-values set, so the popup timer takes the
|
||||
// dedicated permanent-removal branch.
|
||||
const permanentInsufficientItem = {
|
||||
type: 'custom:advanced-camera-card-status-bar-icon' as const,
|
||||
icon: 'mdi:alert',
|
||||
sufficient: false,
|
||||
permanent: true,
|
||||
};
|
||||
|
||||
controller.setItems([sufficientItem, permanentInsufficientItem]);
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(host.getAttribute('hide')).toBe(null);
|
||||
|
||||
// Remove the permanent (insufficient) item — sufficient values are
|
||||
// unchanged, but the popup timer must still start.
|
||||
controller.setItems([sufficientItem]);
|
||||
expect(host.getAttribute('hide')).toBe(null);
|
||||
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(host.getAttribute('hide')).not.toBe(null);
|
||||
});
|
||||
|
||||
it('should hide popup after expiry', () => {
|
||||
const host = createLitElement();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user