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:
1 parent
6eb0a87ca8
commit
fc32727860
215 files changed
+14491
-6160
No files matched your search
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'])],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user