Files
advanced-camera-card/tests/card-controller/view/view-manager.test.ts
T
Dermot Duffy abcba884e5 feat: Add 'call' support to improve 2-way audio experience (#2486)
Draws significant inspiration (and direct styling) from
https://github.com/dermotduffy/advanced-camera-card/pull/2447 . Thank
you @Maudfer !

BREAKING CHANGE:

The microphone condition previously bundled two unrelated signals —
whether a two-way-audio session was connected and whether the microphone
was muted. Connection state is now its own dedicated call condition, and
microphone is reserved purely for mute state. Configs are upgraded
automatically (the card rewrites affected conditions under overrides,
elements, and automations). If you maintain config by hand, convert as
follows:

If you only used connected:

# Before
```yaml
condition: microphone
connected: true
```

# After
```yaml
condition: call
call: true
```
If you used both connected and muted — they must be split into two
conditions, since they no longer live together:

# Before
```yaml
condition: microphone
connected: true
muted: false
```

# After
```yaml
condition: and
conditions:
  - condition: call
    call: true
  - condition: microphone
    muted: false
```
2026-06-30 17:45:13 -07:00

1004 lines
34 KiB
TypeScript

import { ViewContext } from 'view';
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
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 {
QueryExecutorOptions,
ViewModifier,
} 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 { QueryResults } from '../../../src/view/query-results';
import { UnifiedQuery } from '../../../src/view/unified-query';
import { View } from '../../../src/view/view';
import { createCardAPI, createEventQuery, 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('should set basic view', () => {
const view = createView({
view: 'live',
camera: 'camera',
displayMode: 'grid',
});
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(view);
const api = createInitializedCardAPI();
const manager = new ViewManager(api, { viewFactory: factory });
manager.setViewDefault();
expect(manager.getView()).toBe(view);
expect(manager.hasView()).toBeTruthy();
expect(api.getMediaLoadedInfoManager().setSelected).toBeCalledWith('camera');
expect(api.getCardElementManager().scrollReset).toBeCalled();
expect(api.getStyleManager().setExpandedMode).toBeCalled();
expect(api.getConditionStateManager()?.setState).toBeCalledWith({
view: 'live',
camera: 'camera',
displayMode: 'grid',
targetID: 'camera',
substreamID: undefined,
});
expect(api.getCardElementManager().update).toBeCalled();
});
it('should set the engaged substream in condition state', () => {
const view = createView({
view: 'live',
camera: 'camera',
context: { live: { overrides: new Map([['camera', 'substream']]) } },
});
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(view);
const api = createInitializedCardAPI();
new ViewManager(api, { viewFactory: factory }).setViewDefault();
expect(api.getConditionStateManager()?.setState).toBeCalledWith(
expect.objectContaining({ substreamID: 'substream' }),
);
});
it('should set view with minor changes without scroll', () => {
const view_1 = createView({
view: 'live',
camera: 'camera',
});
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(view_1);
const api = createInitializedCardAPI();
const manager = new ViewManager(api, { viewFactory: factory });
manager.setViewDefault();
vi.mocked(api.getCardElementManager().scrollReset).mockClear();
const view_2 = createView({
view: 'live',
camera: 'camera',
displayMode: 'single',
});
factory.getViewDefault.mockReturnValue(view_2);
manager.setViewDefault();
expect(manager.getView()).toBe(view_2);
// Same view name, so scrolling should not happen.
expect(api.getCardElementManager().scrollReset).not.toBeCalled();
});
});
it('should set view with merged context', () => {
const api = createInitializedCardAPI();
const factory = mock<ViewFactory>();
const manager = new ViewManager(api, { viewFactory: factory });
const context: ViewContext = { timeline: {} };
// Setting context with no existing view does nothing.
manager.setViewWithMergedContext(context);
expect(manager.getView()).toBeNull();
const view = createView({
view: 'live',
camera: 'camera',
});
factory.getViewDefault.mockReturnValue(view);
manager.setViewDefault();
manager.setViewWithMergedContext(context);
expect(manager.getView()?.camera).toBe('camera');
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.context).toEqual(context);
});
it('should return epoch', () => {
const factory = mock<ViewFactory>();
const manager = new ViewManager(createCardAPI(), { viewFactory: factory });
expect(manager.getEpoch()).toBeTruthy();
expect(manager.getEpoch().manager).toBe(manager);
});
it('should reset view', () => {
const factory = mock<ViewFactory>();
const manager = new ViewManager(createInitializedCardAPI(), { viewFactory: factory });
manager.reset();
expect(manager.getView()).toBeNull();
expect(manager.hasView()).toBeFalsy();
factory.getViewDefault.mockReturnValue(createView());
manager.setViewDefault();
expect(manager.getView()).not.toBeNull();
expect(manager.hasView()).toBeTruthy();
manager.reset();
expect(manager.getView()).toBeNull();
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();
});
});
describe('should respect microphone navigation lock', () => {
it('should ignore generic view changes when microphone is locking navigation', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView());
const api = createInitializedCardAPI();
vi.mocked(api.getLockManager().isLocked).mockReturnValue(true);
const manager = new ViewManager(api, { viewFactory: factory });
manager.setViewDefault();
expect(manager.getView()).toBeNull();
});
it('should ignore async view changes when microphone is locking navigation', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const api = createInitializedCardAPI();
vi.mocked(api.getLockManager().isLocked).mockReturnValue(true);
const manager = new ViewManager(api, {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()).toBeNull();
});
it('should allow generic view changes when force is set', () => {
const view = createView();
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(view);
const api = createInitializedCardAPI();
vi.mocked(api.getLockManager().isLocked).mockReturnValue(true);
const manager = new ViewManager(api, { viewFactory: factory });
manager.setViewDefault({ force: true });
expect(manager.getView()).toBe(view);
});
it('should allow async view changes when force is set', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const api = createInitializedCardAPI();
vi.mocked(api.getLockManager().isLocked).mockReturnValue(true);
const manager = new ViewManager(api, {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewDefaultWithNewQuery({ force: true });
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
});
it('should set view default', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView());
const manager = new ViewManager(createInitializedCardAPI(), { viewFactory: factory });
manager.setViewDefault();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('should set view by parameters', () => {
const factory = mock<ViewFactory>();
factory.getViewByParameters.mockReturnValue(createView());
const manager = new ViewManager(createInitializedCardAPI(), { viewFactory: factory });
manager.setViewByParameters();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('should set view default with new query', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('should set view by parameters with new query', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewByParameters.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewByParametersWithNewQuery();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('should set view by parameters with existing query', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewByParameters.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewByParametersWithExistingQuery();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('should set view by parameters with an explicitly provided existing query', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewByParameters.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getExistingQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
const query = new UnifiedQuery();
await manager.setViewByParametersWithExistingQuery({ params: { query } });
// An explicitly-passed query is used as-is rather than the base view's.
expect(viewFactory.getViewByParameters).toBeCalledWith(
expect.objectContaining({ params: expect.objectContaining({ query }) }),
);
});
it('should clear the query when an explicit null query is passed', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewByParameters.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getExistingQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewByParametersWithExistingQuery({ params: { query: null } });
// An explicit `null` is respected (clears the query), not overridden by the
// base view's query.
expect(viewFactory.getViewByParameters).toBeCalledWith(
expect.objectContaining({ params: expect.objectContaining({ query: null }) }),
);
});
describe('should handle exceptions', () => {
it('should retry with failSafe when no existing view in sync calls', () => {
const viewFactory = mock<ViewFactory>();
const failSafeView = createView();
const error = new Error('message');
viewFactory.getViewDefault
.mockImplementationOnce(() => {
throw error;
})
.mockReturnValueOnce(failSafeView);
const api = createInitializedCardAPI();
const manager = new ViewManager(api, { viewFactory: viewFactory });
manager.setViewDefault();
expect(manager.hasView()).toBeTruthy();
expect(manager.getView()).toBe(failSafeView);
expect(viewFactory.getViewDefault).toBeCalledWith(
expect.objectContaining({ baseView: null, failSafe: true }),
);
expect(api.getIssueManager().trigger).toBeCalledWith('view_incompatible', {
error,
});
expect(api.getNotificationManager().setNotification).not.toBeCalled();
});
it('should not retry with failSafe when existing view in sync calls', () => {
const viewFactory = mock<ViewFactory>();
const existingView = createView();
const error = new Error('message');
viewFactory.getViewDefault
.mockReturnValueOnce(existingView)
.mockImplementationOnce(() => {
throw error;
});
const api = createInitializedCardAPI();
const manager = new ViewManager(api, { viewFactory: viewFactory });
manager.setViewDefault();
manager.setViewDefault();
expect(manager.getView()).toBe(existingView);
expect(viewFactory.getViewDefault).toBeCalledTimes(2);
expect(api.getIssueManager().trigger).toBeCalledWith('view_incompatible', {
error,
});
});
it('should retry with failSafe when no existing view in async calls', async () => {
const viewFactory = mock<ViewFactory>();
const failSafeView = createView();
const error = new Error('message');
viewFactory.getViewDefault
.mockImplementationOnce(() => {
throw error;
})
.mockReturnValueOnce(failSafeView);
const api = createInitializedCardAPI();
const manager = new ViewManager(api, { viewFactory: viewFactory });
await manager.setViewDefaultWithNewQuery();
expect(manager.hasView()).toBeTruthy();
expect(viewFactory.getViewDefault).toBeCalledWith(
expect.objectContaining({ baseView: null, failSafe: true }),
);
expect(api.getIssueManager().trigger).toBeCalledWith('view_incompatible', {
error,
});
expect(api.getNotificationManager().setNotification).not.toBeCalled();
});
it('should not retry with failSafe when existing view in async calls', async () => {
const viewFactory = mock<ViewFactory>();
const existingView = createView();
const error = new Error('message');
viewFactory.getViewDefault
.mockReturnValueOnce(existingView)
.mockImplementationOnce(() => {
throw error;
});
const api = createInitializedCardAPI();
const manager = new ViewManager(api, { viewFactory: viewFactory });
manager.setViewDefault();
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()).not.toBeNull();
expect(viewFactory.getViewDefault).toBeCalledTimes(2);
expect(api.getIssueManager().trigger).toBeCalledWith('view_incompatible', {
error,
});
});
it('should reset view_incompatible on successful view set', () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView());
const api = createInitializedCardAPI();
const manager = new ViewManager(api, { viewFactory });
manager.setViewDefault();
expect(api.getIssueManager().reset).toBeCalledWith('view_incompatible');
});
it('should return null when failSafe view factory also throws', () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockImplementation(() => {
throw new Error('message');
});
const api = createInitializedCardAPI();
const manager = new ViewManager(api, { viewFactory });
manager.setViewDefault();
expect(manager.hasView()).toBeFalsy();
expect(viewFactory.getViewDefault).toBeCalledTimes(2);
});
it('should handle viewQueryExecutor exceptions in async calls', async () => {
const error = new Error();
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockRejectedValue(error);
const api = createInitializedCardAPI();
const manager = new ViewManager(api, {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewDefaultWithNewQuery();
// The initial view will have been set.
expect(manager.hasView()).toBeTruthy();
// But an error will also be generated.
expect(api.getIssueManager().trigger).toBeCalledWith(
'media_query',
expect.objectContaining({ error }),
);
// The loading flag must be cleared on error — otherwise gallery/viewer
// components render "Awaiting media" indefinitely on top of the error
// notification.
expect(manager.getView()?.context?.loading?.query).toBeUndefined();
});
it('should reset media_query when navigating via the sync path', () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewByParameters.mockReturnValue(createView({ view: 'live' }));
const api = createInitializedCardAPI();
const manager = new ViewManager(api, { viewFactory });
manager.setViewByParameters();
expect(api.getIssueManager().reset).toBeCalledWith('media_query');
});
it('should tolerate the view being reset during a failing async query', async () => {
const error = new Error();
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
const api = createInitializedCardAPI();
const manager = new ViewManager(api, {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
// Concurrent reset during the await — clears `_view` before the
// rejection is processed. The error path must not crash on the null
// view when attempting to clear the loading flag.
viewQueryExecutor.getNewQueryModifiers.mockImplementation(async () => {
manager.reset();
throw error;
});
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()).toBeNull();
expect(api.getIssueManager().trigger).toBeCalledWith(
'media_query',
expect.objectContaining({ error }),
);
});
it('should reset media_query at the start of a new async query', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue(null);
const api = createInitializedCardAPI();
const manager = new ViewManager(api, {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewDefaultWithNewQuery();
// Reset is called twice: once at dispatch (supersedes any prior error)
// and once after success (clears on confirmed success).
expect(api.getIssueManager().reset).toBeCalledWith('media_query');
expect(
vi
.mocked(api.getIssueManager().reset)
.mock.calls.filter(([key]) => key === 'media_query').length,
).toBe(2);
});
});
describe('hasMajorMediaChange', () => {
it('should consider undefined views as major', () => {
const manager = new ViewManager(createCardAPI());
expect(manager.hasMajorMediaChange(undefined)).toBeFalsy();
expect(manager.hasMajorMediaChange(createView())).toBeTruthy();
expect(manager.hasMajorMediaChange()).toBeFalsy();
});
it('should consider view change as major', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView({ view: 'live' }));
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: factory,
});
manager.setViewDefault();
expect(manager.hasMajorMediaChange(createView({ view: 'clips' }))).toBeTruthy();
});
it('should consider camera change as major', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView({ camera: 'camera-1' }));
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: factory,
});
manager.setViewDefault();
expect(manager.hasMajorMediaChange(createView({ camera: 'camera-2' }))).toBeTruthy();
});
it('should consider live substream change as major in live view', () => {
const overrides_1: Map<string, string> = new Map();
overrides_1.set('camera', 'camera-2');
const overrides_2: Map<string, string> = new Map();
overrides_2.set('camera', 'camera-3');
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(
createView({ context: { live: { overrides: overrides_1 } } }),
);
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: factory,
});
manager.setViewDefault();
expect(
manager.hasMajorMediaChange(
createView({ context: { live: { overrides: overrides_2 } } }),
),
).toBeTruthy();
});
it('should not consider live substream change as major in other view', () => {
const overrides_1: Map<string, string> = new Map();
overrides_1.set('camera', 'camera-2');
const overrides_2: Map<string, string> = new Map();
overrides_2.set('camera', 'camera-3');
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(
createView({ view: 'clips', context: { live: { overrides: overrides_1 } } }),
);
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: factory,
});
manager.setViewDefault();
expect(
manager.hasMajorMediaChange(
createView({ view: 'clips', context: { live: { overrides: overrides_2 } } }),
),
).toBeFalsy();
});
it('should consider result change as major in other view', () => {
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(createInitializedCardAPI(), {
viewFactory: factory,
});
manager.setViewDefault();
expect(
manager.hasMajorMediaChange(
createView({ view: 'media', queryResults: queryResults_2 }),
),
).toBeTruthy();
});
it('should not consider selected result change as major in live view', () => {
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(createInitializedCardAPI(), {
viewFactory: factory,
});
manager.setViewDefault();
expect(
manager.hasMajorMediaChange(createView({ queryResults: queryResults_2 })),
).toBeFalsy();
});
});
describe('should initialize', () => {
it('should initialize without querystring', async () => {
const view = createView({
view: 'live',
camera: 'camera',
});
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(view);
const api = createInitializedCardAPI();
const manager = new ViewManager(api, {
viewFactory: viewFactory,
});
await manager.initialize();
expect(manager.getView()).toBe(view);
});
it('should initialize with querystring', async () => {
const api = createCardAPI();
const factory = mock<ViewFactory>();
const manager = new ViewManager(api, { viewFactory: factory });
vi.mocked(api.getQueryStringManager().hasViewRelatedActionsToRun).mockReturnValue(
true,
);
await manager.initialize();
expect(manager.hasView()).toBeFalsy();
});
});
describe('should apply async view modifications', () => {
it('should apply modifications successfully', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView({ view: 'live' }));
const query = new UnifiedQuery();
const queryResults = new QueryResults();
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([
new SetQueryViewModifier({
query: query,
queryResults: queryResults,
}),
]);
const api = createInitializedCardAPI();
const manager = new ViewManager(api, {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.query).toBe(query);
expect(manager.getView()?.queryResults).toBe(queryResults);
expect(manager.getView()?.context?.loading?.query).toBeUndefined();
expect(api.getIssueManager().reset).toBeCalledWith('media_query');
});
it('should not apply modifications if there is a major media change', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const query = new UnifiedQuery();
const queryResults = new QueryResults();
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
viewQueryExecutor.getNewQueryModifiers.mockImplementation(
async (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_view: View,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_executorqueryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> => {
// Simulate a major media change while the async operation is running.
viewFactory.getViewDefault.mockReturnValueOnce(
createView({ view: 'clips', context: { loading: { query: 1 } } }),
);
manager.setViewDefault();
// Now return the modifiers (which should be ignored since there has
// been a major change in the meantime).
return [
new SetQueryViewModifier({
query: query,
queryResults: queryResults,
}),
];
},
);
await manager.setViewDefaultWithNewQuery();
// View set during the async operation should not be touched.
expect(manager.getView()?.is('clips')).toBeTruthy();
expect(manager.getView()?.query).toBeNull();
expect(manager.getView()?.queryResults).toBeNull();
expect(manager.getView()?.context?.loading?.query).toBeUndefined();
});
describe('should manage loading state correctly', () => {
it('should mark as not loading when with major media change', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
viewQueryExecutor.getNewQueryModifiers.mockImplementation(
async (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_view: View,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_executorqueryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> => {
expect(manager.getView()?.context?.loading?.query).not.toBeUndefined();
// Simulate a major media change while the async operation is running.
viewFactory.getViewDefault.mockReturnValueOnce(
createView({ view: 'clips', context: { loading: { query: 1 } } }),
);
manager.setViewDefault();
// Now return the modifiers (which should be ignored since there has
// been a major change in the meantime).
return [];
},
);
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.context?.loading?.query).toBeUndefined();
});
it('should not change loading status if something else is being loaded', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
viewQueryExecutor.getNewQueryModifiers.mockImplementation(
async (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_view: View,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_executorqueryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> => {
expect(manager.getView()?.context?.loading?.query).not.toBeUndefined();
// Simulate a major media change while the async operation is running.
viewFactory.getViewDefault.mockReturnValueOnce(
createView({ view: 'clips', context: { loading: { query: 2 } } }),
);
manager.setViewDefault();
// Now return the modifiers (which should be ignored since there has
// been a major change in the meantime).
return [];
},
);
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.context?.loading?.query).toBe(2);
});
it('should not change loading status if it is unexpected', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValueOnce(createView({ view: 'live' }));
const viewQueryExecutor = mock<ViewQueryExecutor>();
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
viewQueryExecutor.getNewQueryModifiers.mockImplementation(
async (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_view: View,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_executorqueryExecutorOptions?: QueryExecutorOptions,
): Promise<ViewModifier[] | null> => {
expect(manager.getView()?.context?.loading?.query).not.toBeUndefined();
// Simulate another view update (without a major media change)
viewFactory.getViewDefault.mockReturnValueOnce(
createView({ context: { loading: { query: 100 } } }),
);
manager.setViewDefault();
// Now return the modifiers (which should be ignored since there has
// been a major change in the meantime).
return [];
},
);
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.context?.loading?.query).toBe(100);
});
});
describe('should adopt query results when useful', () => {
const testAdoption = async (
originView: AdvancedCameraCardView | undefined,
targetViewName: AdvancedCameraCardView,
startViewName: AdvancedCameraCardView = 'media',
): Promise<boolean> => {
const startView = createView({
view: startViewName,
camera: 'camera',
query: new UnifiedQuery([createEventQuery('c1')]),
queryResults: new QueryResults(),
context: originView ? { gallery: { originView } } : undefined,
});
const targetView = createView({
view: targetViewName,
camera: 'camera',
});
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault
.mockReturnValueOnce(startView)
.mockReturnValueOnce(targetView);
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const manager = new ViewManager(createInitializedCardAPI(), {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
manager.setViewDefault();
await manager.setViewDefaultWithNewQuery();
return vi.mocked(viewQueryExecutor.getNewQueryModifiers).mock.calls.length === 0;
};
it('should return false when no originView context', async () => {
expect(await testAdoption(undefined, 'clips')).toBe(false);
});
it('should return true when originView matches target', async () => {
expect(await testAdoption('clips', 'clips')).toBe(true);
expect(await testAdoption('snapshots', 'snapshots')).toBe(true);
expect(await testAdoption('recordings', 'recordings')).toBe(true);
expect(await testAdoption('reviews', 'reviews')).toBe(true);
});
it('should return false when originView does not match target', async () => {
expect(await testAdoption('clips', 'snapshots')).toBe(false);
expect(await testAdoption('clips', 'recordings')).toBe(false);
expect(await testAdoption('snapshots', 'clips')).toBe(false);
expect(await testAdoption('recordings', 'reviews')).toBe(false);
});
it('should return false when start view is not viewer', async () => {
expect(await testAdoption('clips', 'clips', 'live')).toBe(false);
});
it('should return false when target view is not gallery', async () => {
expect(await testAdoption('clips', 'live')).toBe(false);
});
});
});