fix: Timeline should should show folder media even without camera support (#2216)

- Closes #2205
This commit is contained in:
Dermot Duffy
2025-10-13 21:15:32 -07:00
committed by GitHub
parent ebbd219db7
commit 7edaac079e
19 changed files with 236 additions and 123 deletions
+3 -3
View File
@@ -103,10 +103,10 @@
};
</script>
<!-- Docsify preview (necessary for Google Analytics 4) -->
<!-- Update when there's a docsify release after
https://github.com/docsifyjs/docsify/pull/1702 -->
<!-- Docsify core -->
<script src="./js/docsify.min.js"></script>
<!-- Google tag (gtag.js) - Google Analytics 4 -->
<script src="./js/gtag.min.js"></script>
<!-- Prism Syntax Highlighting for YAML -->
+19 -12
View File
@@ -3,7 +3,11 @@ import { ViewDisplayMode } from '../../config/schema/common/display';
import { AdvancedCameraCardConfig } from '../../config/schema/types';
import { localize } from '../../localize/localize';
import { View, ViewParameters } from '../../view/view';
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
import {
getCameraIDsForViewName,
isViewSupportedByCamera,
isViewSupportedByQueryOnly,
} from '../../view/view-support';
import { CardViewAPI } from '../types';
import { applyViewModifiers } from './modifiers';
import { ViewFactoryOptions, ViewIncompatible, ViewNoCameraError } from './types';
@@ -32,7 +36,7 @@ export class ViewFactory {
cameraID = options.params.camera;
} else {
const cameraIDs = [
...getCameraIDsForViewName(this._api.getCameraManager(), viewName),
...getCameraIDsForViewName(viewName, this._api.getCameraManager()),
];
if (
@@ -73,8 +77,8 @@ export class ViewFactory {
if (!cameraID || !allCameraIDs.has(cameraID)) {
const viewCameraIDs = getCameraIDsForViewName(
this._api.getCameraManager(),
viewName,
this._api.getCameraManager(),
);
// Reset to the default camera.
@@ -98,8 +102,18 @@ export class ViewFactory {
});
}
if (!this.isViewSupportedByCamera(cameraID, viewName)) {
if (options?.failSafe && this.isViewSupportedByCamera(cameraID, VIEW_DEFAULT)) {
if (
!isViewSupportedByCamera(viewName, this._api.getCameraManager(), cameraID) &&
!isViewSupportedByQueryOnly(
viewName,
options?.params?.query ?? options?.baseView?.query,
options?.params?.queryResults ?? options?.baseView?.queryResults,
)
) {
if (
options?.failSafe &&
isViewSupportedByCamera(VIEW_DEFAULT, this._api.getCameraManager(), cameraID)
) {
viewName = VIEW_DEFAULT;
} else {
const capabilities = this._api
@@ -142,13 +156,6 @@ export class ViewFactory {
return view;
}
public isViewSupportedByCamera(
cameraID: string,
view: AdvancedCameraCardView,
): boolean {
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
}
protected _getDefaultDisplayModeForView(
viewName: AdvancedCameraCardView,
config: AdvancedCameraCardConfig,
-2
View File
@@ -1,5 +1,4 @@
import { ViewContext } from 'view';
import { AdvancedCameraCardView } from '../../config/schema/common/const.js';
import { AdvancedCameraCardError } from '../../types.js';
import { ViewItem } from '../../view/item.js';
import { QueryResults } from '../../view/query-results.js';
@@ -73,7 +72,6 @@ export interface ViewManagerInterface {
setViewWithMergedContext(context: ViewContext | null): void;
isViewSupportedByCamera(cameraID: string, view: AdvancedCameraCardView): boolean;
hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean;
}
-9
View File
@@ -1,10 +1,8 @@
import { ViewContext } from 'view';
import { AdvancedCameraCardView } from '../../config/schema/common/const';
import { log } from '../../utils/debug';
import { getStreamCameraID } from '../../utils/substream';
import { QueryClassifier } from '../../view/query-classifier';
import { View } from '../../view/view';
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
import { InitializationAspect } from '../initialization-manager';
import { CardViewAPI } from '../types';
import { ViewFactory } from './factory';
@@ -257,13 +255,6 @@ export class ViewManager implements ViewManagerInterface {
}
}
public isViewSupportedByCamera(
cameraID: string,
view: AdvancedCameraCardView,
): boolean {
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
}
/**
* Detect if the current view has a major "media change" for the given previous view.
* @param oldView The previous view.
+28 -20
View File
@@ -29,7 +29,11 @@ import { getStreamCameraID, hasSubstream } from '../utils/substream';
import { ViewItemClassifier } from '../view/item-classifier';
import { QueryClassifier } from '../view/query-classifier';
import { View } from '../view/view';
import { getCameraIDsForViewName } from '../view/view-to-cameras';
import {
getCameraIDsForViewName,
isViewSupportedByCamera,
isViewSupportedByQueryOnly,
} from '../view/view-support';
export interface MenuButtonControllerOptions {
currentMediaLoadedInfo?: MediaLoadedInfo | null;
@@ -73,12 +77,12 @@ export class MenuButtonController {
this._getIrisButton(config),
this._getCamerasButton(config, cameraManager, options?.view),
this._getSubstreamsButton(config, cameraManager, options?.view),
this._getLiveButton(config, options?.view, options?.viewManager),
this._getClipsButton(config, options?.view, options?.viewManager),
this._getSnapshotsButton(config, options?.view, options?.viewManager),
this._getRecordingsButton(config, options?.view, options?.viewManager),
this._getImageButton(config, options?.view, options?.viewManager),
this._getTimelineButton(config, options?.view, options?.viewManager),
this._getLiveButton(config, cameraManager, options?.view),
this._getClipsButton(config, cameraManager, options?.view),
this._getSnapshotsButton(config, cameraManager, options?.view),
this._getRecordingsButton(config, cameraManager, options?.view),
this._getImageButton(config, cameraManager, options?.view),
this._getTimelineButton(config, cameraManager, options?.view),
this._getDownloadButton(config, cameraManager, options?.view),
this._getCameraUIButton(config, options?.showCameraUIButton),
this._getMicrophoneButton(
@@ -227,10 +231,10 @@ export class MenuButtonController {
protected _getLiveButton(
config: AdvancedCameraCardConfig,
cameraManager: CameraManager,
view?: View | null,
viewManager?: ViewManager | null,
): MenuItem | null {
return view && viewManager?.isViewSupportedByCamera(view.camera, 'live')
return view && isViewSupportedByCamera('live', cameraManager, view.camera)
? {
icon: 'mdi:cctv',
...config.menu.buttons.live,
@@ -244,10 +248,10 @@ export class MenuButtonController {
protected _getClipsButton(
config: AdvancedCameraCardConfig,
cameraManager: CameraManager,
view?: View | null,
viewManager?: ViewManager | null,
): MenuItem | null {
return view && viewManager?.isViewSupportedByCamera(view.camera, 'clips')
return view && isViewSupportedByCamera('clips', cameraManager, view.camera)
? {
icon: 'mdi:filmstrip',
...config.menu.buttons.clips,
@@ -262,10 +266,10 @@ export class MenuButtonController {
protected _getSnapshotsButton(
config: AdvancedCameraCardConfig,
cameraManager: CameraManager,
view?: View | null,
viewManager?: ViewManager | null,
): MenuItem | null {
return view && viewManager?.isViewSupportedByCamera(view.camera, 'snapshots')
return view && isViewSupportedByCamera('snapshots', cameraManager, view.camera)
? {
icon: 'mdi:camera',
...config.menu.buttons.snapshots,
@@ -280,10 +284,10 @@ export class MenuButtonController {
protected _getRecordingsButton(
config: AdvancedCameraCardConfig,
cameraManager: CameraManager,
view?: View | null,
viewManager?: ViewManager | null,
): MenuItem | null {
return view && viewManager?.isViewSupportedByCamera(view.camera, 'recordings')
return view && isViewSupportedByCamera('recordings', cameraManager, view.camera)
? {
icon: 'mdi:album',
...config.menu.buttons.recordings,
@@ -298,10 +302,10 @@ export class MenuButtonController {
protected _getImageButton(
config: AdvancedCameraCardConfig,
cameraManager: CameraManager,
view?: View | null,
viewManager?: ViewManager | null,
): MenuItem | null {
return view && viewManager?.isViewSupportedByCamera(view.camera, 'image')
return view && isViewSupportedByCamera('image', cameraManager, view.camera)
? {
icon: 'mdi:image',
...config.menu.buttons.image,
@@ -315,10 +319,14 @@ export class MenuButtonController {
protected _getTimelineButton(
config: AdvancedCameraCardConfig,
cameraManager: CameraManager,
view?: View | null,
viewManager?: ViewManager | null,
): MenuItem | null {
return view && viewManager?.isViewSupportedByCamera(view.camera, 'timeline')
const shouldShowTimelineButton =
view &&
(isViewSupportedByCamera('timeline', cameraManager, view.camera) ||
isViewSupportedByQueryOnly('timeline', view.query, view.queryResults));
return shouldShowTimelineButton
? {
icon: 'mdi:chart-gantt',
...config.menu.buttons.timeline,
@@ -545,7 +553,7 @@ export class MenuButtonController {
view?: View | null,
): MenuItem | null {
const viewCameraIDs = view
? getCameraIDsForViewName(cameraManager, view.view)
? getCameraIDsForViewName(view.view, cameraManager)
: null;
if (
view?.supportsMultipleDisplayModes() &&
+6 -1
View File
@@ -537,8 +537,13 @@ export class TimelineController {
.resetSelectedResult()
.selectResultIfFound((media) => media.getID() === properties.item, criteria);
const selectedItem = newResults?.getSelectedResult();
const context: ViewContext = mergeViewContext(this._getTimelineContext(), {
mediaViewer: { seek: properties.time },
...(ViewItemClassifier.isEvent(selectedItem) &&
// Only attempt to seek if the event has a real end time, otherwise
// the viewer cannot actually seek there and shows the unseekable
// message.
selectedItem.getEndTime() && { mediaViewer: { seek: properties.time } }),
});
if (!newResults || !newResults.hasSelectedResult()) {
+13 -1
View File
@@ -29,6 +29,7 @@ import { contentsChanged } from '../utils/basic';
import './date-picker.js';
import { AdvancedCameraCardDatePicker, DatePickerEvent } from './date-picker.js';
import './icon';
import { renderMessage } from './message';
import './thumbnail/thumbnail.js';
/**
@@ -139,7 +140,18 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
protected _controller: TimelineController = new TimelineController(this);
protected render(): TemplateResult | void {
if (!this.hass || !this.timelineConfig || !this.keys?.length) {
if (!this.hass || !this.timelineConfig) {
return;
}
if (!this.keys.length) {
if (!this.mini) {
return renderMessage({
message: localize('error.no_camera_or_media_for_timeline'),
icon: 'mdi:chart-gantt',
type: 'info',
});
}
return;
}
+22
View File
@@ -8,6 +8,7 @@ import { TimelineConfig } from '../config/schema/timeline';
import { CardWideConfig } from '../config/schema/types';
import { HomeAssistant } from '../ha/types';
import basicBlockStyle from '../scss/basic-block.scss';
import { QueryClassifier } from '../view/query-classifier';
import './surround.js';
import './timeline-core.js';
@@ -32,6 +33,27 @@ export class AdvancedCameraCardTimeline extends LitElement {
public cardWideConfig?: CardWideConfig;
protected _getKeys(): TimelineKey[] {
const query = this.viewManagerEpoch?.manager.getView()?.query;
// If there's a query, try to extract camera IDs or folder info from it.
if (QueryClassifier.isMediaQuery(query)) {
const cameraIDs = query.getQueryCameraIDs();
if (cameraIDs && cameraIDs.size) {
return [...cameraIDs].map((cameraID) => ({ type: 'camera', cameraID }));
}
} else if (QueryClassifier.isFolderQuery(query)) {
const folderConfig = query.getQuery()?.folder;
if (folderConfig) {
return [
{
type: 'folder' as const,
folder: folderConfig,
},
];
}
}
// Otherwise fall back to all cameras that support media queries.
const keys: TimelineKey[] = [];
for (const camera of this.cameraManager?.getStore().getCameraIDsWithCapability({
anyCapabilities: ['clips', 'snapshots', 'recordings'],
+1
View File
@@ -699,6 +699,7 @@
"no_camera_entity": "No s'ha pogut trobar l'entitat de la càmera",
"no_camera_entity_for_triggers": "Es requereix una entitat de càmera per detectar automàticament els activadors",
"no_camera_id": "No s'ha pogut determinar l'identificador de la càmera per a la següent càmera, és possible que hagis d'establir el paràmetre 'id' manualment",
"no_camera_or_media_for_timeline": "",
"no_dashboard_or_view": "Tant els paràmetres 'dashboard_path' com 'view_path' són necessaris per al mètode d'emissió 'dashboard'",
"no_live_camera": "El paràmetre 'camera_entity' s'ha de configurar i validar per a aquest proveïdor en directe",
"no_supported_camera": "",
+2 -1
View File
@@ -699,9 +699,10 @@
"no_camera_entity": "Could not find camera entity",
"no_camera_entity_for_triggers": "A camera entity is required in order to autodetect triggers",
"no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually",
"no_camera_or_media_for_timeline": "No camera or media available for timeline",
"no_dashboard_or_view": "Both 'dashboard_path' and 'view_path' parameters are required for the 'dashboard' cast method",
"no_live_camera": "The camera_entity parameter must be set and valid for this live provider",
"no_supported_camera": "The selected camera does not support this view",
"no_supported_camera": "The selected camera or media does not support this view",
"no_supported_cameras": "No cameras support this view",
"reconnecting": "Reconnecting",
"stream_not_loading": "The video stream has not yet loaded. This is could for any number of reasons. If configured (and by default), there will be an image refreshing every second until the stream loads correctly",
+1
View File
@@ -699,6 +699,7 @@
"no_camera_entity": "Impossible de trouver l'entité de caméra",
"no_camera_entity_for_triggers": "Une entité caméra est requise pour détecter automatiquement les déclencheurs",
"no_camera_id": "Impossible de déterminer l'identifiant de la caméra suivante. Il faudra peut-être définir le paramètre « ID » manuellement",
"no_camera_or_media_for_timeline": "",
"no_dashboard_or_view": "Les paramètres 'dashboard_path' et 'view_path' sont requis pour la méthode de conversion 'dashboard'",
"no_live_camera": "Le paramètre camera_entity doit être défini et valide pour ce fournisseur en direct",
"no_supported_camera": "La caméra sélectionnée ne prend pas en charge cette vue",
+1
View File
@@ -699,6 +699,7 @@
"no_camera_entity": "Impossibile trovare l'entità fotocamera",
"no_camera_entity_for_triggers": "È necessaria un'entità telecamera per rilevare automaticamente i trigger",
"no_camera_id": "Impossibile determinare l'ID della telecamera , potrebbe essere necessario impostare manualmente il parametro 'ID'",
"no_camera_or_media_for_timeline": "",
"no_dashboard_or_view": "",
"no_live_camera": "Il parametro fotocamera_enty deve essere impostato e valido per questo provider live",
"no_supported_camera": "",
+1
View File
@@ -699,6 +699,7 @@
"no_camera_entity": "Não foi possível encontrar a entidade da câmera",
"no_camera_entity_for_triggers": "Uma entidade de câmera é necessária para detectar automaticamente os gatilhos",
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
"no_camera_or_media_for_timeline": "",
"no_dashboard_or_view": "",
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo",
"no_supported_camera": "",
+1
View File
@@ -699,6 +699,7 @@
"no_camera_entity": "Não existe uma entidade câmera",
"no_camera_entity_for_triggers": "Não existe camera para a acção",
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
"no_camera_or_media_for_timeline": "",
"no_dashboard_or_view": "",
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este serviço ativo",
"no_supported_camera": "",
@@ -1,14 +1,16 @@
import { CameraManager } from '../camera-manager/manager';
import { CapabilitySearchOptions } from '../camera-manager/types';
import { AdvancedCameraCardView } from '../config/schema/common/const';
import { Query } from './query';
import { QueryResults } from './query-results';
/**
* Get cameraIDs that are relevant for a given view name based on camera
* capability (if camera specified).
*/
export const getCameraIDsForViewName = (
cameraManager: CameraManager,
viewName: AdvancedCameraCardView,
cameraManager: CameraManager,
cameraID?: string,
): Set<string> => {
switch (viewName) {
@@ -47,3 +49,27 @@ export const getCameraIDsForViewName = (
});
}
};
export const isViewSupportedByCamera = (
view: AdvancedCameraCardView,
cameraManager: CameraManager,
cameraID: string,
): boolean => {
return !!getCameraIDsForViewName(view, cameraManager, cameraID).size;
};
/**
* Whether a view is supported by a given query ONLY (i.e. regardless of what
* the camera supports).
*/
export const isViewSupportedByQueryOnly = (
view: AdvancedCameraCardView,
query?: Query | null,
queryResults?: QueryResults | null,
): boolean => {
switch (view) {
case 'timeline':
return !!query?.getQuery() && !!queryResults?.hasResults();
}
return false;
};
+1 -1
View File
@@ -253,7 +253,7 @@ describe('getViewByParameters', () => {
view: 'snapshots',
},
}),
).toThrowError(/The selected camera does not support this view/);
).toThrowError(/The selected camera or media does not support this view/);
});
it('should choose live view with failsafe', () => {
@@ -11,18 +11,11 @@ import {
} from '../../../src/card-controller/view/types';
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, 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,
createCapabilities,
createCardAPI,
createStore,
createView,
} from '../../test-utils';
import { createCardAPI, createView } from '../../test-utils';
const createInitializedCardAPI = (initialized?: boolean): CardController => {
const api = createCardAPI();
@@ -301,44 +294,6 @@ describe('should handle exceptions', () => {
});
});
describe('isViewSupportedByCamera', () => {
it.each([
['live' as const, false],
['image' as const, true],
['diagnostics' as const, true],
['clip' as const, false],
['clips' as const, false],
['snapshot' as const, false],
['snapshots' as const, false],
['recording' as const, false],
['recordings' as const, false],
['timeline' as const, false],
['media' as const, true],
])('%s', (viewName: AdvancedCameraCardView, expected: boolean) => {
const api = createInitializedCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({
live: false,
'favorite-events': false,
'favorite-recordings': false,
seek: false,
clips: false,
recordings: false,
snapshots: false,
}),
},
]),
);
const manager = new ViewManager(api);
expect(manager.isViewSupportedByCamera('camera', viewName)).toBe(expected);
});
});
describe('hasMajorMediaChange', () => {
it('should consider undefined views as major', () => {
const manager = new ViewManager(createCardAPI());
@@ -23,6 +23,10 @@ import { createGeneralAction, createViewAction } from '../../src/utils/action.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 {
getCameraIDsForViewName,
isViewSupportedByCamera,
} from '../../src/view/view-support.js';
import { View } from '../../src/view/view.js';
import {
createCameraConfig,
@@ -39,6 +43,7 @@ import {
TestViewMedia,
} from '../test-utils.js';
vi.mock('../../src/view/view-support.js');
vi.mock('../../src/utils/media-player-controller.js');
vi.mock('../../src/card-controller/microphone-manager.js');
@@ -498,7 +503,7 @@ describe('MenuButtonController', () => {
describe('should have live menu button', () => {
it('when in live view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
view: createView({ view: 'live' }),
viewManager: viewManager,
@@ -517,7 +522,7 @@ describe('MenuButtonController', () => {
it('when not in live view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
view: createView({ view: 'clips' }),
viewManager: viewManager,
@@ -536,7 +541,7 @@ describe('MenuButtonController', () => {
it('when not supported', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false);
vi.mocked(isViewSupportedByCamera).mockReturnValue(false);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
@@ -552,7 +557,7 @@ describe('MenuButtonController', () => {
describe('should have clips menu button', () => {
it('when in clips view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
view: createView({ view: 'clips' }),
viewManager: viewManager,
@@ -572,7 +577,7 @@ describe('MenuButtonController', () => {
it('when not in clips view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
@@ -591,7 +596,7 @@ describe('MenuButtonController', () => {
it('when not supported', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false);
vi.mocked(isViewSupportedByCamera).mockReturnValue(false);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
@@ -605,7 +610,7 @@ describe('MenuButtonController', () => {
describe('should have snapshots menu button', () => {
it('when in snapshots view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
view: createView({ view: 'snapshots' }),
viewManager: viewManager,
@@ -631,7 +636,7 @@ describe('MenuButtonController', () => {
it('when not in snapshots view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
@@ -656,7 +661,7 @@ describe('MenuButtonController', () => {
it('when not supported', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false);
vi.mocked(isViewSupportedByCamera).mockReturnValue(false);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
@@ -672,7 +677,7 @@ describe('MenuButtonController', () => {
describe('should have recordings menu button', () => {
it('when in recordings view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
view: createView({ view: 'recordings' }),
viewManager: viewManager,
@@ -698,7 +703,7 @@ describe('MenuButtonController', () => {
it('when not in recordings view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
@@ -723,7 +728,7 @@ describe('MenuButtonController', () => {
it('when not supported', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false);
vi.mocked(isViewSupportedByCamera).mockReturnValue(false);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
@@ -739,7 +744,7 @@ describe('MenuButtonController', () => {
describe('should have image menu button', () => {
it('when in image view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
view: createView({ view: 'image' }),
@@ -759,7 +764,7 @@ describe('MenuButtonController', () => {
it('when not in image view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
view: createView({ view: 'live' }),
@@ -779,7 +784,7 @@ describe('MenuButtonController', () => {
it('when not supported', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false);
vi.mocked(isViewSupportedByCamera).mockReturnValue(false);
const buttons = calculateButtons(controller, {
viewManager: viewManager,
});
@@ -795,7 +800,7 @@ describe('MenuButtonController', () => {
describe('should have timeline button', () => {
it('when in timeline view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
view: createView({ view: 'timeline' }),
viewManager: viewManager,
@@ -817,7 +822,7 @@ describe('MenuButtonController', () => {
it('when not in timeline view', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(isViewSupportedByCamera).mockReturnValue(true);
const buttons = calculateButtons(controller, {
view: createView({ view: 'live' }),
viewManager: viewManager,
@@ -839,7 +844,7 @@ describe('MenuButtonController', () => {
it('when not supported', () => {
const viewManager = mock<ViewManager>();
vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false);
vi.mocked(isViewSupportedByCamera).mockReturnValue(false);
const buttons = calculateButtons(controller, {
view: createView({ view: 'live' }),
viewManager: viewManager,
@@ -1476,6 +1481,10 @@ describe('MenuButtonController', () => {
]),
);
vi.mocked(getCameraIDsForViewName).mockReturnValue(
new Set(['camera-1', 'camera-2']),
);
expect(
calculateButtons(controller, { cameraManager: cameraManager, view: view }),
).toContainEqual({
@@ -1,12 +1,20 @@
import { describe, expect, it, vi } from 'vitest';
import { QueryType } from '../../src/camera-manager/types';
import { AdvancedCameraCardView } from '../../src/config/schema/common/const';
import { CapabilityKey } from '../../src/types';
import { getCameraIDsForViewName } from '../../src/view/view-to-cameras';
import { EventMediaQuery } from '../../src/view/query';
import { QueryResults } from '../../src/view/query-results';
import {
getCameraIDsForViewName,
isViewSupportedByCamera,
isViewSupportedByQueryOnly,
} from '../../src/view/view-support';
import {
createCameraConfig,
createCameraManager,
createCapabilities,
createStore,
generateViewMediaArray,
} from '../test-utils';
describe('getCameraIDsForViewName', () => {
@@ -29,13 +37,13 @@ describe('getCameraIDsForViewName', () => {
]),
);
expect(getCameraIDsForViewName(cameraManager, viewName)).toEqual(
expect(getCameraIDsForViewName(viewName, cameraManager)).toEqual(
new Set(['camera-1', 'camera-2']),
);
expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-1')).toEqual(
expect(getCameraIDsForViewName(viewName, cameraManager, 'camera-1')).toEqual(
new Set(['camera-1', 'camera-2']),
);
expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-2')).toEqual(
expect(getCameraIDsForViewName(viewName, cameraManager, 'camera-2')).toEqual(
new Set(['camera-1', 'camera-2']),
);
});
@@ -62,7 +70,7 @@ describe('getCameraIDsForViewName', () => {
]),
);
expect(getCameraIDsForViewName(cameraManager, viewName)).toEqual(
expect(getCameraIDsForViewName(viewName, cameraManager)).toEqual(
new Set(['camera-2']),
);
});
@@ -89,15 +97,81 @@ describe('getCameraIDsForViewName', () => {
]),
);
expect(getCameraIDsForViewName(cameraManager, viewName)).toEqual(
expect(getCameraIDsForViewName(viewName, cameraManager)).toEqual(
new Set(['camera-1', 'camera-2']),
);
expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-1')).toEqual(
expect(getCameraIDsForViewName(viewName, cameraManager, 'camera-1')).toEqual(
new Set(['camera-1', 'camera-2']),
);
expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-2')).toEqual(
expect(getCameraIDsForViewName(viewName, cameraManager, 'camera-2')).toEqual(
new Set(['camera-2']),
);
});
});
});
describe('isViewSupportedByCamera', () => {
it('should return true for supported view', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([
{
cameraID: 'camera-1',
capabilities: createCapabilities({ live: true }),
},
]),
);
expect(isViewSupportedByCamera('live', cameraManager, 'camera-1')).toBe(true);
});
it('should return false for unsupported view', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([
{
cameraID: 'camera-1',
capabilities: createCapabilities({ live: false }),
},
]),
);
expect(isViewSupportedByCamera('live', cameraManager, 'camera-1')).toBe(false);
});
});
describe('isViewSupportedByQueryOnly', () => {
it.each([
['live' as const],
['image' as const],
['diagnostics' as const],
['clip' as const],
['clips' as const],
['snapshot' as const],
['snapshots' as const],
['recording' as const],
['recordings' as const],
['media' as const],
])('%s', (viewName: AdvancedCameraCardView) => {
expect(isViewSupportedByQueryOnly(viewName)).toBe(false);
});
it('should return false for timeline without query', () => {
expect(isViewSupportedByQueryOnly('timeline')).toBe(false);
});
it('should return true for timeline with query and query results', () => {
const query = new EventMediaQuery([
{
type: QueryType.Event,
cameraIDs: new Set(['camera-1']),
},
]);
const queryResults = new QueryResults({
results: generateViewMediaArray({ count: 5 }),
selectedIndex: 0,
});
expect(isViewSupportedByQueryOnly('timeline', query, queryResults)).toBe(true);
});
});