feat: Add UI optional UI locking when microphone is hot (#2484)
This commit is contained in:
committed by
dermotduffy
parent
9a763db1f3
commit
be7e7d79dc
@@ -14,11 +14,21 @@ import {
|
||||
Interaction,
|
||||
InteractionName,
|
||||
} from '../../../src/card-controller/actions/actions-manager';
|
||||
import type { CardController } from '../../../src/card-controller/controller';
|
||||
import { TemplateRenderer } from '../../../src/card-controller/templates';
|
||||
import { AdvancedCameraCardView } from '../../../src/config/schema/common/const';
|
||||
import { createLogAction } from '../../../src/utils/action';
|
||||
import { createGeneralAction, createLogAction } from '../../../src/utils/action';
|
||||
import { arrayify } from '../../../src/utils/basic';
|
||||
import { createCardAPI, createConfig, createHASS, createView } from '../../test-utils';
|
||||
|
||||
const createAPI = (): CardController => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getLockManager().getAllowedActions).mockImplementation((actions) =>
|
||||
arrayify(actions),
|
||||
);
|
||||
return api;
|
||||
};
|
||||
|
||||
describe('ActionsManager', () => {
|
||||
describe('getMergedActions', () => {
|
||||
const config = {
|
||||
@@ -69,7 +79,7 @@ describe('ActionsManager', () => {
|
||||
});
|
||||
|
||||
it('should get no merged actions with an issue', () => {
|
||||
const api = createCardAPI();
|
||||
const api = createAPI();
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||
createView({ view: 'live' }),
|
||||
);
|
||||
@@ -142,7 +152,7 @@ describe('ActionsManager', () => {
|
||||
],
|
||||
['timeline' as const, {}],
|
||||
])('%s', (viewName: AdvancedCameraCardView, result: Record<string, unknown>) => {
|
||||
const api = createCardAPI();
|
||||
const api = createAPI();
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||
createView({ view: viewName }),
|
||||
);
|
||||
@@ -162,9 +172,12 @@ describe('ActionsManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should handle interaction', async () => {
|
||||
const api = createCardAPI();
|
||||
const api = createAPI();
|
||||
const element = document.createElement('div');
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
|
||||
@@ -193,7 +206,7 @@ describe('ActionsManager', () => {
|
||||
it.each([['malformed_type_of_tap' as const], ['double_tap' as const]])(
|
||||
'%s',
|
||||
(interaction: string) => {
|
||||
const api = createCardAPI();
|
||||
const api = createAPI();
|
||||
const element = document.createElement('div');
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
|
||||
@@ -227,6 +240,9 @@ describe('ActionsManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should handle advanced camera card event', async () => {
|
||||
const action = createLogAction('Hello, world!');
|
||||
@@ -234,7 +250,7 @@ describe('ActionsManager', () => {
|
||||
detail: action,
|
||||
});
|
||||
|
||||
const api = createCardAPI();
|
||||
const api = createAPI();
|
||||
const manager = new ActionsManager(api);
|
||||
|
||||
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||
@@ -254,7 +270,7 @@ describe('ActionsManager', () => {
|
||||
const handler = vi.fn();
|
||||
card.addEventListener('ll-custom', handler);
|
||||
|
||||
const api = createCardAPI();
|
||||
const api = createAPI();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(card);
|
||||
const manager = new ActionsManager(api);
|
||||
|
||||
@@ -264,7 +280,7 @@ describe('ActionsManager', () => {
|
||||
});
|
||||
|
||||
it('should not handle event without detail', async () => {
|
||||
const manager = new ActionsManager(createCardAPI());
|
||||
const manager = new ActionsManager(createAPI());
|
||||
|
||||
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||
await manager.handleCustomActionEvent(new Event('ll-custom'));
|
||||
@@ -273,8 +289,12 @@ describe('ActionsManager', () => {
|
||||
});
|
||||
|
||||
describe('handleActionExecutionRequestEvent', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should execute actions', async () => {
|
||||
const api = createCardAPI();
|
||||
const api = createAPI();
|
||||
const manager = new ActionsManager(api);
|
||||
|
||||
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||
@@ -288,8 +308,12 @@ describe('ActionsManager', () => {
|
||||
});
|
||||
|
||||
describe('executeAction', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should execute actions', async () => {
|
||||
const api = createCardAPI();
|
||||
const api = createAPI();
|
||||
const manager = new ActionsManager(api);
|
||||
|
||||
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||
@@ -298,7 +322,7 @@ describe('ActionsManager', () => {
|
||||
});
|
||||
|
||||
it('should execute actions', async () => {
|
||||
const api = createCardAPI();
|
||||
const api = createAPI();
|
||||
const manager = new ActionsManager(api);
|
||||
|
||||
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||
@@ -312,7 +336,7 @@ describe('ActionsManager', () => {
|
||||
const templateRenderer = mock<TemplateRenderer>();
|
||||
templateRenderer.renderRecursively.mockReturnValue(action);
|
||||
|
||||
const api = createCardAPI();
|
||||
const api = createAPI();
|
||||
const hass = createHASS();
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
||||
|
||||
@@ -324,6 +348,7 @@ describe('ActionsManager', () => {
|
||||
const manager = new ActionsManager(api, templateRenderer);
|
||||
const config = { entity: 'light.office' };
|
||||
const triggerData = { view: { from: 'previous-view', to: 'view' } };
|
||||
vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||
|
||||
await manager.executeActions({ actions: action, config, triggerData });
|
||||
|
||||
@@ -333,6 +358,40 @@ describe('ActionsManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter rendered actions through the lock manager', async () => {
|
||||
const renderedAction = createGeneralAction('reload');
|
||||
const allowedAction = createLogAction('Allowed');
|
||||
|
||||
const templateRenderer = mock<TemplateRenderer>();
|
||||
templateRenderer.renderRecursively.mockReturnValue(renderedAction);
|
||||
|
||||
const api = createAPI();
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||
vi.mocked(api.getLockManager().getAllowedActions).mockReturnValue([allowedAction]);
|
||||
|
||||
const manager = new ActionsManager(api, templateRenderer);
|
||||
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||
|
||||
await manager.executeActions({
|
||||
actions: createLogAction('{{ action }}'),
|
||||
});
|
||||
|
||||
expect(api.getLockManager().getAllowedActions).toBeCalledWith(renderedAction);
|
||||
expect(consoleSpy).toBeCalledWith('Allowed');
|
||||
});
|
||||
|
||||
it('should not execute actions when the lock manager rejects them', async () => {
|
||||
const api = createAPI();
|
||||
vi.mocked(api.getLockManager().getAllowedActions).mockReturnValue([]);
|
||||
|
||||
const manager = new ActionsManager(api);
|
||||
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||
|
||||
await manager.executeActions({ actions: createLogAction('Blocked') });
|
||||
|
||||
expect(consoleSpy).not.toBeCalled();
|
||||
});
|
||||
|
||||
describe('should forward haptics', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -343,7 +402,7 @@ describe('ActionsManager', () => {
|
||||
const handler = vi.fn();
|
||||
window.addEventListener('haptic', handler);
|
||||
|
||||
const api = createCardAPI();
|
||||
const api = createAPI();
|
||||
const manager = new ActionsManager(api);
|
||||
|
||||
await manager.executeActions({ actions: { action: 'none' } });
|
||||
@@ -357,7 +416,7 @@ describe('ActionsManager', () => {
|
||||
const handler = vi.fn();
|
||||
window.addEventListener('haptic', handler);
|
||||
|
||||
const api = createCardAPI();
|
||||
const api = createAPI();
|
||||
const manager = new ActionsManager(api);
|
||||
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValue(false));
|
||||
@@ -375,12 +434,15 @@ describe('ActionsManager', () => {
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should stop actions', async () => {
|
||||
const api = createCardAPI();
|
||||
const api = createAPI();
|
||||
const manager = new ActionsManager(api);
|
||||
|
||||
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { InitializationManager } from '../../src/card-controller/initialization-
|
||||
import { InteractionManager } from '../../src/card-controller/interaction-manager';
|
||||
import { IssueManager } from '../../src/card-controller/issues/issue-manager';
|
||||
import { KeyboardStateManager } from '../../src/card-controller/keyboard-state-manager';
|
||||
import { LockManager } from '../../src/card-controller/lock/manager';
|
||||
import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-manager';
|
||||
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager';
|
||||
import { MicrophoneManager } from '../../src/card-controller/microphone-manager';
|
||||
@@ -50,6 +51,7 @@ vi.mock('../../src/card-controller/hass/hass-manager');
|
||||
vi.mock('../../src/card-controller/initialization-manager');
|
||||
vi.mock('../../src/card-controller/interaction-manager');
|
||||
vi.mock('../../src/card-controller/keyboard-state-manager');
|
||||
vi.mock('../../src/card-controller/lock/manager');
|
||||
vi.mock('../../src/card-controller/media-info-manager');
|
||||
vi.mock('../../src/card-controller/media-player-manager');
|
||||
vi.mock('../../src/card-controller/microphone-manager');
|
||||
@@ -209,6 +211,12 @@ describe('CardController', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return getLockManager', () => {
|
||||
expect(createController().getLockManager()).toBe(
|
||||
vi.mocked(LockManager).mock.instances[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return getMediaLoadedInfoManager', () => {
|
||||
expect(createController().getMediaLoadedInfoManager()).toBe(
|
||||
vi.mocked(MediaLoadedInfoManager).mock.instances[0],
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { LockManager } from '../../../src/card-controller/lock/manager';
|
||||
import {
|
||||
createCameraAction,
|
||||
createDisplayModeAction,
|
||||
createGeneralAction,
|
||||
createLogAction,
|
||||
createMediaPlayerAction,
|
||||
createViewAction,
|
||||
} from '../../../src/utils/action';
|
||||
import { createCardAPI } from '../../test-utils';
|
||||
|
||||
describe('LockManager', () => {
|
||||
it('should report unlocked when no lock source is active', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false);
|
||||
|
||||
expect(new LockManager(api).isLocked()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should report locked when the microphone is locking', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
|
||||
|
||||
expect(new LockManager(api).isLocked()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should reuse lock manager epoch until the lock state changes', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false);
|
||||
const manager = new LockManager(api);
|
||||
|
||||
const unlockedEpoch = manager.getEpoch();
|
||||
expect(manager.getEpoch()).toBe(unlockedEpoch);
|
||||
expect(unlockedEpoch.locked).toBeFalsy();
|
||||
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
|
||||
const lockedEpoch = manager.getEpoch();
|
||||
expect(lockedEpoch).not.toBe(unlockedEpoch);
|
||||
expect(lockedEpoch.locked).toBeTruthy();
|
||||
expect(manager.getEpoch()).toBe(lockedEpoch);
|
||||
});
|
||||
|
||||
it('should not filter actions when unlocked', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false);
|
||||
|
||||
const actions = [createGeneralAction('reload'), createLogAction('Allowed')];
|
||||
|
||||
expect(new LockManager(api).getAllowedActions(actions)).toBe(actions);
|
||||
});
|
||||
|
||||
it('should reject microphone-session-disruptive actions when locked', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
|
||||
const manager = new LockManager(api);
|
||||
|
||||
for (const action of [
|
||||
createViewAction('clips'),
|
||||
createCameraAction('camera_select', 'cam-1'),
|
||||
createCameraAction('live_substream_select', 'cam-1'),
|
||||
createGeneralAction('live_substream_on'),
|
||||
createGeneralAction('live_substream_off'),
|
||||
createGeneralAction('default'),
|
||||
createGeneralAction('pause'),
|
||||
createGeneralAction('reload'),
|
||||
createMediaPlayerAction('media_player.living_room', 'play'),
|
||||
]) {
|
||||
expect(manager.getAllowedActions(action)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve non-disruptive actions when locked', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
|
||||
const manager = new LockManager(api);
|
||||
|
||||
for (const action of [
|
||||
createGeneralAction('play'),
|
||||
createGeneralAction('fullscreen'),
|
||||
createGeneralAction('expand'),
|
||||
createGeneralAction('pip'),
|
||||
createGeneralAction('screenshot'),
|
||||
createGeneralAction('mute'),
|
||||
createGeneralAction('unmute'),
|
||||
createGeneralAction('microphone_unmute'),
|
||||
createDisplayModeAction('grid'),
|
||||
{ action: 'none' as const },
|
||||
]) {
|
||||
expect(manager.getAllowedActions(action)).toEqual([action]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve non-disruptive actions from a mixed action list when locked', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
|
||||
|
||||
const manager = new LockManager(api);
|
||||
const allowedAction = createLogAction('Allowed');
|
||||
|
||||
expect(
|
||||
manager.getAllowedActions([createGeneralAction('reload'), allowedAction]),
|
||||
).toEqual([allowedAction]);
|
||||
});
|
||||
|
||||
it('should report whether all configured actions are blocked', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
|
||||
|
||||
const manager = new LockManager(api);
|
||||
|
||||
expect(
|
||||
manager.areAllActionsBlocked({
|
||||
tap_action: createGeneralAction('reload'),
|
||||
hold_action: createGeneralAction('pause'),
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
manager.areAllActionsBlocked({
|
||||
tap_action: createGeneralAction('reload'),
|
||||
hold_action: createLogAction('Allowed'),
|
||||
}),
|
||||
).toBeFalsy();
|
||||
expect(manager.areAllActionsBlocked({})).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should never report all-actions-blocked when unlocked', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false);
|
||||
|
||||
const manager = new LockManager(api);
|
||||
|
||||
// Even when every action would be blocked under an active policy, an
|
||||
// inactive lock short-circuits to false.
|
||||
expect(
|
||||
manager.areAllActionsBlocked({
|
||||
tap_action: createGeneralAction('reload'),
|
||||
hold_action: createGeneralAction('pause'),
|
||||
}),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -244,6 +244,82 @@ describe('MicrophoneManager', () => {
|
||||
expect(api.getCardElementManager().update).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('isLocking', () => {
|
||||
it('should not lock when muted', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
live: {
|
||||
microphone: {
|
||||
lock: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(manager.isMuted()).toBeTruthy();
|
||||
expect(manager.isLocking()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should lock when unmuted and lock is enabled', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
live: {
|
||||
microphone: {
|
||||
lock: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
|
||||
createMockStream(),
|
||||
);
|
||||
|
||||
await manager.unmute();
|
||||
|
||||
expect(manager.isMuted()).toBeFalsy();
|
||||
expect(manager.isLocking()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not lock when unmuted but lock is disabled', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
live: {
|
||||
microphone: {
|
||||
lock: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
|
||||
createMockStream(),
|
||||
);
|
||||
|
||||
await manager.unmute();
|
||||
|
||||
expect(manager.isMuted()).toBeFalsy();
|
||||
expect(manager.isLocking()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not lock when config is unavailable', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new MicrophoneManager(api);
|
||||
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
|
||||
createMockStream(),
|
||||
);
|
||||
|
||||
await manager.unmute();
|
||||
|
||||
expect(manager.isMuted()).toBeFalsy();
|
||||
expect(manager.isLocking()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should require initialization', async () => {
|
||||
it('should require when configured and supported', async () => {
|
||||
const api = createCardAPI();
|
||||
|
||||
@@ -163,6 +163,74 @@ describe('should not set view without cameras being initialized', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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());
|
||||
|
||||
@@ -386,7 +386,7 @@ describe('MediaGridController', () => {
|
||||
).toBe('3');
|
||||
});
|
||||
|
||||
it('should select cell when interacted with', () => {
|
||||
it('should dispatch a selection request when interacted with', () => {
|
||||
const children = createChildren();
|
||||
const parent = createParent({ children: children, width: 2000 });
|
||||
const controller = createController(parent);
|
||||
@@ -396,11 +396,18 @@ describe('MediaGridController', () => {
|
||||
const clickHandler = vi.fn();
|
||||
parent.addEventListener('click', clickHandler);
|
||||
|
||||
const selectedHandler = vi.fn();
|
||||
parent.addEventListener('advanced-camera-card:media-grid:selected', selectedHandler);
|
||||
|
||||
children[1].click();
|
||||
|
||||
// Click will not be allowed through.
|
||||
// Click is consumed; the controller dispatches the selection request but
|
||||
// does NOT mutate local state. The authoritative selection is applied by
|
||||
// the parent via `selectCell` once it propagates back.
|
||||
expect(clickHandler).not.toBeCalled();
|
||||
expect(controller.getSelected()).toBe('1');
|
||||
expect(selectedHandler).toBeCalledTimes(1);
|
||||
expect(selectedHandler.mock.calls[0][0].detail).toEqual({ selected: '1' });
|
||||
expect(controller.getSelected()).toBeNull();
|
||||
});
|
||||
|
||||
it('should ignore interaction events on already selected cell', () => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import type { LockManager } from '../../src/card-controller/lock/manager';
|
||||
import type { LockManagerEpoch } from '../../src/card-controller/lock/types';
|
||||
import { MenuController } from '../../src/components-lib/menu-controller.js';
|
||||
import { SubmenuItem } from '../../src/components/submenu/types.js';
|
||||
import { MenuConfig, menuConfigSchema } from '../../src/config/schema/menu.js';
|
||||
@@ -12,6 +15,12 @@ const createMenuConfig = (config: unknown): MenuConfig => {
|
||||
return menuConfigSchema.parse(config);
|
||||
};
|
||||
|
||||
const createLock = (locked: boolean, actionsBlocked: boolean): LockManagerEpoch => {
|
||||
const lockManager = mock<LockManager>();
|
||||
lockManager.areAllActionsBlocked.mockReturnValue(actionsBlocked);
|
||||
return { manager: lockManager, locked };
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('MenuController', () => {
|
||||
const action = {
|
||||
@@ -71,6 +80,137 @@ describe('MenuController', () => {
|
||||
expect(host.getAttribute('expanded')).toBeNull();
|
||||
});
|
||||
|
||||
describe('setLockManagerEpoch', () => {
|
||||
it('should trigger update on first lock change', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MenuController(host);
|
||||
vi.mocked(host.requestUpdate).mockClear();
|
||||
|
||||
controller.setLockManagerEpoch(createLock(true, true));
|
||||
expect(host.requestUpdate).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not trigger update when lock epoch is unchanged', () => {
|
||||
const host = createLitElement();
|
||||
const controller = new MenuController(host);
|
||||
const lock = createLock(false, true);
|
||||
controller.setLockManagerEpoch(lock);
|
||||
vi.mocked(host.requestUpdate).mockClear();
|
||||
|
||||
controller.setLockManagerEpoch({ manager: lock.manager, locked: lock.locked });
|
||||
expect(host.requestUpdate).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should reflect lock state in shouldButtonBeInert', () => {
|
||||
const controller = new MenuController(createLitElement());
|
||||
controller.setLockManagerEpoch(createLock(false, true));
|
||||
const button = {
|
||||
type: 'custom:advanced-camera-card-menu-icon' as const,
|
||||
icon: 'mdi:cow',
|
||||
tap_action: {
|
||||
action: 'fire-dom-event' as const,
|
||||
advanced_camera_card_action: 'camera_select' as const,
|
||||
camera: 'cam-1',
|
||||
},
|
||||
};
|
||||
|
||||
expect(controller.shouldButtonBeInert(button)).toBeFalsy();
|
||||
controller.setLockManagerEpoch(createLock(true, true));
|
||||
expect(controller.shouldButtonBeInert(button)).toBeTruthy();
|
||||
controller.setLockManagerEpoch(createLock(false, true));
|
||||
expect(controller.shouldButtonBeInert(button)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldButtonBeInert', () => {
|
||||
const blockedAction = {
|
||||
action: 'fire-dom-event' as const,
|
||||
advanced_camera_card_action: 'camera_select' as const,
|
||||
camera: 'cam-1',
|
||||
};
|
||||
const unblockedAction = {
|
||||
action: 'fire-dom-event' as const,
|
||||
advanced_camera_card_action: 'fullscreen' as const,
|
||||
};
|
||||
|
||||
it('should always return true when user explicitly set `inert: true`', () => {
|
||||
const controller = new MenuController(createLitElement());
|
||||
const button = {
|
||||
type: 'custom:advanced-camera-card-menu-icon' as const,
|
||||
icon: 'mdi:cow',
|
||||
inert: true,
|
||||
tap_action: unblockedAction,
|
||||
};
|
||||
|
||||
// Without the lock active.
|
||||
expect(controller.shouldButtonBeInert(button)).toBeTruthy();
|
||||
// With the lock active.
|
||||
controller.setLockManagerEpoch(createLock(true, false));
|
||||
expect(controller.shouldButtonBeInert(button)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false when lock is inactive and button is not user-inert', () => {
|
||||
const controller = new MenuController(createLitElement());
|
||||
expect(
|
||||
controller.shouldButtonBeInert({
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
icon: 'mdi:cow',
|
||||
tap_action: blockedAction,
|
||||
}),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should never mark submenu containers inert (even when all actions are blocked)', () => {
|
||||
const controller = new MenuController(createLitElement());
|
||||
controller.setLockManagerEpoch(createLock(true, true));
|
||||
|
||||
expect(
|
||||
controller.shouldButtonBeInert({
|
||||
type: 'custom:advanced-camera-card-menu-submenu',
|
||||
icon: 'mdi:menu',
|
||||
items: [],
|
||||
tap_action: blockedAction,
|
||||
}),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
controller.shouldButtonBeInert({
|
||||
type: 'custom:advanced-camera-card-menu-submenu-select',
|
||||
icon: 'mdi:menu',
|
||||
entity: 'select.foo',
|
||||
state_color: true,
|
||||
tap_action: blockedAction,
|
||||
}),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should mark icon buttons inert when locked and all actions are blocked', () => {
|
||||
const controller = new MenuController(createLitElement());
|
||||
controller.setLockManagerEpoch(createLock(true, true));
|
||||
|
||||
expect(
|
||||
controller.shouldButtonBeInert({
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
icon: 'mdi:cow',
|
||||
tap_action: blockedAction,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should NOT mark icon buttons inert when locked and not all actions are blocked', () => {
|
||||
const controller = new MenuController(createLitElement());
|
||||
controller.setLockManagerEpoch(createLock(true, false));
|
||||
|
||||
expect(
|
||||
controller.shouldButtonBeInert({
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
icon: 'mdi:cow',
|
||||
tap_action: blockedAction,
|
||||
hold_action: unblockedAction,
|
||||
}),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should set and sort buttons', () => {
|
||||
it('without a hidden menu', () => {
|
||||
const controller = new MenuController(createLitElement());
|
||||
|
||||
@@ -136,6 +136,7 @@ describe('config defaults', () => {
|
||||
auto_mute: [],
|
||||
auto_unmute: [],
|
||||
disconnect_seconds: 90,
|
||||
lock: true,
|
||||
mute_after_microphone_mute_seconds: 60,
|
||||
},
|
||||
preload: false,
|
||||
|
||||
@@ -43,6 +43,7 @@ import { InteractionManager } from '../src/card-controller/interaction-manager';
|
||||
import { IssueManager } from '../src/card-controller/issues/issue-manager';
|
||||
import { IssueStateManager } from '../src/card-controller/issues/state-manager';
|
||||
import { KeyboardStateManager } from '../src/card-controller/keyboard-state-manager';
|
||||
import { LockManager } from '../src/card-controller/lock/manager';
|
||||
import { MediaLoadedInfoManager } from '../src/card-controller/media-info-manager';
|
||||
import { MediaPlayerManager } from '../src/card-controller/media-player-manager';
|
||||
import { MicrophoneManager } from '../src/card-controller/microphone-manager';
|
||||
@@ -702,6 +703,7 @@ export const createCardAPI = (): CardController => {
|
||||
api.getInitializationManager.mockReturnValue(mock<InitializationManager>());
|
||||
api.getInteractionManager.mockReturnValue(mock<InteractionManager>());
|
||||
api.getKeyboardStateManager.mockReturnValue(mock<KeyboardStateManager>());
|
||||
api.getLockManager.mockReturnValue(mock<LockManager>());
|
||||
api.getMediaLoadedInfoManager.mockReturnValue(mock<MediaLoadedInfoManager>());
|
||||
api.getMediaPlayerManager.mockReturnValue(mock<MediaPlayerManager>());
|
||||
api.getMicrophoneManager.mockReturnValue(mock<MicrophoneManager>());
|
||||
|
||||
Reference in New Issue
Block a user