feat: Implement basic general folder support (#2051)

- Related: #1748
This commit is contained in:
Dermot Duffy
2025-05-21 19:59:21 -07:00
committed by GitHub
parent 2eb0d9e35e
commit c6a4c8aea2
350 changed files with 12837 additions and 4509 deletions
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { Cache } from '../../src/cache/cache';
interface TestCacheValue {
val: number;
}
describe('Cache', () => {
describe('has', () => {
it('positive', () => {
const cache = new Cache<string, TestCacheValue>();
cache.set('test', { val: 2 });
expect(cache.has('test')).toBeTruthy();
});
it('negative', () => {
const cache = new Cache<string, TestCacheValue>();
expect(cache.has('absent')).toBeFalsy();
});
});
describe('get', () => {
it('positive', () => {
const cache = new Cache<string, TestCacheValue>();
cache.set('test', { val: 2 });
expect(cache.get('test')).toEqual({ val: 2 });
});
it('negative', () => {
const cache = new Cache<string, TestCacheValue>();
expect(cache.get('absent')).toBeNull();
});
});
it('delete', () => {
const cache = new Cache<string, TestCacheValue>();
cache.set('test-1', { val: 1 });
cache.set('test-2', { val: 2 });
cache.set('test-3', { val: 3 });
cache.delete('test-2');
expect(cache.get('test-2')).toBeNull();
expect(cache.get('test-1')).toEqual({ val: 1 });
expect(cache.get('test-3')).toEqual({ val: 3 });
});
it('clear', () => {
const cache = new Cache<string, TestCacheValue>();
cache.set('test-1', { val: 1 });
cache.set('test-2', { val: 2 });
cache.set('test-3', { val: 3 });
cache.clear();
expect(cache.get('test-1')).toBeNull();
expect(cache.get('test-2')).toBeNull();
expect(cache.get('test-3')).toBeNull();
});
it('getMatches', () => {
const cache = new Cache<string, TestCacheValue>();
cache.set('test-1', { val: 1 });
cache.set('test-2', { val: 2 });
cache.set('test-3', { val: 3 });
expect(cache.getMatches((obj) => obj.val >= 2)).toEqual([{ val: 2 }, { val: 3 }]);
});
it('entries', () => {
const cache = new Cache<string, TestCacheValue>();
cache.set('test-1', { val: 1 });
cache.set('test-2', { val: 2 });
cache.set('test-3', { val: 3 });
expect([...cache.entries()]).toEqual([
['test-1', { val: 1 }],
['test-2', { val: 2 }],
['test-3', { val: 3 }],
]);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { EqualityCache } from '../../src/cache/equality-cache';
interface TestCacheValue {
val: number;
}
describe('EqualityCache', () => {
describe('has', () => {
it('positive', () => {
const cache = new EqualityCache<string, TestCacheValue>();
cache.set('test', { val: 2 });
expect(cache.has('test')).toBeTruthy();
});
it('negative', () => {
const cache = new EqualityCache<string, TestCacheValue>();
expect(cache.has('absent')).toBeFalsy();
});
});
describe('get', () => {
it('positive', () => {
const cache = new EqualityCache<string, TestCacheValue>();
const original = { val: 2 };
cache.set('test', original);
const replacement = { val: 2 };
cache.set('test', replacement);
expect(cache.get('test')).not.toBe(original);
expect(cache.get('test')).toBe(replacement);
});
it('negative', () => {
const cache = new EqualityCache<string, TestCacheValue>();
expect(cache.get('absent')).toBeNull();
});
});
it('delete', () => {
const cache = new EqualityCache<string, TestCacheValue>();
cache.set('test-1', { val: 1 });
cache.set('test-2', { val: 2 });
cache.set('test-3', { val: 3 });
cache.delete('test-2');
expect(cache.get('test-2')).toBeNull();
expect(cache.get('test-1')).toEqual({ val: 1 });
expect(cache.get('test-3')).toEqual({ val: 3 });
});
it('clear', () => {
const cache = new EqualityCache<string, TestCacheValue>();
cache.set('test-1', { val: 1 });
cache.set('test-2', { val: 2 });
cache.set('test-3', { val: 3 });
cache.clear();
expect(cache.get('test-1')).toBeNull();
expect(cache.get('test-2')).toBeNull();
expect(cache.get('test-3')).toBeNull();
});
it('getMatches', () => {
const cache = new EqualityCache<string, TestCacheValue>();
cache.set('test-1', { val: 1 });
cache.set('test-2', { val: 2 });
cache.set('test-3', { val: 3 });
expect(cache.getMatches((obj) => obj.val >= 2)).toEqual([{ val: 2 }, { val: 3 }]);
});
it('entries', () => {
const cache = new EqualityCache<string, TestCacheValue>();
cache.set('test-1', { val: 1 });
cache.set('test-2', { val: 2 });
cache.set('test-3', { val: 3 });
expect([...cache.entries()]).toEqual([
['test-1', { val: 1 }],
['test-2', { val: 2 }],
['test-3', { val: 3 }],
]);
});
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { EqualityMap } from '../../src/cache/equality-map';
describe('EqualityMap', () => {
it('should set and get values with primitive keys', () => {
const map = new EqualityMap<string, number>();
map.set('a', 1);
expect(map.get('a')).toBe(1);
expect(map.has('a')).toBe(true);
expect(map.get('b')).toBeUndefined();
expect(map.has('b')).toBe(false);
});
it('should set and get values with object keys using deep equality', () => {
const map = new EqualityMap<{ x: number }, string>();
map.set({ x: 1 }, 'foo');
expect(map.get({ x: 1 })).toBe('foo');
expect(map.has({ x: 1 })).toBe(true);
expect(map.get({ x: 2 })).toBeUndefined();
});
it('should overwrite value if key is deeply equal', () => {
const map = new EqualityMap<{ y: number }, string>();
map.set({ y: 2 }, 'bar');
map.set({ y: 2 }, 'baz');
expect(map.get({ y: 2 })).toBe('baz');
expect(map.size).toBe(1);
});
it('should delete values by deep equality', () => {
const map = new EqualityMap<{ z: number }, string>();
map.set({ z: 3 }, 'val');
expect(map.delete({ z: 3 })).toBe(true);
expect(map.get({ z: 3 })).toBeUndefined();
expect(map.size).toBe(0);
});
it('should clear all values', () => {
const map = new EqualityMap<number, string>();
map.set(1, 'a');
map.set(2, 'b');
map.clear();
expect(map.size).toBe(0);
expect(map.get(1)).toBeUndefined();
expect(map.get(2)).toBeUndefined();
});
it('should iterate entries', () => {
const map = new EqualityMap<string, number>();
map.set('a', 1);
map.set('b', 2);
const entries = Array.from(map);
expect(entries).toContainEqual(['a', 1]);
expect(entries).toContainEqual(['b', 2]);
});
it('should iterate keys', () => {
const map = new EqualityMap<string, number>();
map.set('x', 10);
map.set('y', 20);
const keys = Array.from(map.keys());
expect(keys).toContain('x');
expect(keys).toContain('y');
});
it('should iterate values', () => {
const map = new EqualityMap<string, number>();
map.set('foo', 100);
map.set('bar', 200);
const values = Array.from(map.values());
expect(values).toContain(100);
expect(values).toContain(200);
});
it('should call forEach with correct arguments', () => {
const map = new EqualityMap<string, number>();
map.set('a', 1);
map.set('b', 2);
const calls: [number, string][] = [];
map.forEach((value, key) => {
calls.push([value, key]);
});
expect(calls).toContainEqual([1, 'a']);
expect(calls).toContainEqual([2, 'b']);
});
it('should have correct Symbol.toStringTag', () => {
const map = new EqualityMap<number, number>();
expect(Object.prototype.toString.call(map)).toBe('[object EqualityMap]');
});
});
+145
View File
@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ExpiringEqualityCache } from '../../src/cache/expiring-cache';
describe('ExpiringEqualityCache', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should set and get a value', () => {
const cache = new ExpiringEqualityCache<string, number>();
cache.set('foo', 123);
expect(cache.get('foo')).toBe(123);
});
it('should return null for a non-existent key', () => {
const cache = new ExpiringEqualityCache<string, number>();
expect(cache.get('foo')).toBeNull();
});
it('should return null for an expired key', () => {
const cache = new ExpiringEqualityCache<string, number>();
const expiryDate = new Date(Date.now() + 1000);
cache.set('foo', 123, expiryDate);
vi.advanceTimersByTime(1001);
expect(cache.get('foo')).toBeNull();
});
it('should return the value if not expired', () => {
const cache = new ExpiringEqualityCache<string, number>();
const expiryDate = new Date(Date.now() + 1000);
cache.set('foo', 123, expiryDate);
vi.advanceTimersByTime(500);
expect(cache.get('foo')).toBe(123);
});
it('should correctly report has for existing non-expired key', () => {
const cache = new ExpiringEqualityCache<string, number>();
cache.set('foo', 123, new Date(Date.now() + 1000));
expect(cache.has('foo')).toBe(true);
});
it('should correctly report has for existing key with no expiry', () => {
const cache = new ExpiringEqualityCache<string, number>();
cache.set('foo', 123);
expect(cache.has('foo')).toBe(true);
});
it('should correctly report not for has for non-existent key', () => {
const cache = new ExpiringEqualityCache<string, number>();
expect(cache.has('foo')).toBe(false);
});
it('should correctly report not for has for expired key', () => {
const cache = new ExpiringEqualityCache<string, number>();
cache.set('foo', 123, new Date(Date.now() + 1000));
vi.advanceTimersByTime(1001);
expect(cache.has('foo')).toBe(false);
});
it('should delete a key', () => {
const cache = new ExpiringEqualityCache<string, number>();
cache.set('foo', 123);
expect(cache.delete('foo')).toBe(true);
expect(cache.get('foo')).toBeNull();
expect(cache.has('foo')).toBe(false);
});
it('should return false when deleting a non-existent key', () => {
const cache = new ExpiringEqualityCache<string, number>();
expect(cache.delete('foo')).toBe(false);
});
it('should clear all keys', () => {
const cache = new ExpiringEqualityCache<string, number>();
cache.set('foo', 123);
cache.set('bar', 456, new Date(Date.now() + 1000));
cache.clear();
expect(cache.get('foo')).toBeNull();
expect(cache.get('bar')).toBeNull();
expect(cache.has('foo')).toBe(false);
expect(cache.has('bar')).toBe(false);
});
it('should iterate over entries', () => {
const cache = new ExpiringEqualityCache<string, number>();
cache.set('forever', 42);
cache.set('bas', 43, new Date(Date.now()));
cache.set('foo', 44, new Date(Date.now() + 1000));
cache.set('bar', 45, new Date(Date.now() + 2000));
vi.advanceTimersByTime(1001);
expect([...cache.entries()]).toEqual([
['forever', 42],
['bar', 45],
]);
});
it('should return empty iterator for an empty cache', () => {
const cache = new ExpiringEqualityCache<string, number>();
const entries = Array.from(cache.entries());
expect(entries).toHaveLength(0);
});
it('should return empty iterator if all entries are expired', () => {
const cache = new ExpiringEqualityCache<string, number>();
cache.set('foo', 123, new Date(Date.now() - 1000));
cache.set('bar', 456, new Date(Date.now() - 500));
vi.advanceTimersByTime(1); // Ensure current time is past expiry
const entries = Array.from(cache.entries());
expect(entries).toHaveLength(0);
});
it('should get matches for non-expired values', () => {
const cache = new ExpiringEqualityCache<string, { id: number; type: string }>();
cache.set('a', { id: 1, type: 'A' });
cache.set('b', { id: 2, type: 'B' }, new Date(Date.now() + 1000));
cache.set('c', { id: 3, type: 'A' }, new Date(Date.now() - 1000)); // expired
cache.set('d', { id: 4, type: 'A' }, new Date(Date.now() + 500));
const matches = cache.getMatches((value) => value.type === 'A');
expect(matches).toHaveLength(2);
expect(matches).toContainEqual({ id: 1, type: 'A' });
expect(matches).toContainEqual({ id: 4, type: 'A' });
});
it('should return empty array for getMatches on an empty cache', () => {
const cache = new ExpiringEqualityCache<string, { id: number }>();
const matches = cache.getMatches((value) => value.id > 0);
expect(matches).toHaveLength(0);
});
it('should return empty array for getMatches if no values match predicate', () => {
const cache = new ExpiringEqualityCache<string, { id: number }>();
cache.set('a', { id: 1 });
cache.set('b', { id: 2 });
const matches = cache.getMatches((value) => value.id > 5);
expect(matches).toHaveLength(0);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { LRUCache } from '../../src/cache/lru';
interface TestCacheValue {
val: number;
}
describe('LRUCache', () => {
describe('has', () => {
it('positive', () => {
const cache = new LRUCache<string, TestCacheValue>(1);
cache.set('test', { val: 2 });
expect(cache.has('test')).toBeTruthy();
});
it('negative', () => {
const cache = new LRUCache<string, TestCacheValue>(1);
expect(cache.has('absent')).toBeFalsy();
});
});
describe('get', () => {
it('positive', () => {
const cache = new LRUCache<string, TestCacheValue>(1);
cache.set('test', { val: 2 });
expect(cache.get('test')).toEqual({ val: 2 });
});
it('negative', () => {
const cache = new LRUCache<string, TestCacheValue>(1);
expect(cache.get('absent')).toBeNull();
});
});
it('delete', () => {
const cache = new LRUCache<string, TestCacheValue>(1);
cache.set('test-1', { val: 1 });
cache.delete('test-1');
expect(cache.get('test-1')).toBeNull();
});
it('clear', () => {
const cache = new LRUCache<string, TestCacheValue>(1);
cache.set('test-1', { val: 1 });
cache.clear();
expect(cache.get('test-1')).toBeNull();
});
it('getMatches', () => {
const cache = new LRUCache<string, TestCacheValue>(1);
cache.set('test-1', { val: 1 });
cache.set('test-2', { val: 2 });
cache.set('test-3', { val: 3 });
expect(cache.getMatches((obj) => obj.val >= 0)).toEqual([{ val: 3 }]);
});
it('entries', () => {
const cache = new LRUCache<string, TestCacheValue>(1);
cache.set('test-1', { val: 1 });
cache.set('test-2', { val: 2 });
cache.set('test-3', { val: 3 });
expect([...cache.entries()]).toEqual([['test-3', { val: 3 }]]);
});
});
@@ -3,10 +3,7 @@ import { mock } from 'vitest-mock-extended';
import { BrowseMediaCamera } from '../../../src/camera-manager/browse-media/camera';
import { CameraManagerEngine } from '../../../src/camera-manager/engine';
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
import {
Entity,
EntityRegistryManager,
} from '../../../src/utils/ha/registry/entity/types';
import { Entity, EntityRegistryManager } from '../../../src/ha/registry/entity/types';
import { createCameraConfig, createHASS } from '../../test-utils';
describe('BrowseMediaCamera', () => {
@@ -1,55 +0,0 @@
import { describe, expect, it } from 'vitest';
import { BrowseMediaMetadata } from '../../../../src/camera-manager/browse-media/types';
import { getViewMediaFromBrowseMediaArray } from '../../../../src/camera-manager/browse-media/utils/browse-media-to-view-media';
import {
MEDIA_CLASS_IMAGE,
MEDIA_CLASS_VIDEO,
RichBrowseMedia,
} from '../../../../src/utils/ha/browse-media/types';
import { createBrowseMedia } from './test-utils';
const createBrowseMediaChildren = (
overrides: Partial<RichBrowseMedia<BrowseMediaMetadata>>[],
) => {
return overrides.map((override) => createBrowseMedia(override));
};
describe('getViewMediaFromBrowseMediaArray', () => {
it('should ignore absent metadata', () => {
const children = createBrowseMediaChildren([{ _metadata: undefined }]);
expect(getViewMediaFromBrowseMediaArray(children)).toEqual([]);
});
it('should ignore unknown media class', () => {
const children = createBrowseMediaChildren([{ media_class: 'UNKNOWN' }]);
expect(getViewMediaFromBrowseMediaArray(children)).toEqual([]);
});
it('should generate clip view media', () => {
const children = createBrowseMediaChildren([{ media_class: MEDIA_CLASS_VIDEO }]);
const viewMedia = getViewMediaFromBrowseMediaArray(children);
expect(viewMedia).toHaveLength(1);
expect(viewMedia?.[0].getMediaType()).toBe('clip');
expect(viewMedia?.[0].getID()).toBe('camera.test/2024-11-19 07:23:00');
});
it('should generate snapshot view media', () => {
const children = createBrowseMediaChildren([{ media_class: MEDIA_CLASS_IMAGE }]);
const viewMedia = getViewMediaFromBrowseMediaArray(children);
expect(viewMedia).toHaveLength(1);
expect(viewMedia?.[0].getMediaType()).toBe('snapshot');
expect(viewMedia?.[0].getID()).toBe('camera.test/2024-11-19 07:23:00');
});
it('should de-duplicate snapshot/clip duplicates', () => {
const children = createBrowseMediaChildren([
{ media_class: MEDIA_CLASS_IMAGE },
{ media_class: MEDIA_CLASS_IMAGE },
{ media_class: MEDIA_CLASS_VIDEO },
]);
const viewMedia = getViewMediaFromBrowseMediaArray(children);
expect(viewMedia).toHaveLength(1);
expect(viewMedia?.[0].getMediaType()).toBe('clip');
expect(viewMedia?.[0].getID()).toBe('camera.test/2024-11-19 07:23:00');
});
});
@@ -1,23 +0,0 @@
import { BrowseMediaMetadata } from '../../../../src/camera-manager/browse-media/types';
import { RichBrowseMedia } from '../../../../src/utils/ha/browse-media/types';
export const createBrowseMedia = (
media?: Partial<RichBrowseMedia<BrowseMediaMetadata>>,
): RichBrowseMedia<BrowseMediaMetadata> => {
return {
title: 'Test Media',
media_class: 'video',
media_content_type: 'video/mp4',
media_content_id: 'test',
can_play: true,
can_expand: false,
thumbnail: null,
children: null,
_metadata: {
cameraID: 'camera.test',
startDate: new Date('2024-11-19T07:23:00'),
endDate: new Date('2025-11-19T07:24:00'),
},
...media,
};
};
+21 -9
View File
@@ -1,17 +1,29 @@
import { add, sub } from 'date-fns';
import sortBy from 'lodash-es/sortBy';
import { sortBy } from 'lodash-es';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
MemoryRequestCache,
RecordingSegmentsCache,
} from '../../src/camera-manager/cache.js';
import { RecordingSegmentsCache } from '../../src/camera-manager/cache.js';
import { DateRange } from '../../src/camera-manager/range.js';
import { RecordingSegment } from '../../src/camera-manager/types.js';
import {
CameraManagerRequestCache,
CameraQuery,
Engine,
EventQuery,
QueryResultsType,
QueryReturnType,
QueryType,
RecordingSegment,
} from '../../src/camera-manager/types.js';
describe('MemoryRequestCache', () => {
const cache = new MemoryRequestCache();
const request = { request: 'foo' };
const response = { response: 'bar' };
const cache = new CameraManagerRequestCache();
const request: CameraQuery = {
type: QueryType.Event,
cameraIDs: new Set(['camera-1']),
};
const response: QueryReturnType<EventQuery> = {
type: QueryResultsType.Event,
engine: Engine.Generic,
};
beforeEach(() => {
cache.clear();
+3 -3
View File
@@ -8,15 +8,15 @@ import { ReolinkCameraManagerEngine } from '../../src/camera-manager/reolink/eng
import { Engine } from '../../src/camera-manager/types.js';
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
import { CardWideConfig } from '../../src/config/schema/types.js';
import { EntityRegistryManager } from '../../src/utils/ha/registry/entity/types.js';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js';
import { ResolvedMediaCache } from '../../src/ha/resolved-media.js';
import { EntityRegistryManagerMock } from '../ha/registry/entity/mock.js';
import {
createCameraConfig,
createHASS,
createRegistryEntity,
createStateEntity,
} from '../test-utils';
import { EntityRegistryManagerMock } from '../utils/ha/registry/entity/mock.js';
vi.mock('../../src/utils/ha/entity-registry');
vi.mock('../../src/utils/ha/entity-registry/cache');
+29 -32
View File
@@ -9,12 +9,9 @@ import { ActionsExecutor } from '../../../src/card-controller/actions/types';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
import { CameraTriggerEventType } from '../../../src/config/schema/cameras';
import {
Entity,
EntityRegistryManager,
} from '../../../src/utils/ha/registry/entity/types';
import { Entity, EntityRegistryManager } from '../../../src/ha/registry/entity/types';
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils';
import { EntityRegistryManagerMock } from '../../utils/ha/registry/entity/mock';
vi.mock('../../../src/camera-manager/frigate/requests');
@@ -91,8 +88,8 @@ describe('FrigateCamera', () => {
entityRegistryManager: entityRegistryManager,
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
}),
expect(camera.getConfig().frigate.camera_name).toBe('fnt_dr');
});
expect(camera.getConfig().frigate.camera_name).toBe('fnt_dr');
});
it('with a camera_entity without camera_name match', async () => {
@@ -115,8 +112,8 @@ describe('FrigateCamera', () => {
entityRegistryManager: entityRegistryManager,
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
}),
expect(camera.getConfig().frigate.camera_name).toBeUndefined();
});
expect(camera.getConfig().frigate.camera_name).toBeUndefined();
});
it('with a camera_entity without platform match', async () => {
@@ -139,8 +136,8 @@ describe('FrigateCamera', () => {
entityRegistryManager: entityRegistryManager,
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
}),
expect(camera.getConfig().frigate.camera_name).toBeUndefined();
});
expect(camera.getConfig().frigate.camera_name).toBeUndefined();
});
});
});
@@ -161,8 +158,8 @@ describe('FrigateCamera', () => {
entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
}),
expect(camera.getCapabilities()?.has('favorite-events')).toBeTruthy();
});
expect(camera.getCapabilities()?.has('favorite-events')).toBeTruthy();
expect(camera.getCapabilities()?.has('favorite-recordings')).toBeFalsy();
expect(camera.getCapabilities()?.has('seek')).toBeTruthy();
expect(camera.getCapabilities()?.has('clips')).toBeTruthy();
@@ -188,8 +185,8 @@ describe('FrigateCamera', () => {
entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
}),
expect(camera.getCapabilities()?.has('favorite-events')).toBeFalsy();
});
expect(camera.getCapabilities()?.has('favorite-events')).toBeFalsy();
expect(camera.getCapabilities()?.has('favorite-recordings')).toBeFalsy();
expect(camera.getCapabilities()?.has('seek')).toBeFalsy();
expect(camera.getCapabilities()?.has('clips')).toBeFalsy();
@@ -248,8 +245,8 @@ describe('FrigateCamera', () => {
entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
}),
expect(camera.getCapabilities()?.has('ptz')).toBeTruthy();
});
expect(camera.getCapabilities()?.has('ptz')).toBeTruthy();
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
left: ['continuous'],
right: ['continuous'],
@@ -284,8 +281,8 @@ describe('FrigateCamera', () => {
entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
}),
expect(camera.getCapabilities()?.has('ptz')).toBeTruthy();
});
expect(camera.getCapabilities()?.has('ptz')).toBeTruthy();
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
left: [],
right: [],
@@ -319,13 +316,13 @@ describe('FrigateCamera', () => {
entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: eventWatcher,
}),
expect(eventWatcher.subscribe).toBeCalledWith(
hass,
expect.objectContaining({
instanceID: 'CLIENT_ID',
}),
);
});
expect(eventWatcher.subscribe).toBeCalledWith(
hass,
expect.objectContaining({
instanceID: 'CLIENT_ID',
}),
);
});
it('should not subscribe with no trigger events', async () => {
@@ -349,8 +346,8 @@ describe('FrigateCamera', () => {
entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: eventWatcher,
}),
expect(eventWatcher.subscribe).not.toBeCalled();
});
expect(eventWatcher.subscribe).not.toBeCalled();
});
it('should not subscribe without trigger capability', async () => {
@@ -395,8 +392,8 @@ describe('FrigateCamera', () => {
entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: eventWatcher,
}),
expect(eventWatcher.subscribe).not.toBeCalled();
});
expect(eventWatcher.subscribe).not.toBeCalled();
});
it('should unsubscribe on destroy', async () => {
@@ -416,8 +413,8 @@ describe('FrigateCamera', () => {
entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: eventWatcher,
}),
expect(eventWatcher.unsubscribe).not.toBeCalled();
});
expect(eventWatcher.unsubscribe).not.toBeCalled();
await camera.destroy();
expect(eventWatcher.unsubscribe).toBeCalled();
@@ -1,31 +1,32 @@
import { describe, expect, it } from 'vitest';
import { RecordingSegmentsCache, RequestCache } from '../../../src/camera-manager/cache';
import { RecordingSegmentsCache } from '../../../src/camera-manager/cache';
import { FrigateCameraManagerEngine } from '../../../src/camera-manager/frigate/engine-frigate';
import {
FrigateEventViewMedia,
FrigateRecordingViewMedia,
} from '../../../src/camera-manager/frigate/media';
import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/types.js';
import { CameraManagerRequestCache } from '../../../src/camera-manager/types';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import { CameraConfig } from '../../../src/config/schema/cameras';
import { AdvancedCameraCardView } from '../../../src/config/schema/common/const';
import { RawAdvancedCameraCardConfig } from '../../../src/config/types';
import { ViewMedia } from '../../../src/view/media';
import { ViewMedia, ViewMediaType } from '../../../src/view/item';
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
import { TestViewMedia, createCameraConfig, createHASS } from '../../test-utils';
import { EntityRegistryManagerMock } from '../../utils/ha/registry/entity/mock';
const createEngine = (): FrigateCameraManagerEngine => {
return new FrigateCameraManagerEngine(
new EntityRegistryManagerMock(),
new StateWatcher(),
new RecordingSegmentsCache(),
new RequestCache(),
new CameraManagerRequestCache(),
);
};
const createRecordingMedia = (): FrigateRecordingViewMedia => {
return new FrigateRecordingViewMedia(
'recording',
ViewMediaType.Recording,
'camera-1',
{
cameraID: 'camera-1',
@@ -58,7 +59,7 @@ const createEvent = (): FrigateEvent => {
const createClipMedia = (): FrigateEventViewMedia => {
return new FrigateEventViewMedia(
'clip',
ViewMediaType.Clip,
'camera-1',
createEvent(),
'event-clip-content-id',
@@ -68,7 +69,7 @@ const createClipMedia = (): FrigateEventViewMedia => {
const createSnapshotMedia = (): FrigateEventViewMedia => {
return new FrigateEventViewMedia(
'snapshot',
ViewMediaType.Snapshot,
'camera-1',
createEvent(),
'event-snapshot-content-id',
@@ -133,7 +134,9 @@ describe('getMediaDownloadPath', () => {
const endpoint = await createEngine().getMediaDownloadPath(
createHASS(),
createFrigateCameraConfig(),
new ViewMedia('clip', 'camera-1'),
new ViewMedia(ViewMediaType.Clip, {
cameraID: 'camera-1',
}),
);
expect(endpoint).toBeNull();
});
@@ -247,9 +250,9 @@ describe('getCameraEndpoints', () => {
});
describe('with event media type', () => {
it.each([['clip' as const], ['snapshot' as const]])(
it.each([[ViewMediaType.Clip], [ViewMediaType.Snapshot]])(
'%s',
(mediaType: 'clip' | 'snapshot') => {
(mediaType: ViewMediaType) => {
const endpoints = createEngine().getCameraEndpoints(
createCameraConfig({
frigate: {
@@ -284,7 +287,10 @@ describe('getCameraEndpoints', () => {
},
}),
{
media: new TestViewMedia({ mediaType: 'recording', startTime: startTime }),
media: new TestViewMedia({
mediaType: ViewMediaType.Recording,
startTime: startTime,
}),
},
);
@@ -306,7 +312,7 @@ describe('getCameraEndpoints', () => {
},
}),
{
media: new TestViewMedia({ mediaType: 'recording' }),
media: new TestViewMedia({ mediaType: ViewMediaType.Recording }),
},
);
@@ -18,10 +18,10 @@ import {
retainResultSchema,
} from '../../../src/camera-manager/frigate/types';
import { RecordingSegment } from '../../../src/camera-manager/types';
import { homeAssistantWSRequest } from '../../../src/utils/ha/ws-request';
import { homeAssistantWSRequest } from '../../../src/ha/ws-request';
import { createFrigateEvent, createHASS } from '../../test-utils';
vi.mock('../../../src/utils/ha/ws-request');
vi.mock('../../../src/ha/ws-request');
describe('frigate requests', () => {
afterEach(() => {
+47 -34
View File
@@ -7,16 +7,14 @@ import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-fact
import { CameraManagerEngine } from '../../src/camera-manager/engine.js';
import {
CameraManager,
QueryClassifier,
CameraQueryClassifier,
QueryResultClassifier,
} from '../../src/camera-manager/manager.js';
import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraEvent,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
Engine,
EventQuery,
EventQueryResults,
@@ -25,12 +23,13 @@ import {
QueryResultsType,
QueryType,
} from '../../src/camera-manager/types.js';
import { sortMedia } from '../../src/camera-manager/utils/sort-media.js';
import { CardController } from '../../src/card-controller/controller.js';
import { sortItems } from '../../src/card-controller/view/sort.js';
import { CameraConfig } from '../../src/config/schema/cameras.js';
import { HomeAssistant } from '../../src/ha/types.js';
import { PTZMovementType } from '../../src/types.js';
import { ViewMedia } from '../../src/view/media.js';
import { Endpoint, PTZMovementType } from '../../src/types.js';
import { ViewFolder, ViewItem, ViewMedia } from '../../src/view/item.js';
import { ViewItemCapabilities } from '../../src/view/types.js';
import {
TestViewMedia,
createCamera,
@@ -38,53 +37,66 @@ import {
createCapabilities,
createCardAPI,
createConfig,
createFolder,
createHASS,
generateViewMediaArray,
} from '../test-utils.js';
describe('QueryClassifier', async () => {
it('should classify event query', async () => {
expect(QueryClassifier.isEventQuery({ type: QueryType.Event })).toBeTruthy();
expect(QueryClassifier.isEventQuery({ type: QueryType.Recording })).toBeFalsy();
expect(CameraQueryClassifier.isEventQuery({ type: QueryType.Event })).toBeTruthy();
expect(
QueryClassifier.isEventQuery({ type: QueryType.RecordingSegments }),
CameraQueryClassifier.isEventQuery({ type: QueryType.Recording }),
).toBeFalsy();
expect(
CameraQueryClassifier.isEventQuery({ type: QueryType.RecordingSegments }),
).toBeFalsy();
expect(
CameraQueryClassifier.isEventQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
expect(QueryClassifier.isEventQuery({ type: QueryType.MediaMetadata })).toBeFalsy();
});
it('should classify recording query', async () => {
expect(QueryClassifier.isRecordingQuery({ type: QueryType.Event })).toBeFalsy();
expect(QueryClassifier.isRecordingQuery({ type: QueryType.Recording })).toBeTruthy();
expect(
QueryClassifier.isRecordingQuery({ type: QueryType.RecordingSegments }),
CameraQueryClassifier.isRecordingQuery({ type: QueryType.Event }),
).toBeFalsy();
expect(
QueryClassifier.isRecordingQuery({ type: QueryType.MediaMetadata }),
CameraQueryClassifier.isRecordingQuery({ type: QueryType.Recording }),
).toBeTruthy();
expect(
CameraQueryClassifier.isRecordingQuery({ type: QueryType.RecordingSegments }),
).toBeFalsy();
expect(
CameraQueryClassifier.isRecordingQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
});
it('should classify recording segments query', async () => {
expect(
QueryClassifier.isRecordingSegmentsQuery({ type: QueryType.Event }),
CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.Event }),
).toBeFalsy();
expect(
QueryClassifier.isRecordingSegmentsQuery({ type: QueryType.Recording }),
CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.Recording }),
).toBeFalsy();
expect(
QueryClassifier.isRecordingSegmentsQuery({ type: QueryType.RecordingSegments }),
CameraQueryClassifier.isRecordingSegmentsQuery({
type: QueryType.RecordingSegments,
}),
).toBeTruthy();
expect(
QueryClassifier.isRecordingSegmentsQuery({ type: QueryType.MediaMetadata }),
CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
});
it('should classify media metadata query', async () => {
expect(QueryClassifier.isMediaMetadataQuery({ type: QueryType.Event })).toBeFalsy();
expect(
QueryClassifier.isMediaMetadataQuery({ type: QueryType.Recording }),
CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.Event }),
).toBeFalsy();
expect(
QueryClassifier.isMediaMetadataQuery({ type: QueryType.RecordingSegments }),
CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.Recording }),
).toBeFalsy();
expect(
QueryClassifier.isMediaMetadataQuery({ type: QueryType.MediaMetadata }),
CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.RecordingSegments }),
).toBeFalsy();
expect(
CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.MediaMetadata }),
).toBeTruthy();
});
});
@@ -641,7 +653,7 @@ describe('CameraManager', async () => {
const results = new Map([[baseEventQuery, baseEventQueryResults]]);
engine.getEvents.mockResolvedValue(results);
const media = sortMedia(generateViewMediaArray({ count: 5 }));
const media = sortItems(generateViewMediaArray({ count: 5 }));
engine.generateMediaFromEvents.mockReturnValue(media);
expect(await manager.executeMediaQueries([baseEventQuery])).toEqual(media);
@@ -697,7 +709,7 @@ describe('CameraManager', async () => {
const results = new Map([[baseRecordingQuery, baseRecordingQueryResults]]);
engine.getRecordings.mockResolvedValue(results);
const media = sortMedia(generateViewMediaArray({ count: 5 }));
const media = sortItems(generateViewMediaArray({ count: 5 }));
engine.generateMediaFromRecordings.mockReturnValue(media);
expect(await manager.executeMediaQueries([baseRecordingQuery])).toEqual(media);
@@ -718,7 +730,7 @@ describe('CameraManager', async () => {
describe('should extend media queries', () => {
const dateBase = new Date('2024-03-01T20:01:00');
const mediaTwoCameras = generateViewMediaArray({ count: 5 });
const mediaMixedStart: ViewMedia[] = [
const mediaMixedStart: ViewItem[] = [
new TestViewMedia({
startTime: dateBase,
}),
@@ -728,6 +740,7 @@ describe('CameraManager', async () => {
new TestViewMedia({
startTime: add(dateBase, { days: 2 }),
}),
new ViewFolder(createFolder()),
];
it('without hass', async () => {
@@ -748,7 +761,7 @@ describe('CameraManager', async () => {
generateViewMediaArray({ count: 5 }),
{
queries: [{ ...baseEventQuery, limit: 50 }],
results: sortMedia(generateViewMediaArray({ count: 5 })),
results: sortItems(generateViewMediaArray({ count: 5 })),
},
],
[
@@ -759,7 +772,7 @@ describe('CameraManager', async () => {
generateViewMediaArray({ count: 5, cameraIDs: ['office'] }),
{
queries: [{ ...baseEventQuery, limit: 50 }],
results: sortMedia(mediaTwoCameras),
results: sortItems(mediaTwoCameras),
},
],
[
@@ -793,7 +806,7 @@ describe('CameraManager', async () => {
mediaTwoCameras,
{
queries: [{ ...baseEventQuery, limit: 50, start: dateBase }],
results: sortMedia(mediaMixedStart.concat(mediaTwoCameras)),
results: sortItems(mediaMixedStart.concat(mediaTwoCameras)),
},
'later' as const,
],
@@ -811,7 +824,7 @@ describe('CameraManager', async () => {
mediaTwoCameras,
{
queries: [{ ...baseEventQuery, limit: 50, start: dateBase }],
results: sortMedia(mediaMixedStart.concat(mediaTwoCameras)),
results: sortItems(mediaMixedStart.concat(mediaTwoCameras)),
},
'earlier' as const,
],
@@ -823,7 +836,7 @@ describe('CameraManager', async () => {
inputQueries: Map<EventQuery, EventQueryResults>,
// The previously received media.
inputMediaResults: ViewMedia[],
inputResults: ViewItem[],
// The queries expected to be dispatched.
newChunkQueries: EventQuery[],
@@ -834,7 +847,7 @@ describe('CameraManager', async () => {
// The expect extended queries and results.
expected?: {
queries: EventQuery[];
results: ViewMedia[];
results: ViewItem[];
} | null,
direction?: 'earlier' | 'later',
) => {
@@ -853,7 +866,7 @@ describe('CameraManager', async () => {
expect(
await manager.extendMediaQueries(
[...inputQueries.keys()],
inputMediaResults,
inputResults,
direction ?? 'later',
),
).toEqual(expected);
@@ -895,7 +908,7 @@ describe('CameraManager', async () => {
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
const result: CameraEndpoint = {
const result: Endpoint = {
endpoint: 'http://localhost/path/to/media',
};
vi.mocked(engine.getMediaDownloadPath).mockResolvedValue(result);
@@ -919,7 +932,7 @@ describe('CameraManager', async () => {
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
const result: CameraManagerMediaCapabilities = {
const result: ViewItemCapabilities = {
canFavorite: false,
canDownload: false,
};
+2 -2
View File
@@ -6,14 +6,14 @@ import { CameraProxyConfig } from '../../../src/camera-manager/types';
import { ActionsExecutor } from '../../../src/card-controller/actions/types';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import { ProxyConfig } from '../../../src/config/schema/cameras';
import { EntityRegistryManagerLive } from '../../../src/utils/ha/registry/entity';
import { EntityRegistryManagerLive } from '../../../src/ha/registry/entity';
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
import {
createCameraConfig,
createHASS,
createRegistryEntity,
createStateEntity,
} from '../../test-utils';
import { EntityRegistryManagerMock } from '../../utils/ha/registry/entity/mock';
describe('ReolinkCamera', () => {
const cameraEntity = createRegistryEntity({
@@ -9,7 +9,6 @@ import {
vi,
} from 'vitest';
import { mock } from 'vitest-mock-extended';
import { RequestCache } from '../../../src/camera-manager/cache';
import {
ReolinkCameraManagerEngine,
ReolinkQueryResultsClassifier,
@@ -17,6 +16,7 @@ import {
import { ReolinkEventQueryResults } from '../../../src/camera-manager/reolink/types';
import { CameraManagerStore } from '../../../src/camera-manager/store';
import {
CameraManagerRequestCache,
Engine,
EventQuery,
QueryResultsType,
@@ -24,14 +24,12 @@ import {
QueryType,
} from '../../../src/camera-manager/types';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import { BrowseMediaManager } from '../../../src/utils/ha/browse-media/browse-media-manager';
import {
BrowseMedia,
browseMediaSchema,
} from '../../../src/utils/ha/browse-media/types';
import { EntityRegistryManager } from '../../../src/utils/ha/registry/entity/types';
import { ResolvedMediaCache } from '../../../src/utils/ha/resolved-media';
import { homeAssistantWSRequest } from '../../../src/utils/ha/ws-request';
import { BrowseMedia, browseMediaSchema } from '../../../src/ha/browse-media/types';
import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker';
import { EntityRegistryManager } from '../../../src/ha/registry/entity/types';
import { ResolvedMediaCache } from '../../../src/ha/resolved-media';
import { homeAssistantWSRequest } from '../../../src/ha/ws-request';
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
import {
createCamera,
createCameraConfig,
@@ -39,9 +37,8 @@ import {
createRegistryEntity,
createStore,
} from '../../test-utils';
import { EntityRegistryManagerMock } from '../../utils/ha/registry/entity/mock';
vi.mock('../../../src/utils/ha/ws-request');
vi.mock('../../../src/ha/ws-request');
const TEST_CAMERAS: BrowseMedia = {
title: 'Reolink',
@@ -181,15 +178,15 @@ const TEST_FILES: BrowseMedia = {
};
const createEngine = (options?: {
browseMediaManager?: BrowseMediaManager;
browseMediaManager?: BrowseMediaWalker;
entityRegistryManager?: EntityRegistryManager;
}): ReolinkCameraManagerEngine => {
return new ReolinkCameraManagerEngine(
options?.entityRegistryManager ?? new EntityRegistryManagerMock(),
mock<StateWatcher>(),
options?.browseMediaManager ?? new BrowseMediaManager(),
options?.browseMediaManager ?? new BrowseMediaWalker(),
new ResolvedMediaCache(),
new RequestCache(),
new CameraManagerRequestCache(),
);
};
+28 -12
View File
@@ -6,8 +6,8 @@ import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-fact
import { CameraManagerStore } from '../../src/camera-manager/store.js';
import { Engine } from '../../src/camera-manager/types.js';
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
import { EntityRegistryManager } from '../../src/utils/ha/registry/entity/types.js';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media.js';
import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js';
import { ResolvedMediaCache } from '../../src/ha/resolved-media.js';
import { TestViewMedia, createCameraConfig } from '../test-utils.js';
describe('CameraManagerStore', async () => {
@@ -139,14 +139,22 @@ describe('CameraManagerStore', async () => {
}
});
it('getCameraConfigForMedia', async () => {
const store = setupStore();
describe('getCameraConfigForMedia', () => {
it('should return the camera config for media with a camera ID', async () => {
const store = setupStore();
const media_1 = new TestViewMedia({ cameraID: 'camera-visible' });
expect(store.getCameraConfigForMedia(media_1)).toBe(configVisible);
const media_1 = new TestViewMedia({ cameraID: 'camera-visible' });
expect(store.getCameraConfigForMedia(media_1)).toBe(configVisible);
const media_2 = new TestViewMedia({ cameraID: 'camera-not-exist' });
expect(store.getCameraConfigForMedia(media_2)).toBeNull();
const media_2 = new TestViewMedia({ cameraID: 'camera-not-exist' });
expect(store.getCameraConfigForMedia(media_2)).toBeNull();
});
it('should return null as the camera config for media without a camera ID', async () => {
const store = setupStore();
const media = new TestViewMedia({ cameraID: null });
expect(store.getCameraConfigForMedia(media)).toBeNull();
});
});
it('getEngineOfType', async () => {
@@ -199,10 +207,18 @@ describe('CameraManagerStore', async () => {
});
});
it('getEngineForMedia', async () => {
const store = setupStore();
const media = new TestViewMedia({ cameraID: 'camera-visible' });
expect(store.getEngineForMedia(media)).toBe(engineGeneric);
describe('getEngineForMedia', () => {
it('should return the engine for media with a camera ID', async () => {
const store = setupStore();
const media = new TestViewMedia({ cameraID: 'camera-visible' });
expect(store.getEngineForMedia(media)).toBe(engineGeneric);
});
it('should return null as the engine for media without a camera ID', async () => {
const store = setupStore();
const media = new TestViewMedia({ cameraID: null });
expect(store.getEngineForMedia(media)).toBeNull();
});
});
describe('getAllDependentCameras', () => {
@@ -80,7 +80,7 @@ describe('ActionsManager', () => {
expect(manager.getMergedActions()).toEqual({});
});
describe('should get merged actions with live view', () => {
describe('should get merged actions with view', () => {
it.each([
[
'live' as const,
@@ -100,6 +100,16 @@ describe('ActionsManager', () => {
},
},
],
[
'folder' as const,
{
tap_action: {
action: 'navigate',
// Folder also uses the media gallery.
navigation_path: '3',
},
},
],
[
'clip' as const,
{
@@ -313,6 +323,7 @@ describe('ActionsManager', () => {
describe('should forward haptics', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
@@ -329,6 +340,8 @@ describe('ActionsManager', () => {
});
it('should forward warning haptic', async () => {
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
const handler = vi.fn();
window.addEventListener('haptic', handler);
@@ -1,9 +1,17 @@
import { expect, it } from 'vitest';
import { expect, it, vi } from 'vitest';
import { DownloadAction } from '../../../../src/card-controller/actions/actions/download';
import { createCardAPI } from '../../../test-utils';
import { QueryResults } from '../../../../src/view/query-results';
import { createCardAPI, createView, TestViewMedia } from '../../../test-utils';
it('should handle download action', async () => {
it('should handle download action with selected media', async () => {
const api = createCardAPI();
const selectedMedia = new TestViewMedia();
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
queryResults: new QueryResults({ results: [selectedMedia], selectedIndex: 0 }),
}),
);
const action = new DownloadAction(
{},
{
@@ -14,5 +22,22 @@ it('should handle download action', async () => {
await action.execute(api);
expect(api.getDownloadManager().downloadViewerMedia).toBeCalled();
expect(api.getViewItemManager().download).toBeCalledWith(selectedMedia);
});
it('should handle download action without selected media', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
const action = new DownloadAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'download',
},
);
await action.execute(api);
expect(api.getViewItemManager().download).not.toBeCalled();
});
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest';
import { FolderAction } from '../../../../src/card-controller/actions/actions/folder';
import { FolderQuery } from '../../../../src/card-controller/folders/types';
import { FolderViewQuery } from '../../../../src/view/query';
import { createCardAPI, createFolder } from '../../../test-utils';
describe('should handle folder action', async () => {
it('should handle folder action successfully', async () => {
const api = createCardAPI();
const action = new FolderAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'folder',
},
);
const folder = createFolder();
vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(folder);
const query: FolderQuery = {
folder,
path: ['path'],
};
vi.mocked(api.getFoldersManager().generateDefaultFolderQuery).mockReturnValue(query);
await action.execute(api);
expect(
api.getViewManager().setViewByParametersWithExistingQuery,
).toHaveBeenCalledWith({
params: {
view: 'folder',
query: expect.any(FolderViewQuery),
},
});
expect(
vi
.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0][0]?.params?.query?.getQuery(),
).toBe(query);
});
it('should do nothing with non-existent folder', async () => {
const api = createCardAPI();
const action = new FolderAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'folder',
folder: 'NON-EXISTENT-FOLDER',
},
);
vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(null);
await action.execute(api);
expect(
api.getViewManager().setViewByParametersWithExistingQuery,
).not.toHaveBeenCalled();
});
it('should do nothing with non-existent default query', async () => {
const api = createCardAPI();
const action = new FolderAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'folder',
},
);
const folder = createFolder();
vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(folder);
vi.mocked(api.getFoldersManager().generateDefaultFolderQuery).mockReturnValue(null);
await action.execute(api);
expect(
api.getViewManager().setViewByParametersWithExistingQuery,
).not.toHaveBeenCalled();
});
});
@@ -1,18 +1,62 @@
import { expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ScreenshotAction } from '../../../../src/card-controller/actions/actions/screenshot';
import { createCardAPI } from '../../../test-utils';
import { MediaPlayerController } from '../../../../src/types';
import { downloadURL } from '../../../../src/utils/download';
import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils';
it('should handle screenshot action', async () => {
const api = createCardAPI();
const action = new ScreenshotAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'screenshot',
},
);
vi.mock('../../../../src/utils/download');
await action.execute(api);
describe('should handle screenshot action', async () => {
afterEach(() => {
vi.clearAllMocks();
});
expect(api.getDownloadManager().downloadScreenshot).toBeCalled();
it('should handle screenshot action with screenshot URL', async () => {
const api = createCardAPI();
const action = new ScreenshotAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'screenshot',
},
);
const mediaPlayerController = mock<MediaPlayerController>();
mediaPlayerController.getScreenshotURL.mockResolvedValue('screenshot-url');
vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue(
createMediaLoadedInfo({
mediaPlayerController,
}),
);
await action.execute(api);
expect(downloadURL).toBeCalledWith('screenshot-url', 'screenshot.jpg');
});
it('should handle screenshot action without screenshot URL', async () => {
const api = createCardAPI();
const action = new ScreenshotAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'screenshot',
},
);
const mediaPlayerController = mock<MediaPlayerController>();
mediaPlayerController.getScreenshotURL.mockResolvedValue(null);
vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue(
createMediaLoadedInfo({
mediaPlayerController,
}),
);
await action.execute(api);
expect(downloadURL).not.toBeCalled();
});
});
@@ -1,13 +1,9 @@
import { afterAll, expect, it, vi } from 'vitest';
import { createCardAPI } from '../../../test-utils';
import { expect, it, vi } from 'vitest';
import { SleepAction } from '../../../../src/card-controller/actions/actions/sleep';
import { sleep } from '../../../../src/utils/basic';
import { sleep } from '../../../../src/utils/sleep';
import { createCardAPI } from '../../../test-utils';
vi.mock('../../../../src/utils/basic');
afterAll(() => {
vi.restoreAllMocks();
});
vi.mock('../../../../src/utils/sleep');
it('should handle sleep action', async () => {
const api = createCardAPI();
@@ -7,6 +7,7 @@ import { DefaultAction } from '../../../src/card-controller/actions/actions/defa
import { DisplayModeSelectAction } from '../../../src/card-controller/actions/actions/display-mode-select';
import { DownloadAction } from '../../../src/card-controller/actions/actions/download';
import { ExpandAction } from '../../../src/card-controller/actions/actions/expand';
import { FolderAction } from '../../../src/card-controller/actions/actions/folder';
import { FullscreenAction } from '../../../src/card-controller/actions/actions/fullscreen';
import { InternalCallbackAction } from '../../../src/card-controller/actions/actions/internal-callback';
import { LogAction } from '../../../src/card-controller/actions/actions/log';
@@ -184,6 +185,7 @@ describe('ActionFactory', () => {
},
InternalCallbackAction,
],
[{ advanced_camera_card_action: 'folder' as const }, FolderAction],
])(
'advanced_camera_card_action: $advanced_camera_card_action',
(action: Partial<ActionConfig>, classObject: object) => {
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CameraEndpoint } from '../../src/camera-manager/types';
import { CameraURLManager } from '../../src/card-controller/camera-url-manager';
import { CardCameraURLAPI } from '../../src/card-controller/types';
import { Endpoint } from '../../src/types';
import { createCardAPI, createViewWithMedia } from '../test-utils';
const createAPIWithMedia = (): CardCameraURLAPI => {
@@ -20,7 +20,7 @@ describe('CameraURLManager', () => {
const api = createAPIWithMedia();
const manager = new CameraURLManager(api);
const endpoint: CameraEndpoint = {
const endpoint: Endpoint = {
endpoint: 'http://frigate',
};
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { setFoldersFromConfig } from '../../../src/card-controller/config/load-folders';
import { FoldersManager } from '../../../src/card-controller/folders/manager';
import { createCardAPI, createConfig, createFolder } from '../../test-utils';
describe('setFoldersFromConfig', () => {
it('should replace folders', () => {
const api = createCardAPI();
const foldersManager = mock<FoldersManager>();
vi.mocked(api.getFoldersManager).mockReturnValue(foldersManager);
const folders = [createFolder()];
const config = createConfig({
folders,
});
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(config);
setFoldersFromConfig(api);
expect(foldersManager.deleteFolders).toBeCalled();
expect(foldersManager.addFolders).toBeCalledWith(folders);
});
it('should handle exceptions', () => {
const api = createCardAPI();
const foldersManager = mock<FoldersManager>();
vi.mocked(api.getFoldersManager).mockReturnValue(foldersManager);
const error = new Error('test error');
vi.mocked(foldersManager.addFolders).mockImplementation(() => {
throw error;
});
const config = createConfig({
folders: [createFolder()],
});
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(config);
setFoldersFromConfig(api);
expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error);
});
});
+22 -13
View File
@@ -10,8 +10,8 @@ import {
import { ConfigManager } from '../../src/card-controller/config/config-manager';
import { CardController } from '../../src/card-controller/controller';
import { DefaultManager } from '../../src/card-controller/default-manager';
import { DownloadManager } from '../../src/card-controller/download-manager';
import { ExpandManager } from '../../src/card-controller/expand-manager';
import { FoldersManager } from '../../src/card-controller/folders/manager';
import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager';
import { HASSManager } from '../../src/card-controller/hass/hass-manager';
import { InitializationManager } from '../../src/card-controller/initialization-manager';
@@ -25,12 +25,13 @@ import { QueryStringManager } from '../../src/card-controller/query-string-manag
import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager';
import { StyleManager } from '../../src/card-controller/style-manager';
import { TriggersManager } from '../../src/card-controller/triggers-manager';
import { ViewItemManager } from '../../src/card-controller/view/item-manager';
import { ViewManager } from '../../src/card-controller/view/view-manager';
import { ConditionStateManager } from '../../src/conditions/state-manager';
import { AdvancedCameraCardEditor } from '../../src/editor';
import { DeviceRegistryManager } from '../../src/utils/ha/registry/device';
import { EntityRegistryManagerLive } from '../../src/utils/ha/registry/entity';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
import { DeviceRegistryManager } from '../../src/ha/registry/device';
import { EntityRegistryManagerLive } from '../../src/ha/registry/entity';
import { ResolvedMediaCache } from '../../src/ha/resolved-media';
vi.mock('../../src/camera-manager/manager');
vi.mock('../../src/card-controller/actions/actions-manager');
@@ -41,6 +42,7 @@ vi.mock('../../src/card-controller/config/config-manager');
vi.mock('../../src/card-controller/default-manager');
vi.mock('../../src/card-controller/download-manager');
vi.mock('../../src/card-controller/expand-manager');
vi.mock('../../src/card-controller/folders/manager');
vi.mock('../../src/card-controller/fullscreen/fullscreen-manager');
vi.mock('../../src/card-controller/hass/hass-manager');
vi.mock('../../src/card-controller/initialization-manager');
@@ -54,11 +56,12 @@ vi.mock('../../src/card-controller/query-string-manager');
vi.mock('../../src/card-controller/status-bar-item-manager');
vi.mock('../../src/card-controller/style-manager');
vi.mock('../../src/card-controller/triggers-manager');
vi.mock('../../src/card-controller/view/item-manager');
vi.mock('../../src/card-controller/view/view-manager');
vi.mock('../../src/conditions/state-manager');
vi.mock('../../src/utils/ha/registry/device');
vi.mock('../../src/utils/ha/registry/entity');
vi.mock('../../src/utils/ha/resolved-media');
vi.mock('../../src/ha/registry/device');
vi.mock('../../src/ha/registry/entity');
vi.mock('../../src/ha/resolved-media');
const createCardElement = (): CardHTMLElement => {
const element = document.createElement('div') as unknown as CardHTMLElement;
@@ -152,12 +155,6 @@ describe('CardController', () => {
);
});
it('getDownloadManager', () => {
expect(createController().getDownloadManager()).toBe(
vi.mocked(DownloadManager).mock.instances[0],
);
});
it('getEntityRegistryManager', () => {
expect(createController().getEntityRegistryManager()).toBe(
vi.mocked(EntityRegistryManagerLive).mock.instances[0],
@@ -170,6 +167,12 @@ describe('CardController', () => {
);
});
it('getFoldersManager', () => {
expect(createController().getFoldersManager()).toBe(
vi.mocked(FoldersManager).mock.instances[0],
);
});
it('getFullscreenManager', () => {
expect(createController().getFullscreenManager()).toBe(
vi.mocked(FullscreenManager).mock.instances[0],
@@ -270,6 +273,12 @@ describe('CardController', () => {
);
});
it('getViewItemManager', () => {
expect(createController().getViewItemManager()).toBe(
vi.mocked(ViewItemManager).mock.instances[0],
);
});
it('getViewManager', () => {
expect(createController().getViewManager()).toBe(
vi.mocked(ViewManager).mock.instances[0],
@@ -1,94 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { DownloadManager } from '../../src/card-controller/download-manager';
import { MediaPlayerController } from '../../src/types';
import { downloadMedia, downloadURL } from '../../src/utils/download.js';
import {
createCardAPI,
createHASS,
createMediaLoadedInfo,
createViewWithMedia,
} from '../test-utils';
vi.mock('../../src/utils/download.js');
describe('DownloadManager.downloadViewerMedia', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should download', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(createViewWithMedia());
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new DownloadManager(api);
expect(await manager.downloadViewerMedia()).toBeTruthy();
expect(downloadMedia).toBeCalledWith(
api.getHASSManager().getHASS(),
api.getCameraManager(),
api.getViewManager().getView()?.queryResults?.getResult(0),
);
});
it('should not download due to exception thrown', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(createViewWithMedia());
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new DownloadManager(api);
const error = new Error();
vi.mocked(downloadMedia).mockRejectedValue(error);
expect(await manager.downloadViewerMedia()).toBeFalsy();
expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error);
});
it('should not download without hass', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(createViewWithMedia());
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null);
const manager = new DownloadManager(api);
expect(await manager.downloadViewerMedia()).toBeFalsy();
expect(downloadMedia).not.toBeCalled();
});
});
describe('DownloadManager.downloadScreenshot', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('with url', async () => {
const api = createCardAPI();
const mediaPlayerController = mock<MediaPlayerController>();
mediaPlayerController.getScreenshotURL.mockResolvedValue('http://screenshot');
vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue(
createMediaLoadedInfo({
mediaPlayerController,
}),
);
const manager = new DownloadManager(api);
await manager.downloadScreenshot();
expect(downloadURL).toBeCalledWith('http://screenshot', 'screenshot.jpg');
});
it('without url', async () => {
const api = createCardAPI();
const mediaPlayerController = mock<MediaPlayerController>();
mediaPlayerController.getScreenshotURL.mockResolvedValue(null);
vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue(
createMediaLoadedInfo({
mediaPlayerController,
}),
);
const manager = new DownloadManager(api);
await manager.downloadScreenshot();
expect(downloadURL).not.toBeCalled();
});
});
@@ -0,0 +1,172 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { FoldersExecutor } from '../../../src/card-controller/folders/executor';
import { HAFoldersEngine } from '../../../src/card-controller/folders/ha/engine';
import { FolderQuery } from '../../../src/card-controller/folders/types';
import { FolderConfig } from '../../../src/config/schema/folders';
import { Endpoint } from '../../../src/types';
import { ViewFolder } from '../../../src/view/item';
import { createFolder, createHASS, TestViewMedia } from '../../test-utils';
vi.mock('../../../src/card-controller/folders/ha/engine');
vi.mock('../../../../src/utils/ha/download');
describe('FoldersExecutor', () => {
afterEach(() => {
vi.clearAllMocks();
});
describe('getItemCapabilities', () => {
it('should not get capabilities for non-folder media', () => {
const item = new TestViewMedia({ folder: null });
const executor = new FoldersExecutor();
expect(executor.getItemCapabilities(item)).toBeNull();
});
it('should not get capabilities for non-existent folder engine', () => {
const folder: FolderConfig = {
type: 'UNKNOWN',
} as unknown as FolderConfig;
const item = new TestViewMedia({ folder });
const executor = new FoldersExecutor();
expect(executor.getItemCapabilities(item)).toBeNull();
});
it('should get capabilities for HA folder engine', () => {
const folder = createFolder();
const item = new TestViewMedia({ folder });
const capabilities = {
canFavorite: false,
canDownload: true,
};
const haFolderEngine = mock<HAFoldersEngine>();
haFolderEngine.getItemCapabilities.mockReturnValue(capabilities);
const executor = new FoldersExecutor({
ha: haFolderEngine,
});
expect(executor.getItemCapabilities(item)).toEqual(capabilities);
});
});
describe('getDownloadPath', () => {
it('should not get download path for non-existent folder engine', async () => {
const folder: FolderConfig = {
type: 'UNKNOWN',
} as unknown as FolderConfig;
const item = new TestViewMedia({ folder });
const executor = new FoldersExecutor();
expect(await executor.getDownloadPath(createHASS(), item)).toBeNull();
});
it('should get download path for HA folder engine', async () => {
const endpoint: Endpoint = { endpoint: '/media', sign: false };
const haFolderEngine = mock<HAFoldersEngine>();
haFolderEngine.getDownloadPath.mockResolvedValue(endpoint);
const executor = new FoldersExecutor({
ha: haFolderEngine,
});
const item = new TestViewMedia({ folder: createFolder() });
expect(await executor.getDownloadPath(createHASS(), item)).toEqual(endpoint);
});
});
describe('favorite', () => {
it('should not favorite non-existent folder engine', async () => {
const folder: FolderConfig = {
type: 'UNKNOWN',
} as unknown as FolderConfig;
const item = new TestViewMedia({ folder });
const executor = new FoldersExecutor();
await executor.favorite(createHASS(), item, true);
// No observable effect.
});
it('should favorite', async () => {
const haFolderEngine = mock<HAFoldersEngine>();
const executor = new FoldersExecutor({
ha: haFolderEngine,
});
const hass = createHASS();
const item = new TestViewMedia({ folder: createFolder() });
await executor.favorite(hass, item, true);
expect(haFolderEngine.favorite).toBeCalledWith(hass, item, true);
});
});
describe('generateDefaultFolderQuery', () => {
it('should generate default folder query', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: ['media-source://'],
};
const haFolderEngine = mock<HAFoldersEngine>();
haFolderEngine.generateDefaultFolderQuery.mockReturnValue(query);
const executor = new FoldersExecutor({ ha: haFolderEngine });
expect(executor.generateDefaultFolderQuery(folder)).toEqual(query);
});
it('should return null for non-existent folder engine', () => {
const folder: FolderConfig = {
type: 'UNKNOWN',
} as unknown as FolderConfig;
const executor = new FoldersExecutor();
expect(executor.generateDefaultFolderQuery(folder)).toBeNull();
});
});
describe('expandFolder', () => {
it('should reject folders of the wrong type', async () => {
const query = {
folder: { type: 'UNKNOWN' },
} as unknown as FolderQuery;
const executor = new FoldersExecutor();
expect(await executor.expandFolder(createHASS(), query)).toBeNull();
});
it('should expand folder', async () => {
const folder = createFolder();
const query: FolderQuery = {
folder,
path: ['media-source://'],
};
const mediaItem = new TestViewMedia({
folder,
startTime: new Date('2023-04-29T14:27'),
});
const folderItem = new ViewFolder(folder);
const haFolderEngine = mock<HAFoldersEngine>();
haFolderEngine.expandFolder.mockResolvedValue([folderItem, mediaItem, folderItem]);
const executor = new FoldersExecutor({
ha: haFolderEngine,
});
const hass = createHASS();
const results = await executor.expandFolder(hass, query);
expect(results).toEqual([
// Folder will have been sorted to the front and de-duped.
folderItem,
mediaItem,
]);
});
});
});
@@ -0,0 +1,370 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HAFoldersEngine } from '../../../../src/card-controller/folders/ha/engine';
import { FolderQuery } from '../../../../src/card-controller/folders/types';
import { FolderConfig } from '../../../../src/config/schema/folders';
import { getMediaDownloadPath } from '../../../../src/ha/download';
import { homeAssistantWSRequest } from '../../../../src/ha/ws-request';
import { Endpoint } from '../../../../src/types';
import { ViewFolder, ViewMedia } from '../../../../src/view/item';
import {
createBrowseMedia,
createFolder,
createHASS,
TestViewMedia,
} from '../../../test-utils';
vi.mock('../../../../src/ha/download');
vi.mock('../../../../src/ha/ws-request');
describe('HAFoldersEngine', () => {
afterEach(() => {
vi.clearAllMocks();
});
describe('getItemCapabilities', () => {
it('should not be able to download a folder', () => {
const item = new ViewFolder(createFolder());
const engine = new HAFoldersEngine();
expect(engine.getItemCapabilities(item)).toEqual({
canFavorite: false,
canDownload: false,
});
});
it('should be able to download a media item', () => {
const item = new TestViewMedia({ folder: createFolder() });
const engine = new HAFoldersEngine();
expect(engine.getItemCapabilities(item)).toEqual({
canFavorite: false,
canDownload: true,
});
});
});
describe('getDownloadPath', () => {
it('should return null if item is not a media item', async () => {
const item = new ViewFolder(createFolder());
const engine = new HAFoldersEngine();
expect(await engine.getDownloadPath(createHASS(), item)).toBeNull();
});
it('should return a download path for a media item', async () => {
const item = new TestViewMedia({ folder: createFolder() });
const engine = new HAFoldersEngine();
const endpoint: Endpoint = { endpoint: '/media', sign: false };
vi.mocked(getMediaDownloadPath).mockResolvedValue(endpoint);
expect(await engine.getDownloadPath(createHASS(), item)).toEqual(endpoint);
});
});
describe('favorite', () => {
it('should favorite', async () => {
const engine = new HAFoldersEngine();
const item = new TestViewMedia({ folder: createFolder() });
await engine.favorite(createHASS(), item, true);
// No observable effect.
});
});
describe('expandFolder', () => {
it('should reject folders of the wrong type', async () => {
const query = {
folder: { type: 'UNKNOWN' },
} as unknown as FolderQuery;
const engine = new HAFoldersEngine();
expect(await engine.expandFolder(createHASS(), query)).toBeNull();
});
});
describe('should generate default folder query', () => {
it('should generate default folder query', () => {
const folder: FolderConfig = { type: 'ha' };
const engine = new HAFoldersEngine();
const query = engine.generateDefaultFolderQuery(folder);
expect(query).toEqual({
folder,
path: [{ id: 'media-source://', ha: { id: 'media-source://' } }],
});
});
it('should reject folders of the wrong type', async () => {
const folder = createFolder({ type: 'UNKNOWN' } as unknown as FolderConfig);
const engine = new HAFoldersEngine();
expect(engine.generateDefaultFolderQuery(folder)).toBeNull();
});
it('should respect path_url as a priority', async () => {
const folder = createFolder({
ha: {
url: [{ id: 'media-source://1' }],
path: [{ id: 'media-source://2' }],
},
});
const engine = new HAFoldersEngine();
expect(engine.generateDefaultFolderQuery(folder)).toEqual({
folder,
path: [
{ id: 'media-source://1', ha: { id: 'media-source://1' } },
{ id: 'media-source://2', ha: { id: 'media-source://2' } },
],
});
});
it('should respect configured url', async () => {
const folder = createFolder({ ha: { url: [{ id: 'media-source://' }] } });
const engine = new HAFoldersEngine();
expect(engine.generateDefaultFolderQuery(folder)).toEqual({
folder,
path: [{ id: 'media-source://', ha: { id: 'media-source://' } }],
});
});
it('should add default media root as necessary', async () => {
const folder = createFolder({ ha: { path: [{ title: 'Frigate' }] } });
const engine = new HAFoldersEngine();
expect(engine.generateDefaultFolderQuery(folder)).toEqual({
folder,
path: [
{ id: 'media-source://', ha: { id: 'media-source://' } },
{ id: undefined, ha: { title: 'Frigate' } },
],
});
});
});
describe('should expand folder', () => {
it('should expand folder with cache by default', async () => {
const query: FolderQuery = {
folder: { type: 'ha' },
path: [{ id: 'media-source://id' }],
};
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(
createBrowseMedia({
media_content_id: 'media-source://id',
can_expand: true,
children: [
createBrowseMedia({
media_content_id: 'media-source://media-item',
title: 'Media Item',
}),
createBrowseMedia({
media_content_id: 'media-source://frigate',
title: 'Frigate',
can_expand: true,
}),
],
}),
);
const engine = new HAFoldersEngine();
const results = await engine.expandFolder(createHASS(), query);
expect(results?.length).toBe(2);
expect(results?.[0]).toBeInstanceOf(ViewMedia);
expect(results?.[1]).toBeInstanceOf(ViewFolder);
expect(homeAssistantWSRequest).toBeCalledTimes(1);
// Expanding the folder again should use the cache.
await engine.expandFolder(createHASS(), query);
expect(homeAssistantWSRequest).toBeCalledTimes(1);
});
it('should expand folder without cache when requested', async () => {
const query: FolderQuery = {
folder: { type: 'ha' },
path: [{ id: 'media-source://id' }],
};
vi.mocked(homeAssistantWSRequest)
.mockResolvedValueOnce(
createBrowseMedia({
media_content_id: 'media-source://id',
can_expand: true,
children: [
createBrowseMedia({
media_content_id: 'media-source://media-item',
title: 'Media Item',
}),
createBrowseMedia({
media_content_id: 'media-source://frigate',
title: 'Frigate',
can_expand: true,
}),
],
}),
)
.mockResolvedValueOnce([]);
const engine = new HAFoldersEngine();
const results = await engine.expandFolder(createHASS(), query, {
useCache: false,
});
expect(results?.length).toBe(2);
expect(results?.[0]).toBeInstanceOf(ViewMedia);
expect(results?.[1]).toBeInstanceOf(ViewFolder);
expect(homeAssistantWSRequest).toBeCalledTimes(1);
// Expanding the folder again should use the cache.
await engine.expandFolder(createHASS(), query);
expect(homeAssistantWSRequest).toBeCalledTimes(2);
});
it('should not expand without a folder with an id', async () => {
const query: FolderQuery = {
folder: { type: 'ha' },
// There's no component in the query with an id to start from.
path: [{ ha: { title: 'Frigate' } }],
};
const engine = new HAFoldersEngine();
expect(await engine.expandFolder(createHASS(), query)).toBeNull();
});
it('should expand folder with title based query', async () => {
const query: FolderQuery = {
folder: { type: 'ha' },
path: [{ id: 'media-source://' }, { ha: { title: 'Frigate' } }],
};
vi.mocked(homeAssistantWSRequest)
.mockResolvedValueOnce(
createBrowseMedia({
media_content_id: 'media-source://',
can_expand: true,
children: [
createBrowseMedia({
media_content_id: 'media-source://frigate',
title: 'Frigate',
can_expand: true,
}),
],
}),
)
.mockResolvedValueOnce(
createBrowseMedia({
media_content_id: 'media-source://frigate',
can_expand: true,
children: [
createBrowseMedia({
media_content_id: 'media-source://frigate/result',
title: 'Result',
}),
],
}),
);
const engine = new HAFoldersEngine();
const results = await engine.expandFolder(createHASS(), query);
expect(results?.length).toBe(1);
expect(results?.[0]).toBeInstanceOf(ViewMedia);
expect(results?.[0].getID()).toBe('media-source://frigate/result');
});
it('should expand folder with title_re based query', async () => {
const query: FolderQuery = {
folder: { type: 'ha' },
path: [{ id: 'media-source://' }, { ha: { title_re: 'Fri.*' } }],
};
vi.mocked(homeAssistantWSRequest)
.mockResolvedValueOnce(
createBrowseMedia({
media_content_id: 'media-source://',
can_expand: true,
children: [
createBrowseMedia({
media_content_id: 'media-source://frigate',
title: 'Frigate',
can_expand: true,
}),
],
}),
)
.mockResolvedValueOnce(
createBrowseMedia({
media_content_id: 'media-source://frigate',
can_expand: true,
children: [
createBrowseMedia({
media_content_id: 'media-source://frigate/result',
title: 'Result',
}),
],
}),
);
const engine = new HAFoldersEngine();
const results = await engine.expandFolder(createHASS(), query);
expect(results?.length).toBe(1);
expect(results?.[0]).toBeInstanceOf(ViewMedia);
expect(results?.[0].getID()).toBe('media-source://frigate/result');
});
it('should expand folder with ha id based query', async () => {
const query: FolderQuery = {
folder: { type: 'ha' },
path: [
{ id: 'media-source://' },
{ ha: { title: 'Frigate' } },
{ id: 'media-source://frigate/subdir' },
],
};
vi.mocked(homeAssistantWSRequest)
.mockResolvedValueOnce(
createBrowseMedia({
media_content_id: 'media-source://',
can_expand: true,
children: [
createBrowseMedia({
media_content_id: 'media-source://frigate',
title: 'Frigate',
can_expand: true,
}),
],
}),
)
.mockResolvedValueOnce(
createBrowseMedia({
media_content_id: 'media-source://frigate',
can_expand: true,
children: [
createBrowseMedia({
media_content_id: 'media-source://frigate/subdir',
can_expand: true,
}),
],
}),
)
.mockResolvedValueOnce(
createBrowseMedia({
media_content_id: 'media-source://frigate/subdir',
can_expand: true,
children: [
createBrowseMedia({
media_content_id: 'media-source://frigate/subdir/result',
title: 'Result',
}),
],
}),
);
const engine = new HAFoldersEngine();
const results = await engine.expandFolder(createHASS(), query);
expect(results?.length).toBe(1);
expect(results?.[0]).toBeInstanceOf(ViewMedia);
expect(results?.[0].getID()).toBe('media-source://frigate/subdir/result');
});
});
});
@@ -0,0 +1,266 @@
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { FoldersExecutor } from '../../../src/card-controller/folders/executor';
import { FoldersManager } from '../../../src/card-controller/folders/manager';
import { FolderQuery } from '../../../src/card-controller/folders/types';
import { FolderConfig } from '../../../src/config/schema/folders';
import { ResolvedMediaCache } from '../../../src/ha/resolved-media';
import { Endpoint } from '../../../src/types';
import { ViewItemCapabilities } from '../../../src/view/types';
import {
createCardAPI,
createFolder,
createHASS,
TestViewMedia,
} from '../../test-utils';
describe('FoldersManager', () => {
it('should initialize with no folders', () => {
const api = createCardAPI();
const manager = new FoldersManager(api);
expect(manager.getFolderCount()).toBe(0);
});
describe('should add folders', () => {
it('should add a folder correctly without a title', () => {
const manager = new FoldersManager(createCardAPI());
const folder = createFolder({ id: 'folder-1' });
manager.addFolders([folder]);
expect(manager.getFolderCount()).toBe(1);
expect(manager.getFolder('folder-1')).toEqual({
...folder,
title: 'Folder 0',
});
});
it('should add a folder correctly with a title', () => {
const manager = new FoldersManager(createCardAPI());
const folder = createFolder({ id: 'folder-1', title: 'Original Title' });
manager.addFolders([folder]);
expect(manager.getFolderCount()).toBe(1);
expect(manager.getFolder('folder-1')).toEqual(folder);
});
it('should add a folder without an id', () => {
const manager = new FoldersManager(createCardAPI());
const folder = createFolder({ title: 'Title' });
manager.addFolders([folder]);
expect(manager.getFolderCount()).toBe(1);
expect(manager.getFolder('folder/0')).toEqual({
...folder,
id: 'folder/0',
});
});
it('should reject duplicate folder IDs', () => {
const manager = new FoldersManager(createCardAPI());
const folder_1 = createFolder({ id: 'DUP' });
const folder_2 = createFolder({ id: 'DUP' });
expect(() => manager.addFolders([folder_1, folder_2])).toThrowError(
/Duplicate folder id/,
);
});
it('should preserve previous folders', () => {
const manager = new FoldersManager(createCardAPI());
const folder_original = createFolder();
manager.addFolders([folder_original]);
const folder_new = createFolder({ id: 'DUP' });
manager.addFolders([folder_new]);
expect(manager.getFolderCount()).toBe(2);
});
});
it('should delete folders', () => {
const manager = new FoldersManager(createCardAPI());
const folder_1 = createFolder({ id: 'id-1' });
const folder_2 = createFolder({ id: 'id-2' });
manager.addFolders([folder_1, folder_2]);
expect(manager.getFolderCount()).toBe(2);
manager.deleteFolders();
expect(manager.getFolderCount()).toBe(0);
});
describe('should get folders', () => {
it('should get default folder', () => {
const manager = new FoldersManager(createCardAPI());
const folder = createFolder({ id: 'id', title: 'Title' });
manager.addFolders([folder]);
expect(manager.getFolder()).toEqual(folder);
});
it('should get default folders when there are none', () => {
const manager = new FoldersManager(createCardAPI());
expect(manager.getFolder()).toBeNull();
});
it('should get all folders', () => {
const manager = new FoldersManager(createCardAPI());
const folder_1 = createFolder({ id: 'id-1', title: 'Title' });
const folder_2 = createFolder({ id: 'id-2', title: 'Title' });
manager.addFolders([folder_1, folder_2]);
expect([...manager.getFolders()]).toEqual([
['id-1', folder_1],
['id-2', folder_2],
]);
});
it('should get non-existent folder', () => {
const manager = new FoldersManager(createCardAPI());
expect(manager.getFolder('NON-EXISTENT')).toBeNull();
});
});
describe('generateDefaultFolderQuery', () => {
it('should generate default folder query with implicit folder', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: ['media-source://'],
};
const executor = mock<FoldersExecutor>();
vi.mocked(executor.generateDefaultFolderQuery).mockReturnValue(query);
const manager = new FoldersManager(createCardAPI(), executor);
manager.addFolders([folder]);
expect(manager.generateDefaultFolderQuery()).toEqual(query);
});
it('should generate default folder query without any folder', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: ['media-source://'],
};
const executor = mock<FoldersExecutor>();
vi.mocked(executor.generateDefaultFolderQuery).mockReturnValue(query);
const manager = new FoldersManager(createCardAPI(), executor);
// Folder is not added to the manager.
expect(manager.generateDefaultFolderQuery()).toBeNull();
});
it('should generate default folder query with explicit folder', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: ['media-source://'],
};
const executor = mock<FoldersExecutor>();
vi.mocked(executor.generateDefaultFolderQuery).mockReturnValue(query);
const manager = new FoldersManager(createCardAPI(), executor);
expect(manager.generateDefaultFolderQuery(folder)).toEqual(query);
});
});
describe('should expand folder', () => {
it('should expand folder with hass', async () => {
const hass = createHASS();
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const media = new TestViewMedia();
const executor = mock<FoldersExecutor>();
executor.expandFolder.mockResolvedValue([media]);
const manager = new FoldersManager(api, executor);
const folder = createFolder({ id: 'folder-1' });
const engineOptions = {};
const query: FolderQuery = {
folder,
path: ['media-source://'],
};
expect(await manager.expandFolder(query, engineOptions)).toEqual([media]);
expect(executor.expandFolder).toBeCalledWith(hass, query, engineOptions);
});
it('should not expand folder with hass', async () => {
const api = createCardAPI();
const executor = mock<FoldersExecutor>();
const manager = new FoldersManager(api, executor);
const folder = createFolder({ id: 'folder-1' });
expect(
await manager.expandFolder({ folder, path: ['media-source://'] }),
).toBeNull();
expect(executor.expandFolder).not.toBeCalled();
});
});
describe('should get item capabilities', () => {
it('should get item capabilities', () => {
const api = createCardAPI();
const executor = mock<FoldersExecutor>();
const capabilities: ViewItemCapabilities = {
canFavorite: true,
canDownload: true,
};
executor.getItemCapabilities.mockReturnValue(capabilities);
const item = new TestViewMedia();
const manager = new FoldersManager(api, executor);
expect(manager.getItemCapabilities(item)).toEqual(capabilities);
expect(executor.getItemCapabilities).toBeCalledWith(item);
});
});
describe('should get download path', () => {
it('should get download path', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const cache = new ResolvedMediaCache();
vi.mocked(api.getResolvedMediaCache).mockReturnValue(cache);
const executor = mock<FoldersExecutor>();
const item = new TestViewMedia();
const endpoint: Endpoint = { endpoint: 'endpoint' };
executor.getDownloadPath.mockResolvedValue(endpoint);
const manager = new FoldersManager(api, executor);
expect(await manager.getDownloadPath(item)).toEqual(endpoint);
expect(executor.getDownloadPath).toBeCalledWith(hass, item, {
resolvedMediaCache: cache,
});
});
});
describe('should favorite item', () => {
it('should favorite item', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const executor = mock<FoldersExecutor>();
const item = new TestViewMedia();
const manager = new FoldersManager(api, executor);
await manager.favorite(item, true);
expect(executor.favorite).toBeCalledWith(hass, item, true);
});
});
});
@@ -5,13 +5,13 @@ import {
InitializationManager,
} from '../../src/card-controller/initialization-manager';
import { ConditionStateManager } from '../../src/conditions/state-manager';
import { sideLoadHomeAssistantElements } from '../../src/ha/side-load-ha-elements.js';
import { loadLanguages } from '../../src/localize/localize';
import { sideLoadHomeAssistantElements } from '../../src/utils/ha';
import { Initializer } from '../../src/utils/initializer/initializer';
import { createCardAPI, createConfig, createHASS } from '../test-utils';
vi.mock('../../src/localize/localize.js');
vi.mock('../../src/utils/ha/index.js');
vi.mock('../../src/ha/side-load-ha-elements.js');
// @vitest-environment jsdom
describe('InitializationManager', () => {
@@ -90,6 +90,12 @@ describe('InitializationManager', () => {
const manager = new InitializationManager(api);
expect(manager.isInitialized(InitializationAspect.LANGUAGES)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.SIDE_LOAD_ELEMENTS)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.CAMERAS)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.MICROPHONE_CONNECT)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.VIEW)).toBeFalsy();
await manager.initializeMandatory();
expect(loadLanguages).toBeCalled();
@@ -109,6 +115,14 @@ describe('InitializationManager', () => {
},
}),
);
expect(manager.isInitialized(InitializationAspect.LANGUAGES)).toBeTruthy();
expect(
manager.isInitialized(InitializationAspect.SIDE_LOAD_ELEMENTS),
).toBeTruthy();
expect(manager.isInitialized(InitializationAspect.CAMERAS)).toBeTruthy();
expect(manager.isInitialized(InitializationAspect.MICROPHONE_CONNECT)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.VIEW)).toBeTruthy();
});
it('successfully with microphone if configured', async () => {
@@ -3,8 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { InteractionManager } from '../../src/card-controller/interaction-manager';
import { createCardAPI, createConfig, createLitElement } from '../test-utils';
vi.mock('lodash-es/throttle', () => ({
default: vi.fn((fn) => fn),
vi.mock('lodash-es', () => ({
throttle: vi.fn((fn) => fn),
}));
// @vitest-environment jsdom
@@ -6,8 +6,10 @@ import {
MEDIA_PLAYER_SUPPORT_STOP,
MEDIA_PLAYER_SUPPORT_TURN_OFF,
} from '../../src/const';
import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js';
import { HomeAssistant } from '../../src/ha/types.js';
import { EntityRegistryManager } from '../../src/utils/ha/registry/entity/types.js';
import { ViewMediaType } from '../../src/view/item.js';
import { EntityRegistryManagerMock } from '../ha/registry/entity/mock.js';
import {
createCameraConfig,
createCameraManager,
@@ -19,7 +21,6 @@ import {
createStore,
TestViewMedia,
} from '../test-utils.js';
import { EntityRegistryManagerMock } from '../utils/ha/registry/entity/mock.js';
const createHASSWithMediaPlayers = (): HomeAssistant => {
const attributesSupported = {
@@ -484,38 +485,35 @@ describe('MediaPlayerManager', () => {
describe('media', () => {
describe('successfully with', () => {
it.each([
['clip' as const, 'video' as const],
['snapshot' as const, 'image' as const],
])(
'%s',
async (mediaType: 'clip' | 'snapshot', contentType: 'video' | 'image') => {
const media = new TestViewMedia({
title: 'media title',
thumbnail: 'http://thumbnail',
contentID: 'media-source://contentid',
mediaType: mediaType,
});
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new MediaPlayerManager(api);
[ViewMediaType.Clip, 'video' as const],
[ViewMediaType.Snapshot, 'image' as const],
])('%s', async (mediaType: ViewMediaType, contentType: 'video' | 'image') => {
const media = new TestViewMedia({
title: 'media title',
thumbnail: 'http://thumbnail',
contentID: 'media-source://contentid',
mediaType,
});
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new MediaPlayerManager(api);
await manager.playMedia('media_player.foo', media);
await manager.playMedia('media_player.foo', media);
expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith(
'media_player',
'play_media',
{
entity_id: 'media_player.foo',
media_content_id: 'media-source://contentid',
media_content_type: contentType,
extra: {
title: 'media title',
thumb: 'http://thumbnail',
},
expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith(
'media_player',
'play_media',
{
entity_id: 'media_player.foo',
media_content_id: 'media-source://contentid',
media_content_type: contentType,
extra: {
title: 'media title',
thumb: 'http://thumbnail',
},
);
},
);
},
);
});
});
it('without hass', async () => {
@@ -28,8 +28,7 @@ describe('MicrophoneManager', () => {
afterEach(() => {
vi.resetAllMocks();
vi.unstubAllGlobals;
navigator.mediaDevices;
vi.unstubAllGlobals();
});
const createMockStream = (mute?: boolean): MediaStream => {
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager';
import { StatusBarString } from '../../src/config/schema/actions/types';
import { MediaQueriesResults } from '../../src/view/media-queries-results';
import { QueryResults } from '../../src/view/query-results';
import {
TestViewMedia,
createCameraManager,
@@ -92,7 +92,7 @@ describe('StatusBarItemManager', () => {
const cameraManager = createCameraManager();
const media = [new TestViewMedia({ title: 'Media Title' })];
const queryResults = new MediaQueriesResults({
const queryResults = new QueryResults({
results: media,
});
@@ -119,7 +119,7 @@ describe('StatusBarItemManager', () => {
const cameraManager = createCameraManager();
const media = [new TestViewMedia()];
const queryResults = new MediaQueriesResults({
const queryResults = new QueryResults({
results: media,
});
@@ -14,8 +14,9 @@ import {
flushPromises,
} from '../test-utils';
vi.mock('lodash-es/throttle', () => ({
default: vi.fn((fn) => fn),
vi.mock('lodash-es', async () => ({
...(await vi.importActual('lodash-es')),
throttle: vi.fn((fn) => fn),
}));
const baseTriggersConfig: TriggersOptions = {
@@ -0,0 +1,282 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ViewItemManager } from '../../../src/card-controller/view/item-manager';
import { homeAssistantSignPath } from '../../../src/ha/sign-path.js';
import { downloadURL } from '../../../src/utils/download';
import { ViewFolder, ViewMediaType } from '../../../src/view/item';
import {
createCardAPI,
createFolder,
createHASS,
TestViewMedia,
} from '../../test-utils';
vi.mock('../../../src/utils/download');
vi.mock('../../../src/ha/sign-path.js');
describe('ViewItemManager', () => {
describe('getCapabilities', () => {
it('should return null for unsupported item types', () => {
const api = createCardAPI();
const manager = new ViewItemManager(api);
expect(manager.getCapabilities(new TestViewMedia({ cameraID: null }))).toBeNull();
});
it('should return capabilities for camera media', () => {
const api = createCardAPI();
const capabilities = {
canDownload: true,
canFavorite: true,
};
vi.mocked(api.getCameraManager().getMediaCapabilities).mockReturnValue(
capabilities,
);
const manager = new ViewItemManager(api);
const item = new TestViewMedia({ cameraID: 'camera.office' });
expect(manager.getCapabilities(item)).toEqual(capabilities);
});
it('should return capabilities for folder media', () => {
const api = createCardAPI();
const capabilities = {
canDownload: true,
canFavorite: true,
};
vi.mocked(api.getFoldersManager().getItemCapabilities).mockReturnValue(
capabilities,
);
const manager = new ViewItemManager(api);
const item = new TestViewMedia({ folder: createFolder() });
expect(manager.getCapabilities(item)).toEqual(capabilities);
});
});
describe('download', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should not download without hass', async () => {
const api = createCardAPI();
const manager = new ViewItemManager(api);
const item = new TestViewMedia({ cameraID: 'camera.office' });
expect(await manager.download(item)).toBe(true);
expect(downloadURL).not.toHaveBeenCalled();
});
it('should throw error when signing fails', async () => {
const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new ViewItemManager(api);
const item = new TestViewMedia({ cameraID: 'camera.office' });
vi.mocked(api.getCameraManager().getMediaDownloadPath).mockResolvedValue({
sign: true,
endpoint: 'foo',
});
const signError = new Error('sign-error');
vi.mocked(homeAssistantSignPath).mockRejectedValue(signError);
expect(await manager.download(item)).toBe(false);
expect(api.getMessageManager().setErrorIfHigherPriority).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Could not sign media URL for download',
}),
);
expect(consoleSpy).toHaveBeenCalledWith('sign-error');
});
it('should download camera media', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new ViewItemManager(api);
const item = new TestViewMedia({ cameraID: 'camera.office' });
vi.mocked(api.getCameraManager().getMediaDownloadPath).mockResolvedValue({
sign: true,
endpoint: 'foo',
});
vi.mocked(homeAssistantSignPath).mockResolvedValue('http://foo/signed-url');
expect(await manager.download(item)).toBe(true);
expect(downloadURL).toBeCalledWith(
'http://foo/signed-url',
'camera-office_id.mp4',
);
});
it('should download folder media', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new ViewItemManager(api);
const item = new TestViewMedia({ cameraID: null, folder: createFolder() });
vi.mocked(api.getFoldersManager().getDownloadPath).mockResolvedValue({
sign: true,
endpoint: 'foo',
});
vi.mocked(homeAssistantSignPath).mockResolvedValue('http://foo/signed-url');
expect(await manager.download(item)).toBe(true);
expect(downloadURL).toBeCalledWith('http://foo/signed-url', 'media_id.mp4');
});
it('should download media without signing', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new ViewItemManager(api);
const item = new TestViewMedia({ cameraID: 'camera.office' });
vi.mocked(api.getCameraManager().getMediaDownloadPath).mockResolvedValue({
sign: false,
endpoint: 'foo',
});
expect(homeAssistantSignPath).not.toBeCalled();
expect(await manager.download(item)).toBe(true);
expect(downloadURL).toBeCalledWith('foo', 'camera-office_id.mp4');
});
it('should download media without camera or folder', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new ViewItemManager(api);
const item = new TestViewMedia({ cameraID: null, folder: null });
expect(await manager.download(item)).toBe(false);
expect(api.getMessageManager().setErrorIfHigherPriority).toHaveBeenCalledWith(
expect.objectContaining({
message: 'No media to download',
}),
);
});
describe('should generate useful download filenames', () => {
it('should generate filename for media without ID', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new ViewItemManager(api);
const item = new TestViewMedia({ id: null });
vi.mocked(api.getCameraManager().getMediaDownloadPath).mockResolvedValue({
sign: false,
endpoint: 'foo',
});
expect(await manager.download(item)).toBe(true);
expect(downloadURL).toBeCalledWith('foo', 'camera.mp4');
});
it('should generate filename for media with start time', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new ViewItemManager(api);
const item = new TestViewMedia({ startTime: new Date('2025-05-03T17:41:00Z') });
vi.mocked(api.getCameraManager().getMediaDownloadPath).mockResolvedValue({
sign: false,
endpoint: 'foo',
});
expect(await manager.download(item)).toBe(true);
expect(downloadURL).toBeCalledWith('foo', 'camera_id_2025-05-03-17-41-00.mp4');
});
it('should generate filename for snapshot', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new ViewItemManager(api);
const item = new TestViewMedia({ mediaType: ViewMediaType.Snapshot });
vi.mocked(api.getCameraManager().getMediaDownloadPath).mockResolvedValue({
sign: false,
endpoint: 'foo',
});
expect(await manager.download(item)).toBe(true);
expect(downloadURL).toBeCalledWith('foo', 'camera_id.jpg');
});
it('should generate filename for folder without title', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new ViewItemManager(api);
const item = new ViewFolder(createFolder());
vi.mocked(api.getFoldersManager().getDownloadPath).mockResolvedValue({
sign: false,
endpoint: 'foo',
});
expect(await manager.download(item)).toBe(true);
expect(downloadURL).toBeCalledWith('foo', 'media');
});
it('should generate filename for folder with title', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new ViewItemManager(api);
const item = new ViewFolder(createFolder(), { title: 'title' });
vi.mocked(api.getFoldersManager().getDownloadPath).mockResolvedValue({
sign: false,
endpoint: 'foo',
});
expect(await manager.download(item)).toBe(true);
expect(downloadURL).toBeCalledWith('foo', 'title');
});
});
});
describe('favorite', () => {
it('should favorite camera media', async () => {
const api = createCardAPI();
const manager = new ViewItemManager(api);
const item = new TestViewMedia({
cameraID: 'camera.office',
mediaType: ViewMediaType.Snapshot,
});
await manager.favorite(item, true);
expect(api.getCameraManager().favoriteMedia).toBeCalledWith(item, true);
});
it('should favorite folder media', async () => {
const api = createCardAPI();
const manager = new ViewItemManager(api);
const item = new TestViewMedia({
folder: createFolder(),
mediaType: ViewMediaType.Snapshot,
});
await manager.favorite(item, true);
expect(api.getFoldersManager().favorite).toBeCalledWith(item, true);
});
});
});
@@ -2,15 +2,15 @@ import { expect, it } from 'vitest';
import { applyViewModifiers } from '../../../../src/card-controller/view/modifiers';
import { MergeContextViewModifier } from '../../../../src/card-controller/view/modifiers/merge-context';
import { SetQueryViewModifier } from '../../../../src/card-controller/view/modifiers/set-query';
import { EventMediaQueries } from '../../../../src/view/media-queries';
import { MediaQueriesResults } from '../../../../src/view/media-queries-results';
import { EventMediaQuery } from '../../../../src/view/query';
import { QueryResults } from '../../../../src/view/query-results';
import { createView } from '../../../test-utils';
it('should apply view modifiers', () => {
const view = createView();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const query = new EventMediaQuery();
const queryResults = new QueryResults();
const context = {
timeline: { window: { start: new Date(), end: new Date() } },
@@ -1,8 +1,8 @@
import { expect, it } from 'vitest';
import { SetQueryViewModifier } from '../../../../src/card-controller/view/modifiers/set-query';
import { EventMediaQuery } from '../../../../src/view/query';
import { QueryResults } from '../../../../src/view/query-results';
import { createView } from '../../../test-utils';
import { EventMediaQueries } from '../../../../src/view/media-queries';
import { MediaQueriesResults } from '../../../../src/view/media-queries-results';
it('should do nothing without arguments', () => {
const view = createView();
@@ -16,8 +16,8 @@ it('should do nothing without arguments', () => {
it('should set query and results', () => {
const view = createView();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const query = new EventMediaQuery();
const queryResults = new QueryResults();
const modifier = new SetQueryViewModifier({
query: query,
+101 -34
View File
@@ -1,13 +1,15 @@
import { add } from 'date-fns';
import { describe, expect, it, vi } from 'vitest';
import { QueryType } from '../../../src/camera-manager/types';
import { FolderQuery } from '../../../src/card-controller/folders/types';
import { QueryExecutor } from '../../../src/card-controller/view/query-executor';
import { ClipsOrSnapshotsOrAll } from '../../../src/types';
import { EventMediaQueries } from '../../../src/view/media-queries';
import { EventMediaQuery, FolderViewQuery } from '../../../src/view/query';
import {
TestViewMedia,
createCameraManager,
createCardAPI,
createFolder,
createStore,
generateViewMediaArray,
} from '../../test-utils';
@@ -59,7 +61,7 @@ describe('executeDefaultEventQuery', () => {
eventsMediaType: mediaType,
});
expect(results?.query.getQueries()).toEqual(rawQueries);
expect(results?.query.getQuery()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith(
new Set(['camera.office']),
@@ -90,7 +92,7 @@ describe('executeDefaultEventQuery', () => {
const executor = new QueryExecutor(api);
const results = await executor.executeDefaultEventQuery();
expect(results?.query.getQueries()).toEqual(rawQueries);
expect(results?.query.getQuery()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith(
new Set(['camera.office', 'camera.kitchen']),
@@ -170,7 +172,7 @@ describe('executeDefaultRecordingQuery', () => {
cameraID: 'camera.office',
});
expect(results?.query.getQueries()).toEqual(rawQueries);
expect(results?.query.getQuery()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith(
new Set(['camera.office']),
@@ -197,7 +199,7 @@ describe('executeDefaultRecordingQuery', () => {
const executor = new QueryExecutor(api);
const results = await executor.executeDefaultRecordingQuery();
expect(results?.query.getQueries()).toEqual(rawQueries);
expect(results?.query.getQuery()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith(
new Set(['camera.office', 'camera.kitchen']),
@@ -233,10 +235,10 @@ describe('executeDefaultRecordingQuery', () => {
});
});
describe('execute', () => {
describe('executeQuery', () => {
it('should return null when query is empty', async () => {
const executor = new QueryExecutor(createCardAPI());
expect(await executor.execute(new EventMediaQueries())).toBeNull();
expect(await executor.executeQuery(new EventMediaQuery())).toBeNull();
});
describe('should handle result rejections', () => {
@@ -245,23 +247,7 @@ describe('execute', () => {
const media = generateViewMediaArray();
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQueries([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
expect(await executor.execute(query, { rejectResults: (_) => true })).toBeNull();
});
it('not rejected', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQueries([
const query = new EventMediaQuery([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
@@ -270,7 +256,27 @@ describe('execute', () => {
const executor = new QueryExecutor(api);
expect(
await executor.execute(query, { rejectResults: (_) => false }),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
await executor.executeQuery(query, { rejectResults: (_) => true }),
).toBeNull();
});
it('not rejected', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQuery([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
expect(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
await executor.executeQuery(query, { rejectResults: (_) => false }),
).not.toBeNull();
});
});
@@ -284,7 +290,7 @@ describe('execute', () => {
});
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQueries([
const query = new EventMediaQuery([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
@@ -292,10 +298,12 @@ describe('execute', () => {
]);
const executor = new QueryExecutor(api);
const results = await executor.execute(query, {
const results = await executor.executeMediaQuery(query, {
selectResult: { id: 'id-camera.office-42' },
});
expect(results?.getSelectedResult()?.getID()).toBe('id-camera.office-42');
expect(results?.queryResults.getSelectedResult()?.getID()).toBe(
'id-camera.office-42',
);
});
});
@@ -307,7 +315,7 @@ describe('execute', () => {
});
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQueries([
const query = new EventMediaQuery([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
@@ -315,10 +323,12 @@ describe('execute', () => {
]);
const executor = new QueryExecutor(api);
const results = await executor.execute(query, {
const results = await executor.executeMediaQuery(query, {
selectResult: { func: (media) => media.getID() === 'id-camera.office-43' },
});
expect(results?.getSelectedResult()?.getID()).toBe('id-camera.office-43');
expect(results?.queryResults.getSelectedResult()?.getID()).toBe(
'id-camera.office-43',
);
});
it('by time', async () => {
@@ -344,7 +354,7 @@ describe('execute', () => {
];
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQueries([
const query = new EventMediaQuery([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
@@ -352,9 +362,66 @@ describe('execute', () => {
]);
const executor = new QueryExecutor(api);
const results = await executor.execute(query, {
const results = await executor.executeMediaQuery(query, {
selectResult: { time: { time: add(now, { seconds: 1 }) } },
});
expect(results?.getSelectedResult()?.getID()).toBe('id-camera.office-1');
expect(results?.queryResults.getSelectedResult()?.getID()).toBe(
'id-camera.office-1',
);
});
describe('should handle folder query', () => {
it('should return null without raw query', async () => {
const executor = new QueryExecutor(createCardAPI());
expect(await executor.executeQuery(new FolderViewQuery())).toBeNull();
});
it('should return null when query expansion fails', async () => {
const api = createCardAPI();
vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(createFolder());
vi.mocked(api.getFoldersManager().expandFolder).mockResolvedValue(null);
const executor = new QueryExecutor(api);
expect(await executor.executeDefaultFolderQuery()).toBeNull();
});
});
});
describe('executeDefaultFolderQuery', () => {
it('should return null without folders', async () => {
const executor = new QueryExecutor(createCardAPI());
expect(await executor.executeDefaultFolderQuery()).toBeNull();
});
it('should execute query against first folder', async () => {
const api = createCardAPI();
const items = [new TestViewMedia()];
const folder = createFolder();
const query: FolderQuery = {
folder,
path: ['path'],
};
vi.mocked(api.getFoldersManager().generateDefaultFolderQuery).mockReturnValue(query);
vi.mocked(api.getFoldersManager().expandFolder).mockResolvedValue(items);
const executor = new QueryExecutor(api);
const result = await executor.executeDefaultFolderQuery();
expect(result?.query.getQuery()).toEqual(query);
expect(result?.queryResults.getResults()).toEqual(items);
});
it('should return null without folder results', async () => {
const api = createCardAPI();
const folder = createFolder();
const query: FolderQuery = {
folder,
path: ['path'],
};
vi.mocked(api.getFoldersManager().generateDefaultFolderQuery).mockReturnValue(query);
vi.mocked(api.getFoldersManager().expandFolder).mockResolvedValue(null);
const executor = new QueryExecutor(api);
expect(await executor.executeDefaultFolderQuery()).toBeNull();
});
});
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { sortMedia } from '../../../src/camera-manager/utils/sort-media.js';
import { TestViewMedia } from '../../test-utils.js';
import { sortItems } from '../../../src/card-controller/view/sort.js';
import { createFolder, TestViewMedia } from '../../test-utils.js';
import { ViewFolder } from '../../../src/view/item.js';
describe('sortMedia', () => {
const media_1 = new TestViewMedia({
@@ -23,35 +24,48 @@ describe('sortMedia', () => {
startTime: new Date('2023-04-29T14:27'),
cameraID: 'camera-1',
});
const folder_1 = new ViewFolder(createFolder(), { id: 'folder_1' });
const folder_2 = new ViewFolder(createFolder(), { id: 'folder_2' });
it('should sort sorted media', () => {
const media = [media_1, media_2];
expect(sortMedia(media)).toEqual(media);
expect(sortItems(media)).toEqual(media);
});
it('should sort unsorted media', () => {
expect(sortMedia([media_2, media_1])).toEqual([media_1, media_2]);
expect(sortItems([media_2, media_1])).toEqual([media_1, media_2]);
});
it('should remove duplicate id', () => {
expect(sortMedia([media_1, media_2, media_3_dup_id])).toEqual([media_1, media_2]);
expect(sortItems([media_1, media_2, media_3_dup_id])).toEqual([media_1, media_2]);
});
it('should sort by id when time not available', () => {
const folder = createFolder();
expect(
sortMedia([
sortItems([
new TestViewMedia({ id: 'snake' }),
new TestViewMedia({ id: 'zebra' }),
new TestViewMedia({ id: 'aardvark' }),
new ViewFolder(folder, { id: 'folder' }),
]),
).toEqual([
new ViewFolder(folder, { id: 'folder' }),
new TestViewMedia({ id: 'aardvark' }),
new TestViewMedia({ id: 'snake' }),
new TestViewMedia({ id: 'zebra' }),
]);
});
it('should remove de-duplicate by object if no id', () => {
expect(sortMedia([media_1, media_2, media_4_no_id, media_4_no_id])).toEqual([
expect(sortItems([media_1, media_2, media_4_no_id, media_4_no_id])).toEqual([
media_1,
media_2,
media_4_no_id,
]);
});
it('should sort folders at the start', () => {
expect(sortItems([media_1, media_2, folder_1, folder_2])).toEqual([
folder_1,
folder_2,
media_1,
media_2,
]);
});
});
+105 -44
View File
@@ -2,6 +2,7 @@ import { ViewContext } from 'view';
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { QueryType } from '../../../src/camera-manager/types';
import { CardController } from '../../../src/card-controller/controller';
import { ViewFactory } from '../../../src/card-controller/view/factory';
import { SetQueryViewModifier } from '../../../src/card-controller/view/modifiers/set-query';
import {
@@ -11,9 +12,9 @@ import {
import { ViewManager } from '../../../src/card-controller/view/view-manager';
import { ViewQueryExecutor } from '../../../src/card-controller/view/view-query-executor';
import { AdvancedCameraCardView } from '../../../src/config/schema/common/const';
import { ViewMedia } from '../../../src/view/media';
import { EventMediaQueries } from '../../../src/view/media-queries';
import { MediaQueriesResults } from '../../../src/view/media-queries-results';
import { ViewMedia, ViewMediaType } from '../../../src/view/item';
import { EventMediaQuery } from '../../../src/view/query';
import { QueryResults } from '../../../src/view/query-results';
import { View } from '../../../src/view/view';
import {
createCameraManager,
@@ -23,6 +24,14 @@ import {
createView,
} from '../../test-utils';
const createInitializedCardAPI = (initialized?: boolean): CardController => {
const api = createCardAPI();
vi.mocked(api.getInitializationManager().isInitialized).mockReturnValue(
initialized ?? true,
);
return api;
};
describe('should act correctly when view is set', () => {
it('basic view', () => {
const view = createView({
@@ -34,7 +43,7 @@ describe('should act correctly when view is set', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(view);
const api = createCardAPI();
const api = createInitializedCardAPI();
const manager = new ViewManager(api, { viewFactory: factory });
manager.setViewDefault();
@@ -61,7 +70,7 @@ describe('should act correctly when view is set', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(view_1);
const api = createCardAPI();
const api = createInitializedCardAPI();
const manager = new ViewManager(api, { viewFactory: factory });
manager.setViewDefault();
@@ -88,7 +97,7 @@ describe('should act correctly when view is set', () => {
});
it('setViewWithMergedContext', () => {
const api = createCardAPI();
const api = createInitializedCardAPI();
const factory = mock<ViewFactory>();
const manager = new ViewManager(api, { viewFactory: factory });
@@ -120,7 +129,7 @@ it('getEpoch', () => {
it('reset', () => {
const factory = mock<ViewFactory>();
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
const manager = new ViewManager(createInitializedCardAPI(), { viewFactory: factory });
manager.reset();
expect(manager.getView()).toBeNull();
@@ -138,11 +147,37 @@ it('reset', () => {
expect(manager.hasView()).toBeFalsy();
});
describe('should not set view without cameras being initialized', () => {
it('should ignore requests to set generic view without cameras being initialized', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView());
const manager = new ViewManager(createInitializedCardAPI(false), {
viewFactory: factory,
});
manager.setViewDefault();
expect(manager.getView()).toBeNull();
});
it('should ignore requests to set view then modify without cameras being initialized', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView());
const manager = new ViewManager(createInitializedCardAPI(false), {
viewFactory: factory,
});
manager.setViewDefaultWithNewQuery();
expect(manager.getView()).toBeNull();
});
});
it('setViewDefault', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView());
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
const manager = new ViewManager(createInitializedCardAPI(), { viewFactory: factory });
manager.setViewDefault();
expect(manager.getView()?.view).toBe('live');
@@ -153,7 +188,7 @@ it('setViewByParameters', () => {
const factory = mock<ViewFactory>();
factory.getViewByParameters.mockReturnValue(createView());
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
const manager = new ViewManager(createInitializedCardAPI(), { viewFactory: factory });
manager.setViewByParameters();
expect(manager.getView()?.view).toBe('live');
@@ -167,7 +202,7 @@ it('setViewDefaultWithNewQuery', async () => {
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createCardAPI(), {
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
@@ -184,7 +219,7 @@ it('setViewByParametersWithNewQuery', async () => {
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createCardAPI(), {
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
@@ -201,7 +236,7 @@ it('setViewByParametersWithExistingQuery', async () => {
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createCardAPI(), {
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
@@ -220,7 +255,7 @@ describe('should handle exceptions', () => {
throw error;
});
const api = createCardAPI();
const api = createInitializedCardAPI();
const manager = new ViewManager(api, { viewFactory: viewFactory });
manager.setViewDefault();
@@ -235,7 +270,7 @@ describe('should handle exceptions', () => {
throw error;
});
const api = createCardAPI();
const api = createInitializedCardAPI();
const manager = new ViewManager(api, { viewFactory: viewFactory });
await manager.setViewDefaultWithNewQuery();
@@ -250,7 +285,7 @@ describe('should handle exceptions', () => {
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockRejectedValue(error);
const api = createCardAPI();
const api = createInitializedCardAPI();
const manager = new ViewManager(api, {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
@@ -280,7 +315,7 @@ describe('isViewSupportedByCamera', () => {
['timeline' as const, false],
['media' as const, false],
])('%s', (viewName: AdvancedCameraCardView, expected: boolean) => {
const api = createCardAPI();
const api = createInitializedCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
@@ -317,7 +352,9 @@ describe('hasMajorMediaChange', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView({ view: 'live' }));
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: factory,
});
manager.setViewDefault();
expect(manager.hasMajorMediaChange(createView({ view: 'clips' }))).toBeTruthy();
@@ -327,7 +364,9 @@ describe('hasMajorMediaChange', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView({ camera: 'camera-1' }));
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: factory,
});
manager.setViewDefault();
expect(manager.hasMajorMediaChange(createView({ camera: 'camera-2' }))).toBeTruthy();
@@ -345,7 +384,9 @@ describe('hasMajorMediaChange', () => {
createView({ context: { live: { overrides: overrides_1 } } }),
);
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: factory,
});
manager.setViewDefault();
expect(
@@ -367,7 +408,9 @@ describe('hasMajorMediaChange', () => {
createView({ view: 'clips', context: { live: { overrides: overrides_1 } } }),
);
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: factory,
});
manager.setViewDefault();
expect(
@@ -378,16 +421,25 @@ describe('hasMajorMediaChange', () => {
});
it('should consider result change as major in other view', () => {
const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')];
const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 });
const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 });
const media = [
new ViewMedia(ViewMediaType.Clip, {
cameraID: 'camera-1',
}),
new ViewMedia(ViewMediaType.Clip, {
cameraID: 'camera-2',
}),
];
const queryResults_1 = new QueryResults({ results: media, selectedIndex: 0 });
const queryResults_2 = new QueryResults({ results: media, selectedIndex: 1 });
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(
createView({ view: 'media', queryResults: queryResults_1 }),
);
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: factory,
});
manager.setViewDefault();
expect(
@@ -398,14 +450,23 @@ describe('hasMajorMediaChange', () => {
});
it('should not consider selected result change as major in live view', () => {
const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')];
const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 });
const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 });
const media = [
new ViewMedia(ViewMediaType.Clip, {
cameraID: 'camera-1',
}),
new ViewMedia(ViewMediaType.Clip, {
cameraID: 'camera-2',
}),
];
const queryResults_1 = new QueryResults({ results: media, selectedIndex: 0 });
const queryResults_2 = new QueryResults({ results: media, selectedIndex: 1 });
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView({ queryResults: queryResults_1 }));
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: factory,
});
manager.setViewDefault();
expect(
@@ -424,7 +485,7 @@ describe('should initialize', () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(view);
const api = createCardAPI();
const api = createInitializedCardAPI();
const manager = new ViewManager(api, {
viewFactory: viewFactory,
});
@@ -452,14 +513,14 @@ it('should adopt query and results when changing to gallery from viewer', async
const baseView = createView({
view: 'media',
camera: 'camera.office',
query: new EventMediaQueries([
query: new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.office']),
hasClip: true,
},
]),
queryResults: new MediaQueriesResults(),
queryResults: new QueryResults(),
});
const viewFactory = mock<ViewFactory>();
@@ -470,7 +531,7 @@ it('should adopt query and results when changing to gallery from viewer', async
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createCardAPI(), {
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
@@ -496,8 +557,8 @@ describe('should apply async view modifications', () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView({ view: 'live' }));
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const query = new EventMediaQuery();
const queryResults = new QueryResults();
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([
@@ -507,7 +568,7 @@ describe('should apply async view modifications', () => {
}),
]);
const manager = new ViewManager(createCardAPI(), {
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
@@ -523,12 +584,12 @@ describe('should apply async view modifications', () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const query = new EventMediaQuery();
const queryResults = new QueryResults();
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createCardAPI(), {
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
@@ -572,7 +633,7 @@ describe('should apply async view modifications', () => {
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createCardAPI(), {
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
@@ -608,7 +669,7 @@ describe('should apply async view modifications', () => {
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createCardAPI(), {
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
@@ -644,7 +705,7 @@ describe('should apply async view modifications', () => {
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createCardAPI(), {
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
@@ -681,14 +742,14 @@ it('should adopt query and results when changing to gallery from viewer', async
const baseView = createView({
view: 'media',
camera: 'camera.office',
query: new EventMediaQueries([
query: new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.office']),
hasClip: true,
},
]),
queryResults: new MediaQueriesResults(),
queryResults: new QueryResults(),
});
const viewFactory = mock<ViewFactory>();
@@ -699,7 +760,7 @@ it('should adopt query and results when changing to gallery from viewer', async
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createCardAPI(), {
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
@@ -5,10 +5,11 @@ import { QueryExecutor } from '../../../src/card-controller/view/query-executor'
import { ViewQueryExecutor } from '../../../src/card-controller/view/view-query-executor';
import { AdvancedCameraCardView } from '../../../src/config/schema/common/const';
import {
EventMediaQueries,
RecordingMediaQueries,
} from '../../../src/view/media-queries';
import { MediaQueriesResults } from '../../../src/view/media-queries-results';
EventMediaQuery,
FolderViewQuery,
RecordingMediaQuery,
} from '../../../src/view/query';
import { QueryResults } from '../../../src/view/query-results';
import { View } from '../../../src/view/view';
import { createCardAPI, createView } from '../../test-utils';
import { createPopulatedAPI } from './test-utils';
@@ -19,15 +20,15 @@ describe('ViewQueryExecutor', () => {
const executor = mock<QueryExecutor>();
const viewQueryExecutor = new ViewQueryExecutor(createCardAPI(), executor);
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const query = new EventMediaQuery();
const queryResults = new QueryResults();
const view = createView({
query: query,
});
executor.execute.mockResolvedValue(queryResults);
executor.executeQuery.mockResolvedValue({ query, queryResults });
const queryExecutorOptions = {};
const queryExecutorOptions = { useCache: true };
const modifiers = await viewQueryExecutor.getExistingQueryModifiers(
view,
queryExecutorOptions,
@@ -37,7 +38,7 @@ describe('ViewQueryExecutor', () => {
expect(view.query).toBe(query);
expect(view.queryResults).toBe(queryResults);
expect(executor.execute).toBeCalledWith(query, queryExecutorOptions);
expect(executor.executeQuery).toBeCalledWith(query, queryExecutorOptions);
});
it('should not return modifier when query absent', async () => {
@@ -58,7 +59,7 @@ describe('ViewQueryExecutor', () => {
expect(view.query).toBeNull();
expect(view.queryResults).toBeNull();
expect(executor.execute).not.toBeCalled();
expect(executor.executeMediaQuery).not.toBeCalled();
});
});
@@ -79,8 +80,8 @@ describe('ViewQueryExecutor', () => {
});
it('should set query and queryResults for events', async () => {
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const query = new EventMediaQuery();
const queryResults = new QueryResults();
const executor = mock<QueryExecutor>();
executor.executeDefaultEventQuery.mockResolvedValue({
@@ -108,11 +109,12 @@ describe('ViewQueryExecutor', () => {
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultFolderQuery).not.toHaveBeenCalled();
});
it('should set query and queryResults for recordings', async () => {
const query = new RecordingMediaQueries();
const queryResults = new MediaQueriesResults();
const query = new RecordingMediaQuery();
const queryResults = new QueryResults();
const executor = mock<QueryExecutor>();
executor.executeDefaultRecordingQuery.mockResolvedValue({
@@ -150,6 +152,7 @@ describe('ViewQueryExecutor', () => {
},
});
expect(executor.executeDefaultEventQuery).not.toBeCalled();
expect(executor.executeDefaultFolderQuery).not.toHaveBeenCalled();
});
describe('should set timeline window', async () => {
@@ -227,14 +230,15 @@ describe('ViewQueryExecutor', () => {
expect(view?.queryResults).toBeNull();
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultFolderQuery).not.toHaveBeenCalled();
});
});
describe('with a media view', () => {
it('should set query and queryResults for events', async () => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const query = new EventMediaQuery();
const queryResults = new QueryResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
@@ -260,6 +264,7 @@ describe('ViewQueryExecutor', () => {
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultFolderQuery).not.toHaveBeenCalled();
});
});
@@ -276,8 +281,8 @@ describe('ViewQueryExecutor', () => {
eventsMediaType: 'clips' | 'snapshots',
) => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
const query = new EventMediaQuery();
const queryResults = new QueryResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
@@ -306,6 +311,7 @@ describe('ViewQueryExecutor', () => {
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultFolderQuery).not.toHaveBeenCalled();
},
);
});
@@ -315,8 +321,8 @@ describe('ViewQueryExecutor', () => {
'%s',
async (viewName: AdvancedCameraCardView) => {
const executor = mock<QueryExecutor>();
const query = new RecordingMediaQueries();
const queryResults = new MediaQueriesResults();
const query = new RecordingMediaQuery();
const queryResults = new QueryResults();
executor.executeDefaultRecordingQuery.mockResolvedValue({
query: query,
@@ -344,10 +350,52 @@ describe('ViewQueryExecutor', () => {
useCache: false,
},
});
expect(executor.executeDefaultFolderQuery).not.toHaveBeenCalled();
},
);
});
describe('with a folder view', () => {
it('should execute default folder query with folder view', async () => {
const executor = mock<QueryExecutor>();
const query = new FolderViewQuery();
const queryResults = new QueryResults();
executor.executeDefaultFolderQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor);
const view = createView({ view: 'folder', camera: 'camera.office' });
const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
applyViewModifiers(view, modifiers);
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).not.toBeCalled();
expect(executor.executeDefaultRecordingQuery).not.toBeCalled();
expect(executor.executeDefaultFolderQuery).toBeCalledWith({
useCache: false,
});
});
it('should execute default folder query with folder view and handle null results', async () => {
const executor = mock<QueryExecutor>();
executor.executeDefaultFolderQuery.mockResolvedValue(null);
const viewQueryExecutor = new ViewQueryExecutor(createPopulatedAPI(), executor);
const view = createView({ view: 'folder', camera: 'camera.office' });
const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
applyViewModifiers(view, modifiers);
expect(view?.query).toBeNull();
expect(view?.queryResults).toBeNull();
});
});
describe('when setting or removing seek time', () => {
it('should set seek time when results are selected based on time', async () => {
const now = new Date();
@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from 'vitest';
import { ViewManagerEpoch } from '../../../src/card-controller/view/types';
import {
getUpFolderMediaItem,
upFolderClickHandler,
} from '../../../src/components-lib/folder/up-folder';
import { ViewFolder } from '../../../src/view/item';
import { EventMediaQuery, FolderViewQuery } from '../../../src/view/query';
import {
createCardAPI,
createFolder,
createView,
TestViewMedia,
} from '../../test-utils';
describe('upFolderClickHandler', () => {
const item = new TestViewMedia();
it('should ignore non-folder query', () => {
const api = createCardAPI();
const view = createView({
query: new EventMediaQuery(),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
upFolderClickHandler(item, new Event('click'), epoch);
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
});
it('should ignore folder query without raw query', () => {
const api = createCardAPI();
const view = createView({
query: new FolderViewQuery(),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
upFolderClickHandler(item, new Event('click'), epoch);
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
});
it('should ignore folder query wihout parent to go up to', () => {
const api = createCardAPI();
const view = createView({
query: new FolderViewQuery({ folder: createFolder(), path: ['path'] }),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
upFolderClickHandler(item, new Event('click'), epoch);
expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled();
});
it('should go up in the folder hierarchy', () => {
const api = createCardAPI();
const folder = createFolder();
const view = createView({
query: new FolderViewQuery({
folder,
path: ['one', 'two', 'three'],
}),
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const epoch: ViewManagerEpoch = {
manager: api.getViewManager(),
};
upFolderClickHandler(item, new Event('click'), epoch);
expect(api.getViewManager().setViewByParametersWithExistingQuery).toBeCalledWith({
params: {
query: expect.any(FolderViewQuery),
},
});
const query = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery)
.mock.calls[0][0]?.params?.query;
expect(query?.getQuery()).toEqual({
folder,
path: ['one', 'two'],
});
});
});
describe('getUpFolderMediaItem', () => {
it('should ignore non-folder query', () => {
const view = createView({
query: new EventMediaQuery(),
});
expect(getUpFolderMediaItem(view)).toBeNull();
});
it('should ignore folder query without raw query', () => {
const view = createView({
query: new FolderViewQuery(),
});
expect(getUpFolderMediaItem(view)).toBeNull();
});
it('should ignore folder query without parents', () => {
const view = createView({
query: new FolderViewQuery({ folder: createFolder(), path: ['one'] }),
});
expect(getUpFolderMediaItem(view)).toBeNull();
});
it('should get up folder media', () => {
const view = createView({
query: new FolderViewQuery({
folder: createFolder(),
path: ['one', 'two', 'three'],
}),
});
const folderMedia = getUpFolderMediaItem(view);
expect(folderMedia).toBeInstanceOf(ViewFolder);
expect(folderMedia?.getIcon()).toBe('mdi:arrow-up-left');
});
});
@@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ViewManagerInterface } from '../../../src/card-controller/view/types';
import {
FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN,
FolderGalleryController,
} from '../../../src/components-lib/gallery/folder-gallery-controller';
import { THUMBNAIL_WIDTH_DEFAULT } from '../../../src/config/schema/common/controls/thumbnails';
import { ViewFolder } from '../../../src/view/item';
import { FolderViewQuery } from '../../../src/view/query';
import { QueryResults } from '../../../src/view/query-results';
import { createFolder, createView, TestViewMedia } from '../../test-utils';
// @vitest-environment jsdom
describe('FolderGalleryController', () => {
describe('should set thumbnail size', () => {
it('should set thumbnail size explicitly', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
controller.setThumbnailSize(100);
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
'100px',
);
});
it('should set thumbnail size implicitly', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
controller.setThumbnailSize();
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
`${THUMBNAIL_WIDTH_DEFAULT}px`,
);
});
});
describe('should get column width', () => {
it('should get default column width', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
expect(controller.getColumnWidth()).toBe(THUMBNAIL_WIDTH_DEFAULT);
});
it('should get column width with defailts', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
expect(
controller.getColumnWidth({
size: 100,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe(FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN);
});
it('should get column width with explicit size', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
expect(
controller.getColumnWidth({
size: 142,
show_details: false,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe(142);
});
});
describe('should get column count round method', () => {
it('should get default column count round method', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
expect(controller.getColumnCountRoundMethod()).toBe('ceil');
});
it('should get column count round method when details being shown', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
expect(
controller.getColumnCountRoundMethod({
size: 100,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe('floor');
});
});
describe('should handle item clicks', () => {
it('should ignore calls without view', () => {
const host = document.createElement('div');
const controller = new FolderGalleryController(host);
const viewManager = mock<ViewManagerInterface>();
const item = new TestViewMedia();
controller.itemClickHandler(viewManager, item, new Event('click'));
expect(viewManager.setViewByParameters).not.toHaveBeenCalled();
expect(viewManager.setViewByParametersWithExistingQuery).not.toHaveBeenCalled();
});
it('should handle media item click', () => {
const host = document.createElement('div');
const item_1 = new TestViewMedia();
const item_2 = new TestViewMedia();
const controller = new FolderGalleryController(host);
const event = new Event('click');
const view = createView({
queryResults: new QueryResults({ results: [item_1, item_2], selectedIndex: 0 }),
});
const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(viewManager, item_2, event);
expect(viewManager.setViewByParameters).toHaveBeenCalledWith({
params: {
view: 'media',
queryResults: expect.any(QueryResults),
},
});
const newQueryResults =
viewManager.setViewByParameters.mock.calls[0][0]?.params?.queryResults;
expect(newQueryResults).toBeInstanceOf(QueryResults);
expect(newQueryResults?.getSelectedResult()).toBe(item_2);
});
describe('should handle folder click', () => {
it('should handle normal folder click', () => {
const folder = createFolder();
const folderItem = new ViewFolder(folder, {
id: 'parent',
});
const controller = new FolderGalleryController(document.createElement('div'));
const event = new Event('click');
const view = createView({
queryResults: new QueryResults({
results: [new TestViewMedia(), folderItem],
selectedIndex: 0,
}),
query: new FolderViewQuery({
folder,
path: [{ id: 'grandparent' }],
}),
});
const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(viewManager, folderItem, event);
expect(viewManager.setViewByParametersWithExistingQuery).toHaveBeenCalledWith({
params: {
query: expect.any(FolderViewQuery),
},
});
const newQuery =
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params
?.query;
expect(newQuery).toBeInstanceOf(FolderViewQuery);
expect(newQuery?.getQuery()).toEqual({
folder,
path: [{ id: 'grandparent' }, { id: folderItem.getID() }],
});
});
it('should handle folder click without query', () => {
const controller = new FolderGalleryController(document.createElement('div'));
const view = createView({
query: null,
});
const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(
viewManager,
new ViewFolder(createFolder(), {
id: 'parent',
}),
new Event('click'),
);
expect(viewManager.setViewByParametersWithExistingQuery).not.toBeCalled();
expect(viewManager.setViewByParameters).not.toBeCalled();
});
it('should handle folder click without path', () => {
const controller = new FolderGalleryController(document.createElement('div'));
const folder = createFolder();
const view = createView({
query: new FolderViewQuery({
folder,
path: [{ id: 'id' }],
}),
});
const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(
viewManager,
new ViewFolder(folder),
new Event('click'),
);
expect(viewManager.setViewByParametersWithExistingQuery).not.toBeCalled();
expect(viewManager.setViewByParameters).not.toBeCalled();
});
});
});
});
@@ -0,0 +1,650 @@
import { LitElement } from 'lit';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import {
GalleryColumnCountRoundMethod,
GalleryCoreController,
} from '../../../src/components-lib/gallery/gallery-core-controller';
import { GalleryExtendEvent } from '../../../src/components/gallery/types';
import { scrollIntoView } from '../../../src/utils/scroll';
import { sleep } from '../../../src/utils/sleep';
import {
callIntersectionHandler,
createLitElement,
createSlot,
createSlotHost,
createTouch,
createTouchEvent,
flushPromises,
IntersectionObserverMock,
ResizeObserverMock,
} from '../../test-utils';
import { callResizeHandler } from '../../utils/embla/test-utils';
vi.mock('lodash-es', async () => {
return {
...(await vi.importActual('lodash-es')),
throttle: vi.fn((fn) => fn),
};
});
vi.mock('../../../src/utils/sleep');
vi.mock('../../../src/utils/scroll');
// @vitest-environment jsdom
describe('GalleryCoreController', () => {
const createController = (options?: {
host?: LitElement;
getSlot?: () => HTMLSlotElement | null;
getSentinelBottom?: () => HTMLElement | null;
showLoaderTopCallback?: (show: boolean) => void;
showSentinelBottomCallback?: (show: boolean) => void;
}) => {
return new GalleryCoreController(
options?.host ?? createLitElement(),
options?.getSlot ?? (() => null),
options?.getSentinelBottom ?? (() => null),
options?.showLoaderTopCallback ?? (() => null),
options?.showSentinelBottomCallback ?? (() => null),
);
};
beforeAll(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
});
beforeEach(() => {
vi.clearAllMocks();
});
afterAll(() => {
vi.unstubAllGlobals();
});
it('should add controller', () => {
const host = createLitElement();
const controller = createController({ host });
expect(controller).toBeInstanceOf(GalleryCoreController);
expect(host.addController).toBeCalledWith(controller);
});
it('should remove controller', () => {
const host = createLitElement();
const controller = createController({ host });
expect(controller).toBeInstanceOf(GalleryCoreController);
controller.removeController();
expect(host.removeController).toBeCalledWith(controller);
});
it('should remove controller', () => {
const host = createLitElement();
const controller = createController({ host });
expect(controller).toBeInstanceOf(GalleryCoreController);
controller.removeController();
expect(host.removeController).toBeCalledWith(controller);
});
describe('should observe sentintel when host updated', () => {
it('should disconnect and observe sentinel when valid sentinel exists', () => {
const sentinel = document.createElement('div');
const getSentinelBottom = vi.fn(() => sentinel);
const host = createLitElement();
const controller = createController({
host,
getSentinelBottom,
});
controller.hostUpdated();
expect(
vi.mocked(IntersectionObserver).mock.results[0].value.disconnect,
).toBeCalled();
expect(
vi.mocked(IntersectionObserver).mock.results[0].value.observe,
).toBeCalledWith(sentinel);
});
it('should not observe when sentinel is null', () => {
const getSentinelBottom = vi.fn(() => null);
const host = createLitElement();
const controller = createController({
host,
getSentinelBottom,
});
controller.hostUpdated();
expect(
vi.mocked(IntersectionObserver).mock.results[0].value.disconnect,
).toBeCalled();
expect(
vi.mocked(IntersectionObserver).mock.results[0].value.observe,
).not.toBeCalled();
});
});
it('should attach listeners on connect', () => {
const host = createLitElement();
host.addEventListener = vi.fn();
const controller = createController({ host });
controller.hostConnected();
expect(vi.mocked(ResizeObserver).mock.results[0].value.observe).toBeCalledWith(host);
expect(host.addEventListener).toBeCalledWith('wheel', expect.anything(), {
passive: true,
});
expect(host.addEventListener).toBeCalledWith('touchstart', expect.anything(), {
passive: true,
});
expect(host.addEventListener).toBeCalledWith('touchend', expect.anything());
expect(host.requestUpdate).toBeCalled();
});
it('should detach listeners on disconnect', () => {
const host = createLitElement();
host.removeEventListener = vi.fn();
const controller = createController({ host });
controller.hostDisconnected();
expect(vi.mocked(ResizeObserver).mock.results[0].value.disconnect).toBeCalled();
expect(
vi.mocked(IntersectionObserver).mock.results[0].value.disconnect,
).toBeCalled();
expect(host.removeEventListener).toBeCalledWith('wheel', expect.anything());
expect(host.removeEventListener).toBeCalledWith('touchstart', expect.anything());
expect(host.removeEventListener).toBeCalledWith('touchend', expect.anything());
});
describe('should set the number of columns', () => {
it('should update columns based on columnWidth and host width', () => {
const host = createLitElement();
Object.defineProperty(host, 'clientWidth', { value: 492 });
const controller = createController({ host });
controller.setOptions({ columnWidth: 250 });
expect(host.style.getPropertyValue('--advanced-camera-card-gallery-columns')).toBe(
'1',
);
});
it('should calculate at least 1 column', () => {
const host = createLitElement();
Object.defineProperty(host, 'clientWidth', { value: 100 });
const controller = createController({ host });
controller.setOptions({ columnWidth: 200 });
expect(host.style.getPropertyValue('--advanced-camera-card-gallery-columns')).toBe(
'1',
);
});
describe('should respect column count round method', () => {
it.each([
['ceil' as const, 2],
['floor' as const, 1],
])(
'%s',
async (
columnCountRoundMethod: GalleryColumnCountRoundMethod,
expectedColumns: number,
) => {
const host = createLitElement();
Object.defineProperty(host, 'clientWidth', { value: 100 });
const controller = createController({ host });
controller.setOptions({ columnWidth: 51, columnCountRoundMethod });
expect(
host.style.getPropertyValue('--advanced-camera-card-gallery-columns'),
).toBe(String(expectedColumns));
},
);
});
it('should not update columns when columnWidth is not set', () => {
const host = createLitElement();
Object.defineProperty(host, 'clientWidth', { value: 500 });
const controller = createController({ host });
controller.setOptions({});
expect(host.style.getPropertyValue('--advanced-camera-card-gallery-columns')).toBe(
'',
);
});
it('should update columns on resize', () => {
const host = createLitElement();
const controller = createController({ host });
controller.setOptions({ columnWidth: 250 });
const width = 1000;
Object.defineProperty(host, 'clientWidth', { value: width });
callResizeHandler([{ target: host, width, height: 200 }]);
expect(host.style.getPropertyValue('--advanced-camera-card-gallery-columns')).toBe(
'4',
);
});
});
describe('should extend gallery', () => {
describe('should extend gallery up', () => {
describe('should extend gallery up with wheel events', () => {
it('should extend up with wheel event upwards', async () => {
const host = createLitElement();
host.scrollTop = 0;
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
const extendUpCallback = vi
.fn()
.mockImplementation((ev: CustomEvent<GalleryExtendEvent>) => {
expect(showLoaderTop).toHaveBeenLastCalledWith(true);
ev.detail.resolve();
});
host.addEventListener(
'advanced-camera-card:gallery:extend:up',
extendUpCallback,
);
host.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }));
await flushPromises();
expect(showLoaderTop).toHaveBeenLastCalledWith(false);
});
it('should not extend up with wheel event when not at top of component', async () => {
const host = createLitElement();
host.scrollTop = 100;
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
host.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }));
expect(showLoaderTop).not.toHaveBeenCalled();
});
it('should not extend up with wheel event downwards', async () => {
const host = createLitElement();
host.scrollTop = 0;
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
host.dispatchEvent(new WheelEvent('wheel', { deltaY: 100 }));
expect(showLoaderTop).not.toHaveBeenCalled();
});
});
describe('should extend gallery up with touch events', () => {
it('should extend up with touch event upwards', async () => {
const host = createLitElement();
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
const extendUpCallback = vi
.fn()
.mockImplementation((ev: CustomEvent<GalleryExtendEvent>) => {
expect(showLoaderTop).toHaveBeenLastCalledWith(true);
ev.detail.resolve();
});
host.addEventListener(
'advanced-camera-card:gallery:extend:up',
extendUpCallback,
);
host.dispatchEvent(
createTouchEvent('touchstart', { touches: [createTouch({ screenY: 0 })] }),
);
host.dispatchEvent(
createTouchEvent('touchend', {
changedTouches: [createTouch({ screenY: 100 })],
}),
);
await flushPromises();
expect(showLoaderTop).toHaveBeenLastCalledWith(false);
});
it('should not extend up with multiple touches', async () => {
const host = createLitElement();
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
host.dispatchEvent(
createTouchEvent('touchstart', {
touches: [createTouch({ screenY: 0 }), createTouch({ screenY: 0 })],
}),
);
host.dispatchEvent(
createTouchEvent('touchend', {
changedTouches: [createTouch({ screenY: 100 })],
}),
);
await flushPromises();
expect(showLoaderTop).not.toBeCalled();
});
it('should not extend up with touch when when not at top of component', async () => {
const host = createLitElement();
host.scrollTop = 100;
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
host.dispatchEvent(
createTouchEvent('touchstart', {
touches: [createTouch({ screenY: 0 }), createTouch({ screenY: 0 })],
}),
);
host.dispatchEvent(
createTouchEvent('touchend', {
changedTouches: [createTouch({ screenY: 100 })],
}),
);
await flushPromises();
expect(showLoaderTop).not.toBeCalled();
});
it('should not extend up with touch when touches moved downwards', async () => {
const host = createLitElement();
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
host.dispatchEvent(
createTouchEvent('touchstart', {
touches: [createTouch({ screenY: 100 })],
}),
);
host.dispatchEvent(
createTouchEvent('touchend', {
changedTouches: [createTouch({ screenY: 0 })],
}),
);
await flushPromises();
expect(showLoaderTop).not.toBeCalled();
});
});
it('should not extend when extending up is disabled', async () => {
const host = createLitElement();
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: false });
controller.hostConnected();
host.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }));
expect(showLoaderTop).not.toHaveBeenCalled();
});
it('should not artifically sleep if extend up call takes too long', async () => {
vi.useFakeTimers();
const host = createLitElement();
host.scrollTop = 0;
const showLoaderTop = vi.fn();
const controller = createController({
host,
showLoaderTopCallback: showLoaderTop,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
const extendUpCallback = vi
.fn()
.mockImplementation((ev: CustomEvent<GalleryExtendEvent>) => {
vi.advanceTimersByTime(10000);
ev.detail.resolve();
});
host.addEventListener(
'advanced-camera-card:gallery:extend:up',
extendUpCallback,
);
host.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }));
await flushPromises();
expect(sleep).not.toHaveBeenCalled();
});
});
describe('should extend gallery down', () => {
it('should extend down when sentinel intersects', async () => {
const host = createLitElement();
const showSentinelBottom = vi.fn();
const controller = createController({
host,
showSentinelBottomCallback: showSentinelBottom,
});
controller.setOptions({ extendDown: true });
controller.hostConnected();
const extendDownCallback = vi
.fn()
.mockImplementation((ev: CustomEvent<GalleryExtendEvent>) => {
expect(showSentinelBottom).toHaveBeenLastCalledWith(false);
ev.detail.resolve();
});
host.addEventListener(
'advanced-camera-card:gallery:extend:down',
extendDownCallback,
);
callIntersectionHandler(true);
await flushPromises();
expect(showSentinelBottom).toHaveBeenLastCalledWith(false);
});
it('should not extend down when sentinel does not intersect', async () => {
const host = createLitElement();
const showSentinelBottom = vi.fn();
const controller = createController({
host,
showSentinelBottomCallback: showSentinelBottom,
});
controller.setOptions({ extendUp: true });
controller.hostConnected();
callIntersectionHandler(false);
expect(showSentinelBottom).not.toHaveBeenCalled();
});
it('should not extend when extending down is disabled', async () => {
const host = createLitElement();
const showSentinelBottom = vi.fn();
const controller = createController({
host,
showSentinelBottomCallback: showSentinelBottom,
});
controller.setOptions({ extendUp: false });
controller.hostConnected();
callIntersectionHandler(true);
expect(showSentinelBottom).not.toHaveBeenCalled();
});
});
});
describe('should handle content change', () => {
describe('should scroll selected into view', () => {
it('should scroll selected into view on first load', async () => {
const selectedChild = document.createElement('div');
selectedChild.setAttribute('selected', '');
const slot = createSlot();
const host = createSlotHost({
slot,
children: [
document.createElement('div'),
document.createElement('div'),
selectedChild,
],
});
const controller = createController({
host,
getSlot: () => slot,
});
controller.updateContents();
expect(scrollIntoView).toBeCalledWith(selectedChild, {
boundary: host,
block: 'center',
});
});
it('should do nothing on subsequent contents changes', async () => {
const selectedChild = document.createElement('div');
selectedChild.setAttribute('selected', '');
const slot = createSlot();
const host = createSlotHost({
slot,
children: [
document.createElement('div'),
document.createElement('div'),
selectedChild,
],
});
const controller = createController({
host,
getSlot: () => slot,
});
controller.updateContents();
controller.updateContents();
controller.updateContents();
expect(scrollIntoView).toBeCalledTimes(1);
});
it('should do nothing without a selected element', async () => {
const slot = createSlot();
const host = createSlotHost({
slot,
children: [
document.createElement('div'),
document.createElement('div'),
document.createElement('div'),
],
});
const controller = createController({
host,
getSlot: () => slot,
});
controller.updateContents();
expect(scrollIntoView).not.toBeCalled();
});
});
it('should show bottom sentintel if contents change', async () => {
const slot = createSlot();
const host = createSlotHost({
slot,
});
const showSentinelBottom = vi.fn();
const controller = createController({
host,
getSlot: () => slot,
showSentinelBottomCallback: showSentinelBottom,
});
controller.updateContents();
expect(showSentinelBottom).toBeCalledWith(true);
});
it('should do nothing without slot', async () => {
const slot = createSlot();
const host = createSlotHost({
slot,
});
const showSentinelBottom = vi.fn();
const controller = createController({
host,
showSentinelBottomCallback: showSentinelBottom,
});
controller.updateContents();
expect(showSentinelBottom).not.toBeCalled();
expect(scrollIntoView).not.toBeCalled();
});
});
});
@@ -0,0 +1,427 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ExtendedMediaQueryResult } from '../../../src/camera-manager/manager';
import {
EventQuery,
QueryType,
RecordingQuery,
} from '../../../src/camera-manager/types';
import { ViewManagerEpoch } from '../../../src/card-controller/view/types';
import { ViewManager } from '../../../src/card-controller/view/view-manager';
import {
MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN,
MediaGalleryController,
} from '../../../src/components-lib/gallery/media-gallery-controller';
import { THUMBNAIL_WIDTH_DEFAULT } from '../../../src/config/schema/common/controls/thumbnails';
import {
EventMediaQuery,
FolderViewQuery,
RecordingMediaQuery,
} from '../../../src/view/query';
import { QueryResults } from '../../../src/view/query-results';
import {
createCameraManager,
createLitElement,
createView,
TestViewMedia,
} from '../../test-utils';
// @vitest-environment jsdom
describe('MediaGalleryController', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('getMedia', () => {
it('should return null initially', () => {
expect(new MediaGalleryController(createLitElement()).getMedia()).toBeNull();
});
});
describe('setMediaFromView', () => {
it('should set media from view if media is null', () => {
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(null);
expect(controller.getMedia()).toBeNull();
});
it('should set media from view if results are null', () => {
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(createView({ queryResults: new QueryResults() }));
expect(controller.getMedia()).toEqual([]);
});
it('should update media when query results first set', () => {
const media_1 = new TestViewMedia({ id: 'one' });
const media_2 = new TestViewMedia({ id: 'two' });
const newView = createView({
queryResults: new QueryResults({ results: [media_1, media_2] }),
});
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(newView);
expect(controller.getMedia()).toEqual([media_2, media_1]);
});
it('should update media when query results change', () => {
const media_1 = new TestViewMedia({ id: 'one' });
const media_2 = new TestViewMedia({ id: 'two' });
const media_3 = new TestViewMedia({ id: 'three' });
const oldView = createView({
queryResults: new QueryResults({ results: [media_1, media_2] }),
});
const newView = createView({
queryResults: new QueryResults({ results: [media_2, media_3] }),
});
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(newView, oldView);
expect(controller.getMedia()).toEqual([media_3, media_2]);
});
it('should not update media if query results are the same', () => {
const media_1 = new TestViewMedia({ id: 'one' });
const media_2 = new TestViewMedia({ id: 'two' });
const results = [media_1, media_2];
const oldView = createView({
queryResults: new QueryResults({ results }),
});
const newView = createView({
queryResults: new QueryResults({ results }),
});
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(oldView);
const media = controller.getMedia();
controller.setMediaFromView(newView, oldView);
expect(controller.getMedia()).toBe(media);
});
});
describe('should set thumbnail size', () => {
it('should set thumbnail size explicitly', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
controller.setThumbnailSize(100);
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
'100px',
);
});
it('should set thumbnail size implicitly', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
controller.setThumbnailSize();
expect(host.style.getPropertyValue('--advanced-camera-card-thumbnail-size')).toBe(
`${THUMBNAIL_WIDTH_DEFAULT}px`,
);
});
});
describe('should get column width', () => {
it('should get default column width', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
expect(controller.getColumnWidth()).toBe(THUMBNAIL_WIDTH_DEFAULT);
});
it('should get column width with defailts', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
expect(
controller.getColumnWidth({
size: 100,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe(MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN);
});
it('should get column width with explicit size', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
expect(
controller.getColumnWidth({
size: 142,
show_details: false,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe(142);
});
});
describe('should get column count round method', () => {
it('should get default column count round method', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
expect(controller.getColumnCountRoundMethod()).toBe('ceil');
});
it('should get column count round method when details being shown', () => {
const host = document.createElement('div');
const controller = new MediaGalleryController(host);
expect(
controller.getColumnCountRoundMethod({
size: 100,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
show_download_control: true,
}),
).toBe('floor');
});
});
describe('extendMediaGallery', () => {
it('should do nothing if no view is available', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>();
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(null);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled();
});
it('should do nothing if no queries are available', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>();
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(
createView({
query: new EventMediaQuery(),
queryResults: new QueryResults({ results: [new TestViewMedia()] }),
}),
);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled();
});
it('should do nothing if no query results are available', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>();
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(createView());
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled();
});
it('should do nothing if non-media query is present', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>();
viewManagerEpoch.manager.getView = vi
.fn()
.mockReturnValue(createView({ query: new FolderViewQuery() }));
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).not.toHaveBeenCalled();
});
it('should successfully extend event media queries', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>({ manager: mock<ViewManager>() });
const existingRawQueries: EventQuery[] = [
{ type: QueryType.Event, cameraIDs: new Set(['camera.office']) },
];
const existingMedia = [new TestViewMedia()];
const baseView = createView({
query: new EventMediaQuery(existingRawQueries),
queryResults: new QueryResults({ results: existingMedia }),
});
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(baseView);
const newQueries: EventQuery[] = [
{ type: QueryType.Event, cameraIDs: new Set(['camera.office']) },
];
const newResults = [new TestViewMedia()];
const extension: ExtendedMediaQueryResult<EventQuery> = {
queries: newQueries,
results: newResults,
};
vi.mocked(cameraManager.extendMediaQueries).mockResolvedValue(extension);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).toBeCalledWith(
existingRawQueries,
existingMedia,
'earlier',
{ useCache: true },
);
expect(viewManagerEpoch.manager.setViewByParameters).toBeCalledWith({
baseView,
params: {
query: expect.any(EventMediaQuery),
queryResults: expect.any(QueryResults),
},
});
const callArguments = vi.mocked(viewManagerEpoch.manager.setViewByParameters).mock
.lastCall?.[0];
expect(callArguments?.params?.query?.getQuery()).toEqual(newQueries);
expect(callArguments?.params?.queryResults?.getResults()).toEqual(newResults);
});
});
it('should successfully extend recording media queries', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>({ manager: mock<ViewManager>() });
const existingRawQueries: RecordingQuery[] = [
{ type: QueryType.Recording, cameraIDs: new Set(['camera.office']) },
];
const existingMedia = [new TestViewMedia()];
const baseView = createView({
query: new RecordingMediaQuery(existingRawQueries),
queryResults: new QueryResults({ results: existingMedia }),
});
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(baseView);
const newQueries: RecordingQuery[] = [
{ type: QueryType.Recording, cameraIDs: new Set(['camera.office']) },
];
const newResults = [new TestViewMedia()];
const extension: ExtendedMediaQueryResult<RecordingQuery> = {
queries: newQueries,
results: newResults,
};
vi.mocked(cameraManager.extendMediaQueries).mockResolvedValue(extension);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(cameraManager.extendMediaQueries).toBeCalledWith(
existingRawQueries,
existingMedia,
'earlier',
{ useCache: true },
);
expect(viewManagerEpoch.manager.setViewByParameters).toBeCalledWith({
baseView,
params: {
query: expect.any(RecordingMediaQuery),
queryResults: expect.any(QueryResults),
},
});
const callArguments = vi.mocked(viewManagerEpoch.manager.setViewByParameters).mock
.lastCall?.[0];
expect(callArguments?.params?.query?.getQuery()).toEqual(newQueries);
expect(callArguments?.params?.queryResults?.getResults()).toEqual(newResults);
});
it('should handle errors gracefully', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>({ manager: mock<ViewManager>() });
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(
createView({
query: new EventMediaQuery([
{ type: QueryType.Event, cameraIDs: new Set(['camera.office']) },
]),
queryResults: new QueryResults({ results: [new TestViewMedia()] }),
}),
);
vi.mocked(cameraManager.extendMediaQueries).mockRejectedValue(
new Error('Test error'),
);
const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(viewManagerEpoch.manager.setViewByParameters).not.toHaveBeenCalled();
expect(consoleSpy).toBeCalledWith('Test error');
});
it('should handle null extension gracefully', async () => {
const controller = new MediaGalleryController(createLitElement());
const cameraManager = createCameraManager();
const viewManagerEpoch = mock<ViewManagerEpoch>({ manager: mock<ViewManager>() });
viewManagerEpoch.manager.getView = vi.fn().mockReturnValue(
createView({
query: new EventMediaQuery([
{ type: QueryType.Event, cameraIDs: new Set(['camera.office']) },
]),
queryResults: new QueryResults({ results: [new TestViewMedia()] }),
}),
);
vi.mocked(cameraManager.extendMediaQueries).mockResolvedValue(null);
await controller.extendMediaGallery(cameraManager, viewManagerEpoch, 'earlier');
expect(viewManagerEpoch.manager.setViewByParameters).not.toHaveBeenCalled();
});
describe('should handle item click', () => {
it('should do nothing without a view', () => {
const controller = new MediaGalleryController(createLitElement());
const viewManager = mock<ViewManager>();
controller.itemClickHandler(viewManager, 0, new Event('click'));
expect(viewManager.setViewByParameters).not.toHaveBeenCalled();
});
it('should change view to selected item', () => {
const media = [
new TestViewMedia({ id: 'zero' }),
new TestViewMedia({ id: 'one' }),
];
const view = createView({
queryResults: new QueryResults({ results: media, selectedIndex: 0 }),
});
const controller = new MediaGalleryController(createLitElement());
controller.setMediaFromView(view);
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(
viewManager,
// As the media in the gallery is reversed, passing in 0 as a
// reversedIndex argument is requesting the selection of the media item
// with index 1 (from an array of 2 media items).
0,
new Event('click'),
);
expect(viewManager.setViewByParameters).toBeCalledWith({
params: {
view: 'media',
queryResults: expect.any(QueryResults),
},
});
const newQueryResults = vi.mocked(viewManager.setViewByParameters).mock
.lastCall?.[0]?.params?.queryResults;
expect(newQueryResults?.getSelectedResult()).toEqual(media[1]);
});
});
});
@@ -12,11 +12,7 @@ import {
MediaFilterCoreWhen,
MediaFilterMediaType,
} from '../../src/components-lib/media-filter-controller';
import {
EventMediaQueries,
MediaQueries,
RecordingMediaQueries,
} from '../../src/view/media-queries';
import { EventMediaQuery, Query, RecordingMediaQuery } from '../../src/view/query';
import {
createCameraConfig,
createCameraManager,
@@ -139,7 +135,7 @@ describe('MediaFilterController', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({
title: 'Kitchen Camera',
icon: 'mdi:camera',
icon: { icon: 'mdi:camera' },
});
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
@@ -157,7 +153,7 @@ describe('MediaFilterController', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({
title: 'Kitchen Camera',
icon: 'mdi:camera',
icon: { icon: 'mdi:camera' },
});
vi.mocked(cameraManager.getStore).mockReturnValue(
createCameraStore({
@@ -295,9 +291,7 @@ describe('MediaFilterController', () => {
describe('should get correct controls to show', () => {
it('view with events', () => {
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(
createView({ query: new EventMediaQueries() }),
);
viewManager.getView.mockReturnValue(createView({ query: new EventMediaQuery() }));
const cameraManager = createCameraManager();
const controller = new MediaFilterController(createLitElement());
@@ -311,7 +305,7 @@ describe('MediaFilterController', () => {
it('view with recordings', () => {
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(
createView({ query: new RecordingMediaQueries() }),
createView({ query: new RecordingMediaQuery() }),
);
const cameraManager = createCameraManager();
@@ -325,9 +319,7 @@ describe('MediaFilterController', () => {
it('can favorite events', () => {
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(
createView({ query: new EventMediaQueries() }),
);
viewManager.getView.mockReturnValue(createView({ query: new EventMediaQuery() }));
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
createCapabilities({ 'favorite-events': true }),
@@ -344,7 +336,7 @@ describe('MediaFilterController', () => {
it('can favorite recordings', () => {
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(
createView({ query: new RecordingMediaQueries() }),
createView({ query: new RecordingMediaQuery() }),
);
const cameraManager = createCameraManager();
@@ -436,7 +428,7 @@ describe('MediaFilterController', () => {
}),
});
expect(
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQuery(),
).toEqual([
{
cameraIDs: new Set(['camera.kitchen']),
@@ -501,7 +493,7 @@ describe('MediaFilterController', () => {
});
expect(
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQuery(),
).toEqual([
{
cameraIDs: new Set(['camera.kitchen']),
@@ -543,7 +535,7 @@ describe('MediaFilterController', () => {
});
expect(
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQuery(),
).toEqual([
{
cameraIDs: new Set(['camera.kitchen']),
@@ -607,7 +599,7 @@ describe('MediaFilterController', () => {
},
);
expect(
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQuery(),
).toEqual([
{
cameraIDs: new Set(['camera.kitchen']),
@@ -640,7 +632,7 @@ describe('MediaFilterController', () => {
);
expect(
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQuery(),
).toEqual([
{
cameraIDs: new Set(['camera.kitchen']),
@@ -652,14 +644,36 @@ describe('MediaFilterController', () => {
});
describe('should calculate correct defaults', () => {
it('with no queries', () => {
it('with no query', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(createView());
const controller = new MediaFilterController(createLitElement());
controller.setViewManager(viewManager);
controller.computeInitialDefaultsFromView(createCameraManager());
controller.computeInitialDefaultsFromView(cameraManager);
expect(controller.getDefaults()).toBeNull();
});
it('with no raw queries', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(
createView({
query: new EventMediaQuery(),
}),
);
const controller = new MediaFilterController(createLitElement());
controller.setViewManager(viewManager);
controller.computeInitialDefaultsFromView(cameraManager);
expect(controller.getDefaults()).toBeNull();
});
@@ -680,7 +694,7 @@ describe('MediaFilterController', () => {
it.each([
[
'same cameras' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen', 'camera.living_room']),
@@ -696,7 +710,7 @@ describe('MediaFilterController', () => {
],
[
'different cameras' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -710,7 +724,7 @@ describe('MediaFilterController', () => {
],
[
'all cameras' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -720,7 +734,7 @@ describe('MediaFilterController', () => {
],
[
'different favorites ' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -741,7 +755,7 @@ describe('MediaFilterController', () => {
],
[
'all favorites' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -759,7 +773,7 @@ describe('MediaFilterController', () => {
],
[
'all not favorites' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -777,7 +791,7 @@ describe('MediaFilterController', () => {
],
[
'same hasClip' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -795,7 +809,7 @@ describe('MediaFilterController', () => {
],
[
'different hasClip' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -811,7 +825,7 @@ describe('MediaFilterController', () => {
],
[
'same hasSnapshot' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -829,7 +843,7 @@ describe('MediaFilterController', () => {
],
[
'different hasSnapshot' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -845,7 +859,7 @@ describe('MediaFilterController', () => {
],
[
'same what' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -863,7 +877,7 @@ describe('MediaFilterController', () => {
],
[
'different what' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -879,7 +893,7 @@ describe('MediaFilterController', () => {
],
[
'same where' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -897,7 +911,7 @@ describe('MediaFilterController', () => {
],
[
'different where' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -913,7 +927,7 @@ describe('MediaFilterController', () => {
],
[
'same tags' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -931,7 +945,7 @@ describe('MediaFilterController', () => {
],
[
'different tags' as const,
new EventMediaQueries([
new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera.kitchen']),
@@ -947,7 +961,7 @@ describe('MediaFilterController', () => {
],
[
'recordings' as const,
new RecordingMediaQueries([
new RecordingMediaQuery([
{
type: QueryType.Recording,
cameraIDs: new Set(['camera.kitchen']),
@@ -961,7 +975,7 @@ describe('MediaFilterController', () => {
'%s',
(
_name: string,
mediaQueries: MediaQueries,
mediaQueries: Query,
defaults: MediaFilterCoreDefaults | null,
) => {
const viewManager = mock<ViewManager>();
@@ -15,8 +15,9 @@ import {
createSlotHost,
} from '../test-utils';
vi.mock('lodash-es/throttle', () => ({
default: vi.fn((fn) => fn),
vi.mock('lodash-es', async () => ({
...(await vi.importActual('lodash-es')),
throttle: vi.fn((fn) => fn),
}));
const masonry = mock<ExtendedMasonry>();
@@ -1,9 +1,10 @@
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { Capabilities } from '../../src/camera-manager/capabilities.js';
import { CameraManager } from '../../src/camera-manager/manager.js';
import { CameraManagerCameraMetadata } from '../../src/camera-manager/types.js';
import { FoldersManager } from '../../src/card-controller/folders/manager.js';
import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager.js';
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager.js';
import { MicrophoneManager } from '../../src/card-controller/microphone-manager.js';
@@ -17,16 +18,18 @@ import { ViewDisplayMode } from '../../src/config/schema/common/display.js';
import { MenuItem } from '../../src/config/schema/elements/custom/menu/types.js';
import { AdvancedCameraCardConfig } from '../../src/config/schema/types.js';
import { HomeAssistant } from '../../src/ha/types.js';
import { MediaPlayerController } from '../../src/types.js';
import { MediaPlayerController, PTZMovementType } from '../../src/types.js';
import { createGeneralAction, createViewAction } from '../../src/utils/action.js';
import { MediaQueriesResults } from '../../src/view/media-queries-results.js';
import { ViewMedia } from '../../src/view/media.js';
import { ViewMedia, ViewMediaType } from '../../src/view/item.js';
import { QueryResults } from '../../src/view/query-results.js';
import { FolderViewQuery } from '../../src/view/query.js';
import { View } from '../../src/view/view.js';
import {
createCameraConfig,
createCameraManager,
createCapabilities,
createConfig,
createFolder,
createHASS,
createMediaCapabilities,
createMediaLoadedInfo,
@@ -858,8 +861,12 @@ describe('MenuButtonController', () => {
);
const view = createView({
view: 'media',
queryResults: new MediaQueriesResults({
results: [new ViewMedia('clip', 'camera-1')],
queryResults: new QueryResults({
results: [
new ViewMedia(ViewMediaType.Clip, {
cameraID: 'camera-1',
}),
],
selectedIndex: 0,
}),
});
@@ -893,8 +900,8 @@ describe('MenuButtonController', () => {
createMediaCapabilities({ canDownload: true }),
);
const view = createView({
queryResults: new MediaQueriesResults({
results: [new ViewMedia('clip', 'camera-1')],
queryResults: new QueryResults({
results: [new ViewMedia(ViewMediaType.Clip, { cameraID: 'camera-1' })],
selectedIndex: 0,
}),
});
@@ -915,8 +922,8 @@ describe('MenuButtonController', () => {
);
const view = createView({
view: 'live',
queryResults: new MediaQueriesResults({
results: [new ViewMedia('clip', 'camera-1')],
queryResults: new QueryResults({
results: [new ViewMedia(ViewMediaType.Clip, { cameraID: 'camera-1' })],
selectedIndex: 0,
}),
});
@@ -1499,7 +1506,7 @@ describe('MenuButtonController', () => {
const store = createStore([
{
cameraID: 'camera-1',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
@@ -1519,7 +1526,7 @@ describe('MenuButtonController', () => {
const store = createStore([
{
cameraID: 'camera-1',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
@@ -1539,7 +1546,7 @@ describe('MenuButtonController', () => {
const store = createStore([
{
cameraID: 'camera-1',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
@@ -1573,7 +1580,7 @@ describe('MenuButtonController', () => {
const store = createStore([
{
cameraID: 'camera-1',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
@@ -1605,7 +1612,7 @@ describe('MenuButtonController', () => {
const store = createStore([
{
cameraID: 'camera-1',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
@@ -1643,7 +1650,7 @@ describe('MenuButtonController', () => {
},
{
cameraID: 'camera-2',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
const view = createView({
@@ -1701,7 +1708,7 @@ describe('MenuButtonController', () => {
const view = createView({
view: viewName,
camera: 'camera-1',
queryResults: new MediaQueriesResults({
queryResults: new QueryResults({
results: [new TestViewMedia({ id: 'media-1' })],
selectedIndex: 0,
}),
@@ -1750,6 +1757,184 @@ describe('MenuButtonController', () => {
);
});
describe('should have folders button', () => {
it('should have no folders button without folders', () => {
const foldersManager = mock<FoldersManager>();
foldersManager.getFolders.mockReturnValue(new Map().entries());
const buttons = calculateButtons(controller, {
foldersManager,
});
expect(buttons).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
icon: 'mdi:folder',
}),
]),
);
});
it('should have a folders button for a single folder outside the folder view', () => {
const foldersManager = mock<FoldersManager>();
foldersManager.getFolders.mockReturnValue(
new Map([['folder-0', createFolder({ id: 'folder-0' })]]).entries(),
);
const buttons = calculateButtons(controller, {
foldersManager,
});
expect(buttons).toContainEqual({
icon: 'mdi:folder',
enabled: true,
priority: 50,
style: {},
type: 'custom:advanced-camera-card-menu-icon',
title: 'Folders',
tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folder' },
});
});
it('should have a folders button for a single folder inside the folder view', () => {
const folder = createFolder({ id: 'folder-0' });
const foldersManager = mock<FoldersManager>();
foldersManager.getFolders.mockReturnValue(
new Map([['folder-0', folder]]).entries(),
);
const buttons = calculateButtons(controller, {
foldersManager,
view: createView({
view: 'folder',
query: new FolderViewQuery({ folder, path: [{ id: 'one' }] }),
}),
});
expect(buttons).toContainEqual({
icon: 'mdi:folder',
enabled: true,
priority: 50,
style: {
color: 'var(--advanced-camera-card-menu-button-active-color)',
},
type: 'custom:advanced-camera-card-menu-icon',
title: 'Folders',
tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folder' },
});
});
it('should have a folders submenu for multiple folder with a selected folder', () => {
const foldersManager = mock<FoldersManager>();
const selectedFolder = createFolder({ id: 'folder-selected' });
const folders = new Map([
['folder-0', createFolder({ id: 'folder-0' })],
['folder-selected', selectedFolder],
]);
foldersManager.getFolders.mockReturnValue(folders.entries());
const view = createView({
view: 'folder',
query: new FolderViewQuery({
folder: selectedFolder,
path: [{ id: 'id' }],
}),
});
const buttons = calculateButtons(controller, {
foldersManager,
view,
});
expect(buttons).toContainEqual({
icon: 'mdi:folder-multiple',
enabled: true,
priority: 50,
type: 'custom:advanced-camera-card-menu-submenu',
title: 'Folders',
style: {
color: 'var(--advanced-camera-card-menu-button-active-color)',
},
items: [
{
enabled: true,
icon: 'mdi:folder',
selected: false,
style: {},
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'folder',
folder: 'folder-0',
},
title: 'folder-0',
},
{
enabled: true,
icon: 'mdi:folder',
selected: true,
style: {
color: 'var(--advanced-camera-card-menu-button-active-color)',
},
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'folder',
folder: 'folder-selected',
},
title: 'folder-selected',
},
],
});
});
it('should have a folders submenu for multiple folder without a selected folder', () => {
const foldersManager = mock<FoldersManager>();
const folders = new Map([
['folder-0', createFolder({ id: 'folder-0' })],
['folder-1', createFolder({ id: 'folder-1' })],
]);
foldersManager.getFolders.mockReturnValue(folders.entries());
const buttons = calculateButtons(controller, {
foldersManager,
});
expect(buttons).toContainEqual({
icon: 'mdi:folder-multiple',
enabled: true,
priority: 50,
type: 'custom:advanced-camera-card-menu-submenu',
title: 'Folders',
style: {},
items: [
{
enabled: true,
icon: 'mdi:folder',
selected: false,
style: {},
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'folder',
folder: 'folder-0',
},
title: 'folder-0',
},
{
enabled: true,
icon: 'mdi:folder',
selected: false,
style: {},
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'folder',
folder: 'folder-1',
},
title: 'folder-1',
},
],
});
});
});
describe('should handle dynamic buttons', () => {
it('successfully', () => {
const button: MenuItem = {
@@ -2,7 +2,8 @@ import yaml from 'js-yaml';
import { describe, expect, it } from 'vitest';
import { MessageController } from '../../../src/components-lib/message/controller';
import { TROUBLESHOOTING_URL } from '../../../src/const';
import { Message, MessageType } from '../../../src/types';
import { localize } from '../../../src/localize/localize';
import { Message, MessageType, MessageURL } from '../../../src/types';
describe('MessageController', () => {
describe('should return the correct message string', () => {
@@ -64,7 +65,10 @@ describe('MessageController', () => {
it('should show for errors', () => {
const controller = new MessageController();
const message: Message = { message: 'Error message', type: 'error' };
expect(controller.shouldShowTroubleshootingURL(message)).toBe(true);
expect(controller.getURL(message)).toEqual({
link: TROUBLESHOOTING_URL,
title: localize('error.troubleshooting'),
});
});
describe('should not show for other types', () => {
@@ -77,7 +81,7 @@ describe('MessageController', () => {
icon: 'mdi:car',
type,
};
expect(controller.shouldShowTroubleshootingURL(message)).toBe(false);
expect(controller.getURL(message)).toBeNull();
},
);
});
@@ -86,18 +90,21 @@ describe('MessageController', () => {
it('by default', () => {
const controller = new MessageController();
const message: Message = { message: 'Error message', type: 'error' };
expect(controller.getTroubleshootingURL(message)).toBe(TROUBLESHOOTING_URL);
expect(controller.getURL(message)?.link).toBe(TROUBLESHOOTING_URL);
});
it('when specified', () => {
const controller = new MessageController();
const troubleshootingURL = 'http://localhost/troubleshooting.md';
const url: MessageURL = {
link: 'link',
title: 'title',
};
const message: Message = {
message: 'Error message',
type: 'error',
troubleshootingURL: troubleshootingURL,
url,
};
expect(controller.getTroubleshootingURL(message)).toBe(troubleshootingURL);
expect(controller.getURL(message)).toBe(url);
});
});
});
@@ -0,0 +1,247 @@
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../../../src/camera-manager/manager';
import { ThumbnailDetailsController } from '../../../../src/components-lib/thumbnail/details-controller';
import { ViewFolder, ViewMediaType } from '../../../../src/view/item';
import { createFolder, TestViewMedia } from '../../../test-utils';
describe('ThumbnailDetailsController', () => {
describe('should set heading', () => {
it('should set heading on event with what, tags and score', () => {
const item = new TestViewMedia({
what: ['person', 'car'],
tags: ['tag1', 'tag2'],
score: 0.5,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBe('Person, Car: Tag1, Tag2 50.00%');
});
it('should set heading on event with tags', () => {
const item = new TestViewMedia({
tags: ['tag1', 'tag2'],
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBe('Tag1, Tag2');
});
it('should set heading on event with what', () => {
const item = new TestViewMedia({
what: ['person', 'car'],
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBe('Person, Car');
});
it('should set null heading on event with no other information', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Snapshot,
what: null,
tags: null,
score: null,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
it('should set heading on recording with camera metadata', () => {
const cameraManager = mock<CameraManager>();
cameraManager.getCameraMetadata.mockReturnValue({
title: 'Camera Title',
icon: { icon: 'mdi:cow' },
});
const item = new TestViewMedia({
mediaType: ViewMediaType.Recording,
});
const controller = new ThumbnailDetailsController();
controller.calculate(cameraManager, item);
expect(controller.getHeading()).toBe('Camera Title');
});
it('should set heading on recording without camera metadata', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Recording,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
it('should set no heading on folder', () => {
const item = new ViewFolder(createFolder());
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()).toBeNull();
});
});
describe('should set details', () => {
describe('should have title in details', () => {
it('should have icon with title when there are other details', () => {
const item = new TestViewMedia({
title: 'Test Event',
where: ['where1', 'where2'],
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Test Event',
icon: { icon: 'mdi:rename' },
hint: 'Title',
});
});
it('should not have icon with title when there are no other details', () => {
const item = new TestViewMedia({
title: 'Test Event',
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toEqual([
{
title: 'Test Event',
},
]);
});
});
it('should have start time in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: '2025-05-18 17:03:00',
hint: 'Start',
icon: { icon: 'mdi:calendar-clock-outline' },
});
});
describe('should have duration in details', () => {
it('should have duration in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
endTime: new Date('2025-05-18T17:04:00Z'),
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: '1m 0s',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
});
});
it('should have in-progress in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
endTime: null,
inProgress: true,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'In Progress',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
});
});
it('should have duration and in-progress in details', () => {
const item = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
endTime: new Date('2025-05-18T17:04:00Z'),
inProgress: true,
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: '1m 0s In Progress',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
});
});
});
it('should have camera title in details', () => {
const cameraManager = mock<CameraManager>();
cameraManager.getCameraMetadata.mockReturnValue({
title: 'Camera Title',
icon: { icon: 'mdi:cow' },
});
const item = new TestViewMedia({
cameraID: 'camera_1',
});
const controller = new ThumbnailDetailsController();
controller.calculate(cameraManager, item);
expect(controller.getDetails()).toContainEqual({
title: 'Camera Title',
hint: 'Camera',
icon: { icon: 'mdi:cctv' },
});
});
it('should have where in details', () => {
const item = new TestViewMedia({
cameraID: 'camera_1',
where: ['where1', 'where2'],
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Where1, Where2',
hint: 'Where',
icon: { icon: 'mdi:map-marker-outline' },
});
});
it('should have tags in details', () => {
const item = new TestViewMedia({
cameraID: 'camera_1',
tags: ['tag1', 'tag2'],
});
const controller = new ThumbnailDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Tag1, Tag2',
hint: 'Tag',
icon: { icon: 'mdi:tag' },
});
});
it('should have seek in details', () => {
const item = new TestViewMedia();
const controller = new ThumbnailDetailsController();
controller.calculate(null, item, new Date('2025-05-20T07:14:57Z'));
expect(controller.getDetails()).toContainEqual({
title: '07:14:57',
hint: 'Seek',
icon: { icon: 'mdi:clock-fast' },
});
});
});
});
@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../../../src/camera-manager/manager';
import { ThumbnailFeatureController } from '../../../../src/components-lib/thumbnail/feature/controller';
import { ViewFolder } from '../../../../src/view/item';
import { createFolder, TestViewMedia } from '../../../test-utils';
describe('ThumbnailFeatureController', () => {
const itemWithTime = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
title: 'Test Event',
cameraID: 'camera_1',
});
describe('should set title', () => {
it('should set title with start time ', () => {
const controller = new ThumbnailFeatureController();
controller.calculate(null, itemWithTime, false);
expect(controller.getTitle()).toBe('17:03');
});
it('should not set title when details are shown ', () => {
const controller = new ThumbnailFeatureController();
controller.calculate(null, itemWithTime, true);
expect(controller.getTitle()).toBeNull();
});
it('should not set title on media with a thumbnail', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
title: 'Test Event',
cameraID: 'camera_1',
thumbnail: 'thumbnail',
});
controller.calculate(null, itemWithThumbnail, true);
expect(controller.getTitle()).toBeNull();
});
});
describe('should set subtitles', () => {
it('should set subtitle with start date ', () => {
const controller = new ThumbnailFeatureController();
controller.calculate(null, itemWithTime, false);
expect(controller.getSubtitles()).toContain('May 18th');
});
it('should set subtitle with source from item title ', () => {
const controller = new ThumbnailFeatureController();
controller.calculate(null, itemWithTime, false);
expect(controller.getSubtitles()).toContain('Test Event');
});
it('should set subtitle with source from camera title ', () => {
const controller = new ThumbnailFeatureController();
const cameraManager = mock<CameraManager>();
cameraManager.getCameraMetadata.mockReturnValue({
title: 'Camera 1',
icon: { icon: 'mdi:camera' },
});
const itemWithoutTitle = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
title: null,
cameraID: 'camera_1',
});
controller.calculate(cameraManager, itemWithoutTitle, false);
expect(controller.getSubtitles()).toContain('Camera 1');
});
it('should set subtitle falling back to item title when cameraID not found', () => {
const controller = new ThumbnailFeatureController();
const item = new TestViewMedia({
title: 'Title',
cameraID: null,
});
controller.calculate(null, item, false);
expect(controller.getSubtitles()).toContain('Title');
});
it('should not set subtitle on media with a thumbnail', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new TestViewMedia({
startTime: new Date('2025-05-18T17:03:00Z'),
title: 'Test Event',
cameraID: 'camera_1',
thumbnail: 'thumbnail',
});
controller.calculate(null, itemWithThumbnail, false);
expect(controller.getSubtitles()).toEqual([]);
});
it('should not set subtitle on folder media', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new ViewFolder(createFolder(), {
title: 'Test Folder',
});
controller.calculate(null, itemWithThumbnail, false);
expect(controller.getSubtitles()).toContain('Test Folder');
});
});
describe('should set icon', () => {
it('should set icon without a thumbnail', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new TestViewMedia({
thumbnail: null,
icon: 'mdi:cow',
});
controller.calculate(null, itemWithThumbnail, false);
expect(controller.getIcon()).toBe('mdi:cow');
});
it('should not set icon when there is a thumbnail', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new TestViewMedia({
thumbnail: 'thumbnail',
icon: 'mdi:cow',
});
controller.calculate(null, itemWithThumbnail, false);
expect(controller.getIcon()).toBeNull();
});
});
describe('should set thumbnail', () => {
it('should set brand thumbnail', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new TestViewMedia({
thumbnail: 'https://brands.home-assistant.io//amcrest/icon.png',
});
controller.calculate(null, itemWithThumbnail, false);
expect(controller.getThumbnail()).toBe(
'https://brands.home-assistant.io/brands/_/amcrest/icon.png',
);
expect(controller.getThumbnailClass()).toBe('brand');
});
it('should set other thumbnail', () => {
const controller = new ThumbnailFeatureController();
const itemWithThumbnail = new TestViewMedia({
thumbnail: 'https://card.camera/thumbnail.jpg',
});
controller.calculate(null, itemWithThumbnail, false);
expect(controller.getThumbnail()).toBe('https://card.camera/thumbnail.jpg');
expect(controller.getThumbnailClass()).toBeNull();
});
});
});
@@ -2,11 +2,17 @@ import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock, mockClear } from 'vitest-mock-extended';
import { ZoomController } from '../../../src/components-lib/zoom/zoom-controller';
import { ResizeObserverMock, requestAnimationFrameMock } from '../../test-utils';
import {
ResizeObserverMock,
createTouch,
createTouchEvent,
requestAnimationFrameMock,
} from '../../test-utils';
vi.mock('@dermotduffy/panzoom');
vi.mock('lodash-es/throttle', () => ({
default: vi.fn((fn) => fn),
vi.mock('lodash-es', () => ({
round: vi.fn((fn) => fn),
throttle: vi.fn((fn) => fn),
}));
// https://github.com/jsdom/jsdom/issues/2527
@@ -42,23 +48,6 @@ describe('ZoomController', () => {
return zoom;
};
const createTouch = (target: HTMLElement): Touch => {
return {
clientX: 0,
clientY: 0,
force: 0,
identifier: 0,
pageX: 0,
pageY: 0,
radiusX: 0,
radiusY: 0,
rotationAngle: 0,
screenX: 0,
screenY: 0,
target: target,
};
};
beforeAll(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
window.requestAnimationFrame = requestAnimationFrameMock;
@@ -144,24 +133,23 @@ describe('ZoomController', () => {
createAndRegisterZoom(element);
const ev_1 = new TouchEvent('touchstart', {
bubbles: false,
touches: [createTouch(element), createTouch(element)],
const ev_1 = createTouchEvent('touchstart', {
touches: [createTouch({ target: element }), createTouch({ target: element })],
});
element.dispatchEvent(ev_1);
expect(panzoom.handleDown).toBeCalledWith(ev_1);
panzoom.getScale = vi.fn().mockReturnValue(1.2);
const ev_3 = new TouchEvent('touchstart');
const ev_3 = createTouchEvent('touchstart');
element.dispatchEvent(ev_3);
expect(panzoom.handleDown).toBeCalledWith(ev_3);
const ev_4 = new TouchEvent('touchmove');
const ev_4 = createTouchEvent('touchmove');
element.dispatchEvent(ev_4);
expect(panzoom.handleMove).toBeCalledWith(ev_4);
const ev_5 = new TouchEvent('touchend');
const ev_5 = createTouchEvent('touchend');
element.dispatchEvent(ev_5);
expect(panzoom.handleUp).toBeCalledWith(ev_5);
});
@@ -57,33 +57,68 @@ describe('generateViewContextForZoom', () => {
});
// @vitest-environment jsdom
it('handleZoomSettingsObservedEvent', () => {
const viewManager = mock<ViewManager>();
describe('handleZoomSettingsObservedEvent', () => {
it('should reject observed zoom settings without viewManager ', () => {
handleZoomSettingsObservedEvent(
new CustomEvent('advanced-camera-card:zoom:change', {
detail: {
pan: { x: 1, y: 2 },
zoom: 3,
isDefault: true,
unzoomed: true,
},
}),
);
handleZoomSettingsObservedEvent(
new CustomEvent('advanced-camera-card:zoom:change', {
detail: {
pan: { x: 1, y: 2 },
zoom: 3,
isDefault: true,
unzoomed: true,
},
}),
viewManager,
'target',
);
expect(viewManager.setViewByParameters).toBeCalledWith(
expect.objectContaining({
modifiers: [expect.any(MergeContextViewModifier)],
}),
);
// No observable effect.
});
expect(MergeContextViewModifier).toBeCalledWith({
zoom: {
target: {
observed: { pan: { x: 1, y: 2 }, zoom: 3, isDefault: true, unzoomed: true },
requested: null,
it('should reject observed zoom settings without target ', () => {
const viewManager = mock<ViewManager>();
handleZoomSettingsObservedEvent(
new CustomEvent('advanced-camera-card:zoom:change', {
detail: {
pan: { x: 1, y: 2 },
zoom: 3,
isDefault: true,
unzoomed: true,
},
}),
viewManager,
);
expect(viewManager.setViewByParameters).not.toBeCalled();
});
it('should handle observed zoom settings ', () => {
const viewManager = mock<ViewManager>();
handleZoomSettingsObservedEvent(
new CustomEvent('advanced-camera-card:zoom:change', {
detail: {
pan: { x: 1, y: 2 },
zoom: 3,
isDefault: true,
unzoomed: true,
},
}),
viewManager,
'target',
);
expect(viewManager.setViewByParameters).toBeCalledWith(
expect.objectContaining({
modifiers: [expect.any(MergeContextViewModifier)],
}),
);
expect(MergeContextViewModifier).toBeCalledWith({
zoom: {
target: {
observed: { pan: { x: 1, y: 2 }, zoom: 3, isDefault: true, unzoomed: true },
requested: null,
},
},
},
});
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { transformPathURLToPathArray } from '../../../src/config/schema/folders';
import { NonEmptyTuple } from 'type-fest';
describe('transformURLToMediaSourceRoot', () => {
const prefixes: NonEmptyTuple<string>[] = [
['http://card.camera/' as const],
['/' as const],
['' as const],
];
describe('should return the media source root when given a root URL', () => {
it.each(prefixes)('with prefix %s', (urlPrefix: string) => {
const url = `${urlPrefix}media-browser/browser`;
const result = transformPathURLToPathArray(url);
expect(result).toEqual([{ id: 'media-source://' }]);
});
});
describe('should return the media source root when given a valid URL', () => {
it.each(prefixes)('with prefix %s', (urlPrefix: string) => {
const url = `${urlPrefix}media-browser/browser/app%2Cmedia-source%3A%2F%2Fcamera`;
const result = transformPathURLToPathArray(url);
expect(result).toEqual([
{ id: 'media-source://' },
{ id: 'media-source://camera' },
]);
});
});
describe('should return the decoded frigate URL', () => {
it.each(prefixes)('with prefix %s', (urlPrefix: string) => {
const url =
`${urlPrefix}media-browser/browser/app%2Cmedia-source%3A%2F%2F` +
'frigate/image%2Cmedia-source%3A%2F%2Ffrigate%2Ffrigate%2F' +
'event-search%2Fsnapshots%2F%2F%2F%2F%2F%2F/image%2C' +
'media-source%3A%2F%2Ffrigate%2Ffrigate%2Fevent-search' +
'%2Fsnapshots%2F.this_month%2F1746082800%2F%2F%2F%2F';
const result = transformPathURLToPathArray(url);
expect(result).toEqual([
{ id: 'media-source://' },
{ id: 'media-source://frigate' },
{ id: 'media-source://frigate/frigate/event-search/snapshots//////' },
{
id: 'media-source://frigate/frigate/event-search/snapshots/.this_month/1746082800////',
},
]);
});
});
describe('should return the root for unknown URLs', () => {
it.each(prefixes)('with prefix %s', (urlPrefix: string) => {
const url = `${urlPrefix}something-completely-different`;
const result = transformPathURLToPathArray(url);
expect(result).toEqual([{ id: 'media-source://' }]);
});
});
describe('should throw error for non-media source path component', () => {
it.each(prefixes)('with prefix %s', (urlPrefix: string) => {
const url = `${urlPrefix}media-browser/browser,does-not-start-with-media-source`;
expect(() => transformPathURLToPathArray(url)).toThrowError(
/Could not parse valid media source URL/,
);
});
});
});
+4 -1
View File
@@ -132,7 +132,6 @@ describe('config defaults', () => {
mode: 'right',
},
thumbnails: {
mode: 'right',
show_details: false,
show_download_control: true,
show_favorite_control: true,
@@ -215,6 +214,10 @@ describe('config defaults', () => {
enabled: false,
priority: 50,
},
folders: {
enabled: true,
priority: 50,
},
fullscreen: {
enabled: true,
priority: 50,
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import {
brandsUrl,
extractDomainFromBrandUrl,
isBrandUrl,
} from '../../src/ha/brands-url';
describe('brandsUrl', () => {
it('generates a basic icon url', () => {
expect(brandsUrl({ domain: 'amcrest', type: 'icon' })).toBe(
'https://brands.home-assistant.io/amcrest/icon.png',
);
});
it('generates a logo url with brand', () => {
expect(brandsUrl({ domain: 'hikvision', type: 'logo', brand: true })).toBe(
'https://brands.home-assistant.io/brands/hikvision/logo.png',
);
});
it('generates a dark optimized icon@2x url with fallback', () => {
expect(
brandsUrl({
domain: 'unifi',
type: 'icon@2x',
useFallback: true,
darkOptimized: true,
}),
).toBe('https://brands.home-assistant.io/_/unifi/dark_icon@2x.png');
});
it('generates a logo@2x url with all options', () => {
expect(
brandsUrl({
domain: 'dahua',
type: 'logo@2x',
useFallback: true,
darkOptimized: true,
brand: true,
}),
).toBe('https://brands.home-assistant.io/brands/_/dahua/dark_logo@2x.png');
});
});
describe('extractDomainFromBrandUrl', () => {
it('extracts domain from a brands url', () => {
expect(
extractDomainFromBrandUrl(
'https://brands.home-assistant.io/brands/hikvision/logo.png',
),
).toBe('hikvision');
});
});
describe('isBrandUrl', () => {
it('returns true for a valid brands url', () => {
expect(isBrandUrl('https://brands.home-assistant.io/amcrest/icon.png')).toBe(true);
});
it('returns false for a non-brands url', () => {
expect(isBrandUrl('https://example.com/amcrest/icon.png')).toBe(false);
});
it('returns false for undefined', () => {
expect(isBrandUrl(undefined)).toBe(false);
});
it('returns false for null', () => {
expect(isBrandUrl(null)).toBe(false);
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { getViewMediaFromBrowseMediaArray } from '../../../src/ha/browse-media/browse-media-to-view-media';
import {
BrowseMediaMetadata,
MEDIA_CLASS_IMAGE,
MEDIA_CLASS_VIDEO,
RichBrowseMedia,
} from '../../../src/ha/browse-media/types';
import { createBrowseMedia, createRichBrowseMedia } from '../../test-utils';
const createBrowseMediaChildren = (
overrides: Partial<RichBrowseMedia<BrowseMediaMetadata>>[],
) => {
return overrides.map((override) => createRichBrowseMedia(override));
};
describe('getViewMediaFromBrowseMediaArray', () => {
it('should not ignore absent metadata', () => {
const children = [createBrowseMedia()];
const viewMedia = getViewMediaFromBrowseMediaArray(children, {
cameraID: 'camera.office',
});
expect(viewMedia).toHaveLength(1);
expect(viewMedia?.[0].getMediaType()).toBe('clip');
expect(viewMedia?.[0].getID()).toBe('content_id');
});
it('should ignore unknown media class', () => {
const children = createBrowseMediaChildren([{ media_class: 'UNKNOWN' }]);
expect(
getViewMediaFromBrowseMediaArray(children, { cameraID: 'camera.office' }),
).toEqual([]);
});
it('should generate clip view media', () => {
const children = createBrowseMediaChildren([{ media_class: MEDIA_CLASS_VIDEO }]);
const viewMedia = getViewMediaFromBrowseMediaArray(children, {
cameraID: 'camera.office',
});
expect(viewMedia).toHaveLength(1);
expect(viewMedia?.[0].getMediaType()).toBe('clip');
expect(viewMedia?.[0].getID()).toBe('camera.office/2024-11-19 07:23:00');
});
it('should generate snapshot view media', () => {
const children = createBrowseMediaChildren([{ media_class: MEDIA_CLASS_IMAGE }]);
const viewMedia = getViewMediaFromBrowseMediaArray(children, {
cameraID: 'camera.office',
});
expect(viewMedia).toHaveLength(1);
expect(viewMedia?.[0].getMediaType()).toBe('snapshot');
expect(viewMedia?.[0].getID()).toBe('camera.office/2024-11-19 07:23:00');
});
});
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import {
BrowseMediaEventViewMedia,
BrowseMediaViewFolder,
} from '../../../src/ha/browse-media/item';
import { BrowseMediaViewItemFactory } from '../../../src/ha/browse-media/item-factory';
import {
MEDIA_CLASS_IMAGE,
MEDIA_CLASS_VIDEO,
} from '../../../src/ha/browse-media/types';
import { ViewMediaType } from '../../../src/view/item';
import { createBrowseMedia, createFolder } from '../../test-utils';
describe('BrowseMediaViewItemFactory', () => {
it('returns null if can_expand is true without options.folder', () => {
const browseMedia = createBrowseMedia({ can_expand: true });
expect(BrowseMediaViewItemFactory.create(browseMedia)).toBeNull();
});
it('returns a folder if can_expand is true with options.folder', () => {
const browseMedia = createBrowseMedia({ can_expand: true });
const result = BrowseMediaViewItemFactory.create(browseMedia, {
folder: createFolder(),
});
expect(result).toBeInstanceOf(BrowseMediaViewFolder);
});
it('returns BrowseMediaEventViewMedia for MEDIA_CLASS_VIDEO', () => {
const browseMedia = createBrowseMedia({ media_class: MEDIA_CLASS_VIDEO });
const result = BrowseMediaViewItemFactory.create(browseMedia);
expect(result).toBeInstanceOf(BrowseMediaEventViewMedia);
if (result instanceof BrowseMediaEventViewMedia) {
expect(result?.getMediaType()).toBe(ViewMediaType.Clip);
}
});
it('returns BrowseMediaEventViewMedia for MEDIA_CLASS_VIDEO', () => {
const browseMedia = createBrowseMedia({ media_class: MEDIA_CLASS_IMAGE });
const result = BrowseMediaViewItemFactory.create(browseMedia);
expect(result).toBeInstanceOf(BrowseMediaEventViewMedia);
if (result instanceof BrowseMediaEventViewMedia) {
expect(result?.getMediaType()).toBe(ViewMediaType.Snapshot);
}
});
it('returns null for unknown media_class', () => {
const browseMedia = createBrowseMedia({ media_class: 'UNKNOWN' });
expect(BrowseMediaViewItemFactory.create(browseMedia)).toBeNull();
});
});
+321
View File
@@ -0,0 +1,321 @@
import { describe, expect, it } from 'vitest';
import {
BrowseMediaEventViewMedia,
BrowseMediaViewFolder,
} from '../../../src/ha/browse-media/item';
import { VideoContentType, ViewMediaType } from '../../../src/view/item';
import {
createBrowseMedia,
createFolder,
createRichBrowseMedia,
} from '../../test-utils';
describe('BrowseMediaEventViewMedia', () => {
describe('should set cameraID', () => {
it('should set cameraID from the metadata', () => {
const browseMedia = createRichBrowseMedia({
media_content_id: 'media_content_id',
_metadata: {
startDate: new Date('2025-05-05T07:46:00Z'),
endDate: new Date('2025-05-05T07:48:00Z'),
cameraID: 'camera.office',
},
});
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia);
expect(viewMedia.getCameraID()).toBe('camera.office');
});
it('should set cameraID from the options', () => {
const browseMedia = createRichBrowseMedia({
media_content_id: 'media_content_id',
_metadata: {
startDate: new Date('2025-05-05T07:46:00Z'),
endDate: new Date('2025-05-05T07:48:00Z'),
cameraID: 'camera.office',
},
});
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia, {
cameraID: 'camera.kitchen',
});
expect(viewMedia.getCameraID()).toBe('camera.kitchen');
});
});
describe('should set ID', () => {
it('should set id from metadata', () => {
const browseMedia = createRichBrowseMedia({
media_content_id: 'media_content_id',
_metadata: {
startDate: new Date('2025-05-05T07:46:00Z'),
endDate: new Date('2025-05-05T07:48:00Z'),
cameraID: 'camera.office',
},
});
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia);
expect(viewMedia.getID()).toBe('camera.office/2025-05-05 07:46:00');
});
it('should set id from media_content_id from the options', () => {
const browseMedia = createBrowseMedia({
media_content_id: 'media_content_id',
});
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia, {
cameraID: 'camera.kitchen',
});
expect(viewMedia.getID()).toBe('media_content_id');
});
});
describe('should get start and end time', () => {
it('should get start and end time from metadata', () => {
const browseMedia = createRichBrowseMedia({
media_content_id: 'media_content_id',
_metadata: {
startDate: new Date('2025-05-05T07:46:00Z'),
endDate: new Date('2025-05-05T07:48:00Z'),
cameraID: 'camera.office',
},
});
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia);
expect(viewMedia.getStartTime()).toEqual(new Date('2025-05-05T07:46:00Z'));
expect(viewMedia.getEndTime()).toEqual(new Date('2025-05-05T07:48:00Z'));
});
it('should return null start and end time without metadata', () => {
const browseMedia = createBrowseMedia({
media_content_id: 'media_content_id',
});
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia);
expect(viewMedia.getStartTime()).toBeNull();
expect(viewMedia.getEndTime()).toBeNull();
});
});
describe('should get video content type', () => {
it('should get video content type for clip', () => {
const browseMedia = createRichBrowseMedia();
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia);
expect(viewMedia.getVideoContentType()).toBe(VideoContentType.MP4);
});
it('should get null for snapshot', () => {
const browseMedia = createBrowseMedia();
const viewMedia = new BrowseMediaEventViewMedia(
ViewMediaType.Snapshot,
browseMedia,
);
expect(viewMedia.getVideoContentType()).toBeNull();
});
});
it('should get content ID', () => {
const browseMedia = createBrowseMedia({
media_content_id: 'media_content_id',
});
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia);
expect(viewMedia.getContentID()).toEqual('media_content_id');
});
describe('should get title', () => {
it('should get title from metadata start time', () => {
const browseMedia = createRichBrowseMedia({
_metadata: {
startDate: new Date('2025-05-05T07:46:00Z'),
endDate: new Date('2025-05-05T07:48:00Z'),
cameraID: 'camera.office',
},
});
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia);
expect(viewMedia.getTitle()).toEqual('2025-05-05 07:46');
});
it('should get title without metadata', () => {
const browseMedia = createBrowseMedia({
title: 'Test Title',
});
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia);
expect(viewMedia.getTitle()).toEqual('Test Title');
});
});
it('should get thumbnail', () => {
const browseMedia = createRichBrowseMedia({
thumbnail: 'thumbnail.jpg',
});
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia);
expect(viewMedia.getThumbnail()).toEqual('thumbnail.jpg');
});
describe('should get what', () => {
it('should get what from metadata', () => {
const browseMedia = createRichBrowseMedia({
_metadata: {
startDate: new Date('2025-05-05T07:46:00Z'),
endDate: new Date('2025-05-05T07:48:00Z'),
cameraID: 'camera.office',
what: ['car'],
},
});
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia);
expect(viewMedia.getWhat()).toEqual(['car']);
});
it('should return null without metadata', () => {
const browseMedia = createBrowseMedia();
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia);
expect(viewMedia.getWhat()).toBeNull();
});
});
it('should return null for score', () => {
const browseMedia = createBrowseMedia();
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia);
expect(viewMedia.getScore()).toBeNull();
});
it('should return null for tags', () => {
const browseMedia = createBrowseMedia();
const viewMedia = new BrowseMediaEventViewMedia(ViewMediaType.Clip, browseMedia);
expect(viewMedia.getTags()).toBeNull();
});
describe('should determine what is groupable', () => {
it('should return true when groupable', () => {
const browseMedia_1 = createRichBrowseMedia({
_metadata: {
startDate: new Date('2025-05-05T07:46:00Z'),
endDate: new Date('2025-05-05T07:48:00Z'),
cameraID: 'camera.office',
what: ['car'],
},
});
const browseMedia_2 = createRichBrowseMedia({
_metadata: {
startDate: new Date('2025-05-05T07:48:00Z'),
endDate: new Date('2025-05-05T07:50:00Z'),
cameraID: 'camera.office',
what: ['car'],
},
});
const viewMedia_1 = new BrowseMediaEventViewMedia(
ViewMediaType.Clip,
browseMedia_1,
);
const viewMedia_2 = new BrowseMediaEventViewMedia(
ViewMediaType.Clip,
browseMedia_2,
);
expect(viewMedia_1.isGroupableWith(viewMedia_2)).toBe(true);
});
it('should return false when media types are different', () => {
const browseMedia_1 = createRichBrowseMedia({
_metadata: {
startDate: new Date('2025-05-05T07:46:00Z'),
endDate: new Date('2025-05-05T07:48:00Z'),
cameraID: 'camera.office',
what: ['car'],
},
});
const browseMedia_2 = createRichBrowseMedia({
_metadata: {
startDate: new Date('2025-05-05T07:48:00Z'),
endDate: new Date('2025-05-05T07:50:00Z'),
cameraID: 'camera.office',
what: ['car'],
},
});
const viewMedia_1 = new BrowseMediaEventViewMedia(
ViewMediaType.Clip,
browseMedia_1,
);
const viewMedia_2 = new BrowseMediaEventViewMedia(
ViewMediaType.Snapshot,
browseMedia_2,
);
expect(viewMedia_1.isGroupableWith(viewMedia_2)).toBe(false);
});
});
describe('should set icon', () => {
it('should set icon from known media class', () => {
const browseMedia = createBrowseMedia({
media_class: 'channel',
});
const media = new BrowseMediaEventViewMedia(ViewMediaType.Snapshot, browseMedia);
expect(media.getIcon()).toBe('mdi:television-classic');
});
it('should set null icon from unknown media class', () => {
const browseMedia = createBrowseMedia({
media_class: 'unknown',
});
const viewMedia = new BrowseMediaEventViewMedia(
ViewMediaType.Snapshot,
browseMedia,
);
expect(viewMedia.getIcon()).toBeNull();
});
it('should set null icon from null media class', () => {
const browseMedia = createBrowseMedia({
media_class: undefined,
});
const viewMedia = new BrowseMediaEventViewMedia(
ViewMediaType.Snapshot,
browseMedia,
);
expect(viewMedia.getIcon()).toBeNull();
});
});
});
describe('BrowseMediaViewFolder', () => {
it('should set folder', () => {
const folder = createFolder();
const browseMedia = createBrowseMedia();
const viewMedia = new BrowseMediaViewFolder(folder, browseMedia);
expect(viewMedia.getFolder()).toEqual(folder);
});
describe('should set icon', () => {
it('should set icon from known children media class', () => {
const browseMedia = createBrowseMedia({
children_media_class: 'album',
});
const viewMedia = new BrowseMediaViewFolder(createFolder(), browseMedia);
expect(viewMedia.getIcon()).toBe('mdi:album');
});
it('should set null icon from unknown children media class', () => {
const browseMedia = createBrowseMedia({
children_media_class: 'unknown',
});
const viewMedia = new BrowseMediaViewFolder(createFolder(), browseMedia);
expect(viewMedia.getIcon()).toBeNull();
});
});
});
+7
View File
@@ -0,0 +1,7 @@
import { expect, it } from 'vitest';
import { browseMediaSchema } from '../../../src/ha/browse-media/types';
import { createBrowseMedia } from '../../test-utils';
it('should lazy evaluate lazy recursive browse media schema', () => {
expect(browseMediaSchema.parse(createBrowseMedia())).toEqual(createBrowseMedia());
});
+470
View File
@@ -0,0 +1,470 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
BrowseMedia,
BrowseMediaCache,
RichBrowseMedia,
browseMediaSchema,
} from '../../../src/ha/browse-media/types';
import { BrowseMediaStep, BrowseMediaWalker } from '../../../src/ha/browse-media/walker';
import { homeAssistantWSRequest } from '../../../src/ha/ws-request';
import { createBrowseMedia, createHASS } from '../../test-utils';
vi.mock('../../../src/ha/ws-request');
describe('BrowseMediaWalker', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return an empty array if steps are null or empty', async () => {
const walker = new BrowseMediaWalker();
const hass = createHASS();
expect(await walker.walk(hass, null)).toEqual([]);
expect(await walker.walk(hass, [])).toEqual([]);
});
it('should perform a simple walk with one step', async () => {
const child = createBrowseMedia();
const parent = createBrowseMedia({
media_content_id: 'media/parent',
children: [child],
});
vi.mocked(homeAssistantWSRequest).mockResolvedValue(parent);
const hass = createHASS();
const walker = new BrowseMediaWalker();
const result = await walker.walk(hass, [
{
targets: ['media/parent'],
},
]);
expect(homeAssistantWSRequest).toBeCalledWith(hass, browseMediaSchema, {
type: 'media_source/browse_media',
media_content_id: 'media/parent',
});
expect(result).toEqual([child]);
});
it('should filter media using a matcher', async () => {
const childToKeep = createBrowseMedia({
media_content_id: 'media/child-keep',
});
const childToFilter = createBrowseMedia({
media_content_id: 'media/child-filter',
});
const parent = createBrowseMedia({
media_content_id: 'media/parent',
media_content_type: 'directory',
title: 'Parent',
children: [childToKeep, childToFilter],
});
vi.mocked(homeAssistantWSRequest).mockResolvedValue(parent);
const hass = createHASS();
const walker = new BrowseMediaWalker();
const steps: BrowseMediaStep<undefined>[] = [
{
targets: ['media/parent'],
matcher: (media) => media.media_content_id === 'media/child-keep',
},
];
const result = await walker.walk(hass, steps);
expect(homeAssistantWSRequest).toBeCalledWith(hass, browseMediaSchema, {
type: 'media_source/browse_media',
media_content_id: 'media/parent',
});
expect(result).toEqual([childToKeep]);
});
interface TestMetadata {
custom: string;
}
describe('should generate metadata', async () => {
it('should generate simple metadata', async () => {
const child = createBrowseMedia({
media_content_id: 'media/child',
});
const parent = createBrowseMedia({
media_content_id: 'media/parent',
children: [child],
});
const metadataGenerator = (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_media: BrowseMedia,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_parentTarget?: RichBrowseMedia<TestMetadata>,
) => ({
custom: 'foo',
});
vi.mocked(homeAssistantWSRequest).mockResolvedValue(parent);
const steps: BrowseMediaStep<TestMetadata>[] = [
{
targets: ['media/parent'],
metadataGenerator,
},
];
const walker = new BrowseMediaWalker();
const result = await walker.walk(createHASS(), steps);
expect(result.length).toBe(1);
expect(result[0]?._metadata).toEqual({
custom: 'foo',
});
});
it('should handle parents without children', async () => {
const parent = createBrowseMedia({
media_content_id: 'media/parent-with-free-time',
children: null,
});
const metadataGenerator = (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_media: BrowseMedia,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_parentTarget?: RichBrowseMedia<TestMetadata>,
) => ({
custom: 'foo',
});
vi.mocked(homeAssistantWSRequest).mockResolvedValue(parent);
const steps: BrowseMediaStep<TestMetadata>[] = [
{
targets: ['media/parent'],
metadataGenerator,
},
];
const walker = new BrowseMediaWalker();
const result = await walker.walk(createHASS(), steps);
expect(result.length).toBe(0);
});
it('should handle metadata generator that returns null', async () => {
const child = createBrowseMedia({
media_content_id: 'media/child',
});
const parent = createBrowseMedia({
media_content_id: 'media/parent',
children: [child],
});
const metadataGenerator = (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_media: BrowseMedia,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_parentTarget?: RichBrowseMedia<TestMetadata>,
) => null;
vi.mocked(homeAssistantWSRequest).mockResolvedValue(parent);
const steps: BrowseMediaStep<TestMetadata>[] = [
{
targets: ['media/parent'],
metadataGenerator,
},
];
const walker = new BrowseMediaWalker();
const result = await walker.walk(createHASS(), steps);
expect(result).toEqual([child]);
expect(result[0]?._metadata).toBeUndefined();
});
it('should handle recurisve metadata', async () => {
const grandchild = createBrowseMedia({
media_content_id: 'media/grandchild',
can_expand: false,
can_play: true,
});
const subparent = createBrowseMedia({
media_content_id: 'media/subparent',
can_expand: true,
children: [grandchild],
});
const parent = createBrowseMedia({
media_content_id: 'media/parent',
can_expand: true,
children: [subparent],
});
const metadataGenerator = (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_media: BrowseMedia,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_parentTarget?: RichBrowseMedia<TestMetadata>,
) => ({
custom: 'foo',
});
vi.mocked(homeAssistantWSRequest).mockImplementation(
async (_hass, _schema, request) => {
if (request.media_content_id === 'media/parent') {
return parent;
}
if (request.media_content_id === 'media/subparent') {
return subparent;
}
throw new Error(
`Unexpected media_content_id in mock: ${request.media_content_id}`,
);
},
);
const steps: BrowseMediaStep<TestMetadata>[] = [
{
targets: ['media/parent'],
metadataGenerator,
advance: (matchedDirs) => {
return matchedDirs.map((dir) => ({
targets: [dir],
metadataGenerator,
}));
},
},
];
const hass = createHASS();
const walker = new BrowseMediaWalker();
const result = await walker.walk(hass, steps);
expect(result.length).toBe(1);
expect(result[0].media_content_id).toBe('media/grandchild');
expect(result[0]?._metadata).toEqual({
custom: 'foo',
});
});
});
it('should sort media using a sorter', async () => {
const child_1 = createBrowseMedia({
media_content_id: 'media/child-1',
});
const child_2 = createBrowseMedia({
media_content_id: 'media/child-2',
});
const parent = createBrowseMedia({
children: [child_2, child_1],
});
vi.mocked(homeAssistantWSRequest).mockResolvedValue(parent);
const steps: BrowseMediaStep<undefined>[] = [
{
targets: ['media/parent'],
sorter: (media) =>
[...media].sort((a, b) =>
a.media_content_id.localeCompare(b.media_content_id),
),
},
];
const walker = new BrowseMediaWalker();
const result = await walker.walk(createHASS(), steps);
expect(result.map((m) => m.media_content_id)).toEqual([
'media/child-1',
'media/child-2',
]);
});
it('should exit early when earlyExit returns true', async () => {
const child_1 = createBrowseMedia({
media_content_id: 'media/child-1',
});
const child_2 = createBrowseMedia({
media_content_id: 'media/child-2',
});
const parent_1 = createBrowseMedia({
media_content_id: 'media/parent-1',
children: [child_1],
});
const parent_2 = createBrowseMedia({
media_content_id: 'media/parent-2',
children: [child_2],
});
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(parent_1);
vi.mocked(homeAssistantWSRequest).mockImplementation(
async (_hass, _schema, request) => {
if (request.media_content_id === 'media/parent-1') {
return parent_1;
}
if (request.media_content_id === 'media/parent-2') {
return parent_2;
}
throw new Error('Unexpected request');
},
);
const steps: BrowseMediaStep<undefined>[] = [
{
targets: ['media/parent-1', 'media/parent-2'],
concurrency: 1,
earlyExit: (media) => media.length >= 1,
},
];
const hass = createHASS();
const walker = new BrowseMediaWalker();
const result = await walker.walk(hass, steps);
expect(result).toEqual([child_1]);
expect(homeAssistantWSRequest).toBeCalledTimes(1);
expect(homeAssistantWSRequest).toBeCalledWith(hass, browseMediaSchema, {
type: 'media_source/browse_media',
media_content_id: 'media/parent-1',
});
});
it('should advance to next steps for recursive walking', async () => {
const grandchild = createBrowseMedia({
media_content_id: 'media/grandchild',
can_expand: false,
can_play: true,
});
const subparent = createBrowseMedia({
media_content_id: 'media/subparent',
can_expand: true,
children: [grandchild],
});
const parent = createBrowseMedia({
media_content_id: 'media/parent',
can_expand: true,
children: [subparent],
});
vi.mocked(homeAssistantWSRequest).mockImplementation(
async (_hass, _schema, request) => {
if (request.media_content_id === 'media/parent') {
return parent;
}
if (request.media_content_id === 'media/subparent') {
return subparent;
}
throw new Error(
`Unexpected media_content_id in mock: ${request.media_content_id}`,
);
},
);
const steps: BrowseMediaStep<undefined>[] = [
{
targets: ['media/parent'],
matcher: (media) => media.can_expand === true,
advance: (matchedDirs) => {
return matchedDirs.map((dir) => ({
targets: [dir],
matcher: (childMedia) => childMedia.can_play === true,
}));
},
},
];
const hass = createHASS();
const walker = new BrowseMediaWalker();
const result = await walker.walk(hass, steps);
expect(result.length).toBe(1);
expect(result[0].media_content_id).toBe('media/grandchild');
expect(homeAssistantWSRequest).toHaveBeenCalledTimes(2);
expect(homeAssistantWSRequest).toHaveBeenCalledWith(hass, browseMediaSchema, {
type: 'media_source/browse_media',
media_content_id: 'media/parent',
});
expect(homeAssistantWSRequest).toHaveBeenCalledWith(hass, browseMediaSchema, {
type: 'media_source/browse_media',
media_content_id: 'media/subparent',
});
});
it('should use cache when available', async () => {
const child = createBrowseMedia({
media_content_id: 'media/child',
});
const parent = createBrowseMedia({
media_content_id: 'media/parent',
children: [child],
});
vi.mocked(homeAssistantWSRequest).mockResolvedValue(parent);
const cache = new BrowseMediaCache();
const hass = createHASS();
const walker = new BrowseMediaWalker();
expect(await walker.walk(hass, [{ targets: ['media/parent'] }], { cache })).toEqual([
child,
]);
expect(homeAssistantWSRequest).toBeCalledTimes(1);
expect(cache.has('media/parent')).toBe(true);
expect(cache.get('media/parent')).toEqual(parent);
expect(await walker.walk(hass, [{ targets: ['media/parent'] }], { cache })).toEqual([
child,
]);
expect(homeAssistantWSRequest).toBeCalledTimes(1);
});
it('should process multiple targets and combine their children', async () => {
const child_1 = createBrowseMedia({
media_content_id: 'media/child-1',
});
const parent_1 = createBrowseMedia({
media_content_id: 'media/parent-1',
children: [child_1],
});
const child_2 = createBrowseMedia({
media_content_id: 'media/child-2',
});
const parent_2 = createBrowseMedia({
media_content_id: 'media/parent-2',
children: [child_2],
});
vi.mocked(homeAssistantWSRequest).mockImplementation(
async (_hass, _schema, request) => {
if (request.media_content_id === 'media/parent-1') {
return parent_1;
}
if (request.media_content_id === 'media/parent-2') {
return parent_2;
}
throw new Error('Unexpected request');
},
);
const steps: BrowseMediaStep<undefined>[] = [
{
targets: ['media/parent-1', 'media/parent-2'],
},
];
const hass = createHASS();
const walker = new BrowseMediaWalker();
const result = await walker.walk(hass, steps);
expect(homeAssistantWSRequest).toHaveBeenCalledTimes(2);
expect(result).toHaveLength(2);
expect(result).toContainEqual(child_1);
expect(result).toContainEqual(child_2);
});
});
@@ -1,37 +1,39 @@
import { sub } from 'date-fns';
import { describe, expect, it } from 'vitest';
import { isMediaWithinDates } from '../../../../src/camera-manager/browse-media/utils/within-dates';
import { createBrowseMedia } from './test-utils';
import { isMediaWithinDates } from '../../../src/ha/browse-media/within-dates';
import { createBrowseMedia, createRichBrowseMedia } from '../../test-utils';
describe('isMediaWithinDates', () => {
const rangeStart = new Date('2024-11-19T07:00:00');
const rangeEnd = new Date('2024-11-19T08:00:00');
it('should never match media without metadata', () => {
const media = createBrowseMedia({ _metadata: undefined });
const media = createBrowseMedia();
expect(isMediaWithinDates(media, rangeStart, rangeEnd)).toBe(false);
});
it('should always match media without start or end date', () => {
expect(isMediaWithinDates(createBrowseMedia(), undefined, undefined)).toBe(true);
expect(isMediaWithinDates(createRichBrowseMedia(), undefined, undefined)).toBe(true);
});
it('should match without a start date in the range', () => {
expect(isMediaWithinDates(createBrowseMedia(), undefined, rangeEnd)).toBe(true);
expect(isMediaWithinDates(createRichBrowseMedia(), undefined, rangeEnd)).toBe(true);
});
it('should match without an end date in the range', () => {
expect(isMediaWithinDates(createBrowseMedia(), rangeStart, undefined)).toBe(true);
expect(isMediaWithinDates(createRichBrowseMedia(), rangeStart, undefined)).toBe(
true,
);
});
it('should match when ranges overlap', () => {
expect(isMediaWithinDates(createBrowseMedia(), rangeStart, rangeEnd)).toBe(true);
expect(isMediaWithinDates(createRichBrowseMedia(), rangeStart, rangeEnd)).toBe(true);
});
it('should not match when ranges do not overlap', () => {
expect(
isMediaWithinDates(
createBrowseMedia(),
createRichBrowseMedia(),
sub(rangeStart, { days: 1 }),
sub(rangeEnd, { days: 1 }),
),
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest';
import { canonicalizeHAURL } from '../../src/ha/canonical-url';
import { createHASS } from '../test-utils';
describe('canonicalizeHAURL', () => {
it('returns canonicalized URL for HA relative URL', () => {
const hass = createHASS();
hass.hassUrl = vi.fn((url) => 'hass:' + url);
const url = '/media/local/file.mp4';
expect(canonicalizeHAURL(hass, url)).toBe('hass:/media/local/file.mp4');
});
it('returns original URL for absolute URL', () => {
const url = 'https://card.camera/file.mp4';
expect(canonicalizeHAURL(createHASS(), url)).toBe(url);
});
it('returns null if url is null', () => {
expect(canonicalizeHAURL(createHASS())).toBeNull();
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { getMediaDownloadPath } from '../../src/ha/download';
import { ResolvedMediaCache, resolveMedia } from '../../src/ha/resolved-media.js';
import { createHASS } from '../test-utils';
vi.mock('../../src/ha/canonical-url.js', () => ({
canonicalizeHAURL: vi.fn((_hass, url) => `canonicalized:${url}`),
}));
vi.mock('../../src/ha/resolved-media.js', () => ({
resolveMedia: vi.fn(),
}));
describe('getMediaDownloadPath', () => {
it('returns null if contentID is undefined', async () => {
expect(
await getMediaDownloadPath(createHASS(), undefined, mock<ResolvedMediaCache>()),
).toBeNull();
});
it('returns null if contentID is null', async () => {
expect(
await getMediaDownloadPath(createHASS(), null, mock<ResolvedMediaCache>()),
).toBeNull();
});
it('returns null if resolveMedia returns null', async () => {
const hass = createHASS();
const resolvedMediaCache = mock<ResolvedMediaCache>();
vi.mocked(resolveMedia).mockResolvedValueOnce(null);
expect(await getMediaDownloadPath(hass, 'id-1', resolvedMediaCache)).toBeNull();
expect(resolveMedia).toHaveBeenCalledWith(hass, 'id-1', resolvedMediaCache);
});
it('returns endpoint if resolveMedia returns a url', async () => {
const hass = createHASS();
const resolvedMediaCache = mock<ResolvedMediaCache>();
vi.mocked(resolveMedia).mockResolvedValueOnce({
url: '/media/path.mp4',
mime_type: 'video/mp4',
});
expect(await getMediaDownloadPath(hass, 'id-1', resolvedMediaCache)).toEqual({
endpoint: 'canonicalized:/media/path.mp4',
});
expect(resolveMedia).toBeCalledWith(hass, 'id-1', resolvedMediaCache);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest';
import { getEntityStateTranslation } from '../../src/ha/entity-state-translation';
import { createHASS, createRegistryEntity, createStateEntity } from '../test-utils';
describe('getEntityStateTranslation', () => {
it('returns null if state is missing', () => {
expect(getEntityStateTranslation(createHASS(), 'light.office')).toBeNull();
});
it('returns translation_key translation if available', () => {
const entity = createRegistryEntity({
entity_id: 'light.office',
translation_key: 'translation_key',
});
const hass = createHASS({
'light.office': createStateEntity({
state: 'on',
attributes: {},
}),
});
vi.mocked(hass.localize).mockReturnValue('Translated State');
expect(getEntityStateTranslation(hass, 'light.office', { entity })).toBe(
'Translated State',
);
});
it('returns device_class translation if translation_key not available', () => {
const entity = createRegistryEntity({
entity_id: 'light.office',
});
const hass = createHASS({
'light.office': createStateEntity({
state: 'on',
attributes: {
device_class: 'light',
},
}),
});
vi.mocked(hass.localize).mockReturnValue('Translated State');
expect(getEntityStateTranslation(hass, 'light.office', { entity })).toBe(
'Translated State',
);
});
it('returns default translation if translation_key not available', () => {
const entity = createRegistryEntity({
entity_id: 'light.office',
});
const hass = createHASS({
'light.office': createStateEntity({
state: 'on',
}),
});
vi.mocked(hass.localize).mockReturnValue('Translated State');
expect(getEntityStateTranslation(hass, 'light.office', { entity })).toBe(
'Translated State',
);
});
it('returns raw state if no translation found', () => {
const entity = createRegistryEntity({
entity_id: 'light.office',
});
const hass = createHASS({
'light.office': createStateEntity({
state: 'on',
}),
});
vi.mocked(hass.localize).mockReturnValue('');
expect(getEntityStateTranslation(hass, 'light.office', { entity })).toBe('on');
});
it('uses passed in state', () => {
const entity = createRegistryEntity({
entity_id: 'light.office',
});
const hass = createHASS();
vi.mocked(hass.localize).mockReturnValue('');
expect(
getEntityStateTranslation(hass, 'light.office', { entity, state: 'off' }),
).toBe('off');
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest';
import { fireHASSEvent } from '../../src/ha/fire-hass-event';
// @vitest-environment jsdom
describe('fireHASSEvent', () => {
it('should fire an event with specified type and detail', () => {
const target = document.createElement('div');
const handler = vi.fn();
const type = 'll-custom';
const detail = { action: 'test' };
target.addEventListener(type, handler);
fireHASSEvent(target, type, detail);
expect(handler).toBeCalledWith(
expect.objectContaining({
detail,
}),
);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { getEntitiesFromHASS } from '../../src/ha/get-entities';
import { createHASS, createStateEntity } from '../test-utils';
describe('getEntitiesFromHASS', () => {
const hass = createHASS({
'camera.front_door': createStateEntity(),
'light.kitchen': createStateEntity(),
'light.living_room': createStateEntity(),
'sensor.temperature': createStateEntity(),
'switch.garage': createStateEntity(),
});
it('returns all entity ids when no domain is specified', () => {
expect(getEntitiesFromHASS(hass)).toEqual([
'camera.front_door',
'light.kitchen',
'light.living_room',
'sensor.temperature',
'switch.garage',
]);
});
it('returns only entities of the specified domain', () => {
expect(getEntitiesFromHASS(hass, 'light')).toEqual([
'light.kitchen',
'light.living_room',
]);
});
it('returns an empty array if no entities match the domain', () => {
const result = getEntitiesFromHASS(hass, 'binary_sensor');
expect(result).toEqual([]);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { getEntityTitle } from '../../src/ha/get-entity-title';
import type { HomeAssistant } from '../../src/ha/types';
import { createHASS, createStateEntity } from '../test-utils';
describe('getEntityTitle', () => {
const hass: HomeAssistant = createHASS({
'sensor.temperature': createStateEntity({
attributes: {
friendly_name: 'Temperature Sensor',
},
}),
'light.living_room': createStateEntity({
attributes: {
friendly_name: 'Living Room Light',
},
}),
'switch.no_friendly': createStateEntity({
attributes: {},
}),
});
it('returns the friendly_name for a valid entity', () => {
expect(getEntityTitle(hass, 'sensor.temperature')).toBe('Temperature Sensor');
expect(getEntityTitle(hass, 'light.living_room')).toBe('Living Room Light');
});
it('returns null if the entity does not exist', () => {
expect(getEntityTitle(hass, 'sensor.unknown')).toBeNull();
});
it('returns null if hass is undefined', () => {
expect(getEntityTitle(undefined, 'sensor.temperature')).toBeNull();
});
it('returns null if entity is undefined', () => {
expect(getEntityTitle(hass)).toBeNull();
});
it('returns null if friendly_name attribute is missing', () => {
expect(getEntityTitle(hass, 'switch.no_friendly')).toBeNull();
});
it('returns null if both hass and entity are undefined', () => {
expect(getEntityTitle()).toBeNull();
});
});
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest';
import { getHassDifferences } from '../../src/ha/get-hass-differences';
import { createHASS, createStateEntity } from '../test-utils';
describe('getHassDifferences', () => {
const newHass = createHASS({
'light.office': createStateEntity({
entity_id: 'light.office',
state: 'on',
}),
'light.kitchen': createStateEntity({
entity_id: 'light.kitchen',
state: 'on',
}),
'light.attributes_only': createStateEntity({
entity_id: 'light.attributes_only',
state: 'on',
attributes: {
friendly_name: 'Attributes Only After',
},
}),
});
const oldHass = createHASS({
'light.office': createStateEntity({
entity_id: 'light.office',
state: 'off',
}),
'light.kitchen': createStateEntity({
entity_id: 'light.kitchen',
state: 'off',
}),
'light.attributes_only': createStateEntity({
entity_id: 'light.attributes_only',
state: 'on',
attributes: {
friendly_name: 'Attributes Only Before',
},
}),
});
it('should return empty list without difference', () => {
expect(getHassDifferences(newHass, newHass, ['light.office'])).toEqual([]);
});
it('should return differences', () => {
expect(
getHassDifferences(newHass, oldHass, [
'light.office',
'light.kitchen',
'light.attributes_only',
]),
).toEqual([
{
entityID: 'light.office',
oldState: expect.objectContaining({
entity_id: 'light.office',
state: 'off',
}),
newState: expect.objectContaining({
entity_id: 'light.office',
state: 'on',
}),
},
{
entityID: 'light.kitchen',
oldState: expect.objectContaining({
entity_id: 'light.kitchen',
state: 'off',
}),
newState: expect.objectContaining({
entity_id: 'light.kitchen',
state: 'on',
}),
},
{
entityID: 'light.attributes_only',
oldState: expect.objectContaining({
entity_id: 'light.attributes_only',
state: 'on',
attributes: {
friendly_name: 'Attributes Only Before',
},
}),
newState: expect.objectContaining({
entity_id: 'light.attributes_only',
state: 'on',
attributes: {
friendly_name: 'Attributes Only After',
},
}),
},
]);
});
it('should return single difference', () => {
expect(
getHassDifferences(newHass, oldHass, ['light.office', 'light.kitchen'], {
firstOnly: true,
}),
).toEqual([
{
entityID: 'light.office',
oldState: expect.objectContaining({
entity_id: 'light.office',
state: 'off',
}),
newState: expect.objectContaining({
entity_id: 'light.office',
state: 'on',
}),
},
]);
});
it('should return only state differences', () => {
expect(
getHassDifferences(
newHass,
oldHass,
['light.office', 'light.kitchen', 'light.attributes_only'],
{
stateOnly: true,
},
),
).toEqual([
{
entityID: 'light.office',
oldState: expect.objectContaining({
entity_id: 'light.office',
state: 'off',
}),
newState: expect.objectContaining({
entity_id: 'light.office',
state: 'on',
}),
},
{
entityID: 'light.kitchen',
oldState: expect.objectContaining({
entity_id: 'light.kitchen',
state: 'off',
}),
newState: expect.objectContaining({
entity_id: 'light.kitchen',
state: 'on',
}),
},
]);
});
describe('should return empty list with empty values', () => {
it('should return empty list for empty new hass', () => {
expect(getHassDifferences(null, oldHass, ['light.office'])).toEqual([]);
});
it('should return empty list for empty entity list', () => {
expect(getHassDifferences(newHass, oldHass, [])).toEqual([]);
});
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { hasHAConnectionStateChanged } from '../../src/ha/has-hass-connection-changed';
import { createHASS } from '../test-utils';
describe('hasHAConnectionStateChanged', () => {
it('returns false if both oldHass and newHass are undefined', () => {
expect(hasHAConnectionStateChanged()).toBe(false);
});
it('returns false if both oldHass and newHass are null', () => {
expect(hasHAConnectionStateChanged(null, null)).toBe(false);
});
it('returns false if both oldHass and newHass are the same object', () => {
const hass = createHASS();
expect(hasHAConnectionStateChanged(hass, hass)).toBe(false);
});
it('returns false if both oldHass.connected and newHass.connected are the same', () => {
const oldHass = createHASS();
const newHass = createHASS();
oldHass.connected = true;
newHass.connected = true;
expect(hasHAConnectionStateChanged(oldHass, newHass)).toBe(false);
});
it('returns true if oldHass.connected and newHass.connected are different', () => {
const oldHass = createHASS();
const newHass = createHASS();
oldHass.connected = true;
newHass.connected = false;
expect(hasHAConnectionStateChanged(oldHass, newHass)).toBe(true);
});
});
@@ -1,10 +1,10 @@
import { describe, expect, it, vi } from 'vitest';
import { getIntegrationManifest } from '../../../../src/utils/ha/integration';
import { integrationManifestSchema } from '../../../../src/utils/ha/integration/types';
import { homeAssistantWSRequest } from '../../../../src/utils/ha/ws-request';
import { createHASS } from '../../../test-utils';
import { getIntegrationManifest } from '../../../src/ha/integration';
import { integrationManifestSchema } from '../../../src/ha/integration/types';
import { homeAssistantWSRequest } from '../../../src/ha/ws-request.js';
import { createHASS } from '../../test-utils';
vi.mock('../../../../src/utils/ha/ws-request');
vi.mock('../../../src/ha/ws-request.js');
describe('getIntegrationManifest', () => {
it('should get integration manifest', async () => {
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { isHARelativeURL } from '../../src/ha/is-ha-relative-url';
describe('isHARelativeURL', () => {
it('returns true for a simple relative URL', () => {
expect(isHARelativeURL('/image.jpg')).toBe(true);
});
it('returns true for root path', () => {
expect(isHARelativeURL('/')).toBe(true);
});
it('returns false for undefined', () => {
expect(isHARelativeURL()).toBe(false);
});
it('returns false for empty string', () => {
expect(isHARelativeURL('')).toBe(false);
});
it('returns false for absolute URL', () => {
expect(isHARelativeURL('https://card.camera/image.jpg')).toBe(false);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getHassDifferences } from '../../src/ha/get-hass-differences';
import { isHassDifferent } from '../../src/ha/is-hass-different';
import { createHASS, createStateEntity } from '../test-utils';
vi.mock('../../src/ha/get-hass-differences');
describe('isHassDifferent', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('should return true with difference', async () => {
vi.mocked(getHassDifferences).mockReturnValue([
{ entityID: 'light.office', newState: createStateEntity() },
]);
const newHass = createHASS();
const oldHass = createHASS();
const entities = ['light.office'];
expect(isHassDifferent(newHass, oldHass, entities)).toBe(true);
expect(getHassDifferences).toBeCalledWith(newHass, oldHass, entities, {
firstOnly: true,
});
});
it('should return false without difference', async () => {
vi.mocked(getHassDifferences).mockReturnValue([]);
expect(isHassDifferent(createHASS(), createHASS(), ['light.office'])).toBe(false);
});
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { isTriggeredState } from '../../src/ha/is-triggered-state';
describe('isTriggeredState', () => {
it('returns true for a state included in STATES_ON', () => {
expect(isTriggeredState('on')).toBe(true);
expect(isTriggeredState('open')).toBe(true);
expect(isTriggeredState('unlocked')).toBe(true);
});
it('returns false for a state not included in STATES_ON', () => {
expect(isTriggeredState('off')).toBe(false);
expect(isTriggeredState('closed')).toBe(false);
expect(isTriggeredState('locked')).toBe(false);
expect(isTriggeredState('other')).toBe(false);
});
it('returns false for undefined or empty state', () => {
expect(isTriggeredState()).toBe(false);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { isCardInPanel } from '../../src/ha/panel';
import { createLitElement } from '../test-utils';
// @vitest-environment jsdom
describe('isCardInPanel', () => {
it('returns true if card is in a ShadowRoot with correct tag name"', () => {
const card = createLitElement();
const parent = document.createElement('HUI-PANEL-VIEW');
parent.attachShadow({ mode: 'open' });
parent.shadowRoot?.append(card);
expect(isCardInPanel(card)).toBe(true);
});
it('returns false if card is in a ShadowRoot with incorrect tag name"', () => {
const card = createLitElement();
const parent = document.createElement('ANOTHER-VIEW');
parent.attachShadow({ mode: 'open' });
parent.shadowRoot?.append(card);
expect(isCardInPanel(card)).toBe(false);
});
});
@@ -1,12 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createDeviceRegistryCache,
DeviceRegistryManager,
} from '../../../../../src/utils/ha/registry/device';
import { homeAssistantWSRequest } from '../../../../../src/utils/ha/ws-request';
import { createHASS, createRegistryDevice } from '../../../../test-utils.js';
import { DeviceRegistryManager } from '../../../../src/ha/registry/device';
import { DeviceCache } from '../../../../src/ha/registry/device/types';
import { homeAssistantWSRequest } from '../../../../src/ha/ws-request';
import { createHASS, createRegistryDevice } from '../../../test-utils.js';
vi.mock('../../../../../src/utils/ha/ws-request');
vi.mock('../../../../src/ha/ws-request');
vi.spyOn(global.console, 'warn').mockImplementation(() => true);
describe('DeviceRegistryManager', () => {
@@ -16,10 +14,10 @@ describe('DeviceRegistryManager', () => {
describe('getDevice', () => {
it('should not fetch when cached', async () => {
const cache = createDeviceRegistryCache();
const cache = new DeviceCache();
const testDevice = createRegistryDevice({ id: 'test' });
cache.add(testDevice);
cache.set('test', testDevice);
const manager = new DeviceRegistryManager(cache);
expect(await manager.getDevice(createHASS(), 'test')).toEqual(testDevice);
@@ -30,7 +28,7 @@ describe('DeviceRegistryManager', () => {
it('should fetch and cache when not cached', async () => {
const testDevice = createRegistryDevice({ id: 'test' });
const manager = new DeviceRegistryManager(createDeviceRegistryCache());
const manager = new DeviceRegistryManager(new DeviceCache());
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce([testDevice]);
expect(await manager.getDevice(createHASS(), 'test')).toEqual(testDevice);
@@ -48,7 +46,7 @@ describe('DeviceRegistryManager', () => {
it('should return null when fetch fails', async () => {
vi.mocked(homeAssistantWSRequest).mockRejectedValueOnce(new Error('Fetch error'));
const manager = new DeviceRegistryManager(createDeviceRegistryCache());
const manager = new DeviceRegistryManager(new DeviceCache());
expect(await manager.getDevice(createHASS(), 'test')).toBeNull();
vi.mocked(expect(console.warn)).toBeCalledWith('Fetch error');
@@ -65,7 +63,7 @@ describe('DeviceRegistryManager', () => {
notMatchingDevice,
]);
const manager = new DeviceRegistryManager(createDeviceRegistryCache());
const manager = new DeviceRegistryManager(new DeviceCache());
expect(
await manager.getMatchingDevices(hass, (entity) => entity.id == 'matching'),
).toEqual([matchingDevice]);
@@ -1,12 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createEntityRegistryCache,
EntityRegistryManagerLive,
} from '../../../../../src/utils/ha/registry/entity';
import { homeAssistantWSRequest } from '../../../../../src/utils/ha/ws-request';
import { createHASS, createRegistryEntity } from '../../../../test-utils.js';
import { EntityRegistryManagerLive } from '../../../../src/ha/registry/entity';
import { EntityCache } from '../../../../src/ha/registry/entity/types';
import { homeAssistantWSRequest } from '../../../../src/ha/ws-request';
import { createHASS, createRegistryEntity } from '../../../test-utils.js';
vi.mock('../../../../../src/utils/ha/ws-request');
vi.mock('../../../../src/ha/ws-request');
vi.spyOn(global.console, 'warn').mockImplementation(() => true);
describe('EntityRegistryManager', () => {
@@ -16,10 +14,10 @@ describe('EntityRegistryManager', () => {
describe('getEntity', () => {
it('should not fetch when cached', async () => {
const cache = createEntityRegistryCache();
const cache = new EntityCache();
const testEntity = createRegistryEntity({ entity_id: 'test' });
cache.add(testEntity);
cache.set('test', testEntity);
const manager = new EntityRegistryManagerLive(cache);
expect(await manager.getEntity(createHASS(), 'test')).toEqual(testEntity);
@@ -30,7 +28,7 @@ describe('EntityRegistryManager', () => {
it('should fetch and cache when not cached', async () => {
const testEntity = createRegistryEntity({ entity_id: 'test' });
const manager = new EntityRegistryManagerLive(createEntityRegistryCache());
const manager = new EntityRegistryManagerLive(new EntityCache());
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(testEntity);
expect(await manager.getEntity(createHASS(), 'test')).toEqual(testEntity);
@@ -43,7 +41,7 @@ describe('EntityRegistryManager', () => {
it('should return null when entity does not exist', async () => {
vi.mocked(homeAssistantWSRequest).mockRejectedValueOnce(new Error('Not found'));
const manager = new EntityRegistryManagerLive(createEntityRegistryCache());
const manager = new EntityRegistryManagerLive(new EntityCache());
expect(await manager.getEntity(createHASS(), 'missing')).toBeNull();
vi.mocked(expect(console.warn)).toBeCalledWith('Not found');
@@ -54,8 +52,8 @@ describe('EntityRegistryManager', () => {
const cachedEntity = createRegistryEntity({ entity_id: 'cached' });
const notCachedEntity = createRegistryEntity({ entity_id: 'not-cached' });
const cache = createEntityRegistryCache();
cache.add(cachedEntity);
const cache = new EntityCache();
cache.set('cached', cachedEntity);
const manager = new EntityRegistryManagerLive(cache);
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(notCachedEntity);
@@ -79,7 +77,7 @@ describe('EntityRegistryManager', () => {
const entity = createRegistryEntity({ entity_id: 'cached' });
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce([entity]);
const manager = new EntityRegistryManagerLive(createEntityRegistryCache());
const manager = new EntityRegistryManagerLive(new EntityCache());
await manager.fetchEntityList(hass);
@@ -103,7 +101,7 @@ describe('EntityRegistryManager', () => {
const hass = createHASS();
vi.mocked(homeAssistantWSRequest).mockRejectedValueOnce(new Error('Fetch error'));
const manager = new EntityRegistryManagerLive(createEntityRegistryCache());
const manager = new EntityRegistryManagerLive(new EntityCache());
await manager.fetchEntityList(hass);
@@ -121,7 +119,7 @@ describe('EntityRegistryManager', () => {
notMatchingEntity,
]);
const manager = new EntityRegistryManagerLive(createEntityRegistryCache());
const manager = new EntityRegistryManagerLive(new EntityCache());
expect(
await manager.getMatchingEntities(
hass,
@@ -1,17 +1,18 @@
import { HomeAssistant } from '../../../../../src/ha/types';
import { RegistryCache } from '../../../../../src/utils/ha/registry/cache';
import {
Entity,
EntityCache,
EntityRegistryManager,
} from '../../../../../src/utils/ha/registry/entity/types';
} from '../../../../src/ha/registry/entity/types';
import { HomeAssistant } from '../../../../src/ha/types';
export class EntityRegistryManagerMock implements EntityRegistryManager {
protected _cache: RegistryCache<Entity>;
protected _cache = new EntityCache();
protected _fetchedEntityList = false;
constructor(data?: Entity[]) {
this._cache = new RegistryCache<Entity>((ent) => ent.entity_id);
this._cache.add(data ?? []);
data?.forEach((entity) => {
this._cache.set(entity.entity_id, entity);
});
}
public async getEntity(
+93
View File
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ResolvedMediaCache, resolveMedia } from '../../src/ha/resolved-media';
import { ResolvedMedia, resolvedMediaSchema } from '../../src/ha/types';
import { homeAssistantWSRequest } from '../../src/ha/ws-request';
import { errorToConsole } from '../../src/utils/basic';
import { createHASS } from '../test-utils';
vi.mock('../../src/ha/ws-request', () => ({
homeAssistantWSRequest: vi.fn(),
}));
vi.mock('../../src/utils/basic', () => ({
errorToConsole: vi.fn(),
}));
describe('ResolvedMediaCache', () => {
it('should store and retrieve values', () => {
const cache = new ResolvedMediaCache();
const key = 'media-id';
const resolvedMedia = {
id: key,
title: 'Test Media',
mime_type: 'video/mp4',
url: 'http://media',
media_content_type: 'video',
media_content_id: key,
};
cache.set(key, resolvedMedia);
expect(cache.get(key)).toBe(resolvedMedia);
});
});
describe('resolveMedia', () => {
const mediaContentID = 'media-123';
const resolvedMedia: ResolvedMedia = {
mime_type: 'video/mp4',
url: 'http://media',
};
beforeEach(() => {
vi.clearAllMocks();
});
it('returns cached value if present', async () => {
const cache = new ResolvedMediaCache();
cache.set(mediaContentID, resolvedMedia);
const result = await resolveMedia(createHASS(), mediaContentID, cache);
expect(result).toBe(resolvedMedia);
expect(homeAssistantWSRequest).not.toHaveBeenCalled();
});
it('fetches and caches value if not present in cache', async () => {
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(resolvedMedia);
const hass = createHASS();
const cache = new ResolvedMediaCache();
const result = await resolveMedia(hass, mediaContentID, cache);
expect(homeAssistantWSRequest).toBeCalledWith(
hass,
resolvedMediaSchema,
expect.objectContaining({
type: 'media_source/resolve_media',
media_content_id: mediaContentID,
}),
);
expect(result).toEqual(resolvedMedia);
expect(cache.get(mediaContentID)).toEqual(resolvedMedia);
});
it('returns null and logs error if request fails', async () => {
const error = new Error('fail');
vi.mocked(homeAssistantWSRequest).mockRejectedValueOnce(error);
const cache = new ResolvedMediaCache();
const result = await resolveMedia(createHASS(), mediaContentID, cache);
expect(result).toBeNull();
expect(errorToConsole).toBeCalledWith(error);
});
it('does not cache null results', async () => {
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(null);
const cache = new ResolvedMediaCache();
const result = await resolveMedia(createHASS(), mediaContentID, cache);
expect(result).toBeNull();
expect(cache.get(mediaContentID)).toBeNull();
});
it('works without cache argument', async () => {
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(resolvedMedia);
const result = await resolveMedia(createHASS(), mediaContentID);
expect(result).toEqual(resolvedMedia);
});
});
+62
View File
@@ -0,0 +1,62 @@
import { LitElement } from 'lit';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { sideLoadHomeAssistantElements } from '../../src/ha/side-load-ha-elements';
describe('sideLoadHomeAssistantElements', () => {
beforeEach(() => {
vi.stubGlobal('customElements', {
get: vi.fn(),
whenDefined: vi.fn(),
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('returns true if all elements already registered', async () => {
vi.mocked(customElements.get).mockResolvedValue(LitElement);
expect(await sideLoadHomeAssistantElements()).toBe(true);
});
it('returns false when the picture glance card cannot be found', async () => {
vi.mocked(customElements.get).mockReturnValue(undefined);
const createCardElement = vi.fn();
vi.stubGlobal('window', {
loadCardHelpers: vi.fn().mockReturnValue({
createCardElement,
}),
});
expect(await sideLoadHomeAssistantElements()).toBe(false);
});
it('returns true when elements are loaded', async () => {
vi.mocked(customElements.get).mockImplementation((name: string) => {
if (name === 'hui-picture-glance-card') {
const result = LitElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(result as any).getConfigElement = vi.fn();
return LitElement;
}
return undefined;
});
const createCardElement = vi.fn();
vi.stubGlobal('window', {
loadCardHelpers: vi.fn().mockReturnValue({
createCardElement,
}),
});
expect(await sideLoadHomeAssistantElements()).toBe(true);
expect(customElements.whenDefined).toHaveBeenCalledWith('hui-picture-glance-card');
expect(createCardElement).toHaveBeenCalledWith({
type: 'picture-glance',
entities: [],
camera_image: 'dummy-to-load-editor-components',
});
});
});
+38
View File
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { homeAssistantSignPath } from '../../src/ha/sign-path';
import { homeAssistantWSRequest } from '../../src/ha/ws-request.js';
import { signedPathSchema } from '../../src/types';
import { createHASS } from '../test-utils';
vi.mock('../../src/ha/ws-request.js');
describe('homeAssistantSignPath', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('should sign path', async () => {
const hass = createHASS();
const unsignedPath = 'unsigned/path';
const expires = 42;
vi.mocked(homeAssistantWSRequest).mockResolvedValue({
path: 'signed/path',
});
vi.mocked(hass.hassUrl).mockImplementation((url) => 'hass:' + url);
expect(await homeAssistantSignPath(hass, unsignedPath, expires)).toEqual(
'hass:signed/path',
);
expect(homeAssistantWSRequest).toBeCalledWith(hass, signedPathSchema, {
type: 'auth/sign_path',
path: unsignedPath,
expires,
});
});
it('should return null for null response', async () => {
vi.mocked(homeAssistantWSRequest).mockResolvedValue(null);
expect(await homeAssistantSignPath(createHASS(), 'unsigned/path', 42)).toBeNull();
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { sortMediaByStartDate } from '../../src/ha/browse-media/sort-browse-media-by-start-date';
import { BrowseMediaMetadata, RichBrowseMedia } from '../../src/ha/browse-media/types';
import { createBrowseMedia, createRichBrowseMedia } from '../test-utils';
const createMetadata = (
metadata: Partial<BrowseMediaMetadata>,
): BrowseMediaMetadata => ({
cameraID: 'camera.office',
startDate: new Date('2025-05-10T20:22:00Z'),
endDate: new Date('2025-05-10T20:22:10Z'),
...metadata,
});
describe('sortMediaByStartDate', () => {
it('should return an empty array when given an empty array', () => {
const media: RichBrowseMedia<BrowseMediaMetadata>[] = [];
expect(sortMediaByStartDate(media)).toEqual([]);
});
it('should sort media by startDate in descending order', () => {
const media: RichBrowseMedia<BrowseMediaMetadata>[] = [
createRichBrowseMedia({
title: 'Media 1',
_metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }),
}),
createRichBrowseMedia({
title: 'Media 2',
_metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }),
}),
createRichBrowseMedia({
title: 'Media 3',
_metadata: createMetadata({ startDate: new Date('2025-05-11T20:29:00.000Z') }),
}),
];
const expected: RichBrowseMedia<BrowseMediaMetadata>[] = [
createRichBrowseMedia({
title: 'Media 2',
_metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }),
}),
createRichBrowseMedia({
title: 'Media 3',
_metadata: createMetadata({ startDate: new Date('2025-05-11T20:29:00.000Z') }),
}),
createRichBrowseMedia({
title: 'Media 1',
_metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }),
}),
];
expect(sortMediaByStartDate(media)).toEqual(expected);
});
it('should handle items with undefined startDate by placing them at the end', () => {
const media: RichBrowseMedia<BrowseMediaMetadata>[] = [
createRichBrowseMedia({
title: 'Media 1',
_metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }),
}),
createBrowseMedia({
title: 'Media 2',
}),
createRichBrowseMedia({
title: 'Media 3',
_metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }),
}),
createBrowseMedia({
title: 'Media 4',
}),
];
const expected: RichBrowseMedia<BrowseMediaMetadata>[] = [
createBrowseMedia({
title: 'Media 2',
}),
createBrowseMedia({
title: 'Media 4',
}),
createRichBrowseMedia({
title: 'Media 3',
_metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }),
}),
createRichBrowseMedia({
title: 'Media 1',
_metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }),
}),
];
expect(sortMediaByStartDate(media)).toEqual(expected);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { supportsFeature } from '../../src/ha/supports-feature';
import { createStateEntity } from '../test-utils';
describe('supportsFeature', () => {
it('returns true if the feature bit is set', () => {
const stateObj = createStateEntity({
attributes: { supported_features: 4 },
});
expect(supportsFeature(stateObj, 4)).toBe(true);
});
it('returns false if the feature bit is not set', () => {
const stateObj = createStateEntity({
attributes: { supported_features: 2 },
});
expect(supportsFeature(stateObj, 4)).toBe(false);
});
it('returns false if supported_features is undefined', () => {
const stateObj = createStateEntity({
attributes: {},
});
expect(supportsFeature(stateObj, 1)).toBe(false);
});
it('returns true if multiple feature bits are set and one matches', () => {
const stateObj = createStateEntity({
attributes: { supported_features: 6 }, // 2 + 4
});
expect(supportsFeature(stateObj, 2)).toBe(true);
expect(supportsFeature(stateObj, 4)).toBe(true);
});
it('returns false if feature is 0', () => {
const stateObj = createStateEntity({
attributes: { supported_features: 7 },
});
expect(supportsFeature(stateObj, 0)).toBe(false);
});
});
@@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest';
import { CameraProxyConfig } from '../../../src/camera-manager/types.js';
import { CameraProxyConfig } from '../../src/camera-manager/types.js';
import {
addDynamicProxyURL,
getWebProxiedURL,
shouldUseWebProxy,
} from '../../../src/utils/ha/web-proxy.js';
import { createHASS } from '../../test-utils.js';
} from '../../src/ha/web-proxy.js';
import { createHASS } from '../test-utils.js';
describe('getWebProxiedURL', () => {
it('should return proxied URL with v != 0', () => {
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest';
import { ResolvedMedia, resolvedMediaSchema } from '../../src/ha/types';
import { homeAssistantWSRequest } from '../../src/ha/ws-request';
import { createHASS } from '../test-utils';
describe('homeAssistantWSRequest', () => {
const request = {
type: 'foo',
};
const response: ResolvedMedia = {
url: 'https://example.com/media.mp4',
mime_type: 'video/mp4',
};
it('should return parsed data on successful call', async () => {
const hass = createHASS();
vi.mocked(hass.callWS).mockResolvedValueOnce(response);
expect(await homeAssistantWSRequest(hass, resolvedMediaSchema, request)).toEqual(
response,
);
});
it('should return parsed data on successful call with passthrough', async () => {
const hass = createHASS();
vi.mocked(hass.callWS).mockResolvedValueOnce(JSON.stringify(response));
expect(
await homeAssistantWSRequest(hass, resolvedMediaSchema, request, true),
).toEqual(response);
});
it('should throw on error', async () => {
const error = new Error('WS call failed');
const hass = createHASS();
vi.mocked(hass.callWS).mockRejectedValueOnce(error);
await expect(
homeAssistantWSRequest(hass, resolvedMediaSchema, request),
).rejects.toThrowError(/Failed to receive response/);
});
it('should throw on empty response', async () => {
const hass = createHASS();
vi.mocked(hass.callWS).mockResolvedValueOnce(null);
await expect(
homeAssistantWSRequest(hass, resolvedMediaSchema, request),
).rejects.toThrowError(/Received empty response/);
});
it('should throw error on parse failure', async () => {
const hass = createHASS();
vi.mocked(hass.callWS).mockResolvedValueOnce({});
await expect(
homeAssistantWSRequest(hass, resolvedMediaSchema, request),
).rejects.toThrowError(/Received invalid response/);
});
it('should throw on JSON parse failure', async () => {
const malformedJSONResponse = "{ foo: 'test', bar: 123 "; // Malformed JSON
const hass = createHASS();
vi.mocked(hass.callWS).mockResolvedValueOnce(malformedJSONResponse);
await expect(
homeAssistantWSRequest(hass, resolvedMediaSchema, request, true),
).rejects.toThrowError(/Received invalid response/);
});
});
+132 -24
View File
@@ -10,10 +10,7 @@ import { FrigateEvent, FrigateRecording } from '../src/camera-manager/frigate/ty
import { GenericCameraManagerEngine } from '../src/camera-manager/generic/engine-generic';
import { CameraManager } from '../src/camera-manager/manager';
import { CameraManagerStore } from '../src/camera-manager/store';
import {
CameraEventCallback,
CameraManagerMediaCapabilities,
} from '../src/camera-manager/types';
import { CameraEventCallback } from '../src/camera-manager/types';
import { ActionsManager } from '../src/card-controller/actions/actions-manager';
import { AutomationsManager } from '../src/card-controller/automations-manager';
import { CameraURLManager } from '../src/card-controller/camera-url-manager';
@@ -21,8 +18,8 @@ import { CardElementManager } from '../src/card-controller/card-element-manager'
import { ConfigManager } from '../src/card-controller/config/config-manager';
import { CardController } from '../src/card-controller/controller';
import { DefaultManager } from '../src/card-controller/default-manager';
import { DownloadManager } from '../src/card-controller/download-manager';
import { ExpandManager } from '../src/card-controller/expand-manager';
import { FoldersManager } from '../src/card-controller/folders/manager';
import { FullscreenManager } from '../src/card-controller/fullscreen/fullscreen-manager';
import { HASSManager } from '../src/card-controller/hass/hass-manager';
import { StateWatcherSubscriptionInterface } from '../src/card-controller/hass/state-watcher';
@@ -37,10 +34,12 @@ import { QueryStringManager } from '../src/card-controller/query-string-manager'
import { StatusBarItemManager } from '../src/card-controller/status-bar-item-manager';
import { StyleManager } from '../src/card-controller/style-manager';
import { TriggersManager } from '../src/card-controller/triggers-manager';
import { ViewItemManager } from '../src/card-controller/view/item-manager';
import { ViewManager } from '../src/card-controller/view/view-manager';
import { SubmenuInteraction, SubmenuItem } from '../src/components/submenu/types';
import { ConditionStateManager } from '../src/conditions/state-manager';
import { CameraConfig, cameraConfigSchema } from '../src/config/schema/cameras';
import { FolderConfig } from '../src/config/schema/folders';
import {
PerformanceConfig,
performanceConfigSchema,
@@ -50,13 +49,18 @@ import {
advancedCameraCardConfigSchema,
} from '../src/config/schema/types';
import { RawAdvancedCameraCardConfig } from '../src/config/types';
import { CurrentUser, HomeAssistant } from '../src/ha/types';
import {
BrowseMedia,
BrowseMediaMetadata,
RichBrowseMedia,
} from '../src/ha/browse-media/types';
import { Device } from '../src/ha/registry/device/types';
import { Entity, EntityRegistryManager } from '../src/ha/registry/entity/types';
import { CurrentUser, HassStateDifference, HomeAssistant } from '../src/ha/types';
import { CapabilitiesRaw, Interaction, MediaLoadedInfo } from '../src/types';
import { HassStateDifference } from '../src/utils/ha';
import { Device } from '../src/utils/ha/registry/device/types';
import { Entity, EntityRegistryManager } from '../src/utils/ha/registry/entity/types';
import { ViewMedia, ViewMediaType } from '../src/view/media';
import { MediaQueriesResults } from '../src/view/media-queries-results';
import { EventViewMedia, ViewMedia, ViewMediaType } from '../src/view/item';
import { QueryResults } from '../src/view/query-results';
import { ViewItemCapabilities } from '../src/view/types';
import { View, ViewParameters } from '../src/view/view';
export const createCameraConfig = (config?: unknown): CameraConfig => {
@@ -186,7 +190,7 @@ export const createView = (options?: Partial<ViewParameters>): View => {
export const createViewWithMedia = (options?: Partial<ViewParameters>): View => {
const media = generateViewMediaArray({ count: 5 });
return createView({
queryResults: new MediaQueriesResults({
queryResults: new QueryResults({
results: media,
selectedIndex: 0,
}),
@@ -255,8 +259,8 @@ export const createCapabilities = (capabilities?: CapabilitiesRaw): Capabilities
};
export const createMediaCapabilities = (
options?: Partial<CameraManagerMediaCapabilities>,
): CameraManagerMediaCapabilities => {
options?: Partial<ViewItemCapabilities>,
): ViewItemCapabilities => {
return {
canFavorite: false,
canDownload: false,
@@ -308,7 +312,8 @@ export const generateViewMediaArray = (options?: {
// ViewMedia itself has no native way to set startTime and ID that aren't linked
// to an engine.
export class TestViewMedia extends ViewMedia {
export class TestViewMedia extends ViewMedia implements EventViewMedia {
protected _icon: string | null = null;
protected _id: string | null;
protected _startTime: Date | null;
protected _endTime: Date | null;
@@ -316,26 +321,48 @@ export class TestViewMedia extends ViewMedia {
protected _contentID: string | null;
protected _title: string | null;
protected _thumbnail: string | null;
protected _what: string[] | null = null;
protected _score: number | null = null;
protected _tags: string[] | null = null;
protected _where: string[] | null = null;
constructor(options?: {
id?: string | null;
startTime?: Date | null;
mediaType?: ViewMediaType;
cameraID?: string;
cameraID?: string | null;
folder?: FolderConfig | null;
endTime?: Date | null;
inProgress?: boolean;
contentID?: string;
title?: string;
thumbnail?: string;
title?: string | null;
thumbnail?: string | null;
icon?: string | null;
what?: string[] | null;
score?: number | null;
tags?: string[] | null;
where?: string[] | null;
}) {
super(options?.mediaType ?? 'clip', options?.cameraID ?? 'camera');
super(options?.mediaType ?? ViewMediaType.Clip, {
...(options?.cameraID !== null &&
!options?.folder && { cameraID: options?.cameraID ?? 'camera' }),
...(options?.folder && { folder: options.folder }),
});
this._id = options?.id !== undefined ? options.id : 'id';
this._startTime = options?.startTime ?? null;
this._endTime = options?.endTime ?? null;
this._inProgress = options?.inProgress !== undefined ? options.inProgress : false;
this._contentID = options?.contentID ?? null;
this._title = options?.title ?? null;
this._thumbnail = options?.thumbnail ?? null;
this._title = options?.title !== undefined ? options.title : null;
this._thumbnail = options?.thumbnail !== undefined ? options.thumbnail : null;
this._icon = options?.icon !== undefined ? options.icon : null;
this._what = options?.what !== undefined ? options.what : null;
this._score = options?.score !== undefined ? options.score : null;
this._tags = options?.tags !== undefined ? options.tags : null;
this._where = options?.where !== undefined ? options.where : null;
}
public getIcon(): string | null {
return this._icon;
}
public getID(): string | null {
return this._id;
@@ -358,6 +385,22 @@ export class TestViewMedia extends ViewMedia {
public getThumbnail(): string | null {
return this._thumbnail;
}
public getWhat(): string[] | null {
return this._what;
}
public getScore(): number | null {
return this._score;
}
public getTags(): string[] | null {
return this._tags;
}
public getWhere(): string[] | null {
return this._where;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public isGroupableWith(_that: EventViewMedia): boolean {
return false;
}
}
export const ResizeObserverMock = vi.fn(() => ({
@@ -446,8 +489,9 @@ export const callVisibilityHandler = async (visible: boolean): Promise<void> =>
export const createSlotHost = (options?: {
slot?: HTMLSlotElement;
children?: HTMLElement[];
}): HTMLElement => {
const parent = document.createElement('div');
parent?: LitElement;
}): LitElement => {
const parent = options?.parent ?? createLitElement();
parent.attachShadow({ mode: 'open' });
if (options?.slot) {
@@ -499,9 +543,9 @@ export const createCardAPI = (): CardController => {
api.getCardElementManager.mockReturnValue(mock<CardElementManager>());
api.getConditionStateManager.mockReturnValue(mock<ConditionStateManager>());
api.getConfigManager.mockReturnValue(mock<ConfigManager>());
api.getDownloadManager.mockReturnValue(mock<DownloadManager>());
api.getEntityRegistryManager.mockReturnValue(mock<EntityRegistryManager>());
api.getExpandManager.mockReturnValue(mock<ExpandManager>());
api.getFoldersManager.mockReturnValue(mock<FoldersManager>());
api.getFullscreenManager.mockReturnValue(mock<FullscreenManager>());
api.getHASSManager.mockReturnValue(mock<HASSManager>());
api.getInitializationManager.mockReturnValue(mock<InitializationManager>());
@@ -515,6 +559,7 @@ export const createCardAPI = (): CardController => {
api.getStatusBarItemManager.mockReturnValue(mock<StatusBarItemManager>());
api.getStyleManager.mockReturnValue(mock<StyleManager>());
api.getTriggersManager.mockReturnValue(mock<TriggersManager>());
api.getViewItemManager.mockReturnValue(mock<ViewItemManager>());
api.getViewManager.mockReturnValue(mock<ViewManager>());
return api;
@@ -562,3 +607,66 @@ export const createSubmenuInteractionActionEvent = (
export const setScreenfulEnabled = (enabled: boolean): void => {
Object.defineProperty(screenfull, 'isEnabled', { value: enabled, writable: true });
};
export const createTouch = (touch?: Partial<Touch>): Touch => ({
clientX: touch?.clientX ?? 0,
clientY: touch?.clientY ?? 0,
force: touch?.force ?? 0,
identifier: touch?.identifier ?? 0,
pageX: touch?.pageX ?? 0,
pageY: touch?.pageY ?? 0,
radiusX: touch?.radiusX ?? 0,
radiusY: touch?.radiusY ?? 0,
rotationAngle: touch?.rotationAngle ?? 0,
screenX: touch?.screenX ?? 0,
screenY: touch?.screenY ?? 0,
target: touch?.target ?? document.createElement('div'),
});
export const createTouchEvent = (
type: string,
options?: { touches?: Touch[]; changedTouches?: Touch[] },
): TouchEvent => {
return new TouchEvent(type, {
bubbles: false,
touches: options?.touches,
changedTouches: options?.changedTouches,
});
};
export const createFolder = (config?: Partial<FolderConfig>): FolderConfig => {
return {
type: 'ha',
ha: {
path: [{ id: 'media-source://' }],
},
...config,
};
};
export const createBrowseMedia = (media?: Partial<BrowseMedia>): BrowseMedia => {
return {
title: 'Test Media',
media_class: 'video',
media_content_type: 'video/mp4',
media_content_id: 'content_id',
can_play: true,
can_expand: false,
thumbnail: null,
children: null,
...media,
};
};
export const createRichBrowseMedia = (
media?: Partial<RichBrowseMedia<BrowseMediaMetadata>>,
): RichBrowseMedia<BrowseMediaMetadata> => {
return {
...createBrowseMedia(media),
_metadata: media?._metadata ?? {
cameraID: 'camera.test',
startDate: new Date('2024-11-19T07:23:00'),
endDate: new Date('2025-11-19T07:24:00'),
},
};
};
+14 -2
View File
@@ -5,6 +5,7 @@ import { ActionConfig } from '../../src/config/schema/actions/types.js';
import {
createCameraAction,
createDisplayModeAction,
createFolderAction,
createGeneralAction,
createInternalCallbackAction,
createLogAction,
@@ -50,7 +51,7 @@ describe('createViewAction', () => {
});
describe('createCameraAction', () => {
it('should create camera_select', () => {
it('should create camera_select action', () => {
expect(createCameraAction('camera_select', 'camera', { cardID: 'card_id' })).toEqual(
{
action: 'fire-dom-event',
@@ -62,8 +63,19 @@ describe('createCameraAction', () => {
});
});
describe('createFolderAction', () => {
it('should create folder action', () => {
expect(createFolderAction({ folderID: 'folderID', cardID: 'card_id' })).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'folder',
card_id: 'card_id',
folder: 'folderID',
});
});
});
describe('createMediaPlayerAction', () => {
it('should create media_player', () => {
it('should create media_player action', () => {
expect(
createMediaPlayerAction('device', 'play', {
cardID: 'card_id',
+4 -14
View File
@@ -26,7 +26,6 @@ import {
runWhenIdleIfSupported,
setify,
setOrRemoveAttribute,
sleep,
} from '../../src/utils/basic.js';
import { createSlot, createSlotHost } from '../test-utils.js';
@@ -185,6 +184,10 @@ describe('getDurationString', () => {
const end = new Date(2023, 3, 14, 13, 35, 12);
expect(getDurationString(start, end)).toBe('2s');
});
it('should return 0s for no delta', () => {
const start = new Date(2023, 3, 14, 13, 35, 10);
expect(getDurationString(start, start)).toBe('0s');
});
});
describe('allPromises', () => {
@@ -209,19 +212,6 @@ describe('isSuperset', () => {
});
});
describe('sleep', () => {
it('should sleep', async () => {
const spy = vi
.spyOn(global, 'setTimeout')
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
.mockImplementation((func: () => unknown, _time?: number): any => {
func();
});
sleep(10);
expect(spy).toHaveBeenCalledWith(expect.anything(), 10000);
});
});
describe('isValidDate', () => {
it('should be valid date', () => {
expect(isValidDate(new Date(2023, 3, 28))).toBeTruthy();
+5 -5
View File
@@ -1,10 +1,10 @@
import { HassConfig } from 'home-assistant-js-websocket';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { DeviceRegistryManager } from '../../src/ha/registry/device';
import { homeAssistantWSRequest } from '../../src/ha/ws-request';
import { getLanguage } from '../../src/localize/localize';
import { getDiagnostics, getReleaseVersion } from '../../src/utils/diagnostics.js';
import { DeviceRegistryManager } from '../../src/utils/ha/registry/device';
import { homeAssistantWSRequest } from '../../src/utils/ha/ws-request';
import { createHASS, createRegistryDevice } from '../test-utils';
vi.mock('../../package.json', () => ({
@@ -14,10 +14,10 @@ vi.mock('../../package.json', () => ({
gitDate: 'Wed, 6 Sep 2023 21:27:28 -0700',
},
}));
vi.mock('../../src/utils/ha');
vi.mock('../../src/ha');
vi.mock('../../src/localize/localize.js');
vi.mock('../../src/utils/ha/registry/device/index.js');
vi.mock('../../src/utils/ha/ws-request.js');
vi.mock('../../src/ha/registry/device/index.js');
vi.mock('../../src/ha/ws-request.js');
describe('getReleaseVersion', () => {
it('should get release version', () => {
+2 -132
View File
@@ -1,18 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { downloadMedia, downloadURL } from '../../src/utils/download';
import { homeAssistantSignPath } from '../../src/utils/ha';
import { ViewMedia } from '../../src/view/media';
import {
createCameraManager,
createHASS,
createStore,
TestViewMedia,
} from '../test-utils';
vi.mock('../../src/utils/ha');
const media = new ViewMedia('clip', 'camera.office');
import { downloadURL } from '../../src/utils/download';
// @vitest-environment jsdom
describe('downloadURL', () => {
@@ -64,121 +52,3 @@ describe('downloadURL', () => {
expect(windowSpy).toBeCalledWith('http://bar/url.mp4', '_blank');
});
});
describe('downloadMedia', () => {
beforeEach(() => {
vi.spyOn(window, 'location', 'get').mockReturnValue(
mock<Location>({ origin: 'https://foo' }),
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should throw error when no media', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getMediaDownloadPath).mockResolvedValue(null);
expect(downloadMedia(createHASS(), cameraManager, media)).rejects.toThrow(
/No media to download/,
);
});
it('should throw error when signing fails', () => {
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
const cameraManager = createCameraManager();
vi.mocked(cameraManager).getMediaDownloadPath.mockResolvedValue({
sign: true,
endpoint: 'foo',
});
const signError = new Error('sign-error');
vi.mocked(homeAssistantSignPath).mockRejectedValue(signError);
expect(downloadMedia(createHASS(), cameraManager, media)).rejects.toThrow(
/Could not sign media URL for download/,
);
});
it('should download media', async () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager).getMediaDownloadPath.mockResolvedValue({
sign: true,
endpoint: 'foo',
});
vi.mocked(homeAssistantSignPath).mockResolvedValue('http://foo/signed-url');
const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null);
await downloadMedia(createHASS(), cameraManager, media);
expect(windowSpy).toBeCalledWith('http://foo/signed-url', '_blank');
});
it('should download media without signing', async () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager).getMediaDownloadPath.mockResolvedValue({
sign: false,
endpoint: 'https://another/',
});
const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null);
await downloadMedia(createHASS(), cameraManager, media);
expect(windowSpy).toBeCalledWith('https://another/', '_blank');
});
describe('should generate useful download filenames', () => {
it('should generate filename with just camera ID', async () => {
const cameraManager = createCameraManager(
createStore([
{
cameraID: 'camera.office',
},
]),
);
vi.mocked(cameraManager).getMediaDownloadPath.mockResolvedValue({
sign: false,
endpoint: 'https://foo/',
});
const link = document.createElement('a');
link.click = vi.fn();
link.setAttribute = vi.fn();
vi.spyOn(document, 'createElement').mockReturnValue(link);
await downloadMedia(createHASS(), cameraManager, media);
expect(link.setAttribute).toBeCalledWith('download', 'camera-office');
});
it('should generate filename with full details ID', async () => {
const cameraManager = createCameraManager(
createStore([
{
cameraID: 'camera.office',
},
]),
);
vi.mocked(cameraManager).getMediaDownloadPath.mockResolvedValue({
sign: false,
endpoint: 'https://foo/',
});
const link = document.createElement('a');
link.click = vi.fn();
link.setAttribute = vi.fn();
vi.spyOn(document, 'createElement').mockReturnValue(link);
const media = new TestViewMedia({
cameraID: 'camera.office',
id: 'clip-id',
startTime: new Date('2025-03-06T21:31:29Z'),
});
await downloadMedia(createHASS(), cameraManager, media);
expect(link.setAttribute).toBeCalledWith(
'download',
'camera-office_clip-id_2025-03-06-21-31-29',
);
});
});
});
@@ -15,8 +15,8 @@ import {
createTestSlideNodes,
} from '../../test-utils';
vi.mock('lodash-es/debounce', () => ({
default: vi.fn((fn) => fn),
vi.mock('lodash-es', () => ({
debounce: vi.fn((fn) => fn),
}));
// @vitest-environment jsdom
+2 -2
View File
@@ -3,8 +3,8 @@ import { EmblaReInitController } from '../../../src/utils/embla/reinit-controlle
import { requestAnimationFrameMock } from '../../test-utils';
import { callEmblaHandler, createEmblaApiInstance } from './test-utils';
vi.mock('lodash-es/debounce', () => ({
default: vi.fn((fn) => fn),
vi.mock('lodash-es', () => ({
debounce: vi.fn((fn) => fn),
}));
// @vitest-environment jsdom
+1 -1
View File
@@ -2,7 +2,7 @@ import { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
import { EngineType } from 'embla-carousel/components/Engine';
import { LooseOptionsType } from 'embla-carousel/components/Options';
import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler';
import merge from 'lodash-es/merge';
import { merge } from 'lodash-es';
import { vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
+2 -2
View File
@@ -1,9 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { homeAssistantSignPath } from '../../src/ha/sign-path.js';
import { convertEndpointAddressToSignedWebsocket } from '../../src/utils/endpoint';
import { homeAssistantSignPath } from '../../src/utils/ha';
import { createHASS } from '../test-utils';
vi.mock('../../src/utils/ha');
vi.mock('../../src/ha/sign-path.js');
describe('convertEndpointAddressToSignedWebsocket', () => {
it('without signing', async () => {
-78
View File
@@ -1,78 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { HomeAssistant } from '../../../src/ha/types.js';
import {
canonicalizeHAURL,
hasHAConnectionStateChanged,
isHARelativeURL,
} from '../../../src/utils/ha/index.js';
import { createHASS } from '../../test-utils.js';
const createConnected = (connected: boolean): HomeAssistant => {
const hass = createHASS();
hass.connected = connected;
return hass;
};
describe('hasHAConnectionStateChanged', () => {
it('initially connected', () => {
expect(hasHAConnectionStateChanged(null, createConnected(true))).toBeTruthy();
});
it('initially disconnected', () => {
expect(hasHAConnectionStateChanged(null, createConnected(false))).toBeTruthy();
});
it('disconnected', () => {
expect(
hasHAConnectionStateChanged(createConnected(true), createConnected(false)),
).toBeTruthy();
});
it('disconnected via absence', () => {
expect(hasHAConnectionStateChanged(createConnected(true), null)).toBeTruthy();
});
it('connected', () => {
expect(
hasHAConnectionStateChanged(createConnected(false), createConnected(true)),
).toBeTruthy();
});
it('still disconnected', () => {
expect(
hasHAConnectionStateChanged(createConnected(false), createConnected(false)),
).toBeFalsy();
});
it('still connected', () => {
expect(
hasHAConnectionStateChanged(createConnected(true), createConnected(true)),
).toBeFalsy();
});
it('still absent', () => {
expect(hasHAConnectionStateChanged(null, null)).toBeFalsy();
});
});
describe('isHARelativeURL', () => {
it('should return true when URL is HA relative', () => {
expect(isHARelativeURL('/api/foo')).toBeTruthy();
});
it('should return false when URL is not HA relative', () => {
expect(isHARelativeURL('http://localhost/api/foo')).toBeFalsy();
});
});
describe('canonicalizeHAURL', () => {
it('should return canonicalized HA url', () => {
const hass = createHASS();
vi.mocked(hass.hassUrl).mockReturnValue('http://localhost:8123/foo/bar');
expect(canonicalizeHAURL(hass, '/api/foo')).toBe('http://localhost:8123/foo/bar');
});
it('should return untouched URL when not HA relative', () => {
expect(canonicalizeHAURL(createHASS(), 'http://localhost:8123/foo/bar')).toBe(
'http://localhost:8123/foo/bar',
);
});
it('should return null without a URL', () => {
expect(canonicalizeHAURL(createHASS())).toBeNull();
});
});
-49
View File
@@ -1,49 +0,0 @@
import { describe, expect, it } from 'vitest';
import { RegistryCache } from '../../../../src/utils/ha/registry/cache';
interface TestCacheValue {
id: string;
val?: number;
}
describe('RegistryCache', () => {
describe('has', () => {
it('positive', () => {
const cache = new RegistryCache<TestCacheValue>((arg) => arg.id);
cache.add({ id: 'test' });
expect(cache.has('test')).toBeTruthy();
});
it('negative', () => {
const cache = new RegistryCache<TestCacheValue>((arg) => arg.id);
cache.add({ id: 'test' });
expect(cache.has('absent')).toBeFalsy();
});
});
it('getMatches', () => {
const cache = new RegistryCache<TestCacheValue>((arg) => arg.id);
cache.add([
{ id: 'test-1', val: 1 },
{ id: 'test-5', val: 5 },
{ id: 'test-8', val: 8 },
]);
expect(cache.getMatches((obj) => !!obj.val && obj.val >= 5)).toEqual([
{ id: 'test-5', val: 5 },
{ id: 'test-8', val: 8 },
]);
});
describe('get', () => {
it('positive', () => {
const cache = new RegistryCache<TestCacheValue>((arg) => arg.id);
cache.add({ id: 'test', val: 42 });
expect(cache.get('test')).toEqual({ id: 'test', val: 42 });
});
it('negative', () => {
const cache = new RegistryCache<TestCacheValue>((arg) => arg.id);
expect(cache.get('test')).toBeNull();
});
});
});
+3 -3
View File
@@ -7,7 +7,7 @@ import {
hasCameraTruePTZ,
ptzActionToCapabilityKey,
} from '../../src/utils/ptz';
import { MediaQueriesResults } from '../../src/view/media-queries-results';
import { QueryResults } from '../../src/view/query-results';
import {
TestViewMedia,
createCameraManager,
@@ -21,7 +21,7 @@ describe('getPTZTarget', () => {
const media = [new TestViewMedia({ id: 'media-id' })];
const view = createView({
view: 'media',
queryResults: new MediaQueriesResults({ results: media, selectedIndex: 0 }),
queryResults: new QueryResults({ results: media, selectedIndex: 0 }),
});
expect(getPTZTarget(view, { cameraManager: createCameraManager() })).toEqual({
targetID: 'media-id',
@@ -40,7 +40,7 @@ describe('getPTZTarget', () => {
const media = [new TestViewMedia({ id: 'media-id' })];
const view = createView({
view: 'media',
queryResults: new MediaQueriesResults({ results: media, selectedIndex: 0 }),
queryResults: new QueryResults({ results: media, selectedIndex: 0 }),
});
expect(
getPTZTarget(view, { type: 'ptz', cameraManager: createCameraManager() }),
+3 -3
View File
@@ -5,7 +5,7 @@ import {
screenshotImage,
screenshotVideo,
} from '../../src/utils/screenshot';
import { MediaQueriesResults } from '../../src/view/media-queries-results';
import { QueryResults } from '../../src/view/query-results';
import { View } from '../../src/view/view';
import { TestViewMedia, createView } from '../test-utils';
@@ -103,7 +103,7 @@ describe('generateScreenshotTitle', () => {
const view = createView({
view: 'media',
camera: 'camera-1',
queryResults: new MediaQueriesResults({ results: [media], selectedIndex: 0 }),
queryResults: new QueryResults({ results: [media], selectedIndex: 0 }),
});
expect(generateScreenshotFilename(view)).toBe('media_camera-1_id1.jpg');
@@ -118,7 +118,7 @@ describe('generateScreenshotTitle', () => {
const view = createView({
view: 'media',
camera: 'camera-1',
queryResults: new MediaQueriesResults({ results: [media], selectedIndex: 0 }),
queryResults: new QueryResults({ results: [media], selectedIndex: 0 }),
});
expect(generateScreenshotFilename(view)).toBe('media_camera-1.jpg');

Some files were not shown because too many files have changed in this diff Show More