feat: add problem detection framework for common problems (#2412)
Introduces a ProblemManager that detects and surfaces actionable issues (stale config, legacy frigate-hass-card resources, slow/failed streams) via status bar indicators and notification popups with fix actions.
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { ProblemManager } from '../../../src/card-controller/problems/manager';
|
||||
import { ConfigUpgradeProblem } from '../../../src/card-controller/problems/problems/config-upgrade';
|
||||
import { LegacyResourceProblem } from '../../../src/card-controller/problems/problems/legacy-resource';
|
||||
import { StreamNotLoadingProblem } from '../../../src/card-controller/problems/problems/stream-not-loading';
|
||||
import { Problem, ProblemResult } from '../../../src/card-controller/problems/types';
|
||||
import { ConditionStateManager } from '../../../src/conditions/state-manager';
|
||||
import { createCardAPI, createHASS } from '../../test-utils';
|
||||
|
||||
vi.mock('../../../src/card-controller/problems/problems/config-upgrade');
|
||||
vi.mock('../../../src/card-controller/problems/problems/legacy-resource');
|
||||
vi.mock('../../../src/card-controller/problems/problems/stream-not-loading');
|
||||
|
||||
const createProblemResult = (overrides?: Partial<ProblemResult>): ProblemResult => ({
|
||||
icon: 'mdi:test',
|
||||
severity: 'high',
|
||||
notification: {
|
||||
heading: {
|
||||
text: 'Test heading',
|
||||
icon: 'mdi:test',
|
||||
severity: 'high',
|
||||
},
|
||||
text: 'Test text',
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('ProblemManager', () => {
|
||||
let mockConfigUpgrade: Problem;
|
||||
let mockLegacyResource: Problem;
|
||||
let mockStreamNotLoading: Problem;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockConfigUpgrade = mock<Problem>({ key: 'config_upgrade' });
|
||||
mockLegacyResource = mock<Problem>({ key: 'legacy_resource' });
|
||||
mockStreamNotLoading = mock<Problem>({ key: 'stream_not_loading' });
|
||||
|
||||
vi.mocked(ConfigUpgradeProblem).mockImplementation(
|
||||
() => mockConfigUpgrade as unknown as ConfigUpgradeProblem,
|
||||
);
|
||||
vi.mocked(LegacyResourceProblem).mockImplementation(
|
||||
() => mockLegacyResource as unknown as LegacyResourceProblem,
|
||||
);
|
||||
vi.mocked(StreamNotLoadingProblem).mockImplementation(
|
||||
() => mockStreamNotLoading as unknown as StreamNotLoadingProblem,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass config getter to ConfigUpgradeProblem', () => {
|
||||
const api = createCardAPI();
|
||||
new ProblemManager(api);
|
||||
|
||||
const callback = vi.mocked(ConfigUpgradeProblem).mock.calls[0][0];
|
||||
callback();
|
||||
|
||||
expect(api.getConfigManager().getRawConfig).toBeCalled();
|
||||
});
|
||||
|
||||
it('should pass update callback to LegacyResourceProblem', () => {
|
||||
const api = createCardAPI();
|
||||
new ProblemManager(api);
|
||||
|
||||
const callback = vi.mocked(LegacyResourceProblem).mock.calls[0][0];
|
||||
callback();
|
||||
|
||||
expect(api.getCardElementManager().update).toBeCalled();
|
||||
});
|
||||
|
||||
it('should pass update callback to StreamNotLoadingProblem', () => {
|
||||
const api = createCardAPI();
|
||||
new ProblemManager(api);
|
||||
|
||||
const callback = vi.mocked(StreamNotLoadingProblem).mock.calls[0][0];
|
||||
callback();
|
||||
|
||||
expect(api.getCardElementManager().update).toBeCalled();
|
||||
});
|
||||
|
||||
it('should register all built-in problems on construction', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new ProblemManager(api);
|
||||
const presence = manager.getProblemPresence();
|
||||
|
||||
expect('config_upgrade' in presence).toBe(true);
|
||||
expect('legacy_resource' in presence).toBe(true);
|
||||
expect('stream_not_loading' in presence).toBe(true);
|
||||
});
|
||||
|
||||
describe('detectStatic', () => {
|
||||
it('should call detectStatic on all problems', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new ProblemManager(api);
|
||||
const hass = createHASS();
|
||||
|
||||
await manager.detectStatic(hass);
|
||||
|
||||
expect(mockConfigUpgrade.detectStatic).toBeCalledWith(hass);
|
||||
expect(mockLegacyResource.detectStatic).toBeCalledWith(hass);
|
||||
expect(mockStreamNotLoading.detectStatic).toBeCalledWith(hass);
|
||||
expect(api.getCardElementManager().update).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger', () => {
|
||||
it('should trigger a problem and update when state changes', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
|
||||
const manager = new ProblemManager(api);
|
||||
vi.mocked(mockStreamNotLoading.hasResult)
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValue(true);
|
||||
|
||||
manager.trigger('stream_not_loading');
|
||||
|
||||
expect(mockStreamNotLoading.trigger).toBeCalled();
|
||||
expect(api.getCardElementManager().update).toBeCalled();
|
||||
});
|
||||
|
||||
it('should do nothing for unknown key', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new ProblemManager(api);
|
||||
|
||||
manager.trigger(('stream_not_loading' + '_unknown') as never);
|
||||
|
||||
expect(mockStreamNotLoading.trigger).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not update when trigger does not change state', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
|
||||
const manager = new ProblemManager(api);
|
||||
vi.mocked(mockStreamNotLoading.hasResult).mockReturnValue(false);
|
||||
|
||||
manager.trigger('stream_not_loading');
|
||||
|
||||
expect(mockStreamNotLoading.trigger).toBeCalled();
|
||||
expect(api.getCardElementManager().update).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('forceNotify', () => {
|
||||
it('should show notification from getNotification', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new ProblemManager(api);
|
||||
const notification = { text: 'from getNotification' };
|
||||
mockStreamNotLoading.getNotification = vi.fn().mockReturnValue(notification);
|
||||
|
||||
manager.forceNotify('stream_not_loading');
|
||||
|
||||
expect(api.getNotificationManager().setNotification).toBeCalledWith(notification);
|
||||
});
|
||||
|
||||
it('should not show notification when getNotification returns null', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new ProblemManager(api);
|
||||
|
||||
manager.forceNotify('config_upgrade');
|
||||
|
||||
expect(api.getNotificationManager().setNotification).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProblemResults', () => {
|
||||
it('should return results for active problems', () => {
|
||||
const api = createCardAPI();
|
||||
const result = createProblemResult();
|
||||
vi.mocked(mockConfigUpgrade.getResult).mockReturnValue(result);
|
||||
|
||||
const manager = new ProblemManager(api);
|
||||
|
||||
expect(manager.getProblemResults()).toEqual([
|
||||
{ key: 'config_upgrade', problem: result },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array when no problems active', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new ProblemManager(api);
|
||||
|
||||
expect(manager.getProblemResults()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProblemPresence', () => {
|
||||
it('should return presence map', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(mockConfigUpgrade.hasResult).mockReturnValue(true);
|
||||
vi.mocked(mockLegacyResource.hasResult).mockReturnValue(false);
|
||||
|
||||
const manager = new ProblemManager(api);
|
||||
|
||||
expect(manager.getProblemPresence()).toMatchObject({
|
||||
['config_upgrade']: true,
|
||||
['legacy_resource']: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('state change handling', () => {
|
||||
it('should detect dynamic problems on view change', () => {
|
||||
const api = createCardAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
const manager = new ProblemManager(api);
|
||||
vi.mocked(mockStreamNotLoading.hasResult)
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValue(true);
|
||||
|
||||
manager.initialize();
|
||||
|
||||
stateManager.setState({ view: 'live' });
|
||||
|
||||
expect(mockStreamNotLoading.detectDynamic).toBeCalledWith({
|
||||
view: 'live',
|
||||
mediaLoaded: false,
|
||||
});
|
||||
expect(api.getCardElementManager().update).toBeCalled();
|
||||
});
|
||||
|
||||
it('should detect dynamic problems on mediaLoadedInfo change', () => {
|
||||
const api = createCardAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
const manager = new ProblemManager(api);
|
||||
|
||||
manager.initialize();
|
||||
|
||||
stateManager.setState({
|
||||
mediaLoadedInfo: { width: 1920, height: 1080 },
|
||||
});
|
||||
|
||||
expect(mockStreamNotLoading.detectDynamic).toBeCalledWith(
|
||||
expect.objectContaining({ mediaLoaded: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not update when dynamic detection does not change state', () => {
|
||||
const api = createCardAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
const manager = new ProblemManager(api);
|
||||
vi.mocked(mockStreamNotLoading.hasResult).mockReturnValue(false);
|
||||
|
||||
manager.initialize();
|
||||
|
||||
stateManager.setState({ view: 'live' });
|
||||
|
||||
expect(mockStreamNotLoading.detectDynamic).toBeCalled();
|
||||
expect(api.getCardElementManager().update).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninitialize', () => {
|
||||
it('should remove state listener', () => {
|
||||
const api = createCardAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
const manager = new ProblemManager(api);
|
||||
|
||||
manager.initialize();
|
||||
manager.uninitialize();
|
||||
|
||||
stateManager.setState({ view: 'live' });
|
||||
|
||||
expect(mockStreamNotLoading.detectDynamic).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should destroy all problems and clear', () => {
|
||||
const api = createCardAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
const manager = new ProblemManager(api);
|
||||
|
||||
manager.initialize();
|
||||
manager.destroy();
|
||||
|
||||
expect(mockConfigUpgrade.destroy).toBeCalled();
|
||||
expect(mockLegacyResource.destroy).toBeCalled();
|
||||
expect(mockStreamNotLoading.destroy).toBeCalled();
|
||||
expect(manager.getProblemPresence()).toEqual({});
|
||||
|
||||
// State changes after destroy should not trigger detection.
|
||||
stateManager.setState({ view: 'live' });
|
||||
|
||||
expect(mockStreamNotLoading.detectDynamic).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ConfigUpgradeProblem } from '../../../../src/card-controller/problems/problems/config-upgrade';
|
||||
import { isConfigUpgradeable } from '../../../../src/config/management';
|
||||
|
||||
vi.mock('../../../../src/config/management.js');
|
||||
|
||||
describe('ConfigUpgradeProblem', () => {
|
||||
it('should have correct key', () => {
|
||||
const problem = new ConfigUpgradeProblem(() => null);
|
||||
expect(problem.key).toBe('config_upgrade');
|
||||
});
|
||||
|
||||
it('should detect upgradeable config', async () => {
|
||||
vi.mocked(isConfigUpgradeable).mockReturnValue(true);
|
||||
const rawConfig = { type: 'custom:frigate-card' };
|
||||
const problem = new ConfigUpgradeProblem(() => rawConfig);
|
||||
|
||||
await problem.detectStatic();
|
||||
|
||||
expect(problem.hasResult()).toBe(true);
|
||||
expect(isConfigUpgradeable).toBeCalledWith(rawConfig);
|
||||
});
|
||||
|
||||
it('should detect non-upgradeable config', async () => {
|
||||
vi.mocked(isConfigUpgradeable).mockReturnValue(false);
|
||||
const rawConfig = { type: 'custom:advanced-camera-card' };
|
||||
const problem = new ConfigUpgradeProblem(() => rawConfig);
|
||||
|
||||
await problem.detectStatic();
|
||||
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null raw config', async () => {
|
||||
const problem = new ConfigUpgradeProblem(() => null);
|
||||
|
||||
await problem.detectStatic();
|
||||
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
expect(problem.getResult()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return result when upgradeable', async () => {
|
||||
vi.mocked(isConfigUpgradeable).mockReturnValue(true);
|
||||
const problem = new ConfigUpgradeProblem(() => ({ type: 'custom:frigate-card' }));
|
||||
|
||||
await problem.detectStatic();
|
||||
|
||||
const result = problem.getResult();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
icon: 'mdi:update',
|
||||
severity: 'medium',
|
||||
notification: expect.objectContaining({
|
||||
heading: expect.objectContaining({
|
||||
icon: 'mdi:update',
|
||||
severity: 'medium',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { LegacyResourceProblem } from '../../../../src/card-controller/problems/problems/legacy-resource';
|
||||
import { HomeAssistant } from '../../../../src/ha/types';
|
||||
import { createCardAPI, createHASS, createUser } from '../../../test-utils';
|
||||
|
||||
const setupHASSResources = (
|
||||
hass: HomeAssistant,
|
||||
resources: { id: string; type: string; url: string }[],
|
||||
): void => {
|
||||
vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123');
|
||||
vi.mocked(hass.callWS).mockResolvedValue(resources);
|
||||
};
|
||||
|
||||
describe('LegacyResourceProblem', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should have correct key', () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
expect(problem.key).toBe('legacy_resource');
|
||||
});
|
||||
|
||||
describe('detectStatic', () => {
|
||||
it('should skip non-admin users', async () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
const hass = createHASS(undefined, createUser({ is_admin: false }));
|
||||
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect legacy resource regardless of directory', async () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
const hass = createHASS(undefined, createUser({ is_admin: true }));
|
||||
setupHASSResources(hass, [
|
||||
{
|
||||
id: '1',
|
||||
type: 'module',
|
||||
url: '/some/arbitrary/path/frigate-hass-card.js?v=1',
|
||||
},
|
||||
]);
|
||||
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
expect(problem.hasResult()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect when only advanced-camera-card exists', async () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
const hass = createHASS(undefined, createUser({ is_admin: true }));
|
||||
setupHASSResources(hass, [
|
||||
{
|
||||
id: '1',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
|
||||
},
|
||||
]);
|
||||
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle invalid resource data', async () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
const hass = createHASS(undefined, createUser({ is_admin: true }));
|
||||
vi.mocked(hass.callWS).mockResolvedValue('not-an-array');
|
||||
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle websocket failure', async () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
const hass = createHASS(undefined, createUser({ is_admin: true }));
|
||||
vi.mocked(hass.callWS).mockRejectedValue(new Error('connection lost'));
|
||||
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle missing user', async () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
const hass = createHASS();
|
||||
Object.defineProperty(hass, 'user', { value: undefined });
|
||||
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResult', () => {
|
||||
it('should return controls and link when both resources exist', async () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
const hass = createHASS(undefined, createUser({ is_admin: true }));
|
||||
setupHASSResources(hass, [
|
||||
{
|
||||
id: '1',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
|
||||
},
|
||||
]);
|
||||
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
const result = problem.getResult();
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.notification.controls).toHaveLength(1);
|
||||
expect(result?.notification.link).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return link without controls when only legacy exists', async () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
const hass = createHASS(undefined, createUser({ is_admin: true }));
|
||||
setupHASSResources(hass, [
|
||||
{
|
||||
id: '1',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
|
||||
},
|
||||
]);
|
||||
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
const result = problem.getResult();
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.notification.link).toBeDefined();
|
||||
expect(result?.notification.controls).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return null when no result', () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
expect(problem.getResult()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fix', () => {
|
||||
it('should remove legacy resources when correct resource exists', async () => {
|
||||
const triggerUpdate = vi.fn();
|
||||
const problem = new LegacyResourceProblem(triggerUpdate);
|
||||
const hass = createHASS(undefined, createUser({ is_admin: true }));
|
||||
vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123');
|
||||
|
||||
vi.mocked(hass.callWS).mockResolvedValueOnce([
|
||||
{
|
||||
id: '1',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
|
||||
},
|
||||
]);
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
vi.mocked(hass.callWS)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: '2',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await problem.fix(hass);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(hass.callWS).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'lovelace/resources/delete',
|
||||
resource_id: '1',
|
||||
}),
|
||||
);
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
expect(triggerUpdate).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not fix when only legacy resource exists', async () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
const hass = createHASS(undefined, createUser({ is_admin: true }));
|
||||
setupHASSResources(hass, [
|
||||
{
|
||||
id: '1',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
|
||||
},
|
||||
]);
|
||||
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
const result = await problem.fix(hass);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not fix for non-admin', async () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
const hass = createHASS(undefined, createUser({ is_admin: false }));
|
||||
|
||||
const result = await problem.fix(hass);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false on websocket failure during fix', async () => {
|
||||
const triggerUpdate = vi.fn();
|
||||
const problem = new LegacyResourceProblem(triggerUpdate);
|
||||
const hass = createHASS(undefined, createUser({ is_admin: true }));
|
||||
vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123');
|
||||
vi.mocked(hass.callWS).mockResolvedValueOnce([
|
||||
{
|
||||
id: '1',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
|
||||
},
|
||||
]);
|
||||
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
vi.mocked(hass.callWS).mockRejectedValue(new Error('connection lost'));
|
||||
|
||||
const result = await problem.fix(hass);
|
||||
expect(result).toBe(false);
|
||||
expect(triggerUpdate).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should return false when re-detection still finds legacy resource', async () => {
|
||||
const triggerUpdate = vi.fn();
|
||||
const problem = new LegacyResourceProblem(triggerUpdate);
|
||||
const hass = createHASS(undefined, createUser({ is_admin: true }));
|
||||
vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123');
|
||||
|
||||
vi.mocked(hass.callWS).mockResolvedValueOnce([
|
||||
{
|
||||
id: '1',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
|
||||
},
|
||||
]);
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
// Delete succeeds, but re-detection still finds the legacy resource.
|
||||
vi.mocked(hass.callWS)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: '1',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await problem.fix(hass);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(triggerUpdate).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should fix multiple legacy resources', async () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
const hass = createHASS(undefined, createUser({ is_admin: true }));
|
||||
vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123');
|
||||
vi.mocked(hass.callWS).mockResolvedValueOnce([
|
||||
{
|
||||
id: '1',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
|
||||
},
|
||||
{ id: '3', type: 'module', url: '/local/frigate-hass-card.js' },
|
||||
{
|
||||
id: '2',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
|
||||
},
|
||||
]);
|
||||
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
vi.mocked(hass.callWS)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: '2',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(await problem.fix(hass)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResourcePath fallback', () => {
|
||||
it('should handle invalid URLs by stripping query string', async () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
const hass = createHASS(undefined, createUser({ is_admin: true }));
|
||||
vi.mocked(hass.hassUrl).mockReturnValue('not-a-valid-url');
|
||||
vi.mocked(hass.callWS).mockResolvedValue([
|
||||
{
|
||||
id: '1',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js?v=1',
|
||||
},
|
||||
]);
|
||||
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
expect(problem.hasResult()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle invalid URLs without query string', async () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
const hass = createHASS(undefined, createUser({ is_admin: true }));
|
||||
vi.mocked(hass.hassUrl).mockReturnValue('not-a-valid-url');
|
||||
vi.mocked(hass.callWS).mockResolvedValue([
|
||||
{
|
||||
id: '1',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
|
||||
},
|
||||
]);
|
||||
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
expect(problem.hasResult()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callback action', () => {
|
||||
const getCallback = (
|
||||
problem: LegacyResourceProblem,
|
||||
): ((api: unknown) => Promise<void>) | null => {
|
||||
const result = problem.getResult();
|
||||
const action = result?.notification.controls?.[0]?.actions?.tap_action;
|
||||
if (action && 'callback' in action) {
|
||||
return (action as { callback: (api: unknown) => Promise<void> }).callback;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
it('should call fix via the notification control action', async () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
const hass = createHASS(undefined, createUser({ is_admin: true }));
|
||||
vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123');
|
||||
vi.mocked(hass.callWS).mockResolvedValueOnce([
|
||||
{
|
||||
id: '1',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
|
||||
},
|
||||
]);
|
||||
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
const callback = getCallback(problem);
|
||||
expect(callback).toBeDefined();
|
||||
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
||||
|
||||
vi.mocked(hass.callWS)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: '2',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
|
||||
},
|
||||
]);
|
||||
|
||||
await callback?.(api);
|
||||
|
||||
expect(hass.callWS).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'lovelace/resources/delete',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing hass in callback', async () => {
|
||||
const problem = new LegacyResourceProblem(vi.fn());
|
||||
const hass = createHASS(undefined, createUser({ is_admin: true }));
|
||||
vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123');
|
||||
vi.mocked(hass.callWS).mockResolvedValueOnce([
|
||||
{
|
||||
id: '1',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'module',
|
||||
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
|
||||
},
|
||||
]);
|
||||
|
||||
await problem.detectStatic(hass);
|
||||
|
||||
const callback = getCallback(problem);
|
||||
expect(callback).toBeDefined();
|
||||
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null);
|
||||
|
||||
await callback?.(api);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { StreamNotLoadingProblem } from '../../../../src/card-controller/problems/problems/stream-not-loading';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('StreamNotLoadingProblem', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should have correct key', () => {
|
||||
const problem = new StreamNotLoadingProblem(vi.fn());
|
||||
expect(problem.key).toBe('stream_not_loading');
|
||||
});
|
||||
|
||||
describe('detectDynamic', () => {
|
||||
it('should start timer when live and not loaded', () => {
|
||||
const triggerUpdate = vi.fn();
|
||||
const problem = new StreamNotLoadingProblem(triggerUpdate);
|
||||
|
||||
problem.detectDynamic({ view: 'live', mediaLoaded: false });
|
||||
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
|
||||
expect(problem.hasResult()).toBe(true);
|
||||
expect(triggerUpdate).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not start timer when not live', () => {
|
||||
const problem = new StreamNotLoadingProblem(vi.fn());
|
||||
|
||||
problem.detectDynamic({ view: 'media', mediaLoaded: false });
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not start timer when media is loaded', () => {
|
||||
const problem = new StreamNotLoadingProblem(vi.fn());
|
||||
|
||||
problem.detectDynamic({ view: 'live', mediaLoaded: true });
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear timeout when media loads', () => {
|
||||
const problem = new StreamNotLoadingProblem(vi.fn());
|
||||
|
||||
problem.detectDynamic({ view: 'live', mediaLoaded: false });
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
problem.detectDynamic({ view: 'live', mediaLoaded: true });
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear timeout when view changes away from live', () => {
|
||||
const problem = new StreamNotLoadingProblem(vi.fn());
|
||||
|
||||
problem.detectDynamic({ view: 'live', mediaLoaded: false });
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
problem.detectDynamic({ view: 'media', mediaLoaded: false });
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear timed-out state when media loads', () => {
|
||||
const problem = new StreamNotLoadingProblem(vi.fn());
|
||||
|
||||
problem.detectDynamic({ view: 'live', mediaLoaded: false });
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(problem.hasResult()).toBe(true);
|
||||
|
||||
problem.detectDynamic({ view: 'live', mediaLoaded: true });
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
});
|
||||
|
||||
it('should restart timer when camera changes', () => {
|
||||
const triggerUpdate = vi.fn();
|
||||
const problem = new StreamNotLoadingProblem(triggerUpdate);
|
||||
|
||||
problem.detectDynamic({
|
||||
cameraID: 'camera-1',
|
||||
view: 'live',
|
||||
mediaLoaded: false,
|
||||
});
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
// Switch to camera-2: timer restarts from 0 for the new camera.
|
||||
problem.detectDynamic({
|
||||
cameraID: 'camera-2',
|
||||
view: 'live',
|
||||
mediaLoaded: false,
|
||||
});
|
||||
|
||||
// 5 more seconds is not enough for the new 10s timer.
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
|
||||
// Full 10s from camera-2's timer start.
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(problem.hasResult()).toBe(true);
|
||||
expect(triggerUpdate).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not restart timer for same camera while running', () => {
|
||||
const triggerUpdate = vi.fn();
|
||||
const problem = new StreamNotLoadingProblem(triggerUpdate);
|
||||
|
||||
problem.detectDynamic({
|
||||
cameraID: 'camera-1',
|
||||
view: 'live',
|
||||
mediaLoaded: false,
|
||||
});
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
// Same camera again: timer should continue, not restart.
|
||||
problem.detectDynamic({
|
||||
cameraID: 'camera-1',
|
||||
view: 'live',
|
||||
mediaLoaded: false,
|
||||
});
|
||||
|
||||
// 5 more seconds completes the original 10s timer.
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(problem.hasResult()).toBe(true);
|
||||
expect(triggerUpdate).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not restart timer when cameraID is undefined and matches', () => {
|
||||
const triggerUpdate = vi.fn();
|
||||
const problem = new StreamNotLoadingProblem(triggerUpdate);
|
||||
|
||||
problem.detectDynamic({ view: 'live', mediaLoaded: false });
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
// Same undefined cameraID: timer should continue.
|
||||
problem.detectDynamic({ view: 'live', mediaLoaded: false });
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(problem.hasResult()).toBe(true);
|
||||
expect(triggerUpdate).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not restart timer if already timed out', () => {
|
||||
const triggerUpdate = vi.fn();
|
||||
const problem = new StreamNotLoadingProblem(triggerUpdate);
|
||||
|
||||
problem.detectDynamic({ view: 'live', mediaLoaded: false });
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(triggerUpdate).toBeCalledTimes(1);
|
||||
|
||||
// Calling detectDynamic again should not restart timer.
|
||||
problem.detectDynamic({ view: 'live', mediaLoaded: false });
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(triggerUpdate).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger', () => {
|
||||
it('should activate immediately when camera has error and view is live', () => {
|
||||
const problem = new StreamNotLoadingProblem(vi.fn());
|
||||
|
||||
problem.trigger({ cameraID: 'camera-1' });
|
||||
problem.detectDynamic({
|
||||
cameraID: 'camera-1',
|
||||
view: 'live',
|
||||
mediaLoaded: false,
|
||||
});
|
||||
|
||||
expect(problem.hasResult()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not activate with only a trigger', () => {
|
||||
const problem = new StreamNotLoadingProblem(vi.fn());
|
||||
|
||||
problem.trigger({ cameraID: 'camera-1' });
|
||||
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
});
|
||||
|
||||
it('should ignore trigger without cameraID', () => {
|
||||
const problem = new StreamNotLoadingProblem(vi.fn());
|
||||
|
||||
problem.trigger();
|
||||
problem.detectDynamic({ view: 'live', mediaLoaded: false });
|
||||
|
||||
// No camera error recorded, so falls back to timeout behavior.
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear camera error when stream loads', () => {
|
||||
const problem = new StreamNotLoadingProblem(vi.fn());
|
||||
|
||||
problem.trigger({ cameraID: 'camera-1' });
|
||||
problem.detectDynamic({
|
||||
cameraID: 'camera-1',
|
||||
view: 'live',
|
||||
mediaLoaded: false,
|
||||
});
|
||||
|
||||
expect(problem.hasResult()).toBe(true);
|
||||
|
||||
// Stream loaded clears the error for this camera.
|
||||
problem.detectDynamic({
|
||||
cameraID: 'camera-1',
|
||||
view: 'live',
|
||||
mediaLoaded: true,
|
||||
});
|
||||
|
||||
// Camera error was cleared by the successful load, so this unloaded state
|
||||
// falls back to the timer (problem would not activate until after the
|
||||
// timer is reached).
|
||||
problem.detectDynamic({
|
||||
cameraID: 'camera-1',
|
||||
view: 'live',
|
||||
mediaLoaded: false,
|
||||
});
|
||||
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not activate for a different camera', () => {
|
||||
const problem = new StreamNotLoadingProblem(vi.fn());
|
||||
|
||||
problem.trigger({ cameraID: 'camera-1' });
|
||||
problem.detectDynamic({
|
||||
cameraID: 'camera-2',
|
||||
view: 'live',
|
||||
mediaLoaded: false,
|
||||
});
|
||||
|
||||
// camera-2 has no error, so it falls back to timeout behavior.
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNotification', () => {
|
||||
it('should return notification regardless of active state', () => {
|
||||
const problem = new StreamNotLoadingProblem(vi.fn());
|
||||
|
||||
const notification = problem.getNotification();
|
||||
expect(notification).toEqual(
|
||||
expect.objectContaining({
|
||||
heading: expect.objectContaining({
|
||||
text: expect.any(String),
|
||||
}),
|
||||
link: expect.objectContaining({
|
||||
url: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResult', () => {
|
||||
it('should return result when timed out', () => {
|
||||
const problem = new StreamNotLoadingProblem(vi.fn());
|
||||
|
||||
problem.detectDynamic({ view: 'live', mediaLoaded: false });
|
||||
vi.advanceTimersByTime(10000);
|
||||
|
||||
const result = problem.getResult();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
icon: 'mdi:cctv-off',
|
||||
severity: 'high',
|
||||
notification: expect.objectContaining({
|
||||
link: expect.objectContaining({
|
||||
url: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null when not timed out', () => {
|
||||
const problem = new StreamNotLoadingProblem(vi.fn());
|
||||
|
||||
expect(problem.getResult()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should stop timer', () => {
|
||||
const triggerUpdate = vi.fn();
|
||||
const problem = new StreamNotLoadingProblem(triggerUpdate);
|
||||
|
||||
problem.detectDynamic({ view: 'live', mediaLoaded: false });
|
||||
problem.destroy();
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
|
||||
expect(problem.hasResult()).toBe(false);
|
||||
expect(triggerUpdate).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user