feat: Add support for Frigate reviews / detections [initial PR] (#2315)

- Add support for Frigate reviews / detections.
 - Add support for GenAI metadata.
- Significant internal refactor to more flexible "UnifiedQuery" to allow
mixing cameras with simple metadata and review metadata (e.g. a timeline
view of a Frigate camera with reviews, and a Reolink camera with simple
metadata).
 - Add support for folder media as camera media.

There are a few more PRs to commit prior to this going live, but
commiting this for now due to the scale of the change.

BREAKING CHANGE: `media_type` and `events_type` are retired under
`live`, `viewer` and `timeline` configuration sections, instead media
type is associated (once) with the camera under `media`.
This commit is contained in:
Dermot Duffy authored and GitHub committed 2026-01-19 13:32:50 -08:00
1 parent 6eb0a87ca8
commit fc32727860
215 files changed
+14491 -6160

No files matched your search

+27
View File
@@ -16,6 +16,7 @@ import {
createPTZDigitalAction,
createPTZMultiAction,
createSelectOptionAction,
createSetReviewAction,
createViewAction,
getActionConfigGivenAction,
hasAction,
@@ -317,6 +318,32 @@ describe('createEffectAction', () => {
});
});
describe('createSetReviewAction', () => {
it('should create set review action', () => {
expect(createSetReviewAction(true)).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
reviewed: true,
});
});
it('should create set review action with false', () => {
expect(createSetReviewAction(false)).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
reviewed: false,
});
});
it('should create set review action with undefined', () => {
expect(createSetReviewAction()).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'set_review',
reviewed: undefined,
});
});
});
describe('getActionConfigGivenAction', () => {
const action = createViewAction('clips');
+20 -11
View File
@@ -143,7 +143,8 @@ describe('CarouselController', () => {
carousel.selectSlide(11);
expect(getEmblaApi()?.scrollTo).toBeCalledWith(11, false);
// Should not call scrollTo or fire event because index is out of bounds
expect(getEmblaApi()?.scrollTo).not.toBeCalled();
expect(forceSelectListener).not.toBeCalled();
});
@@ -257,7 +258,7 @@ describe('CarouselController', () => {
);
});
it('should recreate carousel when children are added', () => {
it('should reinit carousel when children are added', () => {
const children = createTestSlideNodes();
const root = createRoot();
const parent = createParent({ children: children });
@@ -269,13 +270,17 @@ describe('CarouselController', () => {
expect(originalEmblaApi).toBeTruthy();
originalEmblaApi?.slideNodes.mockReturnValue(children);
parent.appendChild(document.createElement('div'));
const newChild = document.createElement('div');
parent.appendChild(newChild);
callMutationHandler();
expect(originalEmblaApi?.destroy).toBeCalled();
expect(getEmblaApi()).not.toBe(originalEmblaApi);
// Should call reInit instead of destroy/recreate
expect(originalEmblaApi?.reInit).toBeCalledWith({
slides: [...children, newChild],
});
expect(EmblaCarousel).toBeCalledTimes(2);
// Should still be same carousel instance (no new creation)
expect(EmblaCarousel).toBeCalledTimes(1);
});
it('should not recreate carousel when children have not changed', () => {
@@ -298,7 +303,7 @@ describe('CarouselController', () => {
expect(EmblaCarousel).toBeCalledTimes(1);
});
it('should recreate carousel when children are added to slot', () => {
it('should reinit carousel when children are added to slot', () => {
const children = createTestSlideNodes();
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
@@ -312,12 +317,16 @@ describe('CarouselController', () => {
originalEmblaApi?.slideNodes.mockReturnValue(children);
host.appendChild(document.createElement('div'));
const newChild = document.createElement('div');
host.appendChild(newChild);
slot.dispatchEvent(new Event('slotchange'));
expect(originalEmblaApi?.destroy).toBeCalled();
expect(getEmblaApi()).not.toBe(originalEmblaApi);
// Should call reInit instead of destroy/recreate
expect(originalEmblaApi?.reInit).toBeCalledWith({
slides: [...children, newChild],
});
expect(EmblaCarousel).toBeCalledTimes(2);
// Should still be same carousel instance (no new creation)
expect(EmblaCarousel).toBeCalledTimes(1);
});
});
@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from 'vitest';
import { findBestMediaTimeIndex } from '../../src/utils/find-best-media-time-index';
import { ViewFolder } from '../../src/view/item';
import { TestViewMedia } from '../test-utils';
describe('findBestMediaTimeIndex', () => {
it('should handle non-media items', () => {
const folder = new ViewFolder({ id: 'folder', type: 'ha', title: 'folder' }, []);
const media = new TestViewMedia({
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T11:00:00'),
});
const index = findBestMediaTimeIndex(
[folder, media],
new Date('2024-01-01T10:30:00'),
);
expect(index).toBe(1);
});
it('should find longest match', () => {
const media1 = new TestViewMedia({
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T10:30:00'),
});
const media2 = new TestViewMedia({
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T11:00:00'),
});
const index = findBestMediaTimeIndex(
[media1, media2],
new Date('2024-01-01T10:15:00'),
);
expect(index).toBe(1);
});
it('should favor specified camera', () => {
const media1 = new TestViewMedia({
cameraID: 'camera1',
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T11:00:00'),
});
const media2 = new TestViewMedia({
cameraID: 'camera2',
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T10:30:00'),
});
// Even though media1 is longer, favor media2's camera.
const index = findBestMediaTimeIndex(
[media1, media2],
new Date('2024-01-01T10:15:00'),
'camera2',
);
expect(index).toBe(1);
});
it('should handle multiple matches from non-favored cameras', () => {
const media1 = new TestViewMedia({
cameraID: 'camera1',
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T10:30:00'),
});
const media2 = new TestViewMedia({
cameraID: 'camera3',
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T11:00:00'),
});
const index = findBestMediaTimeIndex(
[media1, media2],
new Date('2024-01-01T10:15:00'),
'camera2', // Favored camera not present
);
expect(index).toBe(1); // Longest match wins
});
it('should handle missing start or end time', () => {
const media = new TestViewMedia({
startTime: null,
endTime: null,
});
vi.spyOn(media, 'includesTime').mockReturnValue(true);
const index = findBestMediaTimeIndex([media], new Date());
expect(index).toBeNull();
});
it('should return null when no match', () => {
const media = new TestViewMedia({
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T11:00:00'),
});
const index = findBestMediaTimeIndex([media], new Date('2024-01-01T12:00:00'));
expect(index).toBeNull();
});
it('should reject longer match if favored camera already matched', () => {
const media1 = new TestViewMedia({
cameraID: 'favored',
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T10:30:00'),
});
const media2 = new TestViewMedia({
cameraID: 'not-favored',
startTime: new Date('2024-01-01T10:00:00'),
endTime: new Date('2024-01-01T11:00:00'),
});
// media2 is longer, but media1 is favored and already matches.
const index = findBestMediaTimeIndex(
[media1, media2],
new Date('2024-01-01T10:15:00'),
'favored',
);
expect(index).toBe(0);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { getFolderID } from '../../src/utils/folder';
describe('getFolderID', () => {
it('should get folder id from config', () => {
expect(getFolderID({ id: 'my-folder' })).toBe('my-folder');
});
it('should get folder id from index when no id in config', () => {
expect(getFolderID({}, 5)).toBe('folder/5');
});
it('should use default index 0 when index is missing', () => {
expect(getFolderID({})).toBe('folder/0');
});
it('should handle null config', () => {
expect(getFolderID(null, 2)).toBe('folder/2');
});
it('should handle undefined config', () => {
expect(getFolderID(undefined, 3)).toBe('folder/3');
});
});
+94
View File
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as go2rtcAudio from '../../src/camera-manager/utils/go2rtc/audio';
import {
getResolvedLiveProvider,
liveProviderSupports2WayAudio,
} from '../../src/utils/live-provider';
import { createCameraConfig, createHASS } from '../test-utils';
vi.mock('../../src/camera-manager/utils/go2rtc/audio');
describe('live-provider utils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getResolvedLiveProvider', () => {
it('should resolve webrtc-card with entity', () => {
const config = createCameraConfig({
live_provider: 'auto',
webrtc_card: { entity: 'camera.test' },
});
expect(getResolvedLiveProvider(config)).toBe('webrtc-card');
});
it('should resolve webrtc-card with url', () => {
const config = createCameraConfig({
live_provider: 'auto',
webrtc_card: { url: 'http://test' },
});
expect(getResolvedLiveProvider(config)).toBe('webrtc-card');
});
it('should resolve ha', () => {
const config = createCameraConfig({
live_provider: 'auto',
camera_entity: 'camera.test',
});
expect(getResolvedLiveProvider(config)).toBe('ha');
});
it('should resolve jsmpeg', () => {
const config = createCameraConfig({
live_provider: 'auto',
frigate: { camera_name: 'test' },
});
expect(getResolvedLiveProvider(config)).toBe('jsmpeg');
});
it('should resolve image by default for auto', () => {
const config = createCameraConfig({
live_provider: 'auto',
});
expect(getResolvedLiveProvider(config)).toBe('image');
});
it('should return explicitly configured provider', () => {
const config = createCameraConfig({
live_provider: 'go2rtc',
});
expect(getResolvedLiveProvider(config)).toBe('go2rtc');
});
it('should return image if config is undefined', () => {
expect(getResolvedLiveProvider(undefined)).toBe('image');
});
});
describe('liveProviderSupports2WayAudio', () => {
it('should return false if resolved provider is not go2rtc', async () => {
const config = createCameraConfig({
live_provider: 'ha',
});
const hass = createHASS();
const result = await liveProviderSupports2WayAudio(hass, config);
expect(result).toBe(false);
});
it('should return result from go2rtcSupports2WayAudio', async () => {
const config = createCameraConfig({
live_provider: 'go2rtc',
});
const hass = createHASS();
vi.mocked(go2rtcAudio.supports2WayAudio).mockResolvedValue(true);
const result = await liveProviderSupports2WayAudio(hass, config);
expect(result).toBe(true);
expect(go2rtcAudio.supports2WayAudio).toHaveBeenCalledWith(
hass,
undefined,
undefined,
);
});
});
});
+236
View File
@@ -0,0 +1,236 @@
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ViewItemManager } from '../../src/card-controller/view/item-manager';
import { RemoveContextViewModifier } from '../../src/card-controller/view/modifiers/remove-context';
import { ViewManagerEpoch } from '../../src/card-controller/view/types';
import { ViewManager } from '../../src/card-controller/view/view-manager';
import {
downloadMedia,
navigateToTimeline,
toggleFavorite,
toggleReviewed,
} from '../../src/utils/media-actions';
import { ViewItem, ViewMediaType } from '../../src/view/item';
import { QueryResults } from '../../src/view/query-results';
import { View } from '../../src/view/view';
import { TestViewMedia } from '../test-utils';
describe('MediaActions', () => {
describe('toggleReviewed', () => {
it('should return false if item is not review', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const options = { viewItemManager: mock<ViewItemManager>() };
expect(await toggleReviewed(item, options)).toBe(false);
});
it('should return false if manager is missing', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Review });
const options = {};
expect(await toggleReviewed(item, options)).toBe(false);
});
it('should toggle review status and update view', async () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Review,
reviewed: false,
});
const viewItemManager = mock<ViewItemManager>();
const viewManagerEpoch = mock<ViewManagerEpoch>();
const viewManager = mock<ViewManager>();
const view = mock<View>();
const queryResults = mock<QueryResults>();
viewManagerEpoch.manager = viewManager;
viewManager.getView.mockReturnValue(view);
view.queryResults = queryResults;
queryResults.clone.mockReturnValue(queryResults);
queryResults.removeItem.mockReturnValue(queryResults);
const options = { viewItemManager, viewManagerEpoch };
expect(await toggleReviewed(item, options)).toBe(true);
expect(viewItemManager.reviewMedia).toHaveBeenCalledWith(item, true);
expect(item.isReviewed()).toBe(true);
expect(viewManager.setViewByParameters).toHaveBeenCalled();
});
it('should not update view if queryResults is missing', async () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Review,
reviewed: false,
});
const viewItemManager = mock<ViewItemManager>();
const viewManagerEpoch = mock<ViewManagerEpoch>();
const viewManager = mock<ViewManager>();
const view = mock<View>();
viewManagerEpoch.manager = viewManager;
viewManager.getView.mockReturnValue(view);
view.queryResults = null;
const options = { viewItemManager, viewManagerEpoch };
expect(await toggleReviewed(item, options)).toBe(true);
expect(viewManager.setViewByParameters).not.toHaveBeenCalled();
});
it('should handle manager error', async () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Review,
reviewed: false,
});
const viewItemManager = mock<ViewItemManager>();
const error = new Error('fail');
viewItemManager.reviewMedia.mockRejectedValue(error);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const options = { viewItemManager };
expect(await toggleReviewed(item, options)).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(error.message);
consoleSpy.mockRestore();
});
});
describe('toggleFavorite', () => {
it('should return false if item is not media', async () => {
const options = { viewItemManager: mock<ViewItemManager>() };
expect(await toggleFavorite(null as unknown as ViewItem, options)).toBe(false);
});
it('should return false if manager is missing', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const options = {};
expect(await toggleFavorite(item, options)).toBe(false);
});
it('should toggle favorite status', async () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Clip,
favorite: false,
});
const viewItemManager = mock<ViewItemManager>();
const options = { viewItemManager };
expect(await toggleFavorite(item, options)).toBe(true);
expect(viewItemManager.favorite).toHaveBeenCalledWith(item, true);
});
it('should handle manager error', async () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Clip,
favorite: false,
});
const viewItemManager = mock<ViewItemManager>();
const error = new Error('fail');
viewItemManager.favorite.mockRejectedValue(error);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const options = { viewItemManager };
expect(await toggleFavorite(item, options)).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(error.message);
consoleSpy.mockRestore();
});
});
describe('downloadMedia', () => {
it('should return false if manager is missing', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const options = {};
expect(await downloadMedia(item, options)).toBe(false);
});
it('should download media', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const viewItemManager = mock<ViewItemManager>();
const options = { viewItemManager };
expect(await downloadMedia(item, options)).toBe(true);
expect(viewItemManager.download).toHaveBeenCalledWith(item);
});
it('should handle manager error', async () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const viewItemManager = mock<ViewItemManager>();
const error = new Error('fail');
viewItemManager.download.mockRejectedValue(error);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const options = { viewItemManager };
expect(await downloadMedia(item, options)).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(error.message);
consoleSpy.mockRestore();
});
});
describe('navigateToTimeline', () => {
it('should return early if epoch is missing', () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const options = {};
expect(navigateToTimeline(item, options)).toBeUndefined();
});
it('should navigate to timeline with correct parameters', () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const viewManagerEpoch = mock<ViewManagerEpoch>();
const viewManager = mock<ViewManager>();
const view = mock<View>();
const queryResults = mock<QueryResults>();
viewManagerEpoch.manager = viewManager;
viewManager.getView.mockReturnValue(view);
view.queryResults = queryResults;
queryResults.clone.mockReturnValue(queryResults);
// Make selectResultIfFound call the predicate to gain coverage.
queryResults.selectResultIfFound.mockImplementation((predicate) => {
predicate(item);
return queryResults;
});
const options = { viewManagerEpoch };
navigateToTimeline(item, options);
expect(viewManager.setViewByParameters).toHaveBeenCalledWith({
params: {
view: 'timeline',
queryResults: queryResults,
},
modifiers: [expect.any(RemoveContextViewModifier)],
});
});
it('should handle missing view/queryResults during navigation', () => {
const item = new TestViewMedia({ mediaType: ViewMediaType.Clip });
const viewManagerEpoch = mock<ViewManagerEpoch>();
const viewManager = mock<ViewManager>();
viewManagerEpoch.manager = viewManager;
viewManager.getView.mockReturnValue(null);
const options = { viewManagerEpoch };
navigateToTimeline(item, options);
expect(viewManager.setViewByParameters).toHaveBeenCalledWith({
params: {
view: 'timeline',
queryResults: undefined,
},
modifiers: [new RemoveContextViewModifier(['timeline'])],
});
});
});
});
+3 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { AdvancedCameraCardMediaPlayer, MediaLoadedCapabilities } from '../../src/types';
import { MediaLoadedCapabilities, MediaPlayer } from '../../src/types';
import {
createMediaLoadedInfo,
dispatchExistingMediaLoadedInfoAsEvent,
@@ -14,7 +14,7 @@ import {
import { createMediaLoadedInfo as createTestMediaLoadedInfo } from '../test-utils.js';
const options = {
player: mock<AdvancedCameraCardMediaPlayer>(),
player: mock<MediaPlayer>(),
capabilities: mock<MediaLoadedCapabilities>(),
};
@@ -86,7 +86,7 @@ describe('createMediaLoadedInfo', () => {
// @vitest-environment jsdom
describe('dispatchMediaLoadedEvent', () => {
const options = {
player: mock<AdvancedCameraCardMediaPlayer>(),
player: mock<MediaPlayer>(),
capabilities: mock<MediaLoadedCapabilities>(),
};
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from 'vitest';
import {
dispatchDismissOverlayMessageEvent,
dispatchShowOverlayMessageEvent,
} from '../../src/utils/overlay-message';
// @vitest-environment jsdom
describe('overlay-message utils', () => {
it('should dispatch show overlay message event', () => {
const element = document.createElement('div');
const message = { text: 'test' };
const handler = vi.fn();
element.addEventListener('advanced-camera-card:overlay-message:show', handler);
dispatchShowOverlayMessageEvent(element, message);
expect(handler).toHaveBeenCalled();
const event = handler.mock.calls[0][0];
expect(event.detail).toBe(message);
});
it('should dispatch dismiss overlay message event', () => {
const element = document.createElement('div');
const handler = vi.fn();
element.addEventListener('advanced-camera-card:overlay-message:dismiss', handler);
dispatchDismissOverlayMessageEvent(element);
expect(handler).toHaveBeenCalled();
});
});
+89
View File
@@ -0,0 +1,89 @@
import { Task } from '@lit-labs/task';
import { html, render, TemplateResult } from 'lit';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { renderProgressIndicator } from '../../src/components/progress-indicator';
import { renderTask } from '../../src/utils/task';
vi.mock('../../src/components/progress-indicator');
const getRenderedContent = (template: TemplateResult): string => {
const container = document.createElement('div');
render(template, container);
return container.textContent || '';
};
// @vitest-environment jsdom
describe('task utilities', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should render initial state', () => {
const task = mock<Task<unknown[], string>>();
task.render.mockImplementation((renderers) => renderers.initial?.());
vi.mocked(renderProgressIndicator).mockReturnValue(html`progress`);
const result = renderTask(task, (r) => html`${r}`);
expect(getRenderedContent(result)).toContain('progress');
});
it('should render pending state', () => {
const task = mock<Task<unknown[], string>>();
task.render.mockImplementation((renderers) => renderers.pending?.());
vi.mocked(renderProgressIndicator).mockReturnValue(html`progress`);
const result = renderTask(task, (r) => html`${r}`);
expect(getRenderedContent(result)).toContain('progress');
});
it('should render error state', () => {
const task = mock<Task<unknown[], string>>();
task.render.mockImplementation((renderers) =>
renderers.error?.(new Error('test error')),
);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const errorFunc = vi.fn().mockReturnValue(html`error`);
const result = renderTask(task, (r) => html`${r}`, { errorFunc });
expect(errorFunc).toHaveBeenCalledWith(new Error('test error'));
expect(consoleSpy).toHaveBeenCalled();
expect(getRenderedContent(result)).toContain('error');
});
it('should render complete state', () => {
const task = mock<Task<unknown[], string>>();
task.render.mockImplementation((renderers) => renderers.complete?.('result'));
const result = renderTask(task, (r) => html`complete: ${r}`);
expect(getRenderedContent(result)).toContain('complete: result');
});
it('should use custom inProgressFunc', () => {
const task = mock<Task<unknown[], string>>();
task.render.mockImplementation((renderers) => renderers.initial?.());
const inProgressFunc = () => html`custom progress`;
const result = renderTask(task, (r) => html`${r}`, { inProgressFunc });
expect(getRenderedContent(result)).toContain('custom progress');
});
it('should use cardWideConfig', () => {
const task = mock<Task<unknown[], string>>();
task.render.mockImplementation((renderers) => renderers.initial?.());
const cardWideConfig = {};
vi.mocked(renderProgressIndicator).mockReturnValue(html`progress`);
renderTask(task, (r) => html`${r}`, { cardWideConfig });
expect(renderProgressIndicator).toHaveBeenCalledWith({
cardWideConfig,
});
});
});
+233
View File
@@ -0,0 +1,233 @@
import { Task } from '@lit-labs/task';
import { ReactiveControllerHost } from '@lit/reactive-element';
import { afterEach, assert, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { createFetchThumbnailTask } from '../../src/utils/thumbnail';
import { createHASS, flushPromises } from '../test-utils';
vi.mock('@lit-labs/task');
describe('thumbnail utilities', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('should handle absolute thumbnail URL', async () => {
const host = mock<ReactiveControllerHost>();
const hass = createHASS();
const thumbnailURL = 'http://example.com/thumb.jpg';
createFetchThumbnailTask(
host,
() => hass,
() => thumbnailURL,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call);
const options = call[1];
const result = await options.task([true, thumbnailURL]);
expect(result).toBe(thumbnailURL);
expect(hass.fetchWithAuth).not.toHaveBeenCalled();
});
it('should handle data thumbnail URL', async () => {
const host = mock<ReactiveControllerHost>();
const hass = createHASS();
const thumbnailURL = 'data:image/jpeg;base64,...';
createFetchThumbnailTask(
host,
() => hass,
() => thumbnailURL,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call);
const options = call[1];
const result = await options.task([true, thumbnailURL]);
expect(result).toBe(thumbnailURL);
});
it('should fetch relative thumbnail URL', async () => {
const host = mock<ReactiveControllerHost>();
const hass = createHASS();
const thumbnailURL = '/api/frigate/thumb.jpg';
const dataURL = 'data:image/jpeg;base64,encoded';
const mockResponse = mock<Response>();
Object.defineProperty(mockResponse, 'ok', { value: true });
const mockBlob = new Blob(['test'], { type: 'image/jpeg' });
mockResponse.blob.mockResolvedValue(mockBlob);
vi.mocked(hass.fetchWithAuth).mockResolvedValue(mockResponse);
const mockFileReader = mock<FileReader>({
result: dataURL,
});
vi.stubGlobal(
'FileReader',
vi.fn(() => mockFileReader),
);
createFetchThumbnailTask(
host,
() => hass,
() => thumbnailURL,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call);
const options = call[1];
const runPromise = options.task([true, thumbnailURL]);
await flushPromises();
mockFileReader.onload?.(mock<ProgressEvent<FileReader>>());
const result = await runPromise;
expect(result).toBe(dataURL);
expect(hass.fetchWithAuth).toHaveBeenCalledWith(thumbnailURL);
});
it('should handle fetch failure', async () => {
const host = mock<ReactiveControllerHost>();
const hass = createHASS();
const thumbnailURL = '/api/thumb.jpg';
const mockResponse = mock<Response>();
Object.defineProperty(mockResponse, 'ok', { value: false });
Object.defineProperty(mockResponse, 'statusText', { value: 'Not Found' });
vi.mocked(hass.fetchWithAuth).mockResolvedValue(mockResponse);
createFetchThumbnailTask(
host,
() => hass,
() => thumbnailURL,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call);
const options = call[1];
await expect(options.task([true, thumbnailURL])).rejects.toThrow('Not Found');
});
it('should handle reader error', async () => {
const host = mock<ReactiveControllerHost>();
const hass = createHASS();
const thumbnailURL = '/api/thumb.jpg';
const mockResponse = mock<Response>();
Object.defineProperty(mockResponse, 'ok', { value: true });
mockResponse.blob.mockResolvedValue(new Blob());
vi.mocked(hass.fetchWithAuth).mockResolvedValue(mockResponse);
const mockFileReader = mock<FileReader>();
vi.stubGlobal(
'FileReader',
vi.fn(() => mockFileReader),
);
createFetchThumbnailTask(
host,
() => hass,
() => thumbnailURL,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call);
const options = call[1];
const runPromise = options.task([true, thumbnailURL]);
await flushPromises();
mockFileReader.onerror?.(
new Error('Reader error') as unknown as ProgressEvent<FileReader>,
);
await expect(runPromise).rejects.toThrow('Reader error');
});
it('should handle non-string reader result', async () => {
const host = mock<ReactiveControllerHost>();
const hass = createHASS();
const thumbnailURL = '/api/thumb.jpg';
const mockResponse = mock<Response>();
Object.defineProperty(mockResponse, 'ok', { value: true });
mockResponse.blob.mockResolvedValue(new Blob());
vi.mocked(hass.fetchWithAuth).mockResolvedValue(mockResponse);
const mockFileReader = mock<FileReader>({
result: null as unknown as string, // Non-string result
});
vi.stubGlobal(
'FileReader',
vi.fn(() => mockFileReader),
);
createFetchThumbnailTask(
host,
() => hass,
() => thumbnailURL,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call);
const options = call[1];
const runPromise = options.task([true, thumbnailURL]);
await flushPromises();
mockFileReader.onload?.(mock<ProgressEvent<FileReader>>());
const result = await runPromise;
expect(result).toBeNull();
});
it('should return null if no hass or no url', async () => {
const host = mock<ReactiveControllerHost>();
createFetchThumbnailTask(
host,
() => undefined,
() => undefined,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call);
const options = call[1];
const result = await options.task([false, undefined]);
expect(result).toBeNull();
});
it('should have correct task arguments', () => {
const host = mock<ReactiveControllerHost>();
const hass = createHASS();
const thumbnailURL = 'http://example.com/thumb.jpg';
createFetchThumbnailTask(
host,
() => hass,
() => thumbnailURL,
);
const call = vi.mocked(Task).mock.calls[0];
assert(call && call[1].args);
const args = call[1].args();
expect(args).toEqual([true, thumbnailURL]);
vi.mocked(Task).mockClear();
createFetchThumbnailTask(
host,
() => undefined,
() => undefined,
);
const call2 = vi.mocked(Task).mock.calls[0];
assert(call2 && call2[1].args);
const args2 = call2[1].args();
expect(args2).toEqual([false, undefined]);
});
});