feat: Add hardened error handling and retries (#2451)

- Closes #1830
 - Closes #2099
This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 4bc787e2b7
commit 47bcce93d3
182 changed files with 7877 additions and 4043 deletions
@@ -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();