feat: Add event-based automation triggers (#2537)
This commit is contained in:
committed by
dermotduffy
parent
b701366762
commit
a31816c168
@@ -7,21 +7,18 @@ import {
|
||||
CameraQuery,
|
||||
QueryType,
|
||||
} from '../../../src/camera-manager/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { BROWSE_MEDIA_CACHE_SECONDS } from '../../../src/ha/browse-media/types';
|
||||
import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker';
|
||||
import { ResolvedMediaCache } from '../../../src/ha/resolved-media';
|
||||
import { QuerySource } from '../../../src/query-source';
|
||||
import { ViewMedia } from '../../../src/view/item';
|
||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import { createCameraConfig, createHASS } from '../../test-utils';
|
||||
import { createCameraConfig, createHASS, createHASSManager } from '../../test-utils';
|
||||
|
||||
const createEngine = (): BrowseMediaCameraManagerEngine => {
|
||||
return new BrowseMediaCameraManagerEngine(
|
||||
new EntityRegistryManagerMock(),
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
createHASSManager(),
|
||||
new BrowseMediaWalker(),
|
||||
new ResolvedMediaCache(),
|
||||
new CameraManagerRequestCache(),
|
||||
|
||||
+150
-218
@@ -13,6 +13,8 @@ import {
|
||||
createCameraConfig,
|
||||
createCapabilities,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createHASSEvent,
|
||||
createInitializedCamera,
|
||||
createRegistryEntity,
|
||||
createStateEntity,
|
||||
@@ -25,10 +27,7 @@ describe('Camera', () => {
|
||||
const config = createCameraConfig();
|
||||
const camera = new Camera(
|
||||
config,
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getConfig()).toBe(config);
|
||||
});
|
||||
@@ -38,10 +37,7 @@ describe('Camera', () => {
|
||||
const capabilities = createCapabilities();
|
||||
const camera = await createInitializedCamera(
|
||||
createCameraConfig(),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
capabilities,
|
||||
);
|
||||
expect(camera.getCapabilities()).toBe(capabilities);
|
||||
@@ -50,20 +46,14 @@ describe('Camera', () => {
|
||||
it('when unpopulated', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig(),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getCapabilities()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should get engine', async () => {
|
||||
const engine = new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
);
|
||||
const engine = new GenericCameraManagerEngine(createHASSManager());
|
||||
const camera = new Camera(createCameraConfig(), engine);
|
||||
expect(camera.getEngine()).toBe(engine);
|
||||
});
|
||||
@@ -71,10 +61,7 @@ describe('Camera', () => {
|
||||
it('should set and get id', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig(),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
camera.setID('foo');
|
||||
expect(camera.getID()).toBe('foo');
|
||||
@@ -84,10 +71,7 @@ describe('Camera', () => {
|
||||
it('should throw without id', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig(),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(() => camera.getID()).toThrowError(
|
||||
'Could not determine camera id for the following ' +
|
||||
@@ -107,17 +91,12 @@ describe('Camera', () => {
|
||||
entities: ['camera.foo'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: stateWatcher,
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ stateWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
@@ -130,6 +109,26 @@ describe('Camera', () => {
|
||||
expect(stateWatcher.unsubscribe).toBeCalled();
|
||||
});
|
||||
|
||||
it('should skip initialization when hass is unavailable', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig({
|
||||
triggers: {
|
||||
entities: ['camera.foo'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hassManager: createHASSManager({ hass: null, stateWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
expect(stateWatcher.subscribe).not.toBeCalled();
|
||||
expect(camera.getCapabilities()).toBeNull();
|
||||
});
|
||||
|
||||
it('should set capabilities and use go2rtc metadata endpoint', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig({
|
||||
@@ -138,18 +137,13 @@ describe('Camera', () => {
|
||||
stream: 'stream',
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
|
||||
@@ -180,18 +174,13 @@ describe('Camera', () => {
|
||||
stream: 'stream',
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(false);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false);
|
||||
@@ -206,18 +195,13 @@ describe('Camera', () => {
|
||||
metadata_fetch_timeout_seconds: 20,
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
|
||||
@@ -240,10 +224,7 @@ describe('Camera', () => {
|
||||
live: true,
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
|
||||
@@ -252,9 +233,7 @@ describe('Camera', () => {
|
||||
hass.config.components = ['hass_web_proxy'];
|
||||
|
||||
await camera.initialize({
|
||||
hass,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ hass }),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
|
||||
@@ -281,10 +260,7 @@ describe('Camera', () => {
|
||||
createCameraConfig({
|
||||
proxy: { live: true },
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getLiveProxyConfig()).toEqual(
|
||||
expect.objectContaining({ enabled: true, enforce: true }),
|
||||
@@ -296,10 +272,7 @@ describe('Camera', () => {
|
||||
createCameraConfig({
|
||||
proxy: { media: true },
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getMediaProxyConfig()).toEqual(
|
||||
expect.objectContaining({ enabled: true, enforce: true }),
|
||||
@@ -312,10 +285,7 @@ describe('Camera', () => {
|
||||
live_provider: 'go2rtc',
|
||||
go2rtc: { url: 'http://go2rtc' },
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getLiveProxyConfig()).toEqual(
|
||||
expect.objectContaining({ enabled: true, enforce: false }),
|
||||
@@ -327,10 +297,7 @@ describe('Camera', () => {
|
||||
createCameraConfig({
|
||||
proxy: { media: 'auto' },
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getMediaProxyConfig()).toEqual(
|
||||
expect.objectContaining({ enabled: false, enforce: false }),
|
||||
@@ -344,16 +311,11 @@ describe('Camera', () => {
|
||||
force: ['2-way-audio'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled();
|
||||
@@ -368,16 +330,11 @@ describe('Camera', () => {
|
||||
force: ['2-way-audio'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled();
|
||||
@@ -392,16 +349,11 @@ describe('Camera', () => {
|
||||
force: ['2-way-audio'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled();
|
||||
@@ -415,16 +367,11 @@ describe('Camera', () => {
|
||||
disable: ['2-way-audio'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled();
|
||||
@@ -438,16 +385,11 @@ describe('Camera', () => {
|
||||
disable_except: ['substream'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled();
|
||||
@@ -461,17 +403,12 @@ describe('Camera', () => {
|
||||
disable_except: ['substream', '2-way-audio'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).toHaveBeenCalled();
|
||||
@@ -485,17 +422,12 @@ describe('Camera', () => {
|
||||
disable_except: [],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(liveProviderSupports2WayAudio).toHaveBeenCalled();
|
||||
@@ -510,16 +442,11 @@ describe('Camera', () => {
|
||||
});
|
||||
const camera = new Camera(
|
||||
createCameraConfig({ camera_entity: 'camera.front_door' }),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||
});
|
||||
|
||||
@@ -529,16 +456,11 @@ describe('Camera', () => {
|
||||
it('should leave entity null when camera_entity is unset', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig(),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||
});
|
||||
|
||||
@@ -548,16 +470,11 @@ describe('Camera', () => {
|
||||
it('should leave entity null when entityRegistryManager is not provided', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig({ camera_entity: 'camera.front_door' }),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
expect(camera.getEntity()).toBeNull();
|
||||
@@ -594,10 +511,7 @@ describe('Camera', () => {
|
||||
...(options?.userEntities && { entities: options.userEntities }),
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
const hass = createHASS(
|
||||
@@ -609,9 +523,7 @@ describe('Camera', () => {
|
||||
},
|
||||
);
|
||||
await camera.initialize({
|
||||
hass,
|
||||
stateWatcher,
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ hass, stateWatcher }),
|
||||
...(!options?.omitRegistryManager && {
|
||||
entityRegistryManager: new EntityRegistryManagerMock(
|
||||
options?.registryEntities ?? [cameraEntity, doorbellEntity],
|
||||
@@ -737,10 +649,7 @@ describe('Camera', () => {
|
||||
entities: ['binary_sensor.foo'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
@@ -748,9 +657,7 @@ describe('Camera', () => {
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: stateWatcher,
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ stateWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
@@ -780,27 +687,25 @@ describe('Camera', () => {
|
||||
events: [{ event_type: 'zha_event' }],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
{ eventCallback },
|
||||
);
|
||||
|
||||
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher,
|
||||
hassManager: createHASSManager({ eventWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
expect(eventWatcher.subscribe).toBeCalledTimes(1);
|
||||
const request = vi.mocked(eventWatcher.subscribe).mock.calls[0][1];
|
||||
const request = vi.mocked(eventWatcher.subscribe).mock.calls[0][0];
|
||||
expect(request.event_type).toBe('zha_event');
|
||||
expect(request.matcher).toBeUndefined();
|
||||
|
||||
callEventWatcherCallback(eventWatcher, { command: 'press' });
|
||||
callEventWatcherCallback(
|
||||
eventWatcher,
|
||||
createHASSEvent('zha_event', { command: 'press' }),
|
||||
);
|
||||
|
||||
expect(eventCallback).toBeCalledWith({
|
||||
cameraID: 'camera_1',
|
||||
@@ -812,6 +717,63 @@ describe('Camera', () => {
|
||||
expect(eventWatcher.unsubscribe).toBeCalled();
|
||||
});
|
||||
|
||||
it('should attach a context-only matcher when only a context filter is set', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig({
|
||||
id: 'camera_1',
|
||||
triggers: {
|
||||
events: [{ event_type: 'zha_event', context: { user_id: 'u-1' } }],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hassManager: createHASSManager({ eventWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
const matcher = vi.mocked(eventWatcher.subscribe).mock.calls[0][0].matcher;
|
||||
expect(matcher).toBeDefined();
|
||||
expect(
|
||||
matcher?.(
|
||||
createHASSEvent('zha_event', {}, { id: 'i', user_id: 'u-1', parent_id: null }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matcher?.(
|
||||
createHASSEvent('zha_event', {}, { id: 'i', user_id: 'u-2', parent_id: null }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should expand list-form event_type into one subscription per type', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig({
|
||||
id: 'camera_1',
|
||||
triggers: {
|
||||
events: [{ event_type: ['zha_event', 'deconz_event'] }],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hassManager: createHASSManager({ eventWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
expect(eventWatcher.subscribe).toBeCalledTimes(2);
|
||||
expect(vi.mocked(eventWatcher.subscribe).mock.calls[0][0].event_type).toBe(
|
||||
'zha_event',
|
||||
);
|
||||
expect(vi.mocked(eventWatcher.subscribe).mock.calls[1][0].event_type).toBe(
|
||||
'deconz_event',
|
||||
);
|
||||
});
|
||||
|
||||
it('should attach a matcher when triggers.events entry has data filter', async () => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig({
|
||||
@@ -820,24 +782,23 @@ describe('Camera', () => {
|
||||
events: [{ event_type: 'zha_event', event_data: { command: 'press' } }],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher,
|
||||
hassManager: createHASSManager({ eventWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
const matcher = vi.mocked(eventWatcher.subscribe).mock.calls[0][1].matcher;
|
||||
const matcher = vi.mocked(eventWatcher.subscribe).mock.calls[0][0].matcher;
|
||||
expect(matcher).toBeDefined();
|
||||
expect(matcher?.({ command: 'press', extra: 1 })).toBe(true);
|
||||
expect(matcher?.({ command: 'release' })).toBe(false);
|
||||
expect(
|
||||
matcher?.(createHASSEvent('zha_event', { command: 'press', extra: 1 })),
|
||||
).toBe(true);
|
||||
expect(matcher?.(createHASSEvent('zha_event', { command: 'release' }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not subscribe to events when trigger capability is disabled', async () => {
|
||||
@@ -848,17 +809,12 @@ describe('Camera', () => {
|
||||
events: [{ event_type: 'zha_event' }],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher,
|
||||
hassManager: createHASSManager({ eventWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: false }) },
|
||||
});
|
||||
|
||||
@@ -876,10 +832,7 @@ describe('Camera', () => {
|
||||
entities: ['event.front_door_doorbell'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
@@ -887,9 +840,7 @@ describe('Camera', () => {
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: stateWatcher,
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ stateWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
@@ -913,10 +864,7 @@ describe('Camera', () => {
|
||||
entities: ['event.front_door_doorbell'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
@@ -924,9 +872,7 @@ describe('Camera', () => {
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: stateWatcher,
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ stateWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
|
||||
@@ -953,10 +899,7 @@ describe('Camera', () => {
|
||||
entities: ['binary_sensor.foo'],
|
||||
},
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
@@ -964,9 +907,7 @@ describe('Camera', () => {
|
||||
|
||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: stateWatcher,
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ stateWatcher }),
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: false }) },
|
||||
});
|
||||
|
||||
@@ -1128,10 +1069,7 @@ describe('Camera', () => {
|
||||
(_name: string, cameraConfig: unknown, expectedResult: CameraProxyConfig) => {
|
||||
const camera = new Camera(
|
||||
createCameraConfig(cameraConfig),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getProxyConfig()).toEqual(expectedResult);
|
||||
},
|
||||
@@ -1145,10 +1083,7 @@ describe('Camera', () => {
|
||||
go2rtc: { stream: '' },
|
||||
camera_entity: '',
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
expect(camera.getEndpoints()).toBeNull();
|
||||
});
|
||||
@@ -1162,10 +1097,7 @@ describe('Camera', () => {
|
||||
},
|
||||
camera_entity: 'camera.foo',
|
||||
}),
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
),
|
||||
new GenericCameraManagerEngine(createHASSManager()),
|
||||
);
|
||||
|
||||
expect(camera.getEndpoints()).toEqual({
|
||||
|
||||
@@ -7,8 +7,6 @@ import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye
|
||||
import { ReolinkCameraManagerEngine } from '../../src/camera-manager/reolink/engine-reolink.js';
|
||||
import { TPLinkCameraManagerEngine } from '../../src/camera-manager/tplink/engine-tplink.js';
|
||||
import { Engine } from '../../src/camera-manager/types.js';
|
||||
import { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js';
|
||||
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
|
||||
import { CardWideConfig } from '../../src/config/schema/types.js';
|
||||
import { DeviceRegistryManager } from '../../src/ha/registry/device';
|
||||
import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js';
|
||||
@@ -17,6 +15,7 @@ import { EntityRegistryManagerMock } from '../ha/registry/entity/mock.js';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createRegistryEntity,
|
||||
createStateEntity,
|
||||
} from '../test-utils';
|
||||
@@ -232,8 +231,7 @@ describe('createEngine()', () => {
|
||||
it('should create generic engine', async () => {
|
||||
expect(
|
||||
await createFactory().createEngine(Engine.Generic, {
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||
}),
|
||||
).toBeInstanceOf(GenericCameraManagerEngine);
|
||||
@@ -241,8 +239,7 @@ describe('createEngine()', () => {
|
||||
it('should create frigate engine', async () => {
|
||||
expect(
|
||||
await createFactory().createEngine(Engine.Frigate, {
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||
}),
|
||||
).toBeInstanceOf(FrigateCameraManagerEngine);
|
||||
@@ -250,8 +247,7 @@ describe('createEngine()', () => {
|
||||
it('should create motioneye engine', async () => {
|
||||
expect(
|
||||
await createFactory().createEngine(Engine.MotionEye, {
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||
}),
|
||||
).toBeInstanceOf(MotionEyeCameraManagerEngine);
|
||||
@@ -259,8 +255,7 @@ describe('createEngine()', () => {
|
||||
it('should create reolink engine', async () => {
|
||||
expect(
|
||||
await createFactory().createEngine(Engine.Reolink, {
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||
}),
|
||||
).toBeInstanceOf(ReolinkCameraManagerEngine);
|
||||
@@ -268,8 +263,7 @@ describe('createEngine()', () => {
|
||||
it('should create tplink engine', async () => {
|
||||
expect(
|
||||
await createFactory().createEngine(Engine.TPLink, {
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||
}),
|
||||
).toBeInstanceOf(TPLinkCameraManagerEngine);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { format } from 'date-fns';
|
||||
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { CameraManagerEngine } from '../../../src/camera-manager/engine';
|
||||
import { FrigateCamera } from '../../../src/camera-manager/frigate/camera';
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
FrigateReviewWatcher,
|
||||
} from '../../../src/camera-manager/frigate/watcher';
|
||||
import { ActionsExecutor } from '../../../src/card-controller/actions/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
|
||||
import { CameraTriggerMediaEventType } from '../../../src/config/schema/cameras';
|
||||
import { Entity, EntityRegistryManager } from '../../../src/ha/registry/entity/types';
|
||||
@@ -29,6 +27,7 @@ import {
|
||||
createCameraConfig,
|
||||
createCapabilities,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createRegistryEntity,
|
||||
createStateEntity,
|
||||
} from '../../test-utils';
|
||||
@@ -42,7 +41,7 @@ const callEventWatcherCallback = (
|
||||
): void => {
|
||||
const mock = vi.mocked(eventWatcher.subscribe).mock;
|
||||
expect(mock.calls.length).greaterThan(n);
|
||||
mock.calls[n][1].callback(event);
|
||||
mock.calls[n][0].callback(event);
|
||||
};
|
||||
|
||||
const callReviewWatcherCallback = (
|
||||
@@ -52,7 +51,7 @@ const callReviewWatcherCallback = (
|
||||
): void => {
|
||||
const mock = vi.mocked(reviewWatcher.subscribe).mock;
|
||||
expect(mock.calls.length).greaterThan(n);
|
||||
mock.calls[n][1].callback(review);
|
||||
mock.calls[n][0].callback(review);
|
||||
};
|
||||
|
||||
describe('FrigateCamera', () => {
|
||||
@@ -69,10 +68,8 @@ describe('FrigateCamera', () => {
|
||||
const beforeConfig = { ...config };
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -92,10 +89,8 @@ describe('FrigateCamera', () => {
|
||||
expect(
|
||||
async () =>
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
}),
|
||||
@@ -118,10 +113,8 @@ describe('FrigateCamera', () => {
|
||||
]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -144,10 +137,8 @@ describe('FrigateCamera', () => {
|
||||
]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -170,10 +161,8 @@ describe('FrigateCamera', () => {
|
||||
]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -191,15 +180,15 @@ describe('FrigateCamera', () => {
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
await camera.initialize({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
attributes: { client_id: 'remote_frigate' },
|
||||
hassManager: createHASSManager({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
attributes: { client_id: 'remote_frigate' },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -215,15 +204,15 @@ describe('FrigateCamera', () => {
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
await camera.initialize({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
attributes: {},
|
||||
hassManager: createHASSManager({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
attributes: {},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -238,10 +227,8 @@ describe('FrigateCamera', () => {
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -257,15 +244,15 @@ describe('FrigateCamera', () => {
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
await camera.initialize({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
attributes: { client_id: 'something_else' },
|
||||
hassManager: createHASSManager({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
attributes: { client_id: 'something_else' },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -281,16 +268,16 @@ describe('FrigateCamera', () => {
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
await camera.initialize({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
state: 'unavailable',
|
||||
attributes: {},
|
||||
hassManager: createHASSManager({
|
||||
hass: createHASS({
|
||||
'camera.front_door': createStateEntity({
|
||||
entity_id: 'camera.front_door',
|
||||
state: 'unavailable',
|
||||
attributes: {},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -311,10 +298,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -340,10 +325,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -373,10 +356,8 @@ describe('FrigateCamera', () => {
|
||||
vi.mocked(getPTZInfo).mockRejectedValue(new Error());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -404,10 +385,8 @@ describe('FrigateCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -442,10 +421,8 @@ describe('FrigateCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -475,10 +452,8 @@ describe('FrigateCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -512,10 +487,8 @@ describe('FrigateCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -842,15 +815,12 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
expect(eventWatcher.subscribe).toBeCalledWith(
|
||||
hass,
|
||||
expect.objectContaining({
|
||||
instanceID: 'CLIENT_ID',
|
||||
}),
|
||||
@@ -874,10 +844,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -901,10 +869,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -927,10 +893,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -953,10 +917,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -966,9 +928,10 @@ describe('FrigateCamera', () => {
|
||||
expect(eventWatcher.unsubscribe).toBeCalled();
|
||||
});
|
||||
|
||||
it('should unsubscribe on destroy while event subscription is pending', async () => {
|
||||
it('should not subscribe when destroyed while base initialization is pending', async () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.front_door',
|
||||
frigate: { client_id: 'CLIENT_ID', camera_name: 'front_door' },
|
||||
triggers: {
|
||||
media_events: ['events'],
|
||||
@@ -978,45 +941,40 @@ describe('FrigateCamera', () => {
|
||||
mock<CameraManagerEngine>(),
|
||||
);
|
||||
const hass = createHASS();
|
||||
let resolveSubscribe: () => void = () => {};
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
vi.mocked(eventWatcher.subscribe).mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSubscribe = resolve;
|
||||
|
||||
// Pend base initialization on entity resolution so destroy() can flip
|
||||
// `_destroyed` before initialize() reaches the subscribe calls.
|
||||
let resolveEntity: () => void = () => {};
|
||||
const entityRegistryManager = mock<EntityRegistryManager>();
|
||||
vi.mocked(entityRegistryManager.getEntity).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveEntity = () => resolve(createRegistryEntity());
|
||||
}),
|
||||
);
|
||||
|
||||
const initializePromise = camera.initialize({
|
||||
hass: hass,
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
|
||||
// Pre-built so `_buildCapabilities` (which calls the un-mocked
|
||||
// `liveProviderSupports2WayAudio`) is skipped and init reaches the
|
||||
// pending Frigate event subscribe.
|
||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||
});
|
||||
await vi.waitFor(() => expect(eventWatcher.subscribe).toBeCalled());
|
||||
await vi.waitFor(() => expect(entityRegistryManager.getEntity).toBeCalled());
|
||||
|
||||
await camera.destroy();
|
||||
|
||||
// Destroy iterated `_destroyCallbacks` and called the unsubscribe that
|
||||
// was registered before the (still pending) event subscribe.
|
||||
const subscribeCall = vi.mocked(eventWatcher.subscribe).mock.calls[0];
|
||||
assert(subscribeCall);
|
||||
expect(eventWatcher.unsubscribe).toBeCalledWith(subscribeCall[1]);
|
||||
// `_destroyed` short-circuits initialize() after the pending await, so
|
||||
// neither watcher is ever subscribed.
|
||||
expect(eventWatcher.subscribe).not.toBeCalled();
|
||||
expect(reviewWatcher.subscribe).not.toBeCalled();
|
||||
|
||||
resolveSubscribe();
|
||||
resolveEntity();
|
||||
await initializePromise;
|
||||
|
||||
// The subsequent `_subscribeToReviews` short-circuited on `_destroyed`,
|
||||
// so the review watcher was never subscribed (and so never needs an
|
||||
// unsubscribe -- which would otherwise be ordered before the subscribe
|
||||
// in the per-key PQueue and leak the resulting subscription).
|
||||
expect(eventWatcher.subscribe).not.toBeCalled();
|
||||
expect(eventWatcher.unsubscribe).not.toBeCalled();
|
||||
expect(reviewWatcher.subscribe).not.toBeCalled();
|
||||
expect(reviewWatcher.unsubscribe).not.toBeCalled();
|
||||
});
|
||||
@@ -1106,10 +1064,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -1152,6 +1108,75 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('should always forward end events to clear the trigger', () => {
|
||||
it.each([
|
||||
['with media still present', true, ['front_steps']],
|
||||
['with no media present at end', false, ['front_steps']],
|
||||
['even after the object left the configured zone', true, []],
|
||||
])('%s', async (_name: string, hasClip: boolean, currentZones: string[]) => {
|
||||
const eventCallback = vi.fn();
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
id: 'CAMERA_1',
|
||||
frigate: {
|
||||
camera_name: 'camera.front_door',
|
||||
zones: ['front_steps'],
|
||||
},
|
||||
triggers: {
|
||||
media_events: ['clips'],
|
||||
},
|
||||
}),
|
||||
mock<CameraManagerEngine>(),
|
||||
{
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
);
|
||||
|
||||
const hass = createHASS();
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
|
||||
// An 'end' clears the trigger regardless of the start criteria: the
|
||||
// media may be unchanged or absent, and the object may have left the
|
||||
// zone by now.
|
||||
callEventWatcherCallback(eventWatcher, {
|
||||
type: 'end',
|
||||
before: {
|
||||
id: 'event-1',
|
||||
camera: 'camera.front_door',
|
||||
snapshot: null,
|
||||
has_clip: hasClip,
|
||||
has_snapshot: false,
|
||||
label: 'person',
|
||||
current_zones: currentZones,
|
||||
},
|
||||
after: {
|
||||
id: 'event-1',
|
||||
camera: 'camera.front_door',
|
||||
snapshot: null,
|
||||
has_clip: hasClip,
|
||||
has_snapshot: false,
|
||||
label: 'person',
|
||||
current_zones: currentZones,
|
||||
},
|
||||
});
|
||||
|
||||
expect(eventCallback).toBeCalledWith({
|
||||
type: 'end',
|
||||
cameraID: 'CAMERA_1',
|
||||
id: 'event-1',
|
||||
clip: false,
|
||||
snapshot: false,
|
||||
fidelity: 'high',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle zones correctly', () => {
|
||||
it.each([
|
||||
['has no zone', [], false],
|
||||
@@ -1179,10 +1204,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -1239,10 +1262,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -1296,10 +1317,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const eventWatcher = mock<FrigateEventWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: eventWatcher,
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -1350,15 +1369,12 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
expect(reviewWatcher.subscribe).toBeCalledWith(
|
||||
hass,
|
||||
expect.objectContaining({
|
||||
instanceID: 'CLIENT_ID',
|
||||
}),
|
||||
@@ -1384,10 +1400,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1414,10 +1428,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1449,10 +1461,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1514,10 +1524,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1587,10 +1595,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1660,10 +1666,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1719,10 +1723,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1777,10 +1779,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1839,10 +1839,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1898,10 +1896,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -1965,10 +1961,8 @@ describe('FrigateCamera', () => {
|
||||
const hass = createHASS();
|
||||
const reviewWatcher = mock<FrigateReviewWatcher>();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: reviewWatcher,
|
||||
});
|
||||
@@ -2044,10 +2038,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2075,10 +2067,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2100,10 +2090,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2126,10 +2114,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
await expect(
|
||||
camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
}),
|
||||
@@ -2153,10 +2139,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2179,10 +2163,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2204,10 +2186,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2238,10 +2218,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2274,10 +2252,8 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2295,10 +2271,8 @@ describe('FrigateCamera', () => {
|
||||
|
||||
const hass = createHASS();
|
||||
await camera.initialize({
|
||||
hass: hass,
|
||||
hassManager: createHASSManager({ hass }),
|
||||
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2327,12 +2301,10 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
createRegistryEntity({ entity_id: 'camera.office_frigate' }),
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2366,12 +2338,10 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
createRegistryEntity({ entity_id: 'camera.office_frigate' }),
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2420,12 +2390,10 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
createRegistryEntity({ entity_id: 'camera.office_frigate' }),
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
@@ -2457,12 +2425,10 @@ describe('FrigateCamera', () => {
|
||||
);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
createRegistryEntity({ entity_id: 'camera.office_frigate' }),
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, assert, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { RecordingSegmentsCache } from '../../../src/camera-manager/cache';
|
||||
import { Camera } from '../../../src/camera-manager/camera';
|
||||
import {
|
||||
@@ -33,8 +32,6 @@ import {
|
||||
QueryResultsType,
|
||||
QueryType,
|
||||
} from '../../../src/camera-manager/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { CameraConfig } from '../../../src/config/schema/cameras';
|
||||
import { RawAdvancedCameraCardConfig } from '../../../src/config/types';
|
||||
import { QuerySource } from '../../../src/query-source';
|
||||
@@ -47,6 +44,7 @@ import {
|
||||
createFrigateRecording,
|
||||
createFrigateReview,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createStore,
|
||||
TestViewMedia,
|
||||
} from '../../test-utils';
|
||||
@@ -59,8 +57,7 @@ const createEngine = (options?: {
|
||||
}): FrigateCameraManagerEngine => {
|
||||
return new FrigateCameraManagerEngine(
|
||||
new EntityRegistryManagerMock(),
|
||||
new StateWatcher(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
createHASSManager(),
|
||||
options?.cache ?? new RecordingSegmentsCache(),
|
||||
options?.requestCache ?? new CameraManagerRequestCache(),
|
||||
);
|
||||
@@ -223,10 +220,7 @@ describe('FrigateCameraManagerEngine', () => {
|
||||
const engine = createEngine();
|
||||
vi.mocked(getPTZInfo).mockResolvedValue({ features: [], presets: [] });
|
||||
|
||||
const camera = await engine.createCamera(
|
||||
createHASS(),
|
||||
createFrigateCameraConfig(),
|
||||
);
|
||||
const camera = await engine.createCamera(createFrigateCameraConfig());
|
||||
|
||||
expect(camera).toBeInstanceOf(Camera);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { afterEach, assert, describe, expect, it, vi } from 'vitest';
|
||||
import { Connection } from 'home-assistant-js-websocket';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import {
|
||||
FrigateEventChange,
|
||||
FrigateReviewChange,
|
||||
@@ -8,7 +10,7 @@ import {
|
||||
FrigateReviewWatcher,
|
||||
} from '../../../src/camera-manager/frigate/watcher.js';
|
||||
import { HomeAssistant } from '../../../src/ha/types.js';
|
||||
import { createHASS } from '../../test-utils.js';
|
||||
import { createHASS, createHASSSource, flushPromises } from '../../test-utils.js';
|
||||
|
||||
const createEventChange = (): FrigateEventChange => {
|
||||
return {
|
||||
@@ -68,110 +70,91 @@ const createReviewChange = (): FrigateReviewChange => {
|
||||
},
|
||||
};
|
||||
};
|
||||
const callHASubscribeMessageCallback = (
|
||||
hass: HomeAssistant,
|
||||
data: unknown,
|
||||
n = 0,
|
||||
): void => {
|
||||
|
||||
// Drive the dispatcher registered with `hass.connection.subscribeMessage` to
|
||||
// simulate a Frigate WS message arriving over the bus.
|
||||
const fireMessage = (hass: HomeAssistant, data: unknown, n = 0): void => {
|
||||
const mock = vi.mocked(hass.connection.subscribeMessage).mock;
|
||||
expect(mock.calls.length).greaterThan(n);
|
||||
mock.calls[n][0](data);
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('FrigateEventWatcher', () => {
|
||||
it('should subscribe to a given topic once', async () => {
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
const hass = createHASS();
|
||||
|
||||
await stateWatcher.subscribe(hass, {
|
||||
instanceID: 'frigate',
|
||||
callback: vi.fn(),
|
||||
});
|
||||
|
||||
await stateWatcher.subscribe(hass, {
|
||||
instanceID: 'frigate',
|
||||
callback: vi.fn(),
|
||||
});
|
||||
|
||||
expect(hass.connection.subscribeMessage).toBeCalledTimes(1);
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should only subscribe from a given topic once', async () => {
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
it('should open a WS subscription with the frigate event type and instance id', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new FrigateEventWatcher(source);
|
||||
|
||||
const unsubscribeCallback = vi.fn();
|
||||
vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubscribeCallback);
|
||||
watcher.subscribe({ instanceID: 'frigate', callback: vi.fn() });
|
||||
await flushPromises();
|
||||
|
||||
const request_1 = {
|
||||
instanceID: 'frigate',
|
||||
callback: vi.fn(),
|
||||
};
|
||||
const request_2 = { ...request_1 };
|
||||
|
||||
await stateWatcher.subscribe(hass, request_1);
|
||||
await stateWatcher.subscribe(hass, request_2);
|
||||
|
||||
await stateWatcher.unsubscribe(request_1);
|
||||
expect(unsubscribeCallback).not.toBeCalled();
|
||||
|
||||
await stateWatcher.unsubscribe(request_2);
|
||||
expect(unsubscribeCallback).toBeCalledTimes(1);
|
||||
expect(hass.connection.subscribeMessage).toBeCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
type: 'frigate/events/subscribe',
|
||||
instance_id: 'frigate',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle unsubscribe during pending subscription', async () => {
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
it('should unsubscribe from the WS subscription', async () => {
|
||||
const hass = createHASS();
|
||||
const unsub = vi.fn();
|
||||
vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsub);
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new FrigateEventWatcher(source);
|
||||
const request = { instanceID: 'frigate', callback: vi.fn() };
|
||||
|
||||
let resolveSubscription: ((callback: () => Promise<void>) => void) | undefined;
|
||||
const subscriptionPromise = new Promise<() => Promise<void>>((resolve) => {
|
||||
resolveSubscription = resolve;
|
||||
});
|
||||
vi.mocked(hass.connection.subscribeMessage).mockReturnValue(subscriptionPromise);
|
||||
watcher.subscribe(request);
|
||||
await flushPromises();
|
||||
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: vi.fn(),
|
||||
};
|
||||
watcher.unsubscribe(request);
|
||||
await flushPromises();
|
||||
|
||||
// Start subscription (doesn't complete yet as not awaited).
|
||||
const subscribePromise = stateWatcher.subscribe(hass, request);
|
||||
expect(unsub).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
// Unsubscribe while subscription is still pending.
|
||||
const unsubscribePromise = stateWatcher.unsubscribe(request);
|
||||
it('should drop messages from an old-connection subscription after a swap', async () => {
|
||||
const oldHass = createHASS();
|
||||
const { source, push } = createHASSSource(oldHass);
|
||||
const watcher = new FrigateEventWatcher(source);
|
||||
const callback = vi.fn();
|
||||
|
||||
// Complete the subscription: both subscribe and unsubscribe await the same
|
||||
// pending promise, and unsubscribe then invokes the resolved unsub.
|
||||
const unsubscribeCallback = vi.fn();
|
||||
assert(resolveSubscription);
|
||||
resolveSubscription(unsubscribeCallback);
|
||||
await subscribePromise;
|
||||
await unsubscribePromise;
|
||||
watcher.subscribe({ instanceID: 'frigate', callback });
|
||||
await flushPromises();
|
||||
|
||||
expect(unsubscribeCallback).toBeCalledTimes(1);
|
||||
callHASubscribeMessageCallback(hass, JSON.stringify(createEventChange()));
|
||||
expect(request.callback).not.toBeCalled();
|
||||
// Capture the dispatcher registered against the OLD connection before the
|
||||
// swap, so it still points at the now-stale era guard.
|
||||
const oldDispatcher = vi.mocked(oldHass.connection.subscribeMessage).mock
|
||||
.calls[0][0];
|
||||
|
||||
const newHass = createHASS();
|
||||
newHass.connection = mock<Connection>();
|
||||
vi.mocked(newHass.connection.subscribeMessage).mockResolvedValue(vi.fn());
|
||||
push(newHass);
|
||||
await flushPromises();
|
||||
|
||||
oldDispatcher(JSON.stringify(createEventChange()));
|
||||
expect(callback).not.toBeCalled();
|
||||
});
|
||||
|
||||
describe('should call handler', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('with invalid JSON', async () => {
|
||||
const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true);
|
||||
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new FrigateEventWatcher(source);
|
||||
|
||||
const callback = vi.fn();
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: callback,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request);
|
||||
callHASubscribeMessageCallback(hass, 'NOT_JSON');
|
||||
watcher.subscribe({ instanceID: 'frigate', callback });
|
||||
await flushPromises();
|
||||
fireMessage(hass, 'NOT_JSON');
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
expect(spy).toBeCalledWith(
|
||||
@@ -183,18 +166,15 @@ describe('FrigateEventWatcher', () => {
|
||||
it('with malformed event', async () => {
|
||||
const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true);
|
||||
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new FrigateEventWatcher(source);
|
||||
|
||||
const callback = vi.fn();
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: callback,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request);
|
||||
watcher.subscribe({ instanceID: 'frigate', callback });
|
||||
await flushPromises();
|
||||
const data = JSON.stringify({});
|
||||
callHASubscribeMessageCallback(hass, data);
|
||||
fireMessage(hass, data);
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
expect(spy).toBeCalledWith(
|
||||
@@ -204,71 +184,40 @@ describe('FrigateEventWatcher', () => {
|
||||
});
|
||||
|
||||
it('without a matcher', async () => {
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new FrigateEventWatcher(source);
|
||||
|
||||
const callback = vi.fn();
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: callback,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request);
|
||||
watcher.subscribe({ instanceID: 'frigate', callback });
|
||||
await flushPromises();
|
||||
const eventChange = createEventChange();
|
||||
callHASubscribeMessageCallback(hass, JSON.stringify(eventChange));
|
||||
fireMessage(hass, JSON.stringify(eventChange));
|
||||
|
||||
expect(callback).toBeCalledWith(eventChange);
|
||||
});
|
||||
|
||||
it('with a non-matching instance_id', async () => {
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
const hass = createHASS();
|
||||
|
||||
const callback_1 = vi.fn();
|
||||
const request_1 = {
|
||||
instanceID: 'frigate_1',
|
||||
callback: callback_1,
|
||||
};
|
||||
|
||||
const callback_2 = vi.fn();
|
||||
const request_2 = {
|
||||
instanceID: 'frigate_2',
|
||||
callback: callback_2,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request_1);
|
||||
await stateWatcher.subscribe(hass, request_2);
|
||||
|
||||
const eventChange = createEventChange();
|
||||
callHASubscribeMessageCallback(hass, JSON.stringify(eventChange), 1);
|
||||
|
||||
expect(callback_1).not.toBeCalledWith(eventChange);
|
||||
expect(callback_2).toBeCalledWith(eventChange);
|
||||
});
|
||||
|
||||
it('with a matcher', async () => {
|
||||
const stateWatcher = new FrigateEventWatcher();
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new FrigateEventWatcher(source);
|
||||
|
||||
const matching_callback = vi.fn();
|
||||
const matching_request = {
|
||||
const non_matching_callback = vi.fn();
|
||||
watcher.subscribe({
|
||||
instanceID: 'frigate',
|
||||
callback: matching_callback,
|
||||
matcher: (event: FrigateEventChange) => event.after.camera === 'front_door',
|
||||
};
|
||||
|
||||
const non_matching_callback = vi.fn();
|
||||
const non_matching_request = {
|
||||
});
|
||||
watcher.subscribe({
|
||||
instanceID: 'frigate',
|
||||
callback: non_matching_callback,
|
||||
matcher: (event: FrigateEventChange) => event.after.camera === 'back_door',
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, matching_request);
|
||||
await stateWatcher.subscribe(hass, non_matching_request);
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const eventChange = createEventChange();
|
||||
callHASubscribeMessageCallback(hass, JSON.stringify(eventChange));
|
||||
fireMessage(hass, JSON.stringify(eventChange));
|
||||
|
||||
expect(non_matching_callback).not.toBeCalledWith(eventChange);
|
||||
expect(matching_callback).toBeCalledWith(eventChange);
|
||||
@@ -276,20 +225,20 @@ describe('FrigateEventWatcher', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('FrigateReviewWatcher', () => {
|
||||
it('should subscribe to a given topic once', async () => {
|
||||
const stateWatcher = new FrigateReviewWatcher();
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should subscribe to the frigate reviews channel and dispatch review changes', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new FrigateReviewWatcher(source);
|
||||
|
||||
await stateWatcher.subscribe(hass, {
|
||||
instanceID: 'frigate',
|
||||
callback: vi.fn(),
|
||||
});
|
||||
|
||||
await stateWatcher.subscribe(hass, {
|
||||
instanceID: 'frigate',
|
||||
callback: vi.fn(),
|
||||
});
|
||||
const callback = vi.fn();
|
||||
watcher.subscribe({ instanceID: 'frigate', callback });
|
||||
await flushPromises();
|
||||
|
||||
expect(hass.connection.subscribeMessage).toBeCalledWith(
|
||||
expect.any(Function),
|
||||
@@ -297,73 +246,10 @@ describe('FrigateReviewWatcher', () => {
|
||||
type: 'frigate/reviews/subscribe',
|
||||
}),
|
||||
);
|
||||
expect(hass.connection.subscribeMessage).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('should call handler', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
const reviewChange = createReviewChange();
|
||||
fireMessage(hass, JSON.stringify(reviewChange));
|
||||
|
||||
it('with a review change', async () => {
|
||||
const stateWatcher = new FrigateReviewWatcher();
|
||||
const hass = createHASS();
|
||||
|
||||
const callback = vi.fn();
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: callback,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request);
|
||||
|
||||
const reviewChange = createReviewChange();
|
||||
|
||||
callHASubscribeMessageCallback(hass, JSON.stringify(reviewChange));
|
||||
|
||||
expect(callback).toBeCalledWith(reviewChange);
|
||||
});
|
||||
|
||||
it('with a genai review change', async () => {
|
||||
const stateWatcher = new FrigateReviewWatcher();
|
||||
const hass = createHASS();
|
||||
|
||||
const callback = vi.fn();
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: callback,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request);
|
||||
|
||||
const reviewChange = createReviewChange();
|
||||
reviewChange.type = 'genai';
|
||||
|
||||
callHASubscribeMessageCallback(hass, JSON.stringify(reviewChange));
|
||||
|
||||
expect(callback).toBeCalledWith(reviewChange);
|
||||
});
|
||||
|
||||
it('with invalid JSON', async () => {
|
||||
const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true);
|
||||
|
||||
const stateWatcher = new FrigateReviewWatcher();
|
||||
const hass = createHASS();
|
||||
|
||||
const callback = vi.fn();
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: callback,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request);
|
||||
callHASubscribeMessageCallback(hass, 'NOT_JSON');
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
expect(spy).toBeCalledWith(
|
||||
'Received non-JSON payload from subscription: frigate/reviews/subscribe',
|
||||
'NOT_JSON',
|
||||
);
|
||||
});
|
||||
expect(callback).toBeCalledWith(reviewChange);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { GenericCameraManagerEngine } from '../../../src/camera-manager/generic/engine-generic';
|
||||
import { Engine, QueryResultsType, QueryType } from '../../../src/camera-manager/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { CameraConfig } from '../../../src/config/schema/cameras';
|
||||
import { RawAdvancedCameraCardConfig } from '../../../src/config/types';
|
||||
import { QuerySource } from '../../../src/query-source';
|
||||
@@ -11,15 +8,13 @@ import {
|
||||
TestViewMedia,
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createStateEntity,
|
||||
createStore,
|
||||
} from '../../test-utils';
|
||||
|
||||
const createEngine = (): GenericCameraManagerEngine => {
|
||||
return new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
);
|
||||
return new GenericCameraManagerEngine(createHASSManager());
|
||||
};
|
||||
|
||||
const createGenericCameraConfig = (
|
||||
@@ -35,7 +30,7 @@ describe('GenericCameraManagerEngine', () => {
|
||||
|
||||
it('should initialize camera', async () => {
|
||||
const config = createGenericCameraConfig();
|
||||
const camera = await createEngine().createCamera(createHASS(), config);
|
||||
const camera = await createEngine().createCamera(config);
|
||||
|
||||
expect(camera.getConfig()).toEqual(config);
|
||||
expect(camera.getCapabilities()).toBeTruthy();
|
||||
@@ -50,7 +45,7 @@ describe('GenericCameraManagerEngine', () => {
|
||||
|
||||
it('should get default query parameters', async () => {
|
||||
const config = createGenericCameraConfig();
|
||||
const camera = await createEngine().createCamera(createHASS(), config);
|
||||
const camera = await createEngine().createCamera(config);
|
||||
expect(createEngine().getDefaultQueryParameters(camera, QueryType.Event)).toEqual(
|
||||
{},
|
||||
);
|
||||
@@ -375,16 +370,12 @@ describe('GenericCameraManagerEngine', () => {
|
||||
|
||||
describe('should get camera endpoints', () => {
|
||||
it('default', async () => {
|
||||
const camera = await createEngine().createCamera(
|
||||
createHASS(),
|
||||
createGenericCameraConfig(),
|
||||
);
|
||||
const camera = await createEngine().createCamera(createGenericCameraConfig());
|
||||
expect(camera.getEndpoints()).toBeNull();
|
||||
});
|
||||
|
||||
it('for go2rtc', async () => {
|
||||
const camera = await createEngine().createCamera(
|
||||
createHASS(),
|
||||
createGenericCameraConfig({
|
||||
go2rtc: {
|
||||
stream: 'stream',
|
||||
@@ -403,7 +394,6 @@ describe('GenericCameraManagerEngine', () => {
|
||||
|
||||
it('for webrtc-card', async () => {
|
||||
const camera = await createEngine().createCamera(
|
||||
createHASS(),
|
||||
createGenericCameraConfig({
|
||||
camera_entity: 'camera.office',
|
||||
}),
|
||||
|
||||
@@ -31,7 +31,6 @@ import { StateWatcherSubscriptionInterface } from '../../src/card-controller/has
|
||||
import { sortItems } from '../../src/card-controller/view/sort.js';
|
||||
import { CameraConfig } from '../../src/config/schema/cameras.js';
|
||||
import { advancedCameraCardConfigSchema } from '../../src/config/schema/types.js';
|
||||
import { HomeAssistant } from '../../src/ha/types.js';
|
||||
import { QuerySource } from '../../src/query-source.js';
|
||||
import { Endpoint, PTZMovementType } from '../../src/types.js';
|
||||
import { ViewFolder, ViewItem, ViewMedia } from '../../src/view/item.js';
|
||||
@@ -297,7 +296,7 @@ describe('CameraManager', () => {
|
||||
camera.engineType === undefined ? Engine.Generic : camera.engineType;
|
||||
if (engineType) {
|
||||
vi.mocked(mockEngine.createCamera).mockImplementationOnce(
|
||||
async (_hass: HomeAssistant, cameraConfig: CameraConfig): Promise<Camera> =>
|
||||
async (cameraConfig: CameraConfig): Promise<Camera> =>
|
||||
await createInitializedCamera(
|
||||
cameraConfig,
|
||||
mockEngine,
|
||||
|
||||
@@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { CameraManagerEngine } from '../../../src/camera-manager/engine';
|
||||
import { MotionEyeCamera } from '../../../src/camera-manager/motioneye/camera';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createHASSManager,
|
||||
createRegistryEntity,
|
||||
} from '../../test-utils';
|
||||
|
||||
const cameraEntity = createRegistryEntity({
|
||||
entity_id: 'camera.motioneye',
|
||||
@@ -47,10 +49,8 @@ describe('MotionEyeCamera', () => {
|
||||
});
|
||||
const camera = new MotionEyeCamera(config, mock<CameraManagerEngine>());
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
const endpoints = camera.getEndpoints();
|
||||
@@ -65,10 +65,8 @@ describe('MotionEyeCamera', () => {
|
||||
});
|
||||
const camera = new MotionEyeCamera(config, mock<CameraManagerEngine>());
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
const endpoints = camera.getEndpoints();
|
||||
@@ -83,10 +81,8 @@ describe('MotionEyeCamera', () => {
|
||||
});
|
||||
const camera = new MotionEyeCamera(config, mock<CameraManagerEngine>());
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
const capabilities = camera.getCapabilities();
|
||||
@@ -109,10 +105,8 @@ describe('MotionEyeCamera', () => {
|
||||
});
|
||||
const camera = new MotionEyeCamera(config, mock<CameraManagerEngine>());
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
const capabilities = camera.getCapabilities();
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
QueryResultsType,
|
||||
QueryType,
|
||||
} from '../../../src/camera-manager/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { BrowseMediaMetadata } from '../../../src/ha/browse-media/types';
|
||||
import { BrowseMediaStep, BrowseMediaWalker } from '../../../src/ha/browse-media/walker';
|
||||
import { Entity } from '../../../src/ha/registry/entity/types';
|
||||
@@ -25,6 +23,7 @@ import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createRegistryEntity,
|
||||
createRichBrowseMedia,
|
||||
} from '../../test-utils';
|
||||
@@ -51,8 +50,7 @@ const createEngine = (options?: {
|
||||
}): MotionEyeCameraManagerEngine => {
|
||||
return new MotionEyeCameraManagerEngine(
|
||||
new EntityRegistryManagerMock(options?.entities ?? [createEntity()]),
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
createHASSManager(),
|
||||
options?.walker ?? new BrowseMediaWalker(),
|
||||
new ResolvedMediaCache(),
|
||||
options?.requestCache ?? new CameraManagerRequestCache(),
|
||||
@@ -73,10 +71,8 @@ const createMotionEyeStore = async (
|
||||
});
|
||||
const camera = new MotionEyeCamera(config, engine);
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([entity]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
camera.setID(options?.cameraID ?? 'camera-1');
|
||||
const store = new CameraManagerStore();
|
||||
@@ -136,7 +132,7 @@ describe('MotionEyeCameraManagerEngine', () => {
|
||||
camera_entity: CAMERA_ENTITY_ID,
|
||||
});
|
||||
|
||||
const camera = await engine.createCamera(createHASS(), config);
|
||||
const camera = await engine.createCamera(config);
|
||||
|
||||
expect(camera).toBeInstanceOf(Camera);
|
||||
expect(camera).toBeInstanceOf(MotionEyeCamera);
|
||||
@@ -498,10 +494,8 @@ describe('MotionEyeCameraManagerEngine', () => {
|
||||
const engine = createEngine({ walker });
|
||||
const camera = new MotionEyeCamera(config, engine);
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([createEntity()]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
camera.setID('camera-1');
|
||||
const store = new CameraManagerStore();
|
||||
@@ -547,10 +541,8 @@ describe('MotionEyeCameraManagerEngine', () => {
|
||||
const engine = createEngine({ walker });
|
||||
const camera = new MotionEyeCamera(config, engine);
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([createEntity()]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
camera.setID('camera-1');
|
||||
const store = new CameraManagerStore();
|
||||
@@ -602,10 +594,8 @@ describe('MotionEyeCameraManagerEngine', () => {
|
||||
const engine = createEngine({ walker });
|
||||
const camera = new MotionEyeCamera(config, engine);
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([createEntity()]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
camera.setID('camera-1');
|
||||
const store = new CameraManagerStore();
|
||||
|
||||
@@ -4,14 +4,13 @@ import { CameraManagerEngine } from '../../../src/camera-manager/engine';
|
||||
import { ReolinkCamera } from '../../../src/camera-manager/reolink/camera';
|
||||
import { CameraProxyConfig } from '../../../src/camera-manager/types';
|
||||
import { ActionsExecutor } from '../../../src/card-controller/actions/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { DeviceRegistryManager } from '../../../src/ha/registry/device';
|
||||
import { EntityRegistryManagerLive } from '../../../src/ha/registry/entity';
|
||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createRegistryEntity,
|
||||
createStateEntity,
|
||||
} from '../../test-utils';
|
||||
@@ -103,11 +102,9 @@ describe('ReolinkCamera', () => {
|
||||
expect(
|
||||
async () =>
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: mock<EntityRegistryManagerLive>(),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
}),
|
||||
).rejects.toThrowError('Could not find camera entity');
|
||||
});
|
||||
@@ -128,11 +125,9 @@ describe('ReolinkCamera', () => {
|
||||
expect(
|
||||
async () =>
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
}),
|
||||
).rejects.toThrowError('Could not initialize Reolink camera');
|
||||
});
|
||||
@@ -153,11 +148,9 @@ describe('ReolinkCamera', () => {
|
||||
expect(
|
||||
async () =>
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
}),
|
||||
).rejects.toThrowError('Could not initialize Reolink camera');
|
||||
});
|
||||
@@ -170,11 +163,9 @@ describe('ReolinkCamera', () => {
|
||||
const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(0);
|
||||
@@ -203,11 +194,9 @@ describe('ReolinkCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(3);
|
||||
@@ -227,11 +216,9 @@ describe('ReolinkCamera', () => {
|
||||
]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(7);
|
||||
@@ -251,11 +238,9 @@ describe('ReolinkCamera', () => {
|
||||
]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(0);
|
||||
@@ -275,11 +260,9 @@ describe('ReolinkCamera', () => {
|
||||
]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(7);
|
||||
@@ -301,11 +284,9 @@ describe('ReolinkCamera', () => {
|
||||
]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(0);
|
||||
@@ -334,11 +315,9 @@ describe('ReolinkCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(0);
|
||||
@@ -367,11 +346,9 @@ describe('ReolinkCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(0);
|
||||
@@ -400,11 +377,9 @@ describe('ReolinkCamera', () => {
|
||||
});
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
deviceRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getChannel()).toBe(0);
|
||||
@@ -420,11 +395,9 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -444,7 +417,7 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
createRegistryEntity({
|
||||
entity_id: 'camera.office_reolink',
|
||||
@@ -463,8 +436,6 @@ describe('ReolinkCamera', () => {
|
||||
}),
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -480,18 +451,18 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS({
|
||||
'select.office_reolink_ptz_preset': createStateEntity({
|
||||
state: 'foo',
|
||||
attributes: {
|
||||
options: ['preset-one', 'preset-two'],
|
||||
},
|
||||
hassManager: createHASSManager({
|
||||
hass: createHASS({
|
||||
'select.office_reolink_ptz_preset': createStateEntity({
|
||||
state: 'foo',
|
||||
attributes: {
|
||||
options: ['preset-one', 'preset-two'],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -521,11 +492,9 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -709,11 +678,9 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -739,14 +706,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
buttonEntityPTZLeft,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
await camera.executePTZAction(executor, 'left', { phase: 'start' });
|
||||
@@ -770,11 +735,9 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -812,11 +775,9 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -832,18 +793,18 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS({
|
||||
'select.office_reolink_ptz_preset': createStateEntity({
|
||||
state: 'foo',
|
||||
attributes: {
|
||||
options: ['preset-one', 'preset-two'],
|
||||
},
|
||||
hassManager: createHASSManager({
|
||||
hass: createHASS({
|
||||
'select.office_reolink_ptz_preset': createStateEntity({
|
||||
state: 'foo',
|
||||
attributes: {
|
||||
options: ['preset-one', 'preset-two'],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -871,11 +832,9 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -892,14 +851,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -915,7 +872,7 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
createRegistryEntity({
|
||||
@@ -926,8 +883,6 @@ describe('ReolinkCamera', () => {
|
||||
}),
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toBeNull();
|
||||
@@ -940,7 +895,7 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
buttonEntityPTZZoomIn,
|
||||
@@ -949,8 +904,6 @@ describe('ReolinkCamera', () => {
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -966,14 +919,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1005,14 +956,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1044,14 +993,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1083,14 +1030,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1122,14 +1067,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1152,14 +1095,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1181,14 +1122,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1204,7 +1143,7 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
buttonEntityPTZZoomIn,
|
||||
@@ -1213,8 +1152,6 @@ describe('ReolinkCamera', () => {
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1238,15 +1175,13 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
buttonEntityPTZLeft,
|
||||
buttonEntityPTZStop,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -1262,14 +1197,12 @@ describe('ReolinkCamera', () => {
|
||||
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
numberEntityZoom,
|
||||
]),
|
||||
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ import {
|
||||
QueryReturnType,
|
||||
QueryType,
|
||||
} from '../../../src/camera-manager/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { BrowseMedia, browseMediaSchema } from '../../../src/ha/browse-media/types';
|
||||
import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker';
|
||||
import { DeviceRegistryManager } from '../../../src/ha/registry/device';
|
||||
@@ -36,6 +34,7 @@ import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createInitializedCamera,
|
||||
createRegistryEntity,
|
||||
createStore,
|
||||
@@ -187,8 +186,7 @@ const createEngine = (options?: {
|
||||
return new ReolinkCameraManagerEngine(
|
||||
options?.entityRegistryManager ?? new EntityRegistryManagerMock(),
|
||||
mock<DeviceRegistryManager>(),
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
createHASSManager(),
|
||||
options?.browseMediaManager ?? new BrowseMediaWalker(),
|
||||
new ResolvedMediaCache(),
|
||||
new CameraManagerRequestCache(),
|
||||
@@ -212,7 +210,6 @@ const createStoreWithReolinkCamera = async (
|
||||
): Promise<CameraManagerStore> => {
|
||||
const store = new CameraManagerStore();
|
||||
const camera = await engine.createCamera(
|
||||
createHASS(),
|
||||
createCameraConfig({ camera_entity: 'camera.office', id: 'office' }),
|
||||
);
|
||||
store.addCamera(camera);
|
||||
@@ -252,7 +249,7 @@ describe('ReolinkCameraManagerEngine', () => {
|
||||
unique_id: 'office',
|
||||
});
|
||||
|
||||
const camera = await engine.createCamera(createHASS(), config);
|
||||
const camera = await engine.createCamera(config);
|
||||
|
||||
expect(camera.getConfig()).toBe(config);
|
||||
expect(camera.getEngine()).toBe(engine);
|
||||
@@ -289,7 +286,6 @@ describe('ReolinkCameraManagerEngine', () => {
|
||||
it('should return ui endpoint', async () => {
|
||||
const engine = createPopulatedEngine();
|
||||
const camera = await engine.createCamera(
|
||||
createHASS(),
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.office',
|
||||
reolink: {
|
||||
@@ -308,7 +304,6 @@ describe('ReolinkCameraManagerEngine', () => {
|
||||
it('should return go2rtc endpoint', async () => {
|
||||
const engine = createPopulatedEngine();
|
||||
const camera = await engine.createCamera(
|
||||
createHASS(),
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.office',
|
||||
go2rtc: {
|
||||
@@ -497,7 +492,6 @@ describe('ReolinkCameraManagerEngine', () => {
|
||||
it('should request high resolution if configured', async () => {
|
||||
const engine = createPopulatedEngine();
|
||||
const camera = await engine.createCamera(
|
||||
createHASS(),
|
||||
createCameraConfig({
|
||||
camera_entity: 'camera.office',
|
||||
id: 'office',
|
||||
|
||||
@@ -5,14 +5,13 @@ import { Capabilities } from '../../src/camera-manager/capabilities.js';
|
||||
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
|
||||
import { CameraManagerStore } from '../../src/camera-manager/store.js';
|
||||
import { Engine } from '../../src/camera-manager/types.js';
|
||||
import { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js';
|
||||
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
|
||||
import { DeviceRegistryManager } from '../../src/ha/registry/device/index.js';
|
||||
import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js';
|
||||
import { ResolvedMediaCache } from '../../src/ha/resolved-media.js';
|
||||
import {
|
||||
TestViewMedia,
|
||||
createCameraConfig,
|
||||
createHASSManager,
|
||||
createInitializedCamera,
|
||||
} from '../test-utils.js';
|
||||
|
||||
@@ -31,13 +30,11 @@ describe('CameraManagerStore', async () => {
|
||||
);
|
||||
|
||||
const engineGeneric = await engineFactory.createEngine(Engine.Generic, {
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||
});
|
||||
const engineFrigate = await engineFactory.createEngine(Engine.Frigate, {
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager(),
|
||||
resolvedMediaCache: mock<ResolvedMediaCache>(),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ import { mock } from 'vitest-mock-extended';
|
||||
import { CameraManagerEngine } from '../../../src/camera-manager/engine';
|
||||
import { TPLinkCamera } from '../../../src/camera-manager/tplink/camera';
|
||||
import { ActionsExecutor } from '../../../src/card-controller/actions/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createHASSManager,
|
||||
createRegistryEntity,
|
||||
} from '../../test-utils';
|
||||
|
||||
describe('TPLinkCamera', () => {
|
||||
// Entity patterns from: https://github.com/dermotduffy/advanced-camera-card/issues/2183
|
||||
@@ -77,10 +79,8 @@ describe('TPLinkCamera', () => {
|
||||
expect(
|
||||
async () =>
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
}),
|
||||
).rejects.toThrowError('Could not find camera entity');
|
||||
});
|
||||
@@ -94,10 +94,8 @@ describe('TPLinkCamera', () => {
|
||||
expect(
|
||||
async () =>
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock(),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
}),
|
||||
).rejects.toThrowError('Could not find camera entity');
|
||||
});
|
||||
@@ -113,10 +111,8 @@ describe('TPLinkCamera', () => {
|
||||
const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getEntity()).toBe(cameraEntity);
|
||||
@@ -131,10 +127,8 @@ describe('TPLinkCamera', () => {
|
||||
const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]);
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getEntity()).toBe(cameraEntity);
|
||||
@@ -149,10 +143,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -179,10 +171,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
@@ -203,10 +193,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -225,10 +213,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -249,10 +235,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -270,10 +254,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -299,10 +281,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -328,10 +308,8 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
@@ -365,13 +343,11 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
buttonEntityPanLeft,
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
await camera.executePTZAction(executor, 'left', { phase: 'start' });
|
||||
@@ -404,13 +380,11 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraWithDifferentUniqueId,
|
||||
buttonEntityPanLeft,
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
// Should not find PTZ entities since unique_id doesn't end with _live_view
|
||||
@@ -430,13 +404,11 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraWithoutUniqueId,
|
||||
buttonEntityPanLeft,
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
|
||||
// Should not find PTZ entities since camera has no unique_id
|
||||
@@ -453,13 +425,11 @@ describe('TPLinkCamera', () => {
|
||||
const camera = new TPLinkCamera(config, mock<CameraManagerEngine>());
|
||||
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
hassManager: createHASSManager(),
|
||||
entityRegistryManager: new EntityRegistryManagerMock([
|
||||
cameraEntity,
|
||||
buttonEntityPanLeft, // Only left button available
|
||||
]),
|
||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
});
|
||||
const executor = mock<ActionsExecutor>();
|
||||
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { TPLinkCameraManagerEngine } from '../../../src/camera-manager/tplink/engine-tplink';
|
||||
import { Engine } from '../../../src/camera-manager/types';
|
||||
import { EventWatcherSubscriptionInterface } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||
import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createHASS,
|
||||
createHASSManager,
|
||||
createRegistryEntity,
|
||||
} from '../../test-utils';
|
||||
|
||||
const createEngine = (options?: {
|
||||
entityRegistryManager?: EntityRegistryManagerMock;
|
||||
}): TPLinkCameraManagerEngine => {
|
||||
return new TPLinkCameraManagerEngine(
|
||||
options?.entityRegistryManager ?? new EntityRegistryManagerMock(),
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
createHASSManager(),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,7 +43,7 @@ describe('TPLinkCameraManagerEngine', () => {
|
||||
id: 'tapo_office',
|
||||
});
|
||||
|
||||
const camera = await engine.createCamera(createHASS(), config);
|
||||
const camera = await engine.createCamera(config);
|
||||
|
||||
expect(camera.getConfig()).toBe(config);
|
||||
expect(camera.getEngine()).toBe(engine);
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { ActionsExecutionRequest } from '../../src/card-controller/actions/types.js';
|
||||
import { AutomationsManager } from '../../src/card-controller/automations-manager.js';
|
||||
import { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js';
|
||||
import { ConditionStateManager } from '../../src/condition-trigger/conditions/state-manager.js';
|
||||
import { createCardAPI, flushPromises } from '../test-utils.js';
|
||||
import {
|
||||
createCardAPI,
|
||||
createHASS,
|
||||
createHASSEvent,
|
||||
flushPromises,
|
||||
} from '../test-utils.js';
|
||||
|
||||
describe('AutomationsManager', () => {
|
||||
const actions = [
|
||||
@@ -264,6 +271,40 @@ describe('AutomationsManager', () => {
|
||||
expect(api.getActionsManager().executeActions).toBeCalledTimes(10);
|
||||
});
|
||||
|
||||
it('should execute actions on a matching HA bus event trigger', () => {
|
||||
const api = createCardAPI();
|
||||
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||
vi.mocked(eventWatcher.subscribe).mockResolvedValue();
|
||||
vi.mocked(eventWatcher.unsubscribe).mockResolvedValue();
|
||||
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||
vi.mocked(api.getHASSManager().getEventWatcher).mockReturnValue(eventWatcher);
|
||||
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
|
||||
true,
|
||||
);
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
const automationsManager = new AutomationsManager(api);
|
||||
automationsManager.addAutomations([
|
||||
{
|
||||
triggers: [{ trigger: 'event' as const, event_type: 'zha_event' }],
|
||||
actions: actions,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(eventWatcher.subscribe).toBeCalledTimes(1);
|
||||
|
||||
// Simulate an event arrival.
|
||||
const event = createHASSEvent('zha_event', { command: 'press' });
|
||||
vi.mocked(eventWatcher.subscribe).mock.calls[0][0].callback(event);
|
||||
|
||||
expect(api.getActionsManager().executeActions).toBeCalledTimes(1);
|
||||
expect(
|
||||
vi.mocked(api.getActionsManager().executeActions).mock.calls[0][0].triggerData,
|
||||
).toEqual({ platform: 'event', event });
|
||||
});
|
||||
|
||||
it('should delete automations', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
||||
|
||||
@@ -32,6 +32,24 @@ describe('CardElementManager', () => {
|
||||
expect(manager.getElement()).toBe(element);
|
||||
});
|
||||
|
||||
it('should report whether the element is connected', () => {
|
||||
const element = createCardHTMLElement();
|
||||
const manager = new CardElementManager(
|
||||
createCardAPI(),
|
||||
element,
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
expect(manager.isConnected()).toBe(false);
|
||||
|
||||
document.body.append(element);
|
||||
expect(manager.isConnected()).toBe(true);
|
||||
|
||||
element.remove();
|
||||
expect(manager.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset scroll', () => {
|
||||
const callback = vi.fn();
|
||||
const manager = new CardElementManager(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock, MockProxy } from 'vitest-mock-extended';
|
||||
import { CameraManager } from '../../src/camera-manager/manager';
|
||||
import { ActionsManager } from '../../src/card-controller/actions/actions-manager';
|
||||
import { AutomationsManager } from '../../src/card-controller/automations-manager';
|
||||
@@ -15,6 +16,7 @@ import { DefaultManager } from '../../src/card-controller/default-manager';
|
||||
import { ExpandManager } from '../../src/card-controller/expand-manager';
|
||||
import { FoldersManager } from '../../src/card-controller/folders/manager';
|
||||
import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager';
|
||||
import { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher';
|
||||
import { HASSManager } from '../../src/card-controller/hass/hass-manager';
|
||||
import { InitializationManager } from '../../src/card-controller/initialization-manager';
|
||||
import { InteractionManager } from '../../src/card-controller/interaction-manager';
|
||||
@@ -36,6 +38,7 @@ import { AdvancedCameraCardEditor } from '../../src/editor';
|
||||
import { DeviceRegistryManager } from '../../src/ha/registry/device';
|
||||
import { EntityRegistryManagerLive } from '../../src/ha/registry/entity';
|
||||
import { ResolvedMediaCache } from '../../src/ha/resolved-media';
|
||||
import { createSubscriptionHealth } from './test-utils';
|
||||
|
||||
vi.mock('../../src/camera-manager/manager');
|
||||
vi.mock('../../src/card-controller/actions/actions-manager');
|
||||
@@ -49,7 +52,6 @@ vi.mock('../../src/card-controller/download-manager');
|
||||
vi.mock('../../src/card-controller/expand-manager');
|
||||
vi.mock('../../src/card-controller/folders/manager');
|
||||
vi.mock('../../src/card-controller/fullscreen/fullscreen-manager');
|
||||
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');
|
||||
@@ -78,8 +80,21 @@ const createCardElement = (): CardHTMLElement => {
|
||||
return element;
|
||||
};
|
||||
|
||||
const createController = (): CardController => {
|
||||
return new CardController(createCardElement(), vi.fn(), vi.fn());
|
||||
// Full HASSManager mock for CardController ctor injection (wires
|
||||
// getEventWatcher().getHealth() so construction resolves). Distinct from the
|
||||
// readonly-interface `createHASSManager` helper in tests/test-utils.ts.
|
||||
const createMockHASSManager = (): MockProxy<HASSManager> => {
|
||||
const hassManager = mock<HASSManager>();
|
||||
hassManager.getEventWatcher.mockReturnValue(
|
||||
mock<EventWatcherSubscriptionInterface>({
|
||||
getHealth: () => createSubscriptionHealth(),
|
||||
}),
|
||||
);
|
||||
return hassManager;
|
||||
};
|
||||
|
||||
const createController = (hassManager = createMockHASSManager()): CardController => {
|
||||
return new CardController(createCardElement(), vi.fn(), vi.fn(), hassManager);
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
@@ -104,6 +119,26 @@ describe('CardController', () => {
|
||||
expect(controller.getEffectsManager()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should wire ConditionStateManager as the first hass listener so semantic state is fresh before any other listener runs', () => {
|
||||
const hassManager = createMockHASSManager();
|
||||
createController(hassManager);
|
||||
|
||||
const calls = vi.mocked(hassManager.addListener).mock.calls;
|
||||
|
||||
// ConditionStateManager (CSM) first ordering is load-bearing: StateWatcher
|
||||
// lazy-attaches later; its diff handlers can synchronously write to CSM, so
|
||||
// CSM.hass must be fresh before any listener whose dispatch path reads
|
||||
// condition state.
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const csmListener = calls[0][0];
|
||||
const hass = {} as Parameters<typeof csmListener>[0];
|
||||
csmListener(hass, null);
|
||||
expect(vi.mocked(ConditionStateManager).mock.instances[0].setState).toBeCalledWith({
|
||||
hass,
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessors', () => {
|
||||
it('should return getActionsManager', () => {
|
||||
expect(createController().getActionsManager()).toBe(
|
||||
@@ -196,9 +231,8 @@ describe('CardController', () => {
|
||||
});
|
||||
|
||||
it('should return getHASSManager', () => {
|
||||
expect(createController().getHASSManager()).toBe(
|
||||
vi.mocked(HASSManager).mock.instances[0],
|
||||
);
|
||||
const hassManager = createMockHASSManager();
|
||||
expect(createController(hassManager).getHASSManager()).toBe(hassManager);
|
||||
});
|
||||
|
||||
it('should return getInitializationManager', () => {
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
import { HassEvent } from 'home-assistant-js-websocket';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Connection, HassEvent } from 'home-assistant-js-websocket';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { EventWatcher } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { HomeAssistant } from '../../../src/ha/types';
|
||||
import { createHASS } from '../../test-utils';
|
||||
import {
|
||||
createHASS,
|
||||
createHASSEvent,
|
||||
createHASSSource,
|
||||
flushPromises,
|
||||
useDeterministicTimers,
|
||||
} from '../../test-utils';
|
||||
|
||||
// Drive the dispatcher registered with `hass.connection.subscribeEvents` to
|
||||
// simulate an event arriving over the WS bus.
|
||||
const fireEvent = (hass: HomeAssistant, event: HassEvent, n = 0): void => {
|
||||
const mock = vi.mocked(hass.connection.subscribeEvents).mock;
|
||||
expect(mock.calls.length).greaterThan(n);
|
||||
|
||||
// subscribeEvents(callback, event_type) -- callback is the first argument.
|
||||
mock.calls[n][0]?.(event);
|
||||
};
|
||||
|
||||
const createHassEvent = (event_type: string, data: object = {}): HassEvent => ({
|
||||
event_type,
|
||||
data: data as { [key: string]: string },
|
||||
origin: 'LOCAL',
|
||||
time_fired: '2026-05-25T00:00:00Z',
|
||||
context: { id: 'ctx', user_id: null, parent_id: null },
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('EventWatcher', () => {
|
||||
it('opens a single WS subscription per event_type regardless of subscribers', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
const hass = createHASS();
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() });
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() });
|
||||
it('should open a WS subscription keyed by event_type', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
|
||||
await flushPromises();
|
||||
|
||||
expect(hass.connection.subscribeEvents).toBeCalledTimes(1);
|
||||
expect(vi.mocked(hass.connection.subscribeEvents).mock.calls[0][1]).toBe(
|
||||
@@ -34,100 +40,160 @@ describe('EventWatcher', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('opens separate WS subscriptions for distinct event_types', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
it('should share one WS subscription across subscribers with the same event_type', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() });
|
||||
await watcher.subscribe(hass, { event_type: 'deconz_event', callback: vi.fn() });
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
|
||||
await flushPromises();
|
||||
|
||||
expect(hass.connection.subscribeEvents).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should open separate WS subscriptions for distinct event_types', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
|
||||
watcher.subscribe({ event_type: 'deconz_event', callback: vi.fn() });
|
||||
await flushPromises();
|
||||
|
||||
expect(hass.connection.subscribeEvents).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('only tears down the WS subscription when the last subscriber unsubscribes', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
const hass = createHASS();
|
||||
const unsub = vi.fn();
|
||||
vi.mocked(hass.connection.subscribeEvents).mockResolvedValue(unsub);
|
||||
|
||||
const req1 = { event_type: 'zha_event', callback: vi.fn() };
|
||||
const req2 = { event_type: 'zha_event', callback: vi.fn() };
|
||||
await watcher.subscribe(hass, req1);
|
||||
await watcher.subscribe(hass, req2);
|
||||
|
||||
await watcher.unsubscribe(req1);
|
||||
expect(unsub).not.toBeCalled();
|
||||
|
||||
await watcher.unsubscribe(req2);
|
||||
expect(unsub).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dispatches to all subscribers whose event_type matches', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
it('should dispatch to every subscriber whose event_type matches', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
const cb1 = vi.fn();
|
||||
const cb2 = vi.fn();
|
||||
const event = createHASSEvent('zha_event', { command: 'press' });
|
||||
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb1 });
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb2 });
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: cb1 });
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: cb2 });
|
||||
await flushPromises();
|
||||
|
||||
fireEvent(hass, createHassEvent('zha_event', { command: 'press' }));
|
||||
fireEvent(hass, event);
|
||||
|
||||
expect(cb1).toBeCalledWith({ command: 'press' });
|
||||
expect(cb2).toBeCalledWith({ command: 'press' });
|
||||
expect(cb1).toBeCalledWith(event);
|
||||
expect(cb2).toBeCalledWith(event);
|
||||
});
|
||||
|
||||
it('drops events whose event_type does not match the request', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
it('should gate dispatch on the request matcher when provided', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
const cb = vi.fn();
|
||||
const matcher = vi.fn((event: HassEvent) => (event.data as { x?: number }).x === 1);
|
||||
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb });
|
||||
// Inject an unrelated event into the shared dispatcher.
|
||||
fireEvent(hass, createHassEvent('other_event', { x: 1 }));
|
||||
const matching = createHASSEvent('zha_event', { x: 1 });
|
||||
const nonMatching = createHASSEvent('zha_event', { x: 2 });
|
||||
watcher.subscribe({ event_type: 'zha_event', matcher, callback: cb });
|
||||
await flushPromises();
|
||||
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('gates dispatch on the request matcher when provided', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
const hass = createHASS();
|
||||
const cb = vi.fn();
|
||||
const matcher = vi.fn((data: unknown) => (data as { x?: number }).x === 1);
|
||||
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', matcher, callback: cb });
|
||||
|
||||
fireEvent(hass, createHassEvent('zha_event', { x: 1 }));
|
||||
fireEvent(hass, createHassEvent('zha_event', { x: 2 }));
|
||||
fireEvent(hass, matching);
|
||||
fireEvent(hass, nonMatching);
|
||||
|
||||
expect(matcher).toBeCalledTimes(2);
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
expect(cb).toBeCalledWith({ x: 1 });
|
||||
expect(cb).toBeCalledWith(matching);
|
||||
});
|
||||
|
||||
it('handles unsubscribe during a still-pending subscribe without leaking', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
it('should tear down the WS subscription only when the last subscriber unsubscribes', async () => {
|
||||
const hass = createHASS();
|
||||
const unsub = vi.fn();
|
||||
vi.mocked(hass.connection.subscribeEvents).mockResolvedValue(unsub);
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
const req1 = { event_type: 'zha_event', callback: vi.fn() };
|
||||
const req2 = { event_type: 'zha_event', callback: vi.fn() };
|
||||
|
||||
let resolveSubscription: ((cb: () => Promise<void>) => void) | undefined;
|
||||
const subscriptionPromise = new Promise<() => Promise<void>>((resolve) => {
|
||||
resolveSubscription = resolve;
|
||||
});
|
||||
vi.mocked(hass.connection.subscribeEvents).mockReturnValue(subscriptionPromise);
|
||||
watcher.subscribe(req1);
|
||||
watcher.subscribe(req2);
|
||||
await flushPromises();
|
||||
|
||||
const req = { event_type: 'zha_event', callback: vi.fn() };
|
||||
const subscribePromise = watcher.subscribe(hass, req);
|
||||
|
||||
// Unsubscribe before the underlying connection has resolved.
|
||||
const unsubscribePromise = watcher.unsubscribe(req);
|
||||
|
||||
// Resolve the connection -- the watcher should now have the unsub fn and
|
||||
// call it as part of completing the unsubscribe.
|
||||
resolveSubscription?.(unsub);
|
||||
await subscribePromise;
|
||||
await unsubscribePromise;
|
||||
watcher.unsubscribe(req1);
|
||||
await flushPromises();
|
||||
expect(unsub).not.toBeCalled();
|
||||
|
||||
watcher.unsubscribe(req2);
|
||||
await flushPromises();
|
||||
expect(unsub).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should drop events from an old-connection subscription after a swap', async () => {
|
||||
const oldHass = createHASS();
|
||||
const { source, push } = createHASSSource(oldHass);
|
||||
const watcher = new EventWatcher(source);
|
||||
const cb = vi.fn();
|
||||
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: cb });
|
||||
await flushPromises();
|
||||
|
||||
// Capture the dispatcher registered against the OLD connection BEFORE
|
||||
// the swap, so it still points at the source-bound guard.
|
||||
const oldDispatcher = vi.mocked(oldHass.connection.subscribeEvents).mock.calls[0][0];
|
||||
|
||||
const newHass = createHASS();
|
||||
newHass.connection = mock<Connection>();
|
||||
vi.mocked(newHass.connection.subscribeEvents).mockResolvedValue(vi.fn());
|
||||
push(newHass);
|
||||
await flushPromises();
|
||||
|
||||
// Old dispatcher fires: guard.isConnected() is now false, callback must NOT
|
||||
// receive the event.
|
||||
oldDispatcher?.(createHASSEvent('zha_event', { command: 'press' }));
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not dispatch to a subscriber that registers mid-dispatch', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
const lateCallback = vi.fn();
|
||||
const reentrantCallback = vi.fn(() => {
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: lateCallback });
|
||||
});
|
||||
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: reentrantCallback });
|
||||
await flushPromises();
|
||||
|
||||
fireEvent(hass, createHASSEvent('zha_event', { command: 'press' }));
|
||||
|
||||
expect(reentrantCallback).toBeCalledTimes(1);
|
||||
expect(lateCallback).not.toBeCalled();
|
||||
});
|
||||
|
||||
describe('subscription health monitoring', () => {
|
||||
it('should surface and retry failing subscriptions through getHealth', async () => {
|
||||
useDeterministicTimers();
|
||||
|
||||
const hass = createHASS();
|
||||
vi.mocked(hass.connection.subscribeEvents).mockRejectedValue(new Error('boom'));
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
watcher
|
||||
.getHealth()
|
||||
.getFailures()
|
||||
.map((failure) => failure.key),
|
||||
).toEqual(['zha_event']);
|
||||
|
||||
const before = vi.mocked(hass.connection.subscribeEvents).mock.calls.length;
|
||||
|
||||
watcher.getHealth().retry();
|
||||
|
||||
await flushPromises();
|
||||
expect(vi.mocked(hass.connection.subscribeEvents).mock.calls.length).toBe(
|
||||
before + 1,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,27 +44,62 @@ describe('HASSManager', () => {
|
||||
expect(manager.hasHASS()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should update theme upon setting hass', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new HASSManager(api);
|
||||
describe('as a HASS source', () => {
|
||||
it('should fan out to registered listeners with (hass, oldHass)', () => {
|
||||
const manager = new HASSManager(createCardAPI());
|
||||
const listener = vi.fn();
|
||||
manager.addListener(listener);
|
||||
|
||||
manager.setHASS(createHASS());
|
||||
const hass1 = createHASS();
|
||||
manager.setHASS(hass1);
|
||||
expect(listener).toBeCalledWith(hass1, null);
|
||||
|
||||
expect(api.getStyleManager().applyTheme).toBeCalled();
|
||||
});
|
||||
const hass2 = createHASS();
|
||||
manager.setHASS(hass2);
|
||||
expect(listener).toBeCalledWith(hass2, hass1);
|
||||
});
|
||||
|
||||
it('should set condition manager state', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new HASSManager(api);
|
||||
const hass = createHASS();
|
||||
it('should call listeners in insertion order on every fan-out', () => {
|
||||
const manager = new HASSManager(createCardAPI());
|
||||
const order: string[] = [];
|
||||
manager.addListener(() => order.push('first'));
|
||||
manager.addListener(() => order.push('second'));
|
||||
|
||||
manager.setHASS(hass);
|
||||
manager.setHASS(createHASS());
|
||||
|
||||
expect(api.getConditionStateManager().setState).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
hass: hass,
|
||||
}),
|
||||
);
|
||||
expect(order).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
it('should detach a listener via the returned unlisten callback', () => {
|
||||
const manager = new HASSManager(createCardAPI());
|
||||
const listener = vi.fn();
|
||||
const unlisten = manager.addListener(listener);
|
||||
|
||||
unlisten();
|
||||
manager.setHASS(createHASS());
|
||||
|
||||
expect(listener).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not fan out on null/undefined hass', () => {
|
||||
const manager = new HASSManager(createCardAPI());
|
||||
const listener = vi.fn();
|
||||
manager.addListener(listener);
|
||||
|
||||
manager.setHASS(null);
|
||||
manager.setHASS();
|
||||
|
||||
expect(listener).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should expose current hass via getHASS for source consumers', () => {
|
||||
const manager = new HASSManager(createCardAPI());
|
||||
expect(manager.getHASS()).toBeNull();
|
||||
|
||||
const hass = createHASS();
|
||||
manager.setHASS(hass);
|
||||
expect(manager.getHASS()).toBe(hass);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle connection state change when', () => {
|
||||
|
||||
@@ -1,35 +1,61 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { createHASS, createStateEntity } from '../../test-utils';
|
||||
import { createHASS, createHASSSource, createStateEntity } from '../../test-utils';
|
||||
|
||||
describe('StateWatcher', () => {
|
||||
it('should not subscribe with no entities', () => {
|
||||
const stateWatcher = new StateWatcher();
|
||||
const { source } = createHASSSource();
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
expect(stateWatcher.subscribe(vi.fn(), [])).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should attach to the source lazily on first subscriber', () => {
|
||||
const { source, getListenerCount } = createHASSSource(createHASS());
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
expect(getListenerCount()).toBe(0);
|
||||
stateWatcher.subscribe(vi.fn(), ['binary_sensor.foo']);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should stay attached while other subscribers remain', () => {
|
||||
const { source, getListenerCount } = createHASSSource(createHASS());
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const cb1 = vi.fn();
|
||||
const cb2 = vi.fn();
|
||||
|
||||
stateWatcher.subscribe(cb1, ['binary_sensor.foo']);
|
||||
stateWatcher.subscribe(cb2, ['binary_sensor.bar']);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
|
||||
stateWatcher.unsubscribe(cb1);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should detach from the source when the last subscriber leaves', () => {
|
||||
const { source, getListenerCount } = createHASSSource(createHASS());
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const callback = vi.fn();
|
||||
expect(stateWatcher.subscribe(callback, [])).toBeFalsy();
|
||||
|
||||
stateWatcher.subscribe(callback, ['binary_sensor.foo']);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
|
||||
stateWatcher.unsubscribe(callback);
|
||||
expect(getListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should call back with state change', () => {
|
||||
const stateWatcher = new StateWatcher();
|
||||
const initial = createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
'binary_sensor.bar': createStateEntity({ state: 'off' }),
|
||||
});
|
||||
const { source, push } = createHASSSource(initial);
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const callback = vi.fn();
|
||||
|
||||
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
|
||||
expect(stateWatcher.subscribe(callback, ['binary_sensor.bar'])).toBeTruthy();
|
||||
|
||||
stateWatcher.setHASS(
|
||||
null,
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
'binary_sensor.bar': createStateEntity({ state: 'off' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
|
||||
stateWatcher.setHASS(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
'binary_sensor.bar': createStateEntity({ state: 'off' }),
|
||||
}),
|
||||
push(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
'binary_sensor.bar': createStateEntity({ state: 'on' }),
|
||||
@@ -46,24 +72,33 @@ describe('StateWatcher', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call back without state change', () => {
|
||||
const stateWatcher = new StateWatcher();
|
||||
it('should not call back when oldHass is null on first observed push', () => {
|
||||
const { source, push } = createHASSSource(null);
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const callback = vi.fn();
|
||||
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
|
||||
|
||||
stateWatcher.setHASS(
|
||||
null,
|
||||
stateWatcher.subscribe(callback, ['binary_sensor.foo']);
|
||||
|
||||
push(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
});
|
||||
|
||||
stateWatcher.setHASS(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
it('should not call back without state change', () => {
|
||||
const initial = createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
});
|
||||
const { source, push } = createHASSSource(initial);
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const callback = vi.fn();
|
||||
|
||||
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
|
||||
|
||||
push(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
@@ -73,24 +108,17 @@ describe('StateWatcher', () => {
|
||||
});
|
||||
|
||||
it('should not call back when unsubscribed', () => {
|
||||
const stateWatcher = new StateWatcher();
|
||||
const initial = createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
});
|
||||
const { source, push } = createHASSSource(initial);
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const callback = vi.fn();
|
||||
|
||||
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
|
||||
expect(stateWatcher.unsubscribe(callback));
|
||||
stateWatcher.unsubscribe(callback);
|
||||
|
||||
stateWatcher.setHASS(
|
||||
null,
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
|
||||
stateWatcher.setHASS(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
push(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'off' }),
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { STATE_STARTING } from 'home-assistant-js-websocket';
|
||||
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 {
|
||||
@@ -293,4 +293,89 @@ describe('InitializationManager', () => {
|
||||
|
||||
expect(initializer.uninitialize).toBeCalledWith(InitializationAspect.CAMERAS);
|
||||
});
|
||||
|
||||
describe('should decide whether to trigger initialization', () => {
|
||||
const createReadyAPI = () => {
|
||||
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;
|
||||
};
|
||||
|
||||
it('should initialize when all conditions are met', () => {
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(createReadyAPI(), initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).toBeCalled();
|
||||
});
|
||||
|
||||
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.toBeCalled();
|
||||
});
|
||||
|
||||
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.toBeCalled();
|
||||
});
|
||||
|
||||
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.toBeCalled();
|
||||
});
|
||||
|
||||
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.toBeCalled();
|
||||
});
|
||||
|
||||
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.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createIssueManager } from '../../../src/card-controller/issues/factory'
|
||||
import { IssueManager } from '../../../src/card-controller/issues/issue-manager';
|
||||
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
|
||||
import { createCardAPI } from '../../test-utils';
|
||||
import { createSubscriptionHealth } from '../test-utils';
|
||||
|
||||
describe('createIssueManager', () => {
|
||||
beforeEach(() => {
|
||||
@@ -15,12 +16,15 @@ describe('createIssueManager', () => {
|
||||
});
|
||||
|
||||
it('should return a IssueManager instance', () => {
|
||||
const manager = createIssueManager(createCardAPI());
|
||||
const manager = createIssueManager(createCardAPI(), createSubscriptionHealth());
|
||||
expect(manager).toBeInstanceOf(IssueManager);
|
||||
});
|
||||
|
||||
it('should register all expected issues', () => {
|
||||
const manager = createIssueManager(createCardAPI()).getStateManager();
|
||||
const manager = createIssueManager(
|
||||
createCardAPI(),
|
||||
createSubscriptionHealth(),
|
||||
).getStateManager();
|
||||
|
||||
expect(manager.getIssueDescriptions()).toHaveLength(0);
|
||||
|
||||
@@ -29,6 +33,7 @@ describe('createIssueManager', () => {
|
||||
'config_upgrade',
|
||||
'config_upgrade_failure',
|
||||
'connection',
|
||||
'event_subscription',
|
||||
'initialization',
|
||||
'legacy_resource',
|
||||
'media_query',
|
||||
@@ -51,7 +56,7 @@ describe('createIssueManager', () => {
|
||||
const api = createCardAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
const manager = createIssueManager(api);
|
||||
const manager = createIssueManager(api, createSubscriptionHealth());
|
||||
|
||||
manager.trigger('media_query', { error: new Error('x') });
|
||||
manager.trigger('initialization', { error: new Error('x') });
|
||||
@@ -76,7 +81,7 @@ describe('createIssueManager', () => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
const manager = createIssueManager(api);
|
||||
const manager = createIssueManager(api, createSubscriptionHealth());
|
||||
|
||||
// Setting view starts the media_load timer (via the condition state
|
||||
// listener → evaluate → detectDynamic).
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { EventSubscriptionIssue } from '../../../../src/card-controller/issues/issues/event-subscription';
|
||||
import { Issue } from '../../../../src/card-controller/issues/types';
|
||||
import { SubscriptionFailure } from '../../../../src/ha/connection/subscription-health-monitor';
|
||||
import { localize } from '../../../../src/localize/localize';
|
||||
import { createSubscriptionHealth } from '../../test-utils';
|
||||
|
||||
const createSubscriptionFailure = (key: string): SubscriptionFailure<string> => ({
|
||||
key,
|
||||
error: new Error(key),
|
||||
failureCount: 1,
|
||||
});
|
||||
|
||||
describe('EventSubscriptionIssue', () => {
|
||||
it('should register the change callback as a health listener', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
const changeCallback = vi.fn();
|
||||
|
||||
new EventSubscriptionIssue(health, changeCallback);
|
||||
|
||||
expect(health.addListener).toBeCalledWith(changeCallback);
|
||||
});
|
||||
|
||||
it('should have no issue when there are no failures', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
const issue = new EventSubscriptionIssue(health, vi.fn());
|
||||
|
||||
expect(issue.hasIssue()).toBe(false);
|
||||
expect(issue.getIssue()).toBeNull();
|
||||
expect(issue.getNotification()).toBeNull();
|
||||
});
|
||||
|
||||
it('should describe the failing event types sorted, at medium severity', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
health.getFailures.mockReturnValue([
|
||||
createSubscriptionFailure('zebra_event'),
|
||||
createSubscriptionFailure('alpha_event'),
|
||||
]);
|
||||
const issue = new EventSubscriptionIssue(health, vi.fn());
|
||||
|
||||
expect(issue.hasIssue()).toBe(true);
|
||||
|
||||
const description = issue.getIssue();
|
||||
expect(description?.severity).toBe('medium');
|
||||
expect(description?.notification.heading?.text).toBe(
|
||||
localize('issues.event_subscription.heading'),
|
||||
);
|
||||
expect(description?.notification.metadata?.map((detail) => detail.text)).toEqual([
|
||||
'alpha_event',
|
||||
'zebra_event',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should offer a retry control on the notification', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
health.getFailures.mockReturnValue([createSubscriptionFailure('zha_event')]);
|
||||
const issue = new EventSubscriptionIssue(health, vi.fn());
|
||||
|
||||
expect(issue.getNotification()?.controls?.[0].icon).toBe('mdi:refresh');
|
||||
});
|
||||
|
||||
it('should re-drive the failing subscriptions on retry', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
const issue = new EventSubscriptionIssue(health, vi.fn());
|
||||
|
||||
expect(issue.retry()).toBe(true);
|
||||
expect(health.retry).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not opt into IssueManager-scheduled retries', () => {
|
||||
// No `needsRetry()` means the subscription manager stays the sole auto-retry
|
||||
// loop; the IssueManager never schedules this issue.
|
||||
const issue: Issue = new EventSubscriptionIssue(createSubscriptionHealth(), vi.fn());
|
||||
|
||||
expect(issue.needsRetry).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should remove its health listener on destroy', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
const unsubscribe = vi.fn();
|
||||
health.addListener.mockReturnValue(unsubscribe);
|
||||
const issue = new EventSubscriptionIssue(health, vi.fn());
|
||||
|
||||
issue.destroy();
|
||||
|
||||
expect(unsubscribe).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -539,7 +539,7 @@ describe('IssueStateManager', () => {
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should destroy all issues and clear', () => {
|
||||
it('should clear, reset and destroy all issues', () => {
|
||||
const manager = createManager();
|
||||
manager.destroy();
|
||||
|
||||
@@ -549,6 +549,14 @@ describe('IssueStateManager', () => {
|
||||
expect(mockConfigUpgrade.reset).toBeCalled();
|
||||
expect(mockLegacyResource.reset).toBeCalled();
|
||||
expect(mockMediaLoad.reset).toBeCalled();
|
||||
|
||||
assert(mockConfigUpgrade.destroy);
|
||||
assert(mockLegacyResource.destroy);
|
||||
assert(mockMediaLoad.destroy);
|
||||
expect(mockConfigUpgrade.destroy).toBeCalled();
|
||||
expect(mockLegacyResource.destroy).toBeCalled();
|
||||
expect(mockMediaLoad.destroy).toBeCalled();
|
||||
|
||||
expect(manager.getIssuePresence().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { vi } from 'vitest';
|
||||
import { mock, MockProxy } from 'vitest-mock-extended';
|
||||
import { SubscriptionHealthInterface } from '../../src/ha/connection/subscription-health-monitor';
|
||||
|
||||
// A benign mocked event-subscription health surface: no failures, listeners
|
||||
// return a no-op unsubscribe. Tests configure `getFailures`/`retry` as needed.
|
||||
export const createSubscriptionHealth = (): MockProxy<
|
||||
SubscriptionHealthInterface<string>
|
||||
> => {
|
||||
const health = mock<SubscriptionHealthInterface<string>>();
|
||||
health.getFailures.mockReturnValue([]);
|
||||
health.addListener.mockReturnValue(vi.fn());
|
||||
return health;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { CallTrigger } from '../../../src/condition-trigger/triggers/triggers/ca
|
||||
import { CameraTrigger } from '../../../src/condition-trigger/triggers/triggers/camera';
|
||||
import { ConfigTrigger } from '../../../src/condition-trigger/triggers/triggers/config';
|
||||
import { DisplayModeTrigger } from '../../../src/condition-trigger/triggers/triggers/display-mode';
|
||||
import { EventTrigger } from '../../../src/condition-trigger/triggers/triggers/event';
|
||||
import { ExpandTrigger } from '../../../src/condition-trigger/triggers/triggers/expand';
|
||||
import { FullscreenTrigger } from '../../../src/condition-trigger/triggers/triggers/fullscreen';
|
||||
import { InitializedTrigger } from '../../../src/condition-trigger/triggers/triggers/initialized';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from '../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { ViewTrigger } from '../../../src/condition-trigger/triggers/triggers/view';
|
||||
import { Trigger } from '../../../src/config/schema/condition-trigger/triggers/types';
|
||||
import { createHASSManager } from '../../test-utils';
|
||||
|
||||
type TriggerEvaluatorConstructor = new (...args: never[]) => TriggerEvaluator;
|
||||
|
||||
@@ -32,9 +34,11 @@ describe('createTriggerEvaluator', () => {
|
||||
const context = (): TriggerEvaluatorContext => ({
|
||||
stateManager: new ConditionStateManager(),
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
hassManager: createHASSManager(),
|
||||
});
|
||||
|
||||
it.each<[Trigger, TriggerEvaluatorConstructor]>([
|
||||
[{ trigger: 'event', event_type: 'zha_event' }, EventTrigger],
|
||||
[{ trigger: 'state', entity_id: 'binary_sensor.x' }, StateTrigger],
|
||||
[{ trigger: 'numeric_state', entity_id: 'sensor.x', above: 5 }, NumericStateTrigger],
|
||||
[{ trigger: 'template', value_template: '{{ true }}' }, TemplateTrigger],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
|
||||
import { TriggersManager } from '../../../src/condition-trigger/triggers/manager';
|
||||
import { Trigger } from '../../../src/config/schema/condition-trigger/triggers/types';
|
||||
import { createHASS, createStateEntity } from '../../test-utils';
|
||||
import { createHASS, createHASSManager, createStateEntity } from '../../test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('TriggersManager', () => {
|
||||
@@ -14,7 +14,7 @@ describe('TriggersManager', () => {
|
||||
listener: Mock;
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const manager = new TriggersManager(triggers, stateManager);
|
||||
const manager = new TriggersManager(triggers, stateManager, createHASSManager());
|
||||
const listener = vi.fn();
|
||||
return { manager, stateManager, listener };
|
||||
};
|
||||
@@ -107,6 +107,7 @@ describe('TriggersManager', () => {
|
||||
const manager = new TriggersManager(
|
||||
[{ trigger: 'camera', cameras: ['front'], enabled }],
|
||||
stateManager,
|
||||
createHASSManager(),
|
||||
);
|
||||
const listener = vi.fn();
|
||||
manager.addListener(listener);
|
||||
@@ -169,6 +170,7 @@ describe('TriggersManager', () => {
|
||||
const manager = new TriggersManager(
|
||||
[{ trigger: 'camera', cameras: ['front', 'back'], enabled: ENABLED_TEMPLATE }],
|
||||
stateManager,
|
||||
createHASSManager(),
|
||||
);
|
||||
const listener = vi.fn();
|
||||
manager.addListener(listener);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { CallTrigger } from '../../../../src/condition-trigger/triggers/triggers/call';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('CallTrigger', () => {
|
||||
@@ -11,10 +11,9 @@ describe('CallTrigger', () => {
|
||||
): { stateManager: ConditionStateManager; callback: Mock } => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
new CallTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
}).subscribe(callback);
|
||||
new CallTrigger(trigger, createTriggerEvaluatorContext({ stateManager })).subscribe(
|
||||
callback,
|
||||
);
|
||||
return { stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { CameraTrigger } from '../../../../src/condition-trigger/triggers/triggers/camera';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createConfig } from '../../../test-utils';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('CameraTrigger', () => {
|
||||
@@ -16,10 +16,10 @@ describe('CameraTrigger', () => {
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
const cameraTrigger = new CameraTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
});
|
||||
const cameraTrigger = new CameraTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
return { cameraTrigger, stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { ConfigTrigger } from '../../../../src/condition-trigger/triggers/triggers/config';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createConfig } from '../../../test-utils';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('ConfigTrigger', () => {
|
||||
@@ -16,10 +16,10 @@ describe('ConfigTrigger', () => {
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
const configTrigger = new ConfigTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
});
|
||||
const configTrigger = new ConfigTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
return { configTrigger, stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { DisplayModeTrigger } from '../../../../src/condition-trigger/triggers/triggers/display-mode';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('DisplayModeTrigger', () => {
|
||||
@@ -11,10 +11,10 @@ describe('DisplayModeTrigger', () => {
|
||||
): { stateManager: ConditionStateManager; callback: Mock } => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
new DisplayModeTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
}).subscribe(callback);
|
||||
new DisplayModeTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
).subscribe(callback);
|
||||
return { stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import {
|
||||
EventSubscriptionRequest,
|
||||
EventWatcherSubscriptionInterface,
|
||||
} from '../../../../src/card-controller/hass/event-watcher';
|
||||
import { EventTrigger } from '../../../../src/condition-trigger/triggers/triggers/event';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createHASSEvent, createHASSManager } from '../../../test-utils';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
interface Harness {
|
||||
trigger: EventTrigger;
|
||||
eventWatcher: EventWatcherSubscriptionInterface;
|
||||
callback: Mock;
|
||||
}
|
||||
|
||||
const create = (config: TriggerOfType<'event'>): Harness => {
|
||||
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||
const hassManager = createHASSManager({ eventWatcher });
|
||||
|
||||
const callback = vi.fn();
|
||||
const trigger = new EventTrigger(
|
||||
config,
|
||||
createTriggerEvaluatorContext({ hassManager }),
|
||||
);
|
||||
return { trigger, eventWatcher, callback };
|
||||
};
|
||||
|
||||
const getLastMatcher = (
|
||||
eventWatcher: EventWatcherSubscriptionInterface,
|
||||
n = 0,
|
||||
): EventSubscriptionRequest['matcher'] =>
|
||||
vi.mocked(eventWatcher.subscribe).mock.calls[n][0].matcher;
|
||||
|
||||
const callEventCallback = (
|
||||
eventWatcher: EventWatcherSubscriptionInterface,
|
||||
event: ReturnType<typeof createHASSEvent>,
|
||||
n = 0,
|
||||
): void => {
|
||||
vi.mocked(eventWatcher.subscribe).mock.calls[n][0].callback(event);
|
||||
};
|
||||
|
||||
describe('EventTrigger', () => {
|
||||
it('should register one EventWatcher request per event_type', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: 'zha_event',
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
expect(eventWatcher.subscribe).toBeCalledTimes(1);
|
||||
expect(vi.mocked(eventWatcher.subscribe).mock.calls[0][0].event_type).toBe(
|
||||
'zha_event',
|
||||
);
|
||||
});
|
||||
|
||||
it('should dedupe duplicate event_types in list form', () => {
|
||||
// A repeated entry would otherwise produce two requests and fire the
|
||||
// callback twice for every matching event.
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: ['zha_event', 'zha_event'],
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
expect(eventWatcher.subscribe).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should expand list-form event_type into one request per type', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: ['zha_event', 'deconz_event'],
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
expect(eventWatcher.subscribe).toBeCalledTimes(2);
|
||||
expect(vi.mocked(eventWatcher.subscribe).mock.calls[0][0].event_type).toBe(
|
||||
'zha_event',
|
||||
);
|
||||
expect(vi.mocked(eventWatcher.subscribe).mock.calls[1][0].event_type).toBe(
|
||||
'deconz_event',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fire with the full HA event on dispatch', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: 'zha_event',
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
const event = createHASSEvent('zha_event', { command: 'press' });
|
||||
callEventCallback(eventWatcher, event);
|
||||
expect(callback).toBeCalledWith({ platform: 'event', event });
|
||||
});
|
||||
|
||||
it('should omit the matcher when neither event_data nor context is set', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: 'zha_event',
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
expect(vi.mocked(eventWatcher.subscribe).mock.calls[0][0].matcher).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should attach an event_data matcher', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: 'zha_event',
|
||||
event_data: { command: 'press' },
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
const matcher = getLastMatcher(eventWatcher);
|
||||
expect(matcher?.(createHASSEvent('zha_event', { command: 'press' }))).toBe(true);
|
||||
expect(matcher?.(createHASSEvent('zha_event', { command: 'release' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('should attach a context matcher', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: 'zha_event',
|
||||
context: { user_id: 'u-1' },
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
const matcher = getLastMatcher(eventWatcher);
|
||||
expect(
|
||||
matcher?.(
|
||||
createHASSEvent('zha_event', {}, { id: 'i', user_id: 'u-1', parent_id: null }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matcher?.(
|
||||
createHASSEvent('zha_event', {}, { id: 'i', user_id: 'u-2', parent_id: null }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should AND event_data and context filters', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: 'zha_event',
|
||||
event_data: { command: 'press' },
|
||||
context: { user_id: 'u-1' },
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
const matcher = getLastMatcher(eventWatcher);
|
||||
|
||||
const matchingContext = { id: 'i', user_id: 'u-1', parent_id: null };
|
||||
const nonMatchingContext = { id: 'i', user_id: 'u-2', parent_id: null };
|
||||
|
||||
expect(
|
||||
matcher?.(createHASSEvent('zha_event', { command: 'press' }, matchingContext)),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matcher?.(createHASSEvent('zha_event', { command: 'press' }, nonMatchingContext)),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matcher?.(createHASSEvent('zha_event', { command: 'release' }, matchingContext)),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should unsubscribe every request on destroy', () => {
|
||||
const { trigger, eventWatcher, callback } = create({
|
||||
trigger: 'event',
|
||||
event_type: ['zha_event', 'deconz_event'],
|
||||
});
|
||||
trigger.subscribe(callback);
|
||||
trigger.destroy();
|
||||
expect(eventWatcher.unsubscribe).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should be a no-op when destroyed without subscribing', () => {
|
||||
const { trigger, eventWatcher } = create({
|
||||
trigger: 'event',
|
||||
event_type: 'zha_event',
|
||||
});
|
||||
trigger.destroy();
|
||||
expect(eventWatcher.unsubscribe).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { ExpandTrigger } from '../../../../src/condition-trigger/triggers/triggers/expand';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('ExpandTrigger', () => {
|
||||
@@ -11,10 +11,10 @@ describe('ExpandTrigger', () => {
|
||||
): { stateManager: ConditionStateManager; callback: Mock } => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
new ExpandTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
}).subscribe(callback);
|
||||
new ExpandTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
).subscribe(callback);
|
||||
return { stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { FullscreenTrigger } from '../../../../src/condition-trigger/triggers/triggers/fullscreen';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('FullscreenTrigger', () => {
|
||||
@@ -15,10 +15,10 @@ describe('FullscreenTrigger', () => {
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
const fullscreenTrigger = new FullscreenTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
});
|
||||
const fullscreenTrigger = new FullscreenTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
return { fullscreenTrigger, stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { InteractionTrigger } from '../../../../src/condition-trigger/triggers/triggers/interaction';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('InteractionTrigger', () => {
|
||||
@@ -11,10 +11,10 @@ describe('InteractionTrigger', () => {
|
||||
): { stateManager: ConditionStateManager; callback: Mock } => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
new InteractionTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
}).subscribe(callback);
|
||||
new InteractionTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
).subscribe(callback);
|
||||
return { stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { MediaLoadedTrigger } from '../../../../src/condition-trigger/triggers/triggers/media-loaded';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createMediaLoadedInfo } from '../../../test-utils';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('MediaLoadedTrigger', () => {
|
||||
@@ -12,10 +12,10 @@ describe('MediaLoadedTrigger', () => {
|
||||
): { stateManager: ConditionStateManager; callback: Mock } => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
new MediaLoadedTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
}).subscribe(callback);
|
||||
new MediaLoadedTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
).subscribe(callback);
|
||||
return { stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { MicrophoneState } from '../../../../src/card-controller/types';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { MicrophoneTrigger } from '../../../../src/condition-trigger/triggers/triggers/microphone';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('MicrophoneTrigger', () => {
|
||||
@@ -19,10 +19,10 @@ describe('MicrophoneTrigger', () => {
|
||||
): { stateManager: ConditionStateManager; callback: Mock } => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
new MicrophoneTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
}).subscribe(callback);
|
||||
new MicrophoneTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
).subscribe(callback);
|
||||
return { stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
|
||||
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { NumericStateTrigger } from '../../../../src/condition-trigger/triggers/triggers/numeric-state';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createHASS, createStateEntity } from '../../../test-utils';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
const SENSOR = 'sensor.temperature';
|
||||
const SENSOR_TWO = 'sensor.humidity';
|
||||
@@ -21,10 +21,10 @@ describe('NumericStateTrigger', () => {
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
const trigger = new NumericStateTrigger(config, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
});
|
||||
const trigger = new NumericStateTrigger(
|
||||
config,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
return { trigger, stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
|
||||
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { StateTrigger } from '../../../../src/condition-trigger/triggers/triggers/state';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createHASS, createStateEntity } from '../../../test-utils';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
const ENTITY = 'binary_sensor.door';
|
||||
const ENTITY_TWO = 'binary_sensor.window';
|
||||
@@ -20,10 +20,10 @@ describe('StateTrigger', () => {
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
const trigger = new StateTrigger(config, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
});
|
||||
const trigger = new StateTrigger(
|
||||
config,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
return { trigger, stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { TemplateTrigger } from '../../../../src/condition-trigger/triggers/triggers/template';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createHASS, createStateEntity } from '../../../test-utils';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
const ENTITY_ONE = 'sensor.foo';
|
||||
const ENTITY_TWO = 'sensor.bar';
|
||||
@@ -21,10 +21,10 @@ describe('TemplateTrigger', () => {
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
const trigger = new TemplateTrigger(config, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
});
|
||||
const trigger = new TemplateTrigger(
|
||||
config,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
return { trigger, stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { TriggerEvaluatorContext } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createHASSManager } from '../../../test-utils';
|
||||
|
||||
export const createTriggerEvaluatorContext = (
|
||||
context?: Partial<TriggerEvaluatorContext>,
|
||||
): TriggerEvaluatorContext => ({
|
||||
stateManager: new ConditionStateManager(),
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
hassManager: createHASSManager(),
|
||||
...context,
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { TriggeredTrigger } from '../../../../src/condition-trigger/triggers/triggers/triggered';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('TriggeredTrigger', () => {
|
||||
@@ -11,10 +11,10 @@ describe('TriggeredTrigger', () => {
|
||||
): { stateManager: ConditionStateManager; callback: Mock } => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
new TriggeredTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
}).subscribe(callback);
|
||||
new TriggeredTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
).subscribe(callback);
|
||||
return { stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { TemplateRenderer } from '../../../../src/card-controller/templates';
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
import { ViewTrigger } from '../../../../src/condition-trigger/triggers/triggers/view';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
@@ -15,10 +15,10 @@ describe('ViewTrigger', () => {
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
const viewTrigger = new ViewTrigger(trigger, {
|
||||
stateManager,
|
||||
templateRenderer: new TemplateRenderer(),
|
||||
});
|
||||
const viewTrigger = new ViewTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
return { viewTrigger, stateManager, callback };
|
||||
};
|
||||
|
||||
|
||||
@@ -28,8 +28,9 @@ const getTypes = (
|
||||
// composites.
|
||||
const COMPOSITES = ['or', 'and', 'not'];
|
||||
|
||||
// `config` only ever detects a change, so it is a trigger but not a condition.
|
||||
const TRIGGER_ONLY = ['config'];
|
||||
// `config` only ever detects a change, and `event` is HA-side trigger-only (HA
|
||||
// has no `condition: event` -- events are momentary).
|
||||
const TRIGGER_ONLY = ['config', 'event'];
|
||||
|
||||
// `user`/`user_agent` are static per session, so they are conditions but not
|
||||
// triggers.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SubscriptionHealthMonitor } from '../../../src/ha/connection/subscription-health-monitor';
|
||||
import { HASSWebSocketSubscriptionStatus } from '../../../src/ha/connection/subscription-manager';
|
||||
|
||||
interface TestRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
type State = HASSWebSocketSubscriptionStatus<string, TestRequest>['state'];
|
||||
|
||||
const status = (
|
||||
state: State,
|
||||
request: TestRequest,
|
||||
key: string,
|
||||
extra?: { error?: unknown; failureCount?: number },
|
||||
): HASSWebSocketSubscriptionStatus<string, TestRequest> => ({
|
||||
state,
|
||||
request,
|
||||
key,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('SubscriptionHealthMonitor', () => {
|
||||
it('should report a failing key with its error and failure count', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const error = new Error('boom');
|
||||
|
||||
monitor.update(
|
||||
status('failing', { id: 'a' }, 'zha_event', { error, failureCount: 2 }),
|
||||
);
|
||||
|
||||
expect(monitor.getFailures()).toEqual([
|
||||
{ key: 'zha_event', error, failureCount: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore waiting so it never clears a failing key', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const request = { id: 'a' };
|
||||
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('waiting', request, 'zha_event'));
|
||||
|
||||
expect(monitor.getFailures().map((f) => f.key)).toEqual(['zha_event']);
|
||||
});
|
||||
|
||||
it('should clear a key once it subscribes', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const request = { id: 'a' };
|
||||
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('subscribed', request, 'zha_event'));
|
||||
|
||||
expect(monitor.getFailures()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should clear a key once its request unsubscribes', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const request = { id: 'a' };
|
||||
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('unsubscribed', request, 'zha_event'));
|
||||
|
||||
expect(monitor.getFailures()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should report each failing key once regardless of subscriber count', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
|
||||
monitor.update(status('failing', { id: 'a' }, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('failing', { id: 'b' }, 'zha_event', { failureCount: 1 }));
|
||||
|
||||
expect(monitor.getFailures().map((f) => f.key)).toEqual(['zha_event']);
|
||||
});
|
||||
|
||||
it('should notify listeners only when a key changes failing state', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const listener = vi.fn();
|
||||
monitor.addListener(listener);
|
||||
const request = { id: 'a' };
|
||||
|
||||
// Healthy -> failing: one notification.
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
|
||||
// Still failing (next attempt, same key): no membership change, no notify.
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 2 }));
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
|
||||
// Failing -> healthy: one more notification.
|
||||
monitor.update(status('subscribed', request, 'zha_event'));
|
||||
expect(listener).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should stop notifying after the returned unsubscribe is called', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const listener = vi.fn();
|
||||
const remove = monitor.addListener(listener);
|
||||
|
||||
remove();
|
||||
monitor.update(status('failing', { id: 'a' }, 'zha_event', { failureCount: 1 }));
|
||||
|
||||
expect(listener).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should retry one request per failing key and leave healthy keys alone', () => {
|
||||
const retry = vi.fn();
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(retry);
|
||||
const failingA = { id: 'a' };
|
||||
const failingB = { id: 'b' };
|
||||
const healthy = { id: 'c' };
|
||||
|
||||
// Two requests on a failing key, plus a separate healthy key.
|
||||
monitor.update(status('failing', failingA, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('failing', failingB, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('subscribed', healthy, 'deconz_event'));
|
||||
|
||||
monitor.retry();
|
||||
|
||||
// Exactly one retry, for one of the failing key's requests; never the
|
||||
// healthy key.
|
||||
expect(retry).toBeCalledTimes(1);
|
||||
expect([failingA, failingB]).toContainEqual(retry.mock.calls[0][0]);
|
||||
});
|
||||
|
||||
it('should retry the failing request even when a subscribed sibling is stored first', () => {
|
||||
const retry = vi.fn();
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(retry);
|
||||
const subscribed = { id: 'a' };
|
||||
const failing = { id: 'b' };
|
||||
|
||||
// Subscribed sibling recorded before the failing one on the same key.
|
||||
monitor.update(status('subscribed', subscribed, 'zha_event'));
|
||||
monitor.update(status('failing', failing, 'zha_event', { failureCount: 1 }));
|
||||
|
||||
monitor.retry();
|
||||
|
||||
expect(retry).toBeCalledTimes(1);
|
||||
expect(retry).toBeCalledWith(failing);
|
||||
});
|
||||
|
||||
it('should retry one request for each distinct failing key', () => {
|
||||
const retry = vi.fn();
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(retry);
|
||||
const a = { id: 'a' };
|
||||
const b = { id: 'b' };
|
||||
|
||||
monitor.update(status('failing', a, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('failing', b, 'deconz_event', { failureCount: 1 }));
|
||||
|
||||
monitor.retry();
|
||||
|
||||
expect(retry).toBeCalledTimes(2);
|
||||
expect(retry.mock.calls.map((c) => c[0])).toEqual(expect.arrayContaining([a, b]));
|
||||
});
|
||||
|
||||
it('should not notify when a never-failed request unsubscribes', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const listener = vi.fn();
|
||||
monitor.addListener(listener);
|
||||
|
||||
monitor.update(status('unsubscribed', { id: 'a' }, 'zha_event'));
|
||||
|
||||
expect(listener).not.toBeCalled();
|
||||
expect(monitor.getFailures()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,797 @@
|
||||
import { Connection, STATE_RUNNING, STATE_STARTING } from 'home-assistant-js-websocket';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import {
|
||||
HASSConnectionSubscriptionManager,
|
||||
HASSWebSocketStatusCallback,
|
||||
HASSWebSocketSubscriptionStatus,
|
||||
} from '../../../src/ha/connection/subscription-manager';
|
||||
import {
|
||||
HASSWebSocketLiveness,
|
||||
HASSWebSocketOpenCallback,
|
||||
} from '../../../src/ha/connection/types';
|
||||
import { HASSSource } from '../../../src/ha/source';
|
||||
import {
|
||||
createHASS,
|
||||
createHASSSource,
|
||||
flushPromises,
|
||||
useDeterministicTimers,
|
||||
} from '../../test-utils';
|
||||
|
||||
interface TestRequest {
|
||||
key: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface CapturedOpenCall {
|
||||
request: TestRequest;
|
||||
connection: Connection;
|
||||
guard: HASSWebSocketLiveness;
|
||||
unsub: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Captures every open-callback invocation so tests can drive its dispatcher and
|
||||
// observe the guard state.
|
||||
const createRecordingOpenCallback = (): {
|
||||
openCallback: HASSWebSocketOpenCallback;
|
||||
calls: CapturedOpenCall[];
|
||||
} => {
|
||||
const calls: CapturedOpenCall[] = [];
|
||||
const openCallback: HASSWebSocketOpenCallback = async (connection, guard) => {
|
||||
const unsub = vi.fn().mockResolvedValue(undefined);
|
||||
calls.push({ request: { key: 'unused' }, connection, guard, unsub });
|
||||
return unsub;
|
||||
};
|
||||
return { openCallback, calls };
|
||||
};
|
||||
|
||||
// An open callback that always rejects, driving the retry/backoff machinery.
|
||||
const createFailingOpenCallback = (): HASSWebSocketOpenCallback =>
|
||||
vi.fn().mockRejectedValue(new Error('boom'));
|
||||
|
||||
const createManager = (
|
||||
source: HASSSource,
|
||||
): HASSConnectionSubscriptionManager<string, TestRequest> =>
|
||||
new HASSConnectionSubscriptionManager<string, TestRequest>((r) => r.key, source);
|
||||
|
||||
// Wires a source (seeded with `initial`) to a fresh manager, returning the
|
||||
// source's drivers alongside it.
|
||||
const setup = (initial: Parameters<typeof createHASSSource>[0] = createHASS()) => {
|
||||
const { source, push, getListenerCount } = createHASSSource(initial);
|
||||
return { manager: createManager(source), push, getListenerCount };
|
||||
};
|
||||
|
||||
// A HASS on a brand-new connection, so pushing it forces an era swap.
|
||||
const createSwappedHASS = (): ReturnType<typeof createHASS> => {
|
||||
const hass = createHASS();
|
||||
hass.connection = mock<Connection>();
|
||||
vi.mocked(hass.connection.subscribeEvents).mockResolvedValue(vi.fn());
|
||||
return hass;
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('HASSConnectionSubscriptionManager', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('attach lifecycle', () => {
|
||||
it('should not attach to source until first subscribe', () => {
|
||||
const { getListenerCount } = setup();
|
||||
expect(getListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should attach on first subscribe and detach on last unsubscribe', () => {
|
||||
const { manager, getListenerCount } = setup();
|
||||
const req = { key: 'a' };
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe(req, openCallback);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
|
||||
manager.unsubscribe(req);
|
||||
expect(getListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should bootstrap from source on first attach', async () => {
|
||||
const hass = createHASS();
|
||||
const { manager } = setup(hass);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].connection).toBe(hass.connection);
|
||||
});
|
||||
|
||||
it('should defer when source has no HASS yet', async () => {
|
||||
const { manager, push } = setup(null);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(0);
|
||||
|
||||
push(createHASS());
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should defer when initial HASS is not ready', async () => {
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
const { manager, push } = setup(notReady);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(0);
|
||||
|
||||
const ready = createHASS();
|
||||
ready.config.state = STATE_RUNNING;
|
||||
push(ready);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('era transitions', () => {
|
||||
it('should replace KSM on connection swap and re-submit requests', async () => {
|
||||
const hass1 = createHASS();
|
||||
const { manager, push } = setup(hass1);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].connection).toBe(hass1.connection);
|
||||
|
||||
const hass2 = createSwappedHASS();
|
||||
push(hass2);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].connection).toBe(hass2.connection);
|
||||
});
|
||||
|
||||
it('should flip old-era guards to dead on swap', async () => {
|
||||
const { manager, push } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
const era1Guard = calls[0].guard;
|
||||
expect(era1Guard.isConnected()).toBe(true);
|
||||
|
||||
push(createSwappedHASS());
|
||||
await flushPromises();
|
||||
|
||||
expect(era1Guard.isConnected()).toBe(false);
|
||||
expect(calls[1].guard.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('should close old-era subscriptions on a connection swap', async () => {
|
||||
const { manager, push } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
push(createSwappedHASS());
|
||||
await flushPromises();
|
||||
|
||||
// The old era's subscription is closed, not abandoned.
|
||||
expect(calls[0].unsub).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should close old-era subscriptions when HA goes not-ready', async () => {
|
||||
const ready = createHASS();
|
||||
const { manager, push } = setup(ready);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
notReady.connection = ready.connection;
|
||||
push(notReady);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls[0].unsub).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should mint a fresh era when reanimating from a dead not-ready era, even with the same Connection', async () => {
|
||||
const ready = createHASS();
|
||||
const { manager, push } = setup(ready);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
const oldGuard = calls[0].guard;
|
||||
expect(oldGuard.isConnected()).toBe(true);
|
||||
|
||||
// Go not-ready, keeping the same connection identity.
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
notReady.connection = ready.connection;
|
||||
push(notReady);
|
||||
expect(oldGuard.isConnected()).toBe(false);
|
||||
|
||||
// Re-ready with the SAME Connection: a fresh era must be minted so old
|
||||
// guards stay dead.
|
||||
push(ready);
|
||||
await flushPromises();
|
||||
|
||||
expect(oldGuard.isConnected()).toBe(false);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].guard.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not replace KSM on a same-connection HASS push', async () => {
|
||||
const hass = createHASS();
|
||||
const { manager, push } = setup(hass);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
push(hass);
|
||||
push(hass);
|
||||
await flushPromises();
|
||||
|
||||
// No retry triggered because nothing failed; same era.
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should drop the live era on not-ready and re-establish on the next ready push', async () => {
|
||||
const ready = createHASS();
|
||||
const { manager, push } = setup(ready);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
push(notReady);
|
||||
|
||||
// Old guard should be dead.
|
||||
expect(calls[0].guard.isConnected()).toBe(false);
|
||||
|
||||
push(ready);
|
||||
await flushPromises();
|
||||
|
||||
// Fresh era, fresh submit.
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatch', () => {
|
||||
it('should return current subscribers from getRequestsForKey synchronously', () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const r1 = { key: 'a', label: 'r1' };
|
||||
const r2 = { key: 'a', label: 'r2' };
|
||||
const r3 = { key: 'b', label: 'r3' };
|
||||
|
||||
manager.subscribe(r1, openCallback);
|
||||
manager.subscribe(r2, openCallback);
|
||||
manager.subscribe(r3, openCallback);
|
||||
|
||||
expect(manager.getRequestsForKey('a')).toEqual([r1, r2]);
|
||||
expect(manager.getRequestsForKey('b')).toEqual([r3]);
|
||||
|
||||
manager.unsubscribe(r1);
|
||||
expect(manager.getRequestsForKey('a')).toEqual([r2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify refcount and serialization are delegated to KSM', () => {
|
||||
it('should refcount per key: a single WS sub per key regardless of subscribers', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a', label: 'first' }, openCallback);
|
||||
manager.subscribe({ key: 'a', label: 'second' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should open distinct WS subs for distinct keys', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
manager.subscribe({ key: 'b' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('time-spaced retries', () => {
|
||||
it('should not retry on subsequent HASS pushes; retries fire on the timer', async () => {
|
||||
useDeterministicTimers();
|
||||
const hass = createHASS();
|
||||
const { manager, push } = setup(hass);
|
||||
const failing = createFailingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, failing);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(failing).toBeCalledTimes(1);
|
||||
|
||||
// HASS pushes do not retry while the retry-timer is scheduled.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
push(hass);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
expect(failing).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should retry after the backoff delay elapses', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager } = setup();
|
||||
const failing = createFailingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, failing);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(failing).toBeCalledTimes(1);
|
||||
|
||||
// 1st retry: ~1s.
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(failing).toBeCalledTimes(2);
|
||||
|
||||
// 2nd retry: ~2s.
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(failing).toBeCalledTimes(3);
|
||||
|
||||
// 3rd retry: ~4s.
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
expect(failing).toBeCalledTimes(4);
|
||||
});
|
||||
|
||||
it('should reset the backoff on a connection swap', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager, push } = setup();
|
||||
const failing = createFailingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, failing);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Burn through a few backoff steps so the next would be a longer delay.
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
const callsBeforeSwap = vi.mocked(failing).mock.calls.length;
|
||||
|
||||
// Swap. The fresh era should retry immediately on the new connection.
|
||||
push(createSwappedHASS());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(vi.mocked(failing).mock.calls.length).toBe(callsBeforeSwap + 1);
|
||||
|
||||
// First retry on the new era is back at ~1s, not whatever the previous
|
||||
// era's accumulated delay was.
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(vi.mocked(failing).mock.calls.length).toBe(callsBeforeSwap + 2);
|
||||
});
|
||||
|
||||
it('should reset the backoff on a readiness transition (not-ready -> ready)', async () => {
|
||||
useDeterministicTimers();
|
||||
const ready = createHASS();
|
||||
const { manager, push } = setup(ready);
|
||||
const failing = createFailingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, failing);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
const callsBefore = vi.mocked(failing).mock.calls.length;
|
||||
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
push(notReady);
|
||||
|
||||
push(ready);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Re-ready triggers a fresh era and a fresh submission.
|
||||
expect(vi.mocked(failing).mock.calls.length).toBe(callsBefore + 1);
|
||||
|
||||
// Next retry is back to ~1s (fresh backoff).
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(vi.mocked(failing).mock.calls.length).toBe(callsBefore + 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stale catch token guard', () => {
|
||||
it('should not let a stale rejection from a swapped-out KSM clear the new submission marker', async () => {
|
||||
const { manager, push } = setup();
|
||||
|
||||
let rejectFirst: ((e: Error) => void) | undefined;
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
// First call (on the initial connection) rejects later.
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<() => Promise<void>>((_, reject) => {
|
||||
rejectFirst = reject;
|
||||
}),
|
||||
)
|
||||
// Subsequent calls succeed.
|
||||
.mockResolvedValue(vi.fn());
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(openCallback).toBeCalledTimes(1);
|
||||
|
||||
// Swap to a new connection BEFORE the first call settles.
|
||||
const hass2 = createSwappedHASS();
|
||||
push(hass2);
|
||||
await flushPromises();
|
||||
expect(openCallback).toBeCalledTimes(2);
|
||||
|
||||
// Now reject the stale first call. The catch must NOT wipe the marker for
|
||||
// the in-flight second submission, so the next same-connection push must
|
||||
// NOT submit again.
|
||||
rejectFirst?.(new Error('boom'));
|
||||
await flushPromises();
|
||||
|
||||
push(hass2);
|
||||
await flushPromises();
|
||||
expect(openCallback).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should be a no-op when called without any subscribers', () => {
|
||||
const { manager, getListenerCount } = setup();
|
||||
manager.destroy();
|
||||
expect(getListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should swallow a failed KSM unsubscribe during drain', async () => {
|
||||
const { manager } = setup();
|
||||
const failingUnsub = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
.mockResolvedValue(failingUnsub);
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
manager.destroy();
|
||||
await flushPromises();
|
||||
|
||||
expect(failingUnsub).toBeCalled();
|
||||
});
|
||||
|
||||
it('should detach source listener, drain KSM, clear state, and flip guards dead', async () => {
|
||||
const { manager, getListenerCount } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
manager.subscribe({ key: 'b' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
const guardA = calls[0].guard;
|
||||
const guardB = calls[1].guard;
|
||||
expect(guardA.isConnected()).toBe(true);
|
||||
expect(guardB.isConnected()).toBe(true);
|
||||
|
||||
manager.destroy();
|
||||
|
||||
expect(getListenerCount()).toBe(0);
|
||||
expect(guardA.isConnected()).toBe(false);
|
||||
expect(guardB.isConnected()).toBe(false);
|
||||
expect(manager.getRequestsForKey('a')).toEqual([]);
|
||||
expect(manager.getRequestsForKey('b')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should close a subscription still queued in KSM when destroy is called', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
// Destroy before KSM admits the subscribe task: the durable mirror still
|
||||
// drives teardown, so the subscription is closed once it opens rather
|
||||
// than leaking.
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
manager.destroy();
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].unsub).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsubscribe', () => {
|
||||
it('should not flip guards dead on unsubscribe as era still live for other subscribers', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
const r1 = { key: 'a' };
|
||||
const r2 = { key: 'a' };
|
||||
manager.subscribe(r1, openCallback);
|
||||
manager.subscribe(r2, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
const guard = calls[0].guard;
|
||||
manager.unsubscribe(r1);
|
||||
expect(guard.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('should detach from source when the last subscriber leaves', async () => {
|
||||
const { manager, getListenerCount } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
|
||||
const req = { key: 'a' };
|
||||
manager.subscribe(req, openCallback);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
|
||||
manager.unsubscribe(req);
|
||||
expect(getListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should swallow a failed unsubscribe', async () => {
|
||||
const { manager } = setup();
|
||||
const failingUnsub = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
.mockResolvedValue(failingUnsub);
|
||||
|
||||
const req = { key: 'a' };
|
||||
manager.subscribe(req, openCallback);
|
||||
await flushPromises();
|
||||
manager.unsubscribe(req);
|
||||
await flushPromises();
|
||||
|
||||
expect(failingUnsub).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status callback', () => {
|
||||
const collectStatuses = (): {
|
||||
statusCallback: HASSWebSocketStatusCallback<string, TestRequest>;
|
||||
events: HASSWebSocketSubscriptionStatus<string, TestRequest>[];
|
||||
} => {
|
||||
const events: HASSWebSocketSubscriptionStatus<string, TestRequest>[] = [];
|
||||
return { statusCallback: (s) => events.push(s), events };
|
||||
};
|
||||
|
||||
it('should emit waiting then subscribed on a successful subscribe', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, statusCallback);
|
||||
await flushPromises();
|
||||
|
||||
expect(events.map((e) => e.state)).toEqual(['waiting', 'subscribed']);
|
||||
expect(events[1].failureCount).toBeUndefined();
|
||||
expect(events[1].error).toBeUndefined();
|
||||
expect(events[1].key).toBe('a');
|
||||
});
|
||||
|
||||
it('should emit waiting when subscribed before HA is ready', () => {
|
||||
const { manager } = setup(null);
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, statusCallback);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].state).toBe('waiting');
|
||||
});
|
||||
|
||||
it('should emit failing with the error and incremented failureCount', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager } = setup();
|
||||
const failing = createFailingOpenCallback();
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, failing, statusCallback);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// waiting, failing(1).
|
||||
const failures = events.filter((e) => e.state === 'failing');
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(String(failures[0].error)).toMatch(/boom/);
|
||||
expect(failures[0].failureCount).toBe(1);
|
||||
|
||||
// Second attempt also fails -> failing(2).
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
const failures2 = events.filter((e) => e.state === 'failing');
|
||||
expect(failures2).toHaveLength(2);
|
||||
expect(failures2[1].failureCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should emit subscribed without a failureCount after eventual success', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager } = setup();
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(vi.fn().mockResolvedValue(undefined));
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, statusCallback);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
const subscribed = events.filter((e) => e.state === 'subscribed');
|
||||
expect(subscribed).toHaveLength(1);
|
||||
expect(subscribed[0].failureCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should emit unsubscribed on unsubscribe', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
const req = { key: 'a' };
|
||||
|
||||
manager.subscribe(req, openCallback, statusCallback);
|
||||
await flushPromises();
|
||||
const before = events.length;
|
||||
|
||||
manager.unsubscribe(req);
|
||||
|
||||
expect(events.length).toBe(before + 1);
|
||||
expect(events[events.length - 1].state).toBe('unsubscribed');
|
||||
});
|
||||
|
||||
it('should not emit on unsubscribe for an unknown request', () => {
|
||||
const { manager } = setup();
|
||||
const { events } = collectStatuses();
|
||||
|
||||
manager.unsubscribe({ key: 'never-subscribed' });
|
||||
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should emit waiting for all requests when HA goes not-ready', async () => {
|
||||
const { manager, push } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const a = collectStatuses();
|
||||
const b = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, a.statusCallback);
|
||||
manager.subscribe({ key: 'b' }, openCallback, b.statusCallback);
|
||||
await flushPromises();
|
||||
a.events.length = 0;
|
||||
b.events.length = 0;
|
||||
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
push(notReady);
|
||||
|
||||
expect(a.events.map((e) => e.state)).toEqual(['waiting']);
|
||||
expect(b.events.map((e) => e.state)).toEqual(['waiting']);
|
||||
});
|
||||
|
||||
it('should emit subscribed after an era reanimation', async () => {
|
||||
const { manager, push } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, statusCallback);
|
||||
await flushPromises();
|
||||
events.length = 0;
|
||||
|
||||
push(createSwappedHASS());
|
||||
await flushPromises();
|
||||
|
||||
expect(events.map((e) => e.state)).toEqual(['waiting', 'subscribed']);
|
||||
});
|
||||
|
||||
it('should isolate a throwing status callback from the retry state machine', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
const events: HASSWebSocketSubscriptionStatus<string, TestRequest>[] = [];
|
||||
const statusCallback: HASSWebSocketStatusCallback<string, TestRequest> = (s) => {
|
||||
events.push(s);
|
||||
if (s.state === 'subscribed') {
|
||||
throw new Error('observer boom');
|
||||
}
|
||||
};
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, statusCallback);
|
||||
await flushPromises();
|
||||
|
||||
// The throw during `subscribed` is swallowed: no `failing` transition and
|
||||
// no retry.
|
||||
expect(events.map((e) => e.state)).toEqual(['waiting', 'subscribed']);
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry', () => {
|
||||
it('should cancel the pending retry timer and submit immediately', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager } = setup();
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(vi.fn().mockResolvedValue(undefined));
|
||||
const req = { key: 'a' };
|
||||
|
||||
manager.subscribe(req, openCallback);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(openCallback).toBeCalledTimes(1);
|
||||
|
||||
// Pending timer would fire ~1s from now. retry runs the second attempt
|
||||
// synchronously instead of waiting.
|
||||
manager.retry(req);
|
||||
await flushPromises();
|
||||
expect(openCallback).toBeCalledTimes(2);
|
||||
|
||||
// Advance well past the original 1s schedule: nothing further fires.
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(openCallback).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should reset the backoff so the next failure schedules at the base delay', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager } = setup();
|
||||
const failing = createFailingOpenCallback();
|
||||
const req = { key: 'a' };
|
||||
|
||||
manager.subscribe(req, failing);
|
||||
|
||||
// Burn through three failures to escalate the backoff.
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(failing).toBeCalledTimes(3);
|
||||
|
||||
// User clicks retry: counter resets. Next failure should schedule at ~1s
|
||||
// again, not ~8s.
|
||||
manager.retry(req);
|
||||
await flushPromises();
|
||||
expect(failing).toBeCalledTimes(4);
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(failing).toBeCalledTimes(4);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(failing).toBeCalledTimes(5);
|
||||
});
|
||||
|
||||
it('should be a no-op for an unknown request', () => {
|
||||
const { manager } = setup();
|
||||
expect(() => manager.retry({ key: 'never-subscribed' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not submit in a dead era, but reset the backoff for next era', async () => {
|
||||
useDeterministicTimers();
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
const { manager, push } = setup(notReady);
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
.mockResolvedValue(vi.fn().mockResolvedValue(undefined));
|
||||
const req = { key: 'a' };
|
||||
|
||||
manager.subscribe(req, openCallback);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(openCallback).not.toBeCalled();
|
||||
|
||||
manager.retry(req);
|
||||
await flushPromises();
|
||||
expect(openCallback).not.toBeCalled();
|
||||
|
||||
// Era starts; the request submits.
|
||||
const ready = createHASS();
|
||||
ready.config.state = STATE_RUNNING;
|
||||
push(ready);
|
||||
await flushPromises();
|
||||
expect(openCallback).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { matchesEventData } from '../../src/ha/event-data-match';
|
||||
|
||||
describe('matchesEventData', () => {
|
||||
describe('non-object data', () => {
|
||||
it.each([['string'], [42], [true], [null], [undefined]])(
|
||||
'rejects non-object data (%s) with a non-empty filter',
|
||||
(data) => {
|
||||
expect(matchesEventData({ a: 1 }, data)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('matches when every filter key matches', () => {
|
||||
expect(matchesEventData({ command: 'press' }, { command: 'press', extra: 1 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when a filter value differs', () => {
|
||||
expect(matchesEventData({ command: 'press' }, { command: 'release' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when a filter key is missing from data', () => {
|
||||
expect(matchesEventData({ command: 'press' }, { other: 'press' })).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores extra keys in data', () => {
|
||||
expect(matchesEventData({ a: 1 }, { a: 1, b: 2, c: 3 })).toBe(true);
|
||||
});
|
||||
|
||||
it('matches nested objects as a subset', () => {
|
||||
expect(
|
||||
matchesEventData(
|
||||
{ device: { id: 'abc' } },
|
||||
{ device: { id: 'abc', name: 'Front Door' } },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects nested objects when a nested key differs', () => {
|
||||
expect(matchesEventData({ device: { id: 'abc' } }, { device: { id: 'xyz' } })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('matches arrays element-wise', () => {
|
||||
expect(matchesEventData({ tags: ['a', 'b'] }, { tags: ['a', 'b'] })).toBe(true);
|
||||
});
|
||||
|
||||
it('matches arrays as a subset by index (filter shorter than data)', () => {
|
||||
// Same partial-match semantics lodash uses for objects: a shorter filter
|
||||
// array matches if every index it specifies matches in data. Useful when
|
||||
// the user only cares about the first N values.
|
||||
expect(matchesEventData({ tags: ['a'] }, { tags: ['a', 'b'] })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects arrays with mismatched elements at the same index', () => {
|
||||
expect(matchesEventData({ tags: ['a', 'c'] }, { tags: ['a', 'b'] })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when filter array is longer than data array', () => {
|
||||
expect(matchesEventData({ tags: ['a', 'b'] }, { tags: ['a'] })).toBe(false);
|
||||
});
|
||||
|
||||
it('distinguishes null from undefined', () => {
|
||||
expect(matchesEventData({ a: null }, { a: null })).toBe(true);
|
||||
expect(matchesEventData({ a: null }, { a: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts an empty filter against any object', () => {
|
||||
expect(matchesEventData({}, {})).toBe(true);
|
||||
expect(matchesEventData({}, { anything: 1, nested: { x: 2 } })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { HassEventBase } from 'home-assistant-js-websocket';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { matchesEventContext, matchesEventData } from '../../src/ha/event-match';
|
||||
|
||||
const ctx = (
|
||||
overrides: Partial<HassEventBase['context']> = {},
|
||||
): HassEventBase['context'] => ({
|
||||
id: 'ctx-id',
|
||||
user_id: null,
|
||||
parent_id: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('matchesEventData', () => {
|
||||
// Without a nested dict, HA does a plain items-subset compare -- every filter
|
||||
// key present, values strictly equal.
|
||||
describe('no nested dict in the filter', () => {
|
||||
it.each([['string'], [42], [true], [null], [undefined]])(
|
||||
'should reject non-object data (%s)',
|
||||
(data) => {
|
||||
expect(matchesEventData({ a: 1 }, data)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('should match when every filter key matches, ignoring extra data keys', () => {
|
||||
expect(
|
||||
matchesEventData({ command: 'press' }, { command: 'press', extra: 1 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject when a filter value differs', () => {
|
||||
expect(matchesEventData({ command: 'press' }, { command: 'release' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject when a filter key is missing from data', () => {
|
||||
expect(matchesEventData({ command: 'press' }, { other: 'press' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should distinguish null from a different scalar', () => {
|
||||
expect(matchesEventData({ a: null }, { a: null })).toBe(true);
|
||||
expect(matchesEventData({ a: null }, { a: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept an empty filter against any object', () => {
|
||||
expect(matchesEventData({}, {})).toBe(true);
|
||||
expect(matchesEventData({}, { anything: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
// HA's test_event_data_with_list: top-level lists are strict (order+length).
|
||||
it('should match a top-level list by strict equality', () => {
|
||||
expect(matchesEventData({ tags: [1, 2] }, { tags: [1, 2] })).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a top-level list that is a superset, subset, or scalar', () => {
|
||||
expect(matchesEventData({ tags: [1, 2] }, { tags: [1, 2, 3] })).toBe(false);
|
||||
expect(matchesEventData({ tags: [1, 2] }, { tags: [1] })).toBe(false);
|
||||
expect(matchesEventData({ tags: [1, 2] }, { tags: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
// Python's `bool` is a subtype of `int`, so HA equates true/false with 1/0.
|
||||
it('should equate booleans with 0/1 (Python ==)', () => {
|
||||
expect(matchesEventData({ flag: true }, { flag: 1 })).toBe(true);
|
||||
expect(matchesEventData({ flag: 1 }, { flag: true })).toBe(true);
|
||||
expect(matchesEventData({ flag: false }, { flag: 0 })).toBe(true);
|
||||
expect(matchesEventData({ flag: true }, { flag: 2 })).toBe(false);
|
||||
expect(matchesEventData({ flag: 2 }, { flag: true })).toBe(false);
|
||||
expect(matchesEventData({ flag: true }, { flag: 'on' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// A nested dict makes HA validate the whole filter via voluptuous
|
||||
// (extra=ALLOW_EXTRA, required=True).
|
||||
describe('nested dict in the filter', () => {
|
||||
// HA's test_if_fires_on_event_with_nested_data: nested dicts are
|
||||
// subset-matched -- listed keys required, extra keys allowed.
|
||||
it('should allow extra keys inside a nested object', () => {
|
||||
expect(
|
||||
matchesEventData(
|
||||
{ device: { id: 'abc' } },
|
||||
{ device: { id: 'abc', name: 'Front Door' } },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should match a nested object with exact contents', () => {
|
||||
expect(
|
||||
matchesEventData({ device: { id: 'abc' } }, { device: { id: 'abc' } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a nested object missing a required key', () => {
|
||||
expect(matchesEventData({ device: { id: 'abc' } }, { device: { x: 'abc' } })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a nested object when a value differs', () => {
|
||||
expect(
|
||||
matchesEventData({ device: { id: 'abc' } }, { device: { id: 'xyz' } }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject when the event value is not an object', () => {
|
||||
expect(matchesEventData({ device: { id: 'abc' } }, { device: 'abc' })).toBe(false);
|
||||
});
|
||||
|
||||
// HA's test_event_data_with_list_nested: nested lists are
|
||||
// membership-matched -- every event element must be one of the filter
|
||||
// array's entries.
|
||||
it('should match a nested list by membership', () => {
|
||||
const filter = { svc: { tags: [1, 2] } };
|
||||
expect(matchesEventData(filter, { svc: { tags: [1, 2] } })).toBe(true);
|
||||
expect(matchesEventData(filter, { svc: { tags: [1] } })).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a nested list with an element outside the filter set', () => {
|
||||
expect(
|
||||
matchesEventData({ svc: { tags: [1, 2] } }, { svc: { tags: [1, 2, 3] } }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject a nested list when the event value is not a list', () => {
|
||||
expect(matchesEventData({ svc: { tags: [1, 2] } }, { svc: { tags: 1 } })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
matchesEventData({ svc: { tags: [1, 2] } }, { svc: { other: [1, 2] } }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should membership-match a sibling top-level list given a nested dict', () => {
|
||||
expect(matchesEventData({ meta: {}, tags: [1, 2] }, { meta: {}, tags: [1] })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// The Python bool/int equivalence also propagates through the slow path.
|
||||
it('should equate booleans with 0/1 inside nested structures', () => {
|
||||
expect(
|
||||
matchesEventData({ device: { armed: true } }, { device: { armed: 1 } }),
|
||||
).toBe(true);
|
||||
expect(matchesEventData({ meta: {}, vals: [true] }, { meta: {}, vals: [1] })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesEventContext', () => {
|
||||
it('should accept any context when the filter is empty', () => {
|
||||
expect(matchesEventContext({}, ctx())).toBe(true);
|
||||
});
|
||||
|
||||
it('should match a scalar filter against an equal value', () => {
|
||||
expect(matchesEventContext({ id: 'ctx-id' }, ctx({ id: 'ctx-id' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a scalar filter when the value differs', () => {
|
||||
expect(matchesEventContext({ id: 'other' }, ctx({ id: 'ctx-id' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('should match a list filter when the value is a member', () => {
|
||||
expect(matchesEventContext({ id: ['ctx-id', 'other'] }, ctx({ id: 'ctx-id' }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a list filter when the value is not a member', () => {
|
||||
expect(matchesEventContext({ id: ['a', 'b'] }, ctx({ id: 'ctx-id' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject when an explicit filter targets a null event field', () => {
|
||||
expect(matchesEventContext({ user_id: 'u1' }, ctx({ user_id: null }))).toBe(false);
|
||||
});
|
||||
|
||||
it('should AND across multiple filter fields', () => {
|
||||
const event = ctx({ id: 'i', user_id: 'u', parent_id: 'p' });
|
||||
expect(matchesEventContext({ id: 'i', user_id: 'u', parent_id: 'p' }, event)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
matchesEventContext({ id: 'i', user_id: 'u', parent_id: 'OTHER' }, event),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should ignore undefined filter fields', () => {
|
||||
expect(
|
||||
matchesEventContext({ id: undefined, user_id: 'u' }, ctx({ user_id: 'u' })),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { STATE_RUNNING } from 'home-assistant-js-websocket';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isHassReady } from '../../src/ha/is-hass-ready';
|
||||
import { createHASS } from '../test-utils';
|
||||
|
||||
describe('isHassReady', () => {
|
||||
it('should return false for null', () => {
|
||||
expect(isHassReady(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(isHassReady(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when disconnected', () => {
|
||||
const hass = createHASS();
|
||||
hass.connected = false;
|
||||
expect(isHassReady(hass)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when integrations are still loading', () => {
|
||||
const hass = createHASS();
|
||||
hass.connected = true;
|
||||
hass.config.state = 'NOT_RUNNING';
|
||||
expect(isHassReady(hass)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when connected and running', () => {
|
||||
const hass = createHASS();
|
||||
hass.connected = true;
|
||||
hass.config.state = STATE_RUNNING;
|
||||
expect(isHassReady(hass)).toBe(true);
|
||||
});
|
||||
});
|
||||
+87
-8
@@ -1,4 +1,9 @@
|
||||
import { HassEntities, HassEntity, STATE_RUNNING } from 'home-assistant-js-websocket';
|
||||
import {
|
||||
HassEntities,
|
||||
HassEntity,
|
||||
HassEvent,
|
||||
STATE_RUNNING,
|
||||
} from 'home-assistant-js-websocket';
|
||||
import { LitElement } from 'lit';
|
||||
import screenfull from 'screenfull';
|
||||
import { expect, vi } from 'vitest';
|
||||
@@ -41,6 +46,7 @@ import { FullscreenManager } from '../src/card-controller/fullscreen/fullscreen-
|
||||
import { EventWatcherSubscriptionInterface } from '../src/card-controller/hass/event-watcher';
|
||||
import { HASSManager } from '../src/card-controller/hass/hass-manager';
|
||||
import { StateWatcherSubscriptionInterface } from '../src/card-controller/hass/state-watcher';
|
||||
import { HASSManagerReadonlyInterface } from '../src/card-controller/hass/types';
|
||||
import { InitializationManager } from '../src/card-controller/initialization-manager';
|
||||
import { InteractionManager } from '../src/card-controller/interaction-manager';
|
||||
import { IssueManager } from '../src/card-controller/issues/issue-manager';
|
||||
@@ -77,6 +83,7 @@ import {
|
||||
} from '../src/ha/browse-media/types';
|
||||
import { Device } from '../src/ha/registry/device/types';
|
||||
import { Entity, EntityRegistryManager } from '../src/ha/registry/entity/types';
|
||||
import { HASSListener, HASSSource } from '../src/ha/source';
|
||||
import { CurrentUser, HassStateDifference, HomeAssistant } from '../src/ha/types';
|
||||
import { QuerySource } from '../src/query-source';
|
||||
import { Severity } from '../src/severity';
|
||||
@@ -124,9 +131,7 @@ export const createInitializedCamera = async (
|
||||
): Promise<Camera> => {
|
||||
const camera = new Camera(config, engine);
|
||||
await camera.initialize({
|
||||
hass: createHASS(),
|
||||
stateWatcher: stateWatcher ?? mock<StateWatcherSubscriptionInterface>(),
|
||||
eventWatcher: mock<EventWatcherSubscriptionInterface>(),
|
||||
hassManager: createHASSManager({ stateWatcher }),
|
||||
...(capabilities ? { capabilityOptions: { capabilities } } : {}),
|
||||
});
|
||||
return camera;
|
||||
@@ -153,6 +158,60 @@ export const createHASS = (states?: HassEntities, user?: CurrentUser): HomeAssis
|
||||
return hass;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a driveable HASSSource backed by a single `hass` value. The
|
||||
* returned `push(hass)` synchronously updates the source's current hass and
|
||||
* fans out to every registered listener with `(hass, oldHass)`.
|
||||
*/
|
||||
export const createHASSSource = (
|
||||
initial?: HomeAssistant | null,
|
||||
): {
|
||||
source: HASSSource;
|
||||
push: (hass: HomeAssistant) => void;
|
||||
getListenerCount: () => number;
|
||||
} => {
|
||||
let current: HomeAssistant | null = initial ?? null;
|
||||
const listeners = new Set<HASSListener>();
|
||||
const source: HASSSource = {
|
||||
getHASS: () => current,
|
||||
addListener: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
source,
|
||||
push: (hass) => {
|
||||
const prev = current;
|
||||
current = hass;
|
||||
for (const l of listeners) {
|
||||
l(hass, prev);
|
||||
}
|
||||
},
|
||||
getListenerCount: () => listeners.size,
|
||||
};
|
||||
};
|
||||
|
||||
export const createHASSManager = (options?: {
|
||||
hass?: HomeAssistant | null;
|
||||
stateWatcher?: StateWatcherSubscriptionInterface;
|
||||
eventWatcher?: EventWatcherSubscriptionInterface;
|
||||
}): HASSManagerReadonlyInterface => {
|
||||
const hassManager = mock<HASSManagerReadonlyInterface>();
|
||||
hassManager.getHASS.mockReturnValue(
|
||||
options?.hass === undefined ? createHASS() : options.hass,
|
||||
);
|
||||
hassManager.getStateWatcher.mockReturnValue(
|
||||
options?.stateWatcher ?? mock<StateWatcherSubscriptionInterface>(),
|
||||
);
|
||||
hassManager.getEventWatcher.mockReturnValue(
|
||||
options?.eventWatcher ?? mock<EventWatcherSubscriptionInterface>(),
|
||||
);
|
||||
return hassManager;
|
||||
};
|
||||
|
||||
export const createUser = (user?: Partial<CurrentUser>): CurrentUser => ({
|
||||
id: 'user',
|
||||
is_owner: false,
|
||||
@@ -285,8 +344,7 @@ export const createStore = (
|
||||
cameraProps.config ?? createCameraConfig(),
|
||||
cameraProps.engine ??
|
||||
new GenericCameraManagerEngine(
|
||||
mock<StateWatcherSubscriptionInterface>(),
|
||||
mock<EventWatcherSubscriptionInterface>(),
|
||||
createHASSManager(),
|
||||
mock<EntityRegistryManager>(),
|
||||
eventCallback,
|
||||
),
|
||||
@@ -744,14 +802,26 @@ export const callStateWatcherCallback = (
|
||||
|
||||
export const callEventWatcherCallback = (
|
||||
eventWatcher: EventWatcherSubscriptionInterface,
|
||||
data: unknown,
|
||||
event: HassEvent,
|
||||
n = 0,
|
||||
): void => {
|
||||
const mock = vi.mocked(eventWatcher.subscribe).mock;
|
||||
expect(mock.calls.length).greaterThan(n);
|
||||
mock.calls[n][1].callback(data);
|
||||
mock.calls[n][0].callback(event);
|
||||
};
|
||||
|
||||
export const createHASSEvent = (
|
||||
event_type: string,
|
||||
data: Record<string, unknown> = {},
|
||||
context: HassEvent['context'] = { id: 'ctx', user_id: null, parent_id: null },
|
||||
): HassEvent => ({
|
||||
event_type,
|
||||
data,
|
||||
origin: 'LOCAL',
|
||||
time_fired: '2026-06-19T21:12:18Z',
|
||||
context,
|
||||
});
|
||||
|
||||
/**
|
||||
* Flush resolved promises.
|
||||
*/
|
||||
@@ -759,6 +829,15 @@ export const flushPromises = async (): Promise<void> => {
|
||||
await new Promise(process.nextTick);
|
||||
};
|
||||
|
||||
/**
|
||||
* Install fake timers and pin Math.random to 1 (max jitter), so exponential
|
||||
* backoff retry delays advance by exact, predictable durations.
|
||||
*/
|
||||
export const useDeterministicTimers = (): void => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
};
|
||||
|
||||
export const createInteractionActionEvent = (
|
||||
action: string,
|
||||
): CustomEvent<Interaction> => {
|
||||
|
||||
@@ -86,6 +86,24 @@ describe('KeyedSubscriptionManager', () => {
|
||||
expect(manager.getRequestsForKey('a')).toEqual([reqA2]);
|
||||
});
|
||||
|
||||
it('should roll back the request when the underlying subscribeFn rejects', async () => {
|
||||
const manager = create();
|
||||
const subscribeFn = vi.fn().mockRejectedValue(new Error('ws-fail'));
|
||||
|
||||
const req = { key: 'a', callback: vi.fn() };
|
||||
await expect(manager.subscribe(req, subscribeFn)).rejects.toThrow('ws-fail');
|
||||
|
||||
// The failed subscriber must not be left dispatching against a
|
||||
// never-established connection.
|
||||
expect(manager.getRequestsForKey('a')).toEqual([]);
|
||||
|
||||
// A subsequent successful subscribe should re-attempt the underlying call.
|
||||
const successFn = vi.fn().mockResolvedValue(vi.fn());
|
||||
await manager.subscribe(req, successFn);
|
||||
expect(successFn).toBeCalledTimes(1);
|
||||
expect(manager.getRequestsForKey('a')).toEqual([req]);
|
||||
});
|
||||
|
||||
it('should treat unsubscribe of an unknown request as a no-op', async () => {
|
||||
const manager = create();
|
||||
const unsub = vi.fn();
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ExponentialBackoff } from '../../src/utils/exponential-backoff';
|
||||
|
||||
describe('ExponentialBackoff', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should start at attempts=0', () => {
|
||||
const backoff = new ExponentialBackoff({ baseSeconds: 1, maxSeconds: 60 });
|
||||
expect(backoff.getAttempts()).toBe(0);
|
||||
});
|
||||
|
||||
it('should compute exponential delays with the configured base', () => {
|
||||
// Pin jitter to 1.0 so we can read the raw exponential values.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const backoff = new ExponentialBackoff({
|
||||
baseSeconds: 1,
|
||||
maxSeconds: 60,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
|
||||
expect(backoff.next()).toBe(1);
|
||||
expect(backoff.next()).toBe(2);
|
||||
expect(backoff.next()).toBe(4);
|
||||
expect(backoff.next()).toBe(8);
|
||||
expect(backoff.next()).toBe(16);
|
||||
});
|
||||
|
||||
it('should cap delays at maxSeconds', () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const backoff = new ExponentialBackoff({
|
||||
baseSeconds: 1,
|
||||
maxSeconds: 10,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
|
||||
backoff.next();
|
||||
backoff.next();
|
||||
backoff.next();
|
||||
backoff.next();
|
||||
|
||||
expect(backoff.next()).toBe(10);
|
||||
expect(backoff.next()).toBe(10);
|
||||
});
|
||||
|
||||
it('should apply jitter within the configured range', () => {
|
||||
// Math.random() returns 0; jitter = jitterMin.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
const backoff = new ExponentialBackoff({
|
||||
baseSeconds: 10,
|
||||
maxSeconds: 100,
|
||||
jitterMin: 0.5,
|
||||
jitterMax: 1.0,
|
||||
});
|
||||
|
||||
expect(backoff.next()).toBe(5);
|
||||
});
|
||||
|
||||
it('should increment the attempt counter on each next()', () => {
|
||||
const backoff = new ExponentialBackoff({ baseSeconds: 1, maxSeconds: 60 });
|
||||
|
||||
expect(backoff.getAttempts()).toBe(0);
|
||||
backoff.next();
|
||||
expect(backoff.getAttempts()).toBe(1);
|
||||
backoff.next();
|
||||
expect(backoff.getAttempts()).toBe(2);
|
||||
});
|
||||
|
||||
it('should reset the attempt counter and start over', () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const backoff = new ExponentialBackoff({
|
||||
baseSeconds: 1,
|
||||
maxSeconds: 60,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
|
||||
backoff.next();
|
||||
backoff.next();
|
||||
backoff.next();
|
||||
expect(backoff.getAttempts()).toBe(3);
|
||||
|
||||
backoff.reset();
|
||||
expect(backoff.getAttempts()).toBe(0);
|
||||
expect(backoff.next()).toBe(1);
|
||||
});
|
||||
|
||||
it('should default jitter to [0.5, 1.0] when not provided', () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
const backoff = new ExponentialBackoff({ baseSeconds: 4, maxSeconds: 100 });
|
||||
|
||||
// jitter = 0.5 + 0 * (1.0 - 0.5) = 0.5; delay = 4 * 0.5 = 2.
|
||||
expect(backoff.next()).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -71,4 +71,36 @@ describe('Initializer', () => {
|
||||
|
||||
expect(initializer.isInitialized('foo')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should discard an initialization that was uninitialized while it was running', async () => {
|
||||
const initializer = new Initializer();
|
||||
|
||||
let finishInitializer: () => void = () => undefined;
|
||||
const initializing = initializer.initializeIfNecessary(
|
||||
'foo',
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishInitializer = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
// A uninitialize lands while the initializer is still running.
|
||||
initializer.uninitialize('foo');
|
||||
|
||||
finishInitializer();
|
||||
await initializing;
|
||||
|
||||
expect(initializer.isInitialized('foo')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should initialize again after being uninitialized', async () => {
|
||||
const initializer = new Initializer();
|
||||
|
||||
await initializer.initializeIfNecessary('foo');
|
||||
initializer.uninitialize('foo');
|
||||
expect(initializer.isInitialized('foo')).toBeFalsy();
|
||||
|
||||
await initializer.initializeIfNecessary('foo');
|
||||
expect(initializer.isInitialized('foo')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RetryTimer } from '../../src/utils/retry-timer';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('RetryTimer', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should schedule a callback after the current backoff delay', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.schedule(cb);
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(cb).not.toBeCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should advance the counter on schedule by default', () => {
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
timer.schedule(() => {});
|
||||
expect(timer.getAttempts()).toBe(1);
|
||||
timer.schedule(() => {});
|
||||
expect(timer.getAttempts()).toBe(2);
|
||||
});
|
||||
|
||||
it('should not advance the counter when schedule is called with advance: false', () => {
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
timer.schedule(() => {}, { advance: false });
|
||||
expect(timer.getAttempts()).toBe(0);
|
||||
timer.schedule(() => {}, { advance: false });
|
||||
expect(timer.getAttempts()).toBe(0);
|
||||
});
|
||||
|
||||
it('should advance the counter via advance()', () => {
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
timer.advance();
|
||||
expect(timer.getAttempts()).toBe(1);
|
||||
timer.advance();
|
||||
expect(timer.getAttempts()).toBe(2);
|
||||
});
|
||||
|
||||
it('should use a longer delay after advance()', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.advance();
|
||||
timer.schedule(cb);
|
||||
|
||||
// Counter is 1, delay should be base * 2^1 = 2 seconds.
|
||||
vi.advanceTimersByTime(1999);
|
||||
expect(cb).not.toBeCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should cancel a pending callback', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.schedule(cb);
|
||||
timer.cancel();
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should reset both the timer and the counter', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.advance();
|
||||
timer.advance();
|
||||
timer.schedule(cb, { advance: false });
|
||||
expect(timer.getAttempts()).toBe(2);
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
|
||||
timer.reset();
|
||||
expect(timer.getAttempts()).toBe(0);
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should report running state', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
timer.schedule(() => {});
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should advance the counter when schedule is called with advance: true', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
|
||||
timer.schedule(() => {}, { advance: true });
|
||||
expect(timer.getAttempts()).toBe(1);
|
||||
timer.schedule(() => {}, { advance: true });
|
||||
expect(timer.getAttempts()).toBe(2);
|
||||
});
|
||||
|
||||
it('should produce a fixed delay when configured with base=max and jitter=1', () => {
|
||||
// The "static delay" idiom: callers wanting a non-growing delay configure
|
||||
// the backoff to flatten out. No special mode in the class.
|
||||
vi.useFakeTimers();
|
||||
const timer = new RetryTimer({
|
||||
baseSeconds: 30,
|
||||
maxSeconds: 30,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.advance();
|
||||
timer.advance();
|
||||
timer.schedule(cb);
|
||||
vi.advanceTimersByTime(29_999);
|
||||
expect(cb).not.toBeCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should accept a plain number as shorthand for a fixed delay', () => {
|
||||
vi.useFakeTimers();
|
||||
const timer = new RetryTimer(30);
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.advance();
|
||||
timer.schedule(cb);
|
||||
vi.advanceTimersByTime(29_999);
|
||||
expect(cb).not.toBeCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('setOptions', () => {
|
||||
it('should apply the new backoff config to the next schedule', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({
|
||||
baseSeconds: 1,
|
||||
maxSeconds: 60,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.setOptions({
|
||||
baseSeconds: 10,
|
||||
maxSeconds: 10,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
timer.schedule(cb);
|
||||
vi.advanceTimersByTime(9_999);
|
||||
expect(cb).not.toBeCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should preserve the attempt counter and any pending callback', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({
|
||||
baseSeconds: 1,
|
||||
maxSeconds: 60,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.advance();
|
||||
timer.advance();
|
||||
timer.schedule(cb, { advance: false });
|
||||
expect(timer.getAttempts()).toBe(2);
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
|
||||
// Idempotent setOptions doesn't touch counter or pending timer.
|
||||
timer.setOptions({
|
||||
baseSeconds: 1,
|
||||
maxSeconds: 60,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
expect(timer.getAttempts()).toBe(2);
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user