fix: Clear the initialized condition state when the card is not usable (#2646)
The `initialized` state (used in conditions/triggers) was written once and never cleared, so it meant "has this card ever been initialized" while everything reading it took it as "is this card usable now". Home Assistant takes a card off the page and puts it back whenever its dashboard tab is left and returned to, so the card initialized again while the state claimed it was initialized throughout: `trigger: initialized` fired once per card rather than once per startup, and automations were dropped in between. The card lifecycle is now an explicit state machine (`SessionManager`), the only writer of `initialized`, which separates a card that is starting up from one initializing part of itself again while it runs. A new `ever` parameter (conditions/triggers) selects the old latched behaviour. `remote_control` uses that parameter to keep its two camera priorities correct under repeated starts. With `camera_priority: entity` the card now re-reads the entity every time it starts, so a camera selected while the card was away is picked up on return. With `camera_priority: card` the card writes the entity on its first start only, unchanged, since repeating that write would overwrite a camera the user had selected. Closes: #2642 BREAKING CHANGE: `condition: initialized` is now `false` whenever the card is not usable, and `trigger: initialized` fires each time the card starts up rather than only the first time. Set `ever: true` on either to keep the previous behaviour.
This commit is contained in:
@@ -0,0 +1,708 @@
|
||||
import { STATE_RUNNING, STATE_STARTING } from 'home-assistant-js-websocket';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import {
|
||||
InitializationAspect,
|
||||
InitializationManager,
|
||||
} from '../../../src/card-controller/initialization/initialization-manager';
|
||||
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
|
||||
import { sideLoadHomeAssistantElements } from '../../../src/ha/side-load-ha-elements.js';
|
||||
import { loadLanguages } from '../../../src/localize/localize';
|
||||
import type { Initializer } from '../../../src/utils/initializer/initializer';
|
||||
import { createConfig } from '../../config/test-utils';
|
||||
import { createCardAPI, createHASS } from '../../test-utils';
|
||||
|
||||
vi.mock('../../../src/localize/localize.js');
|
||||
vi.mock('../../../src/ha/side-load-ha-elements.js');
|
||||
|
||||
// An API that passes the whole start predicate, checked both when an attempt is
|
||||
// queued and again when it runs.
|
||||
const createReadyAPI = (): ReturnType<typeof createCardAPI> => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(true);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
vi.mocked(api.getCardElementManager().isConnected).mockReturnValue(true);
|
||||
const hass = createHASS();
|
||||
hass.connected = true;
|
||||
hass.config.state = STATE_RUNNING;
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
||||
vi.mocked(api.getIssueManager().getStateManager().hasFullCardIssue).mockReturnValue(
|
||||
false,
|
||||
);
|
||||
return api;
|
||||
};
|
||||
|
||||
describe('InitializationManager', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('should correctly determine when mandatory initialization is required', () => {
|
||||
it('should handle without config', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new InitializationManager(api);
|
||||
|
||||
expect(manager.areMandatoryAspectsInitialized()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should handle without aspects', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new InitializationManager(api);
|
||||
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
|
||||
expect(manager.areMandatoryAspectsInitialized()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should handle with microphone if configured', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new InitializationManager(api);
|
||||
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
vi.mocked(
|
||||
api.getMicrophoneManager().shouldConnectOnInitialization,
|
||||
).mockReturnValue(true);
|
||||
|
||||
expect(manager.areMandatoryAspectsInitialized()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should initialize mandatory', () => {
|
||||
it('should handle without hass', async () => {
|
||||
const manager = new InitializationManager(createCardAPI());
|
||||
await manager.initializeMandatory();
|
||||
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should handle without config', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new InitializationManager(api);
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||
|
||||
await manager.initializeMandatory();
|
||||
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should be a no-op when hass.config.state is not RUNNING', async () => {
|
||||
const api = createReadyAPI();
|
||||
const hass = createHASS();
|
||||
hass.config.state = STATE_STARTING;
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
||||
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toHaveBeenCalled();
|
||||
expect(initializer.initializeIfNecessary).not.toHaveBeenCalled();
|
||||
expect(api.getIssueManager().trigger).not.toHaveBeenCalled();
|
||||
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should succeed', async () => {
|
||||
const stateListener = vi.fn();
|
||||
const stateMananger = new ConditionStateManager();
|
||||
stateMananger.addListener(stateListener);
|
||||
|
||||
const api = createReadyAPI();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateMananger);
|
||||
const config = createConfig();
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(config);
|
||||
const manager = new InitializationManager(api);
|
||||
|
||||
expect(manager.isInitialized(InitializationAspect.LANGUAGES)).toBeFalsy();
|
||||
expect(manager.isInitialized(InitializationAspect.SIDE_LOAD_ELEMENTS)).toBeFalsy();
|
||||
expect(manager.isInitialized(InitializationAspect.CAMERAS)).toBeFalsy();
|
||||
expect(manager.isInitialized(InitializationAspect.MICROPHONE_CONNECT)).toBeFalsy();
|
||||
expect(manager.isInitialized(InitializationAspect.VIEW)).toBeFalsy();
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(loadLanguages).toHaveBeenCalled();
|
||||
expect(sideLoadHomeAssistantElements).toHaveBeenCalled();
|
||||
expect(api.getCameraManager().initializeCamerasFromConfig).toHaveBeenCalled();
|
||||
expect(api.getViewManager().initialize).toHaveBeenCalled();
|
||||
expect(api.getMicrophoneManager().connect).not.toHaveBeenCalled();
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalled();
|
||||
|
||||
expect(manager.getSessionManager().wasEverInitialized()).toBeTruthy();
|
||||
|
||||
expect(stateListener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
change: {
|
||||
initialized: true,
|
||||
everInitialized: true,
|
||||
config,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(manager.isInitialized(InitializationAspect.LANGUAGES)).toBeTruthy();
|
||||
expect(
|
||||
manager.isInitialized(InitializationAspect.SIDE_LOAD_ELEMENTS),
|
||||
).toBeTruthy();
|
||||
expect(manager.isInitialized(InitializationAspect.CAMERAS)).toBeTruthy();
|
||||
expect(manager.isInitialized(InitializationAspect.MICROPHONE_CONNECT)).toBeFalsy();
|
||||
expect(manager.isInitialized(InitializationAspect.VIEW)).toBeTruthy();
|
||||
expect(manager.isInitialized(InitializationAspect.INITIAL_TRIGGER)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load the template renderer for a templated config', async () => {
|
||||
const api = createReadyAPI();
|
||||
vi.mocked(api.getConfigManager().hasTemplate).mockReturnValue(true);
|
||||
const loadRenderer = vi.mocked(api.getTemplateManager().loadRenderer);
|
||||
|
||||
const manager = new InitializationManager(api);
|
||||
|
||||
expect(manager.isInitialized(InitializationAspect.TEMPLATE_RENDERER)).toBeFalsy();
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(loadRenderer).toHaveBeenCalled();
|
||||
expect(manager.isInitialized(InitializationAspect.TEMPLATE_RENDERER)).toBeTruthy();
|
||||
expect(manager.areMandatoryAspectsInitialized()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not load the template renderer for a config without templates', async () => {
|
||||
const api = createReadyAPI();
|
||||
vi.mocked(api.getConfigManager().hasTemplate).mockReturnValue(false);
|
||||
const loadRenderer = vi.mocked(api.getTemplateManager().loadRenderer);
|
||||
|
||||
const manager = new InitializationManager(api);
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(loadRenderer).not.toHaveBeenCalled();
|
||||
expect(manager.isInitialized(InitializationAspect.TEMPLATE_RENDERER)).toBeFalsy();
|
||||
expect(manager.areMandatoryAspectsInitialized()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should succeed with microphone if configured', async () => {
|
||||
const api = createReadyAPI();
|
||||
vi.mocked(
|
||||
api.getMicrophoneManager().shouldConnectOnInitialization,
|
||||
).mockReturnValue(true);
|
||||
|
||||
const manager = new InitializationManager(api);
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(api.getMicrophoneManager().connect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not report a session that ended while it was initializing', async () => {
|
||||
const api = createReadyAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
const manager = new InitializationManager(api);
|
||||
|
||||
// The card leaves the page midway through, e.g. because the dashboard tab
|
||||
// changed while the cameras were still initializing.
|
||||
vi.mocked(api.getViewManager().initialize).mockImplementation(async () => {
|
||||
manager.getSessionManager().end();
|
||||
return true;
|
||||
});
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
// Ending a session before the card started writes nothing: there is
|
||||
// nothing to take back.
|
||||
expect(stateManager.getState().initialized).toBeUndefined();
|
||||
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should stop when a full-card issue appears during initialization', async () => {
|
||||
const api = createReadyAPI();
|
||||
|
||||
// The predicate passes at dequeue time, then a full-card issue (from any
|
||||
// source) appears after the first step: the run stops there without an error
|
||||
// of its own.
|
||||
vi.mocked(api.getIssueManager().getStateManager().hasFullCardIssue)
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValue(true);
|
||||
|
||||
const manager = new InitializationManager(api);
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(api.getCameraManager().initializeCamerasFromConfig).not.toHaveBeenCalled();
|
||||
expect(api.getViewManager().initialize).not.toHaveBeenCalled();
|
||||
expect(api.getIssueManager().trigger).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a languages and side load elements failure', async () => {
|
||||
const api = createReadyAPI();
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
initializer.initializeMultipleIfNecessary.mockRejectedValue(
|
||||
new Error('initialization failed'),
|
||||
);
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should handle cameras initialization failure', async () => {
|
||||
const api = createReadyAPI();
|
||||
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
// First call (languages/side-load) succeeds, second (cameras) fails.
|
||||
initializer.initializeMultipleIfNecessary
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockRejectedValueOnce(new Error('cameras failed'));
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
|
||||
expect(api.getIssueManager().trigger).toHaveBeenCalledWith(
|
||||
'initialization',
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle initial trigger initialization failure', async () => {
|
||||
const api = createReadyAPI();
|
||||
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
|
||||
|
||||
// First initializeIfNecessary call (view) succeeds, second
|
||||
// (initial_trigger) fails.
|
||||
initializer.initializeIfNecessary
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockRejectedValueOnce(new Error('triggers failed'));
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
|
||||
expect(api.getIssueManager().trigger).toHaveBeenCalledWith(
|
||||
'initialization',
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle VIEW initialization failure', async () => {
|
||||
const api = createReadyAPI();
|
||||
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
|
||||
initializer.initializeIfNecessary.mockRejectedValueOnce(
|
||||
new Error('view initialization failed'),
|
||||
);
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
|
||||
expect(api.getIssueManager().trigger).toHaveBeenCalledWith(
|
||||
'initialization',
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should stop without an error when an aspect declines', async () => {
|
||||
const api = createReadyAPI();
|
||||
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
|
||||
|
||||
// An aspect that could not complete (e.g. the view when no view could be
|
||||
// set) declines rather than throwing: the run stops so a later attempt
|
||||
// retries, and no initialization error is raised.
|
||||
initializer.initializeIfNecessary.mockResolvedValueOnce(false);
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
|
||||
expect(api.getIssueManager().trigger).not.toHaveBeenCalledWith(
|
||||
'initialization',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle non-Error thrown during initialization', async () => {
|
||||
const api = createReadyAPI();
|
||||
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
// Throw a non-Error to exercise the else-branch in _runStep
|
||||
initializer.initializeMultipleIfNecessary.mockRejectedValueOnce('string error');
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
|
||||
expect(api.getIssueManager().trigger).toHaveBeenCalledWith(
|
||||
'initialization',
|
||||
expect.objectContaining({ error: 'string error' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should decline when the config vanishes mid-initialization', async () => {
|
||||
const api = createReadyAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
// The config disappears while languages load. The CAMERAS initializer
|
||||
// quietly does nothing without a configuration, so the run must stop
|
||||
// before that aspect would be marked initialized against nothing.
|
||||
vi.mocked(loadLanguages).mockImplementation(async () => {
|
||||
vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(false);
|
||||
});
|
||||
|
||||
const manager = new InitializationManager(api);
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(api.getCameraManager().initializeCamerasFromConfig).not.toHaveBeenCalled();
|
||||
expect(api.getIssueManager().trigger).not.toHaveBeenCalled();
|
||||
expect(stateManager.getState().initialized).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should subscribe automations before reporting the card started', async () => {
|
||||
const api = createReadyAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
// A trigger watching `initialized` or `config` must already be attached
|
||||
// when that state is written, so it can fire on that very change.
|
||||
const order: string[] = [];
|
||||
vi.mocked(api.getAutomationsManager().subscribe).mockImplementation(() => {
|
||||
order.push('subscribe');
|
||||
});
|
||||
stateManager.addListener(() => order.push('report'));
|
||||
|
||||
const manager = new InitializationManager(api);
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(order).toEqual(['subscribe', 'report']);
|
||||
});
|
||||
|
||||
it('should read the config when reporting the card started', async () => {
|
||||
const api = createReadyAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
// The configuration changes while the view initializes: the card must be
|
||||
// reported started against the configuration as it stands then, not as it
|
||||
// stood when the run began.
|
||||
const newConfig = createConfig({ menu: { style: 'none' } });
|
||||
vi.mocked(api.getViewManager().initialize).mockImplementation(async () => {
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(newConfig);
|
||||
return true;
|
||||
});
|
||||
|
||||
const manager = new InitializationManager(api);
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(stateManager.getState().initialized).toBe(true);
|
||||
expect(stateManager.getState().config).toBe(newConfig);
|
||||
});
|
||||
|
||||
it('should decline when the config is null at the end of a run', async () => {
|
||||
const api = createReadyAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
vi.mocked(api.getViewManager().initialize).mockImplementation(async () => {
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null);
|
||||
return true;
|
||||
});
|
||||
|
||||
const manager = new InitializationManager(api);
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(stateManager.getState().initialized).toBeUndefined();
|
||||
expect(api.getIssueManager().trigger).not.toHaveBeenCalled();
|
||||
|
||||
// A configuration can only return by being set, which invalidates the
|
||||
// VIEW aspect -- after which the next attempt reports normally.
|
||||
const config = createConfig();
|
||||
vi.mocked(api.getViewManager().initialize).mockResolvedValue(true);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(config);
|
||||
manager.invalidateAspect(InitializationAspect.VIEW);
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(stateManager.getState().initialized).toBe(true);
|
||||
expect(stateManager.getState().config).toBe(config);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle later runs in a session', () => {
|
||||
it('should end the session when a later run fails', async () => {
|
||||
const api = createReadyAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
const manager = new InitializationManager(api);
|
||||
|
||||
await manager.initializeMandatory();
|
||||
expect(stateManager.getState().initialized).toBe(true);
|
||||
|
||||
manager.invalidateAspect(InitializationAspect.VIEW);
|
||||
vi.mocked(api.getViewManager().initialize).mockRejectedValue(
|
||||
new Error('view failed'),
|
||||
);
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(api.getIssueManager().trigger).toHaveBeenCalledWith(
|
||||
'initialization',
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
);
|
||||
expect(stateManager.getState().initialized).toBe(false);
|
||||
|
||||
// A card that has come back down has still ever been initialized.
|
||||
expect(stateManager.getState().everInitialized).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep the session when a later run declines', async () => {
|
||||
const api = createReadyAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
const manager = new InitializationManager(api);
|
||||
|
||||
await manager.initializeMandatory();
|
||||
expect(stateManager.getState().initialized).toBe(true);
|
||||
|
||||
manager.invalidateAspect(InitializationAspect.VIEW);
|
||||
vi.mocked(api.getViewManager().initialize).mockResolvedValue(false);
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(stateManager.getState().initialized).toBe(true);
|
||||
expect(api.getIssueManager().trigger).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should refuse initializations that were overtaken', () => {
|
||||
it('should refuse an attempt queued before a disconnect', async () => {
|
||||
const api = createReadyAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
const manager = new InitializationManager(api);
|
||||
|
||||
let releaseCameras = (): void => {};
|
||||
const blockedCameras = new Promise<void>((resolve) => {
|
||||
releaseCameras = resolve;
|
||||
});
|
||||
vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockImplementation(
|
||||
() => blockedCameras,
|
||||
);
|
||||
|
||||
const first = manager.initializeMandatory();
|
||||
const second = manager.initializeMandatory();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(api.getCameraManager().initializeCamerasFromConfig).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
// The card leaves the page while the first initialization awaits the
|
||||
// cameras and the second attempt waits in the queue.
|
||||
vi.mocked(api.getCardElementManager().isConnected).mockReturnValue(false);
|
||||
manager.invalidateAspect(InitializationAspect.CAMERAS);
|
||||
manager.invalidateAspect(InitializationAspect.INITIAL_TRIGGER);
|
||||
manager.getSessionManager().end();
|
||||
|
||||
releaseCameras();
|
||||
await first;
|
||||
await second;
|
||||
|
||||
expect(api.getCameraManager().initializeCamerasFromConfig).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(stateManager.getState().initialized).toBeUndefined();
|
||||
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not raise an issue when a superseded initialization fails', async () => {
|
||||
const api = createReadyAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
const manager = new InitializationManager(api);
|
||||
|
||||
let failCameras = (): void => {};
|
||||
const blockedCameras = new Promise<void>((_, reject) => {
|
||||
failCameras = (): void => reject(new Error('cameras torn down'));
|
||||
});
|
||||
vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockImplementation(
|
||||
() => blockedCameras,
|
||||
);
|
||||
|
||||
const attempt = manager.initializeMandatory();
|
||||
await vi.waitFor(() =>
|
||||
expect(api.getCameraManager().initializeCamerasFromConfig).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
// The card leaves the page, and the in-flight camera work then fails
|
||||
// because of that very teardown. An error from a card state that no
|
||||
// longer exists must not become a full-card issue that greets the card
|
||||
// when it returns.
|
||||
vi.mocked(api.getCardElementManager().isConnected).mockReturnValue(false);
|
||||
manager.invalidateAspect(InitializationAspect.CAMERAS);
|
||||
manager.invalidateAspect(InitializationAspect.INITIAL_TRIGGER);
|
||||
manager.getSessionManager().end();
|
||||
|
||||
failCameras();
|
||||
await attempt;
|
||||
|
||||
expect(api.getIssueManager().trigger).not.toHaveBeenCalled();
|
||||
expect(stateManager.getState().initialized).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should decline when an aspect was invalidated mid-initialization', async () => {
|
||||
const api = createReadyAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
const manager = new InitializationManager(api);
|
||||
|
||||
// A configuration change lands while the initial trigger step runs,
|
||||
// invalidating an aspect an earlier step already completed. Reporting the
|
||||
// card started then would call it ready with stale cameras -- and the
|
||||
// initialization that follows would not make `initialized` change again.
|
||||
vi.mocked(
|
||||
api.getCameraTriggersManager().handleInitialCameraTriggers,
|
||||
).mockImplementation(async () => {
|
||||
manager.invalidateAspect(InitializationAspect.CAMERAS);
|
||||
return true;
|
||||
});
|
||||
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(stateManager.getState().initialized).toBeUndefined();
|
||||
expect(api.getIssueManager().trigger).not.toHaveBeenCalled();
|
||||
|
||||
// The next attempt initializes the invalidated aspect and reports the
|
||||
// card started.
|
||||
await manager.initializeMandatory();
|
||||
|
||||
expect(stateManager.getState().initialized).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should invalidate aspects', () => {
|
||||
const createInitializedAPI = (): {
|
||||
api: ReturnType<typeof createCardAPI>;
|
||||
initializer: ReturnType<typeof mock<Initializer>>;
|
||||
stateManager: ConditionStateManager;
|
||||
} => {
|
||||
const api = createCardAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
stateManager.setState({ initialized: true, everInitialized: true });
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
return { api, initializer: mock<Initializer>(), stateManager };
|
||||
};
|
||||
|
||||
it('should invalidate mandatory aspects without ending the session', () => {
|
||||
const { api, initializer, stateManager } = createInitializedAPI();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
manager.invalidateMandatoryAspects();
|
||||
|
||||
expect(initializer.uninitialize).toHaveBeenCalledWith(
|
||||
InitializationAspect.CAMERAS,
|
||||
);
|
||||
expect(initializer.uninitialize).toHaveBeenCalledWith(
|
||||
InitializationAspect.MICROPHONE_CONNECT,
|
||||
);
|
||||
expect(initializer.uninitialize).toHaveBeenCalledWith(
|
||||
InitializationAspect.TEMPLATE_RENDERER,
|
||||
);
|
||||
expect(initializer.uninitialize).toHaveBeenCalledWith(InitializationAspect.VIEW);
|
||||
expect(initializer.uninitialize).toHaveBeenCalledWith(
|
||||
InitializationAspect.INITIAL_TRIGGER,
|
||||
);
|
||||
expect(stateManager.getState().initialized).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep the current session when an aspect is being invalidated', () => {
|
||||
const { api, initializer, stateManager } = createInitializedAPI();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
manager.invalidateAspect(InitializationAspect.CAMERAS);
|
||||
|
||||
expect(initializer.uninitialize).toHaveBeenCalledWith(
|
||||
InitializationAspect.CAMERAS,
|
||||
);
|
||||
expect(stateManager.getState().initialized).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should decide whether to trigger initialization', () => {
|
||||
it('should initialize when all conditions are met', () => {
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(createReadyAPI(), initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not initialize without config', () => {
|
||||
const api = createReadyAPI();
|
||||
vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(false);
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not initialize when the element is disconnected', () => {
|
||||
const api = createReadyAPI();
|
||||
vi.mocked(api.getCardElementManager().isConnected).mockReturnValue(false);
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not initialize when hass is not ready', () => {
|
||||
const api = createReadyAPI();
|
||||
const hass = createHASS();
|
||||
hass.connected = true;
|
||||
hass.config.state = STATE_STARTING;
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not initialize when already initialized', () => {
|
||||
const initializer = mock<Initializer>();
|
||||
initializer.isInitializedMultiple.mockReturnValue(true);
|
||||
const manager = new InitializationManager(createReadyAPI(), initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not initialize while a full-card issue is shown', () => {
|
||||
const api = createReadyAPI();
|
||||
vi.mocked(
|
||||
api.getIssueManager().getStateManager().hasFullCardIssue,
|
||||
).mockReturnValue(true);
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { RawAdvancedCameraCardConfig } from '../../../src/config/types';
|
||||
import { MountedCard } from '../../browser/mounted-card';
|
||||
import {
|
||||
createStillCameraHASS,
|
||||
createStillImageCameraConfig,
|
||||
createStillImageCardConfig,
|
||||
isMediaLoadedInfoEventDetail,
|
||||
} from '../../browser/test-utils';
|
||||
|
||||
const STARTED_MESSAGE = 'card-started';
|
||||
const OTHER_CAMERA_ENTITY = 'camera.other';
|
||||
|
||||
const createConfig = (
|
||||
overrides?: Partial<RawAdvancedCameraCardConfig>,
|
||||
): RawAdvancedCameraCardConfig =>
|
||||
createStillImageCardConfig({
|
||||
automations: [
|
||||
{
|
||||
triggers: [{ trigger: 'initialized' }],
|
||||
actions: [
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'log',
|
||||
message: STARTED_MESSAGE,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mount = async (
|
||||
overrides?: Partial<RawAdvancedCameraCardConfig>,
|
||||
): Promise<MountedCard> =>
|
||||
await MountedCard.create(
|
||||
createConfig(overrides),
|
||||
createStillCameraHASS({ cameras: [OTHER_CAMERA_ENTITY] }),
|
||||
);
|
||||
|
||||
// Only the messages the automation logged, since the card logs other things at
|
||||
// the same level.
|
||||
const getStartedMessages = (card: MountedCard): string[] =>
|
||||
card.console.getMessages('info').filter((message) => message === STARTED_MESSAGE);
|
||||
|
||||
// The cameras the card has actually loaded media for, in order. Media that
|
||||
// named no camera is left out, since these are only read to ask which camera
|
||||
// the card ended up on.
|
||||
const getLoadedCameraIDs = (card: MountedCard): string[] =>
|
||||
card.events
|
||||
.getEntries('advanced-camera-card:media:loaded')
|
||||
.map((entry) => entry.detail)
|
||||
.filter(isMediaLoadedInfoEventDetail)
|
||||
.map((detail) => detail.info.targetID)
|
||||
.filter((targetID): targetID is string => targetID !== undefined);
|
||||
|
||||
describe('SessionManager', () => {
|
||||
it('should fire an initialized trigger each time the card starts', async () => {
|
||||
const card = await mount();
|
||||
|
||||
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(1));
|
||||
|
||||
// The card leaving the page is a change of the value the trigger watches,
|
||||
// and must not fire it.
|
||||
card.detach();
|
||||
await card.updateComplete;
|
||||
|
||||
expect(getStartedMessages(card)).toHaveLength(1);
|
||||
|
||||
card.attach();
|
||||
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(2));
|
||||
});
|
||||
|
||||
it('should start the card again once Home Assistant comes back', async () => {
|
||||
const card = await mount();
|
||||
|
||||
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(1));
|
||||
|
||||
// Losing Home Assistant changes the value the trigger watches, and must not
|
||||
// fire it.
|
||||
card.setConnected(false);
|
||||
await card.updateComplete;
|
||||
|
||||
expect(getStartedMessages(card)).toHaveLength(1);
|
||||
|
||||
card.setConnected(true);
|
||||
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(2));
|
||||
});
|
||||
|
||||
it('should initialize the new cameras without starting the card again', async () => {
|
||||
const card = await mount();
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(1));
|
||||
|
||||
// A camera change is the heaviest configuration change there is: the
|
||||
// cameras are destroyed and initialized again.
|
||||
card.setConfig(
|
||||
createConfig({ cameras: [createStillImageCameraConfig(OTHER_CAMERA_ENTITY)] }),
|
||||
);
|
||||
|
||||
// Media loading for the new camera is what says the change took effect at
|
||||
// all, without which the assertion below would pass on a card that ignored
|
||||
// the configuration.
|
||||
await vi.waitFor(() =>
|
||||
expect(getLoadedCameraIDs(card)).toContain(OTHER_CAMERA_ENTITY),
|
||||
);
|
||||
|
||||
expect(getStartedMessages(card)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should apply an override keyed on the card being started every time it starts', async () => {
|
||||
// The menu is configured away and the override is the only thing that
|
||||
// brings it back, so a menu on screen means the condition matched.
|
||||
const card = await mount({
|
||||
menu: { style: 'none' },
|
||||
overrides: [
|
||||
{
|
||||
conditions: [{ condition: 'initialized' }],
|
||||
merge: { menu: { style: 'outside' } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await card.waitForSelector('advanced-camera-card-menu');
|
||||
|
||||
card.detach();
|
||||
card.attach();
|
||||
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(2));
|
||||
|
||||
await card.waitForSelector('advanced-camera-card-menu');
|
||||
});
|
||||
|
||||
it('should not show the loading indicator again once the card has started', async () => {
|
||||
const card = await mount({
|
||||
performance: { features: { card_loading_indicator: true } },
|
||||
});
|
||||
|
||||
const loading = await card.waitForSelector('advanced-camera-card-loading');
|
||||
await vi.waitFor(() => expect(loading.hasAttribute('loaded')).toBe(true));
|
||||
|
||||
card.detach();
|
||||
card.attach();
|
||||
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(2));
|
||||
|
||||
// A card that has been on screen once must not show the loading indicator
|
||||
// again when it is re-attached.
|
||||
expect(
|
||||
(await card.waitForSelector('advanced-camera-card-loading')).hasAttribute(
|
||||
'loaded',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
SessionManager,
|
||||
SessionState,
|
||||
} from '../../../src/card-controller/initialization/session-manager';
|
||||
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
|
||||
import { createTriggerEvaluator } from '../../../src/condition-trigger/triggers/factory';
|
||||
import { initializedTriggerSchema } from '../../../src/config/schema/condition-trigger/triggers/custom/initialized';
|
||||
import { createTriggerEvaluatorContext } from '../../condition-trigger/triggers/triggers/test-utils';
|
||||
import { createConfig } from '../../config/test-utils';
|
||||
import { createCardAPI } from '../../test-utils';
|
||||
|
||||
const createSessionManager = (): {
|
||||
sessionManager: SessionManager;
|
||||
stateManager: ConditionStateManager;
|
||||
} => {
|
||||
const api = createCardAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
return { sessionManager: new SessionManager(api), stateManager };
|
||||
};
|
||||
|
||||
// Take a session through a successful initialization run, leaving the card
|
||||
// started (RUNNING).
|
||||
const completeInitialization = (
|
||||
sessionManager: SessionManager,
|
||||
config = createConfig(),
|
||||
): void => {
|
||||
sessionManager.reportInitializationSucceeded(
|
||||
sessionManager.startInitialization(),
|
||||
config,
|
||||
);
|
||||
};
|
||||
|
||||
describe('SessionManager', () => {
|
||||
it('should start idle with nothing published', () => {
|
||||
const { sessionManager, stateManager } = createSessionManager();
|
||||
|
||||
expect(sessionManager.getState()).toBe(SessionState.IDLE);
|
||||
expect(sessionManager.wasEverInitialized()).toBeFalsy();
|
||||
expect(stateManager.getState().initialized).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('should start initializations', () => {
|
||||
it('should start the first initialization of a session', () => {
|
||||
const { sessionManager } = createSessionManager();
|
||||
|
||||
const token = sessionManager.startInitialization();
|
||||
|
||||
expect(sessionManager.getState()).toBe(SessionState.INITIALIZING);
|
||||
expect(sessionManager.isCurrentInitialization(token)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should keep a started card while an aspect is initialized again', () => {
|
||||
const { sessionManager } = createSessionManager();
|
||||
completeInitialization(sessionManager);
|
||||
|
||||
sessionManager.startInitialization();
|
||||
|
||||
expect(sessionManager.getState()).toBe(SessionState.RUNNING);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should report a successful initialization', () => {
|
||||
it('should publish the session and config in one change', () => {
|
||||
const { sessionManager, stateManager } = createSessionManager();
|
||||
const listener = vi.fn();
|
||||
stateManager.addListener(listener);
|
||||
const config = createConfig();
|
||||
|
||||
sessionManager.reportInitializationSucceeded(
|
||||
sessionManager.startInitialization(),
|
||||
config,
|
||||
);
|
||||
|
||||
expect(sessionManager.getState()).toBe(SessionState.RUNNING);
|
||||
expect(sessionManager.wasEverInitialized()).toBeTruthy();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
change: { config: config, initialized: true, everInitialized: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should republish only the config on a later initialization', () => {
|
||||
const { sessionManager, stateManager } = createSessionManager();
|
||||
completeInitialization(sessionManager);
|
||||
|
||||
const listener = vi.fn();
|
||||
stateManager.addListener(listener);
|
||||
const newConfig = createConfig({ menu: { style: 'none' } });
|
||||
sessionManager.reportInitializationSucceeded(
|
||||
sessionManager.startInitialization(),
|
||||
newConfig,
|
||||
);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
change: { config: newConfig },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should publish nothing on a later initialization with an unchanged config', () => {
|
||||
const { sessionManager, stateManager } = createSessionManager();
|
||||
completeInitialization(sessionManager);
|
||||
|
||||
const listener = vi.fn();
|
||||
stateManager.addListener(listener);
|
||||
sessionManager.reportInitializationSucceeded(
|
||||
sessionManager.startInitialization(),
|
||||
createConfig(),
|
||||
);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should decline an initialization', () => {
|
||||
it('should return to idle before the card has started', () => {
|
||||
const { sessionManager, stateManager } = createSessionManager();
|
||||
const listener = vi.fn();
|
||||
stateManager.addListener(listener);
|
||||
|
||||
sessionManager.reportInitializationDeclined(sessionManager.startInitialization());
|
||||
|
||||
expect(sessionManager.getState()).toBe(SessionState.IDLE);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should keep a started card', () => {
|
||||
const { sessionManager, stateManager } = createSessionManager();
|
||||
completeInitialization(sessionManager);
|
||||
|
||||
const listener = vi.fn();
|
||||
stateManager.addListener(listener);
|
||||
sessionManager.reportInitializationDeclined(sessionManager.startInitialization());
|
||||
|
||||
expect(sessionManager.getState()).toBe(SessionState.RUNNING);
|
||||
expect(stateManager.getState().initialized).toBe(true);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should fail an initialization', () => {
|
||||
it('should return to idle before the card has started', () => {
|
||||
const { sessionManager, stateManager } = createSessionManager();
|
||||
const listener = vi.fn();
|
||||
stateManager.addListener(listener);
|
||||
|
||||
sessionManager.reportInitializationFailed(sessionManager.startInitialization());
|
||||
|
||||
expect(sessionManager.getState()).toBe(SessionState.IDLE);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should end a started card', () => {
|
||||
const { sessionManager, stateManager } = createSessionManager();
|
||||
completeInitialization(sessionManager);
|
||||
|
||||
sessionManager.reportInitializationFailed(sessionManager.startInitialization());
|
||||
|
||||
expect(sessionManager.getState()).toBe(SessionState.IDLE);
|
||||
expect(stateManager.getState().initialized).toBe(false);
|
||||
|
||||
// A card that has been turndown has still been "ever initialized".
|
||||
expect(stateManager.getState().everInitialized).toBe(true);
|
||||
expect(sessionManager.wasEverInitialized()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should end a session', () => {
|
||||
it('should return to idle after an ended sessiond', () => {
|
||||
const { sessionManager, stateManager } = createSessionManager();
|
||||
completeInitialization(sessionManager);
|
||||
|
||||
sessionManager.end();
|
||||
|
||||
expect(sessionManager.getState()).toBe(SessionState.IDLE);
|
||||
expect(stateManager.getState().initialized).toBe(false);
|
||||
});
|
||||
|
||||
it('should write nothing before the card has started', () => {
|
||||
const { sessionManager, stateManager } = createSessionManager();
|
||||
const listener = vi.fn();
|
||||
stateManager.addListener(listener);
|
||||
|
||||
sessionManager.startInitialization();
|
||||
sessionManager.end();
|
||||
|
||||
expect(sessionManager.getState()).toBe(SessionState.IDLE);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should leave the card reported as ever initialized', () => {
|
||||
const { sessionManager, stateManager } = createSessionManager();
|
||||
completeInitialization(sessionManager);
|
||||
|
||||
sessionManager.end();
|
||||
|
||||
expect(stateManager.getState().everInitialized).toBe(true);
|
||||
expect(sessionManager.wasEverInitialized()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should refuse stale tokens', () => {
|
||||
it('should refuse a token from before the session ended', () => {
|
||||
const { sessionManager, stateManager } = createSessionManager();
|
||||
const listener = vi.fn();
|
||||
stateManager.addListener(listener);
|
||||
|
||||
const token = sessionManager.startInitialization();
|
||||
sessionManager.end();
|
||||
|
||||
expect(sessionManager.isCurrentInitialization(token)).toBeFalsy();
|
||||
|
||||
sessionManager.reportInitializationSucceeded(token, createConfig());
|
||||
sessionManager.reportInitializationDeclined(token);
|
||||
sessionManager.reportInitializationFailed(token);
|
||||
|
||||
expect(sessionManager.getState()).toBe(SessionState.IDLE);
|
||||
expect(sessionManager.wasEverInitialized()).toBeFalsy();
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refuse a token that was already used', () => {
|
||||
const { sessionManager, stateManager } = createSessionManager();
|
||||
const listener = vi.fn();
|
||||
stateManager.addListener(listener);
|
||||
|
||||
const config = createConfig();
|
||||
const token = sessionManager.startInitialization();
|
||||
sessionManager.reportInitializationSucceeded(token, config);
|
||||
sessionManager.reportInitializationSucceeded(
|
||||
token,
|
||||
createConfig({ menu: { style: 'none' } }),
|
||||
);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(stateManager.getState().config).toBe(config);
|
||||
});
|
||||
});
|
||||
|
||||
// The user-facing behaviour the machine exists for, driven through the real
|
||||
// schema, trigger factory and evaluator rather than hand-written state.
|
||||
describe('should drive the initialized trigger', () => {
|
||||
it('should fire once per session and not when a session ends', () => {
|
||||
const { sessionManager, stateManager } = createSessionManager();
|
||||
|
||||
const trigger = initializedTriggerSchema.parse({ trigger: 'initialized' });
|
||||
const evaluator = createTriggerEvaluator(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
const callback = vi.fn();
|
||||
evaluator.subscribe(callback);
|
||||
|
||||
completeInitialization(sessionManager);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The session ending is a true -> false change of the watched value, but
|
||||
// must not fire the trigger.
|
||||
sessionManager.end();
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
completeInitialization(sessionManager);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user