@digital-alchemy/hass 25.11.16 → 25.11.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/helpers/entity-state.d.mts +20 -3
- package/dist/helpers/fetch/service-list.d.mts +86 -5
- package/dist/helpers/id-by.d.mts +1 -1
- package/dist/helpers/utility.d.mts +1 -1
- package/dist/mock_assistant/mock-assistant.module.mjs.map +1 -1
- package/dist/mock_assistant/services/websocket-api.service.d.mts +3 -1
- package/dist/mock_assistant/services/websocket-api.service.mjs +43 -4
- package/dist/mock_assistant/services/websocket-api.service.mjs.map +1 -1
- package/dist/services/feature.service.d.mts +2 -2
- package/dist/services/feature.service.mjs.map +1 -1
- package/dist/services/id-by.service.mjs +4 -1
- package/dist/services/id-by.service.mjs.map +1 -1
- package/dist/services/reference.service.d.mts +1 -1
- package/dist/services/reference.service.mjs +62 -7
- package/dist/services/reference.service.mjs.map +1 -1
- package/dist/testing/area.spec.mjs +142 -1
- package/dist/testing/area.spec.mjs.map +1 -1
- package/dist/testing/call-proxy.spec.d.mts +1 -0
- package/dist/testing/call-proxy.spec.mjs +204 -0
- package/dist/testing/call-proxy.spec.mjs.map +1 -0
- package/dist/testing/conversation.spec.mjs +1 -1
- package/dist/testing/conversation.spec.mjs.map +1 -1
- package/dist/testing/entity.spec.mjs +14 -0
- package/dist/testing/entity.spec.mjs.map +1 -1
- package/dist/testing/id-by.spec.mjs +38 -0
- package/dist/testing/id-by.spec.mjs.map +1 -1
- package/dist/testing/ref-by.spec.mjs +805 -4
- package/dist/testing/ref-by.spec.mjs.map +1 -1
- package/dist/testing/scheduler.spec.d.mts +1 -0
- package/dist/testing/scheduler.spec.mjs +412 -0
- package/dist/testing/scheduler.spec.mjs.map +1 -0
- package/dist/testing/websocket.spec.mjs +25 -0
- package/dist/testing/websocket.spec.mjs.map +1 -1
- package/dist/testing/workflow.spec.mjs +1 -1
- package/dist/testing/workflow.spec.mjs.map +1 -1
- package/package.json +17 -16
- package/src/helpers/entity-state.mts +20 -3
- package/src/helpers/fetch/service-list.mts +89 -5
- package/src/helpers/id-by.mts +1 -0
- package/src/helpers/utility.mts +1 -1
- package/src/mock_assistant/mock-assistant.module.mts +0 -1
- package/src/mock_assistant/services/websocket-api.service.mts +46 -4
- package/src/services/feature.service.mts +9 -6
- package/src/services/id-by.service.mts +4 -1
- package/src/services/reference.service.mts +78 -9
- package/src/testing/area.spec.mts +166 -2
- package/src/testing/call-proxy.spec.mts +241 -0
- package/src/testing/conversation.spec.mts +1 -1
- package/src/testing/entity.spec.mts +15 -0
- package/src/testing/id-by.spec.mts +50 -0
- package/src/testing/ref-by.spec.mts +965 -4
- package/src/testing/scheduler.spec.mts +444 -0
- package/src/testing/websocket.spec.mts +33 -0
- package/src/testing/workflow.spec.mts +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TAnyFunction, TServiceParams } from "@digital-alchemy/core";
|
|
1
|
+
import type { TAnyFunction, TOffset, TServiceParams } from "@digital-alchemy/core";
|
|
2
2
|
import { DOWN, NONE, sleep, UP } from "@digital-alchemy/core";
|
|
3
3
|
import type { Dayjs } from "dayjs";
|
|
4
4
|
import dayjs from "dayjs";
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
ByIdProxy,
|
|
10
10
|
ENTITY_STATE,
|
|
11
11
|
HassReferenceService,
|
|
12
|
+
OnStateForOptions,
|
|
12
13
|
RemoveCallback,
|
|
13
14
|
} from "../helpers/index.mts";
|
|
14
15
|
import { domain, perf } from "../helpers/index.mts";
|
|
@@ -86,6 +87,7 @@ export function ReferenceService({
|
|
|
86
87
|
hass,
|
|
87
88
|
logger,
|
|
88
89
|
internal,
|
|
90
|
+
scheduler,
|
|
89
91
|
event,
|
|
90
92
|
}: TServiceParams): HassReferenceService {
|
|
91
93
|
const { is } = internal.utils;
|
|
@@ -94,6 +96,9 @@ export function ReferenceService({
|
|
|
94
96
|
entity: ENTITY,
|
|
95
97
|
property: PROPERTY,
|
|
96
98
|
): Get<ENTITY_STATE<ENTITY>, PROPERTY> {
|
|
99
|
+
if (!is.string(property)) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
97
102
|
const valid = ["state", "attributes", "last"].some(i => property.startsWith(i));
|
|
98
103
|
if (!valid) {
|
|
99
104
|
logger.error({ entity, name: proxyGetLogic, property }, `invalid property lookup`);
|
|
@@ -131,9 +136,12 @@ export function ReferenceService({
|
|
|
131
136
|
"attributes",
|
|
132
137
|
"entity_id",
|
|
133
138
|
"history",
|
|
134
|
-
"
|
|
139
|
+
"last_changed",
|
|
140
|
+
"last_reported",
|
|
141
|
+
"last_updated",
|
|
135
142
|
"nextState",
|
|
136
143
|
"once",
|
|
144
|
+
"onStateFor",
|
|
137
145
|
"onUpdate",
|
|
138
146
|
"previous",
|
|
139
147
|
"removeAllListeners",
|
|
@@ -170,8 +178,62 @@ export function ReferenceService({
|
|
|
170
178
|
// things that shouldn't be needed: this extract
|
|
171
179
|
// eslint-disable-next-line sonarjs/function-return-type
|
|
172
180
|
get: (_, property: Extract<keyof ByIdProxy<ENTITY_ID>, string>) => {
|
|
181
|
+
// Handle Symbol properties (e.g., when vitest formats test output)
|
|
182
|
+
if (!is.string(property)) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
173
185
|
hass.diagnostics.reference?.get_property.publish({ entity_id, property });
|
|
174
186
|
switch (property) {
|
|
187
|
+
// #MARK: runAfter
|
|
188
|
+
case "onStateFor": {
|
|
189
|
+
return function ({
|
|
190
|
+
context,
|
|
191
|
+
...options
|
|
192
|
+
}: OnStateForOptions<ENTITY_ID>): RemoveCallback {
|
|
193
|
+
let timerRemove: RemoveCallback;
|
|
194
|
+
const remove = proxy.onUpdate((new_state, old_state) => {
|
|
195
|
+
const matches = options.matches
|
|
196
|
+
? options.matches(new_state, old_state)
|
|
197
|
+
: options.state === new_state.state;
|
|
198
|
+
if (!matches) {
|
|
199
|
+
if (timerRemove) {
|
|
200
|
+
timerRemove();
|
|
201
|
+
timerRemove = undefined;
|
|
202
|
+
logger.trace({ context, entity_id }, "cleared timer - state no longer matches");
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (timerRemove) {
|
|
208
|
+
logger.trace({ context, entity_id }, "timer already running, skipping");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
timerRemove = scheduler.setTimeout(async () => {
|
|
212
|
+
logger.trace(
|
|
213
|
+
{ context, entity_id, for: options.for },
|
|
214
|
+
"timer fired - executing callback",
|
|
215
|
+
);
|
|
216
|
+
internal.safeExec({
|
|
217
|
+
context,
|
|
218
|
+
exec: async () => await options.exec(proxy),
|
|
219
|
+
});
|
|
220
|
+
}, options.for);
|
|
221
|
+
logger.trace(
|
|
222
|
+
{ context, entity_id, for: options.for },
|
|
223
|
+
"started timer for state condition",
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return internal.removeFn(() => {
|
|
228
|
+
if (timerRemove) {
|
|
229
|
+
timerRemove();
|
|
230
|
+
}
|
|
231
|
+
remove();
|
|
232
|
+
logger.trace({ context, entity_id }, "removed [onStateFor] listener");
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
175
237
|
// #MARK: onUpdate
|
|
176
238
|
case "onUpdate": {
|
|
177
239
|
return (callback: TAnyFunction) => {
|
|
@@ -246,7 +308,7 @@ export function ReferenceService({
|
|
|
246
308
|
|
|
247
309
|
// #MARK: nextState
|
|
248
310
|
case "nextState": {
|
|
249
|
-
return async (timeout?:
|
|
311
|
+
return async (timeout?: TOffset) =>
|
|
250
312
|
await new Promise<ENTITY_STATE<ENTITY_ID>>(async done => {
|
|
251
313
|
// - set up cleanup function
|
|
252
314
|
const remove = () => {
|
|
@@ -274,9 +336,13 @@ export function ReferenceService({
|
|
|
274
336
|
|
|
275
337
|
// - race!
|
|
276
338
|
let wait: ReturnType<typeof sleep>;
|
|
277
|
-
if (is.
|
|
339
|
+
if (is.undefined(timeout)) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const duration = internal.utils.getIntervalMs(timeout);
|
|
343
|
+
if (duration > NONE) {
|
|
278
344
|
// keep track of sleep so it can be cleaned up also
|
|
279
|
-
wait = sleep(
|
|
345
|
+
wait = sleep(duration);
|
|
280
346
|
await wait;
|
|
281
347
|
wait = undefined;
|
|
282
348
|
if (done) {
|
|
@@ -290,10 +356,9 @@ export function ReferenceService({
|
|
|
290
356
|
|
|
291
357
|
// #MARK: waitForState
|
|
292
358
|
case "waitForState": {
|
|
293
|
-
return async (state: string | number, timeout?:
|
|
359
|
+
return async (state: string | number, timeout?: TOffset) =>
|
|
294
360
|
await new Promise<ENTITY_STATE<ENTITY_ID>>(async done => {
|
|
295
361
|
const remove = () => {
|
|
296
|
-
done = undefined;
|
|
297
362
|
listeners.delete(remove);
|
|
298
363
|
done = undefined;
|
|
299
364
|
logger.trace({ entity_id }, "remove [waitForState] listener");
|
|
@@ -326,8 +391,12 @@ export function ReferenceService({
|
|
|
326
391
|
|
|
327
392
|
event.on(entity_id, complete);
|
|
328
393
|
let wait: ReturnType<typeof sleep>;
|
|
329
|
-
if (is.
|
|
330
|
-
|
|
394
|
+
if (is.undefined(timeout)) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const duration = internal.utils.getIntervalMs(timeout);
|
|
398
|
+
if (duration > NONE) {
|
|
399
|
+
wait = sleep(duration);
|
|
331
400
|
await wait;
|
|
332
401
|
wait = undefined;
|
|
333
402
|
if (done) {
|
|
@@ -2,9 +2,9 @@ import { sleep } from "@digital-alchemy/core";
|
|
|
2
2
|
import { subscribe } from "diagnostics_channel";
|
|
3
3
|
|
|
4
4
|
import type { AreaDetails } from "../helpers/index.mts";
|
|
5
|
-
import { AREA_REGISTRY_UPDATED } from "../helpers/index.mts";
|
|
5
|
+
import { AREA_REGISTRY_UPDATED, ENTITY_REGISTRY_UPDATED } from "../helpers/index.mts";
|
|
6
6
|
import { hassTestRunner, INTERNAL_MESSAGE } from "../mock_assistant/index.mts";
|
|
7
|
-
import type { TAreaId } from "../user.mts";
|
|
7
|
+
import type { ANY_ENTITY, TAreaId } from "../user.mts";
|
|
8
8
|
|
|
9
9
|
const EXAMPLE_AREA = {
|
|
10
10
|
area_id: "empty_area" as TAreaId,
|
|
@@ -200,4 +200,168 @@ describe("API", () => {
|
|
|
200
200
|
});
|
|
201
201
|
});
|
|
202
202
|
});
|
|
203
|
+
|
|
204
|
+
describe("apply", () => {
|
|
205
|
+
it("should apply area to a single entity", async () => {
|
|
206
|
+
expect.assertions(2);
|
|
207
|
+
await hassTestRunner.run(({ lifecycle, hass, event }) => {
|
|
208
|
+
const spy = vi.spyOn(hass.socket, "sendMessage").mockImplementation(async () => undefined);
|
|
209
|
+
lifecycle.onReady(async () => {
|
|
210
|
+
const entity = "sensor.magic" as ANY_ENTITY;
|
|
211
|
+
const area = "living_room" as TAreaId;
|
|
212
|
+
const response = hass.area.apply(area, [entity]);
|
|
213
|
+
setImmediate(() => event.emit(ENTITY_REGISTRY_UPDATED));
|
|
214
|
+
const result = await response;
|
|
215
|
+
|
|
216
|
+
expect(spy).toHaveBeenCalledWith({
|
|
217
|
+
area_id: area,
|
|
218
|
+
entity_id: entity,
|
|
219
|
+
type: "config/entity_registry/update",
|
|
220
|
+
});
|
|
221
|
+
expect(result.updated).toEqual([entity]);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("should apply area to multiple entities", async () => {
|
|
227
|
+
expect.assertions(3);
|
|
228
|
+
await hassTestRunner.run(({ lifecycle, hass, event }) => {
|
|
229
|
+
lifecycle.onReady(async () => {
|
|
230
|
+
const updateCalls: Array<{ area_id: TAreaId; entity_id: ANY_ENTITY; type: string }> = [];
|
|
231
|
+
vi.spyOn(hass.socket, "sendMessage").mockImplementation(async message => {
|
|
232
|
+
if (message?.type === "config/entity_registry/update") {
|
|
233
|
+
updateCalls.push(
|
|
234
|
+
message as { area_id: TAreaId; entity_id: ANY_ENTITY; type: string },
|
|
235
|
+
);
|
|
236
|
+
// Emit event asynchronously to ensure listener is registered
|
|
237
|
+
setImmediate(() => event.emit(ENTITY_REGISTRY_UPDATED));
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const entities = ["sensor.magic", "light.kitchen_lamp"] as ANY_ENTITY[];
|
|
243
|
+
const area = "living_room" as TAreaId;
|
|
244
|
+
const result = await hass.area.apply(area, entities);
|
|
245
|
+
|
|
246
|
+
expect(updateCalls).toHaveLength(2);
|
|
247
|
+
expect(updateCalls).toEqual([
|
|
248
|
+
{
|
|
249
|
+
area_id: area,
|
|
250
|
+
entity_id: entities[0],
|
|
251
|
+
type: "config/entity_registry/update",
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
area_id: area,
|
|
255
|
+
entity_id: entities[1],
|
|
256
|
+
type: "config/entity_registry/update",
|
|
257
|
+
},
|
|
258
|
+
]);
|
|
259
|
+
expect(result.updated).toEqual(entities);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("should skip entities that already have the correct area", async () => {
|
|
265
|
+
expect.assertions(2);
|
|
266
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
267
|
+
lifecycle.onReady(async () => {
|
|
268
|
+
const spy = vi
|
|
269
|
+
.spyOn(hass.socket, "sendMessage")
|
|
270
|
+
.mockImplementation(async () => undefined);
|
|
271
|
+
// Find an entity that already has an area assigned
|
|
272
|
+
const entityWithArea = hass.entity.registry.current.find(item => item.area_id !== null);
|
|
273
|
+
if (!entityWithArea) {
|
|
274
|
+
throw new Error("No entity with area found in fixtures");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const entity = entityWithArea.entity_id as ANY_ENTITY;
|
|
278
|
+
const area = entityWithArea.area_id as TAreaId;
|
|
279
|
+
const result = await hass.area.apply(area, [entity]);
|
|
280
|
+
|
|
281
|
+
const updateCalls = spy.mock.calls.filter(
|
|
282
|
+
call => call[0]?.type === "config/entity_registry/update",
|
|
283
|
+
);
|
|
284
|
+
expect(updateCalls).toHaveLength(0);
|
|
285
|
+
expect(result.updated).toEqual([]);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("should throw error for unknown entity", async () => {
|
|
291
|
+
expect.assertions(1);
|
|
292
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
293
|
+
lifecycle.onReady(async () => {
|
|
294
|
+
const unknownEntity = "sensor.unknown_entity" as ANY_ENTITY;
|
|
295
|
+
const area = "living_room" as TAreaId;
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
await hass.area.apply(area, [unknownEntity]);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
// InternalError structure: check various possible properties
|
|
301
|
+
const err = error as Record<string, unknown>;
|
|
302
|
+
const errorString = String(error);
|
|
303
|
+
const hasCode = err.code === "UNKNOWN_ENTITY";
|
|
304
|
+
const hasName = err.name === "UNKNOWN_ENTITY";
|
|
305
|
+
const hasMessage = String(err.message || "").includes("UNKNOWN_ENTITY");
|
|
306
|
+
const hasString = errorString.includes("UNKNOWN_ENTITY");
|
|
307
|
+
expect(hasCode || hasName || hasMessage || hasString).toBe(true);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("should wait for ENTITY_REGISTRY_UPDATED before continuing", async () => {
|
|
314
|
+
expect.assertions(1);
|
|
315
|
+
await hassTestRunner.run(({ lifecycle, hass, event }) => {
|
|
316
|
+
vi.spyOn(hass.socket, "sendMessage").mockImplementation(async () => undefined);
|
|
317
|
+
lifecycle.onReady(async () => {
|
|
318
|
+
const entity = "sensor.magic" as ANY_ENTITY;
|
|
319
|
+
const area = "living_room" as TAreaId;
|
|
320
|
+
const response = hass.area.apply(area, [entity]);
|
|
321
|
+
let order = "";
|
|
322
|
+
setTimeout(() => {
|
|
323
|
+
order += "a";
|
|
324
|
+
event.emit(ENTITY_REGISTRY_UPDATED);
|
|
325
|
+
}, 5);
|
|
326
|
+
await response;
|
|
327
|
+
order += "b";
|
|
328
|
+
expect(order).toEqual("ab");
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("should return only updated entities when some are skipped", async () => {
|
|
334
|
+
expect.assertions(2);
|
|
335
|
+
await hassTestRunner.run(({ lifecycle, hass, event }) => {
|
|
336
|
+
lifecycle.onReady(async () => {
|
|
337
|
+
const spy = vi.spyOn(hass.socket, "sendMessage").mockImplementation(async message => {
|
|
338
|
+
if (message?.type === "config/entity_registry/update") {
|
|
339
|
+
setImmediate(() => event.emit(ENTITY_REGISTRY_UPDATED));
|
|
340
|
+
}
|
|
341
|
+
return undefined;
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Find an entity that already has "kitchen" area so it will be skipped
|
|
345
|
+
const entityWithArea = hass.entity.registry.current.find(
|
|
346
|
+
item => item.area_id === "kitchen",
|
|
347
|
+
);
|
|
348
|
+
if (!entityWithArea) {
|
|
349
|
+
throw new Error("No entity with kitchen area found in fixtures");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const existingEntity = entityWithArea.entity_id as ANY_ENTITY;
|
|
353
|
+
const newEntity = "sensor.magic" as ANY_ENTITY;
|
|
354
|
+
const newArea = "kitchen" as TAreaId;
|
|
355
|
+
|
|
356
|
+
const result = await hass.area.apply(newArea, [existingEntity, newEntity]);
|
|
357
|
+
|
|
358
|
+
const updateCalls = spy.mock.calls.filter(
|
|
359
|
+
call => call[0]?.type === "config/entity_registry/update",
|
|
360
|
+
);
|
|
361
|
+
expect(updateCalls).toHaveLength(1);
|
|
362
|
+
expect(result.updated).toEqual([newEntity]);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
});
|
|
203
367
|
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { HassServiceDTO } from "../helpers/index.mts";
|
|
2
|
+
import { hassTestRunner } from "../mock_assistant/index.mts";
|
|
3
|
+
|
|
4
|
+
afterEach(async () => {
|
|
5
|
+
await hassTestRunner.teardown();
|
|
6
|
+
vi.restoreAllMocks();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe("CallProxy", () => {
|
|
10
|
+
describe("pauseMessages", () => {
|
|
11
|
+
it("should return undefined when pauseMessages is true", async () => {
|
|
12
|
+
expect.assertions(2);
|
|
13
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
14
|
+
const spy = vi.spyOn(hass.socket, "sendMessage").mockImplementation(async () => undefined);
|
|
15
|
+
hass.socket.pauseMessages = true;
|
|
16
|
+
|
|
17
|
+
lifecycle.onReady(async () => {
|
|
18
|
+
const result = await hass.call.switch.turn_on({
|
|
19
|
+
entity_id: "switch.porch_light",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(result).toBeUndefined();
|
|
23
|
+
const callServiceCalls = spy.mock.calls.filter(
|
|
24
|
+
call =>
|
|
25
|
+
call[0]?.type === "call_service" &&
|
|
26
|
+
call[0]?.domain === "switch" &&
|
|
27
|
+
call[0]?.service === "turn_on",
|
|
28
|
+
);
|
|
29
|
+
expect(callServiceCalls).toHaveLength(0);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should call sendMessage when pauseMessages is false", async () => {
|
|
35
|
+
expect.assertions(1);
|
|
36
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
37
|
+
const spy = vi.spyOn(hass.socket, "sendMessage").mockImplementation(async () => undefined);
|
|
38
|
+
hass.socket.pauseMessages = false;
|
|
39
|
+
|
|
40
|
+
lifecycle.onReady(async () => {
|
|
41
|
+
await hass.call.switch.turn_on({
|
|
42
|
+
entity_id: "switch.porch_light",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(spy).toHaveBeenCalledWith(
|
|
46
|
+
expect.objectContaining({
|
|
47
|
+
domain: "switch",
|
|
48
|
+
service: "turn_on",
|
|
49
|
+
service_data: {
|
|
50
|
+
entity_id: "switch.porch_light",
|
|
51
|
+
},
|
|
52
|
+
type: "call_service",
|
|
53
|
+
}),
|
|
54
|
+
true,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("return_response", () => {
|
|
62
|
+
it("should include return_response in payload when service has response.optional", async () => {
|
|
63
|
+
expect.assertions(1);
|
|
64
|
+
const mockServices: HassServiceDTO[] = [
|
|
65
|
+
{
|
|
66
|
+
domain: "switch",
|
|
67
|
+
services: {
|
|
68
|
+
turn_on: {
|
|
69
|
+
description: "Test service",
|
|
70
|
+
fields: {},
|
|
71
|
+
name: "turn_on",
|
|
72
|
+
response: { optional: true },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
|
|
79
|
+
mock_assistant.services.loadFixtures(mockServices);
|
|
80
|
+
vi.spyOn(hass.fetch, "listServices").mockImplementation(async () => mockServices);
|
|
81
|
+
|
|
82
|
+
const spy = vi
|
|
83
|
+
.spyOn(hass.socket, "sendMessage")
|
|
84
|
+
.mockImplementation(async () => ({ response: { success: true } }));
|
|
85
|
+
|
|
86
|
+
lifecycle.onReady(async () => {
|
|
87
|
+
await hass.call.switch.turn_on({ entity_id: "switch.bedroom_lamp" });
|
|
88
|
+
|
|
89
|
+
expect(spy).toHaveBeenCalledWith(
|
|
90
|
+
expect.objectContaining({
|
|
91
|
+
domain: "switch",
|
|
92
|
+
return_response: true,
|
|
93
|
+
service: "turn_on",
|
|
94
|
+
type: "call_service",
|
|
95
|
+
}),
|
|
96
|
+
true,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should return response when service returns a response", async () => {
|
|
103
|
+
expect.assertions(1);
|
|
104
|
+
const mockServices: HassServiceDTO[] = [
|
|
105
|
+
{
|
|
106
|
+
domain: "switch",
|
|
107
|
+
services: {
|
|
108
|
+
turn_on: {
|
|
109
|
+
description: "Test service",
|
|
110
|
+
fields: {},
|
|
111
|
+
name: "turn_on",
|
|
112
|
+
response: { optional: true },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
|
|
119
|
+
mock_assistant.services.loadFixtures(mockServices);
|
|
120
|
+
vi.spyOn(hass.fetch, "listServices").mockImplementation(async () => mockServices);
|
|
121
|
+
|
|
122
|
+
const mockResponse = { data: "test_response" };
|
|
123
|
+
vi.spyOn(hass.socket, "sendMessage").mockImplementation(async () => ({
|
|
124
|
+
response: mockResponse,
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
lifecycle.onReady(async () => {
|
|
128
|
+
const result = await hass.call.switch.turn_on({ entity_id: "switch.bedroom_lamp" });
|
|
129
|
+
expect(result).toEqual(mockResponse);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should return undefined when service does not return a response", async () => {
|
|
135
|
+
expect.assertions(1);
|
|
136
|
+
const mockServices: HassServiceDTO[] = [
|
|
137
|
+
{
|
|
138
|
+
domain: "switch",
|
|
139
|
+
services: {
|
|
140
|
+
turn_on: {
|
|
141
|
+
description: "Test service",
|
|
142
|
+
fields: {},
|
|
143
|
+
name: "turn_on",
|
|
144
|
+
response: { optional: true },
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
|
|
151
|
+
mock_assistant.services.loadFixtures(mockServices);
|
|
152
|
+
vi.spyOn(hass.fetch, "listServices").mockImplementation(async () => mockServices);
|
|
153
|
+
|
|
154
|
+
vi.spyOn(hass.socket, "sendMessage").mockImplementation(async () => ({}));
|
|
155
|
+
|
|
156
|
+
lifecycle.onReady(async () => {
|
|
157
|
+
const result = await hass.call.switch.turn_on({ entity_id: "switch.bedroom_lamp" });
|
|
158
|
+
expect(result).toBeUndefined();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("early access", () => {
|
|
165
|
+
it("should call console.trace when accessed before load with LOG_LEVEL trace", async () => {
|
|
166
|
+
expect.assertions(1);
|
|
167
|
+
const traceSpy = vi.spyOn(console, "trace").mockImplementation(() => {});
|
|
168
|
+
|
|
169
|
+
await hassTestRunner.configure({ boilerplate: { LOG_LEVEL: "trace" } }).run(({ hass }) => {
|
|
170
|
+
hass.call.switch;
|
|
171
|
+
});
|
|
172
|
+
expect(traceSpy).toHaveBeenCalledWith(`hass.call`);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("should not log error when accessed before load with LOG_LEVEL not trace", async () => {
|
|
176
|
+
expect.assertions(2);
|
|
177
|
+
await hassTestRunner
|
|
178
|
+
.configure({ boilerplate: { LOG_LEVEL: "warn" } })
|
|
179
|
+
.run(({ lifecycle, hass, internal }) => {
|
|
180
|
+
const logger = internal.boilerplate.logger.getBaseLogger();
|
|
181
|
+
const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
|
|
182
|
+
const traceSpy = vi.spyOn(console, "trace").mockImplementation(() => {});
|
|
183
|
+
|
|
184
|
+
// Access hass.call in onPreInit, which runs before onBootstrap (where services load)
|
|
185
|
+
hass.call.switch;
|
|
186
|
+
|
|
187
|
+
lifecycle.onReady(() => {
|
|
188
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
189
|
+
expect(traceSpy).not.toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("proxy methods", () => {
|
|
196
|
+
it("should return true for has when domain exists", async () => {
|
|
197
|
+
expect.assertions(1);
|
|
198
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
199
|
+
lifecycle.onReady(() => {
|
|
200
|
+
expect("switch" in hass.call).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("should return false for has when domain does not exist", async () => {
|
|
206
|
+
expect.assertions(1);
|
|
207
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
208
|
+
lifecycle.onReady(() => {
|
|
209
|
+
expect("nonexistent_domain" in hass.call).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("should return ownKeys with all domain keys", async () => {
|
|
215
|
+
expect.assertions(2);
|
|
216
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
217
|
+
lifecycle.onReady(() => {
|
|
218
|
+
const keys = Object.keys(hass.call);
|
|
219
|
+
expect(keys).toContain("switch");
|
|
220
|
+
expect(keys.length).toBeGreaterThan(0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("should return false when trying to set a property", async () => {
|
|
226
|
+
expect.assertions(1);
|
|
227
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
228
|
+
lifecycle.onReady(() => {
|
|
229
|
+
try {
|
|
230
|
+
// @ts-expect-error testing
|
|
231
|
+
hass.call.test_domain = {};
|
|
232
|
+
} catch {
|
|
233
|
+
// Some environments throw, others return false
|
|
234
|
+
}
|
|
235
|
+
// Verify the property was not set
|
|
236
|
+
expect("test_domain" in hass.call).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -18,7 +18,7 @@ describe("Conversation Service", () => {
|
|
|
18
18
|
},
|
|
19
19
|
];
|
|
20
20
|
|
|
21
|
-
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
21
|
+
await hassTestRunner.bootLibrariesFirst().run(({ lifecycle, hass }) => {
|
|
22
22
|
const spy = vi
|
|
23
23
|
.spyOn(hass.socket, "sendMessage")
|
|
24
24
|
.mockImplementation(async () => ({ agents: mockAgents }));
|
|
@@ -242,8 +242,23 @@ describe("Entity", () => {
|
|
|
242
242
|
const spy = vi.fn();
|
|
243
243
|
subscribe(hass.diagnostics.entity.refresh_entities.name, spy);
|
|
244
244
|
|
|
245
|
+
// Mock getAllEntities to return entities so refresh doesn't call process.exit()
|
|
246
|
+
vi.spyOn(hass.fetch, "getAllEntities").mockResolvedValue([
|
|
247
|
+
{
|
|
248
|
+
attributes: {},
|
|
249
|
+
context: { id: "test", parent_id: null, user_id: null },
|
|
250
|
+
entity_id: "sensor.magic",
|
|
251
|
+
last_changed: dayjs(),
|
|
252
|
+
last_reported: dayjs(),
|
|
253
|
+
last_updated: dayjs(),
|
|
254
|
+
state: "unavailable",
|
|
255
|
+
} as ENTITY_STATE<ANY_ENTITY>,
|
|
256
|
+
]);
|
|
257
|
+
|
|
245
258
|
lifecycle.onReady(async () => {
|
|
246
259
|
await hass.entity.refresh();
|
|
260
|
+
// Wait for setImmediate to complete
|
|
261
|
+
await sleep(10);
|
|
247
262
|
expect(spy).toHaveBeenCalledWith(
|
|
248
263
|
expect.objectContaining({
|
|
249
264
|
emitUpdates: [],
|
|
@@ -137,6 +137,56 @@ describe("enabled entities", () => {
|
|
|
137
137
|
});
|
|
138
138
|
});
|
|
139
139
|
});
|
|
140
|
+
|
|
141
|
+
describe("unique_id", () => {
|
|
142
|
+
it("find entity by unique_id without platform", async () => {
|
|
143
|
+
expect.assertions(1);
|
|
144
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
145
|
+
lifecycle.onReady(() => {
|
|
146
|
+
const entity = hass.idBy.unique_id(
|
|
147
|
+
"e1806fdc93296bbd5ab42967003cd38729ff9ba6cfeefc3e15a03ad01ac894fe",
|
|
148
|
+
);
|
|
149
|
+
expect(entity).toBe("sensor.magic");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("find entity by unique_id with matching platform", async () => {
|
|
155
|
+
expect.assertions(1);
|
|
156
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
157
|
+
lifecycle.onReady(() => {
|
|
158
|
+
const entity = hass.idBy.unique_id(
|
|
159
|
+
"e1806fdc93296bbd5ab42967003cd38729ff9ba6cfeefc3e15a03ad01ac894fe",
|
|
160
|
+
"synapse",
|
|
161
|
+
);
|
|
162
|
+
expect(entity).toBe("sensor.magic");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("find entity by unique_id with non-matching platform", async () => {
|
|
168
|
+
expect.assertions(1);
|
|
169
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
170
|
+
lifecycle.onReady(() => {
|
|
171
|
+
const entity = hass.idBy.unique_id(
|
|
172
|
+
"e1806fdc93296bbd5ab42967003cd38729ff9ba6cfeefc3e15a03ad01ac894fe",
|
|
173
|
+
"sun",
|
|
174
|
+
);
|
|
175
|
+
expect(entity).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("find entity by unique_id with different platform", async () => {
|
|
181
|
+
expect.assertions(1);
|
|
182
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
183
|
+
lifecycle.onReady(() => {
|
|
184
|
+
const entity = hass.idBy.unique_id("5622d76001a335e3ea893c4d60d31b3d-next_dawn", "sun");
|
|
185
|
+
expect(entity).toBe("sensor.sun_next_dawn");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
140
190
|
});
|
|
141
191
|
|
|
142
192
|
describe("disabled entities", () => {
|