@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.
Files changed (54) hide show
  1. package/dist/helpers/entity-state.d.mts +20 -3
  2. package/dist/helpers/fetch/service-list.d.mts +86 -5
  3. package/dist/helpers/id-by.d.mts +1 -1
  4. package/dist/helpers/utility.d.mts +1 -1
  5. package/dist/mock_assistant/mock-assistant.module.mjs.map +1 -1
  6. package/dist/mock_assistant/services/websocket-api.service.d.mts +3 -1
  7. package/dist/mock_assistant/services/websocket-api.service.mjs +43 -4
  8. package/dist/mock_assistant/services/websocket-api.service.mjs.map +1 -1
  9. package/dist/services/feature.service.d.mts +2 -2
  10. package/dist/services/feature.service.mjs.map +1 -1
  11. package/dist/services/id-by.service.mjs +4 -1
  12. package/dist/services/id-by.service.mjs.map +1 -1
  13. package/dist/services/reference.service.d.mts +1 -1
  14. package/dist/services/reference.service.mjs +62 -7
  15. package/dist/services/reference.service.mjs.map +1 -1
  16. package/dist/testing/area.spec.mjs +142 -1
  17. package/dist/testing/area.spec.mjs.map +1 -1
  18. package/dist/testing/call-proxy.spec.d.mts +1 -0
  19. package/dist/testing/call-proxy.spec.mjs +204 -0
  20. package/dist/testing/call-proxy.spec.mjs.map +1 -0
  21. package/dist/testing/conversation.spec.mjs +1 -1
  22. package/dist/testing/conversation.spec.mjs.map +1 -1
  23. package/dist/testing/entity.spec.mjs +14 -0
  24. package/dist/testing/entity.spec.mjs.map +1 -1
  25. package/dist/testing/id-by.spec.mjs +38 -0
  26. package/dist/testing/id-by.spec.mjs.map +1 -1
  27. package/dist/testing/ref-by.spec.mjs +805 -4
  28. package/dist/testing/ref-by.spec.mjs.map +1 -1
  29. package/dist/testing/scheduler.spec.d.mts +1 -0
  30. package/dist/testing/scheduler.spec.mjs +412 -0
  31. package/dist/testing/scheduler.spec.mjs.map +1 -0
  32. package/dist/testing/websocket.spec.mjs +25 -0
  33. package/dist/testing/websocket.spec.mjs.map +1 -1
  34. package/dist/testing/workflow.spec.mjs +1 -1
  35. package/dist/testing/workflow.spec.mjs.map +1 -1
  36. package/package.json +17 -16
  37. package/src/helpers/entity-state.mts +20 -3
  38. package/src/helpers/fetch/service-list.mts +89 -5
  39. package/src/helpers/id-by.mts +1 -0
  40. package/src/helpers/utility.mts +1 -1
  41. package/src/mock_assistant/mock-assistant.module.mts +0 -1
  42. package/src/mock_assistant/services/websocket-api.service.mts +46 -4
  43. package/src/services/feature.service.mts +9 -6
  44. package/src/services/id-by.service.mts +4 -1
  45. package/src/services/reference.service.mts +78 -9
  46. package/src/testing/area.spec.mts +166 -2
  47. package/src/testing/call-proxy.spec.mts +241 -0
  48. package/src/testing/conversation.spec.mts +1 -1
  49. package/src/testing/entity.spec.mts +15 -0
  50. package/src/testing/id-by.spec.mts +50 -0
  51. package/src/testing/ref-by.spec.mts +965 -4
  52. package/src/testing/scheduler.spec.mts +444 -0
  53. package/src/testing/websocket.spec.mts +33 -0
  54. package/src/testing/workflow.spec.mts +1 -1
@@ -14,9 +14,15 @@ describe("References", () => {
14
14
  describe("refBy.id", () => {
15
15
  it("can grab references by id", async () => {
16
16
  expect.assertions(2);
17
- await hassTestRunner.run(({ lifecycle, hass }) => {
17
+ await hassTestRunner.run(({ lifecycle, hass, logger, context }) => {
18
18
  lifecycle.onReady(() => {
19
19
  const sensor = hass.refBy.id("sensor.magic");
20
+ sensor.onStateFor({
21
+ context,
22
+ exec: () => logger.info("HIT"),
23
+ for: "5h",
24
+ matches: new_state => new_state.state === "test",
25
+ });
20
26
  expect(sensor).toBeDefined();
21
27
  expect(sensor.state).toBe("unavailable");
22
28
  });
@@ -168,7 +174,7 @@ describe("References", () => {
168
174
  describe("functionality", () => {
169
175
  describe("operators", () => {
170
176
  it("has", async () => {
171
- expect.assertions(15);
177
+ expect.assertions(18);
172
178
  await hassTestRunner.run(({ lifecycle, hass }) => {
173
179
  lifecycle.onReady(() => {
174
180
  const entity = hass.refBy.id("switch.bedroom_lamp");
@@ -178,9 +184,12 @@ describe("References", () => {
178
184
  "attributes",
179
185
  "entity_id",
180
186
  "history",
181
- "last",
187
+ "last_changed",
188
+ "last_reported",
189
+ "last_updated",
182
190
  "nextState",
183
191
  "once",
192
+ "onStateFor",
184
193
  "onUpdate",
185
194
  "previous",
186
195
  "removeAllListeners",
@@ -212,9 +221,9 @@ describe("References", () => {
212
221
  "last_updated",
213
222
  "context",
214
223
  "history",
215
- "last",
216
224
  "nextState",
217
225
  "once",
226
+ "onStateFor",
218
227
  "onUpdate",
219
228
  "previous",
220
229
  "removeAllListeners",
@@ -296,6 +305,958 @@ describe("References", () => {
296
305
  });
297
306
  });
298
307
  });
308
+
309
+ it("returns undefined for non-string property access", async () => {
310
+ expect.assertions(1);
311
+ await hassTestRunner.run(({ lifecycle, hass }) => {
312
+ lifecycle.onReady(() => {
313
+ const sensor = hass.refBy.id("sensor.magic");
314
+ const symbolProperty = Symbol("test");
315
+ // Accessing with a Symbol should return undefined
316
+ // This tests the proxyGetLogic non-string check
317
+ expect(Reflect.get(sensor, symbolProperty)).toBeUndefined();
318
+ });
319
+ });
320
+ });
321
+
322
+ it("nextState returns early when timeout is undefined", async () => {
323
+ expect.assertions(1);
324
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
325
+ lifecycle.onReady(async () => {
326
+ const sensor = hass.refBy.id("sensor.magic");
327
+ // Call nextState without timeout - should wait indefinitely for state change
328
+ const nextStatePromise = sensor.nextState();
329
+
330
+ // Emit a state update - promise should resolve with the new state
331
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
332
+ state: "updated",
333
+ });
334
+
335
+ const result = await nextStatePromise;
336
+ // Should resolve with the updated state when timeout is undefined
337
+ expect(result?.state).toBe("updated");
338
+ });
339
+ });
340
+ });
341
+
342
+ it("nextState kills timer when removed before timeout", async () => {
343
+ expect.assertions(1);
344
+ vi.useFakeTimers();
345
+ await hassTestRunner.run(({ lifecycle, hass }) => {
346
+ lifecycle.onReady(async () => {
347
+ const sensor = hass.refBy.id("sensor.magic");
348
+ let promiseResolved = false;
349
+ // Call nextState with timeout
350
+ const nextStatePromise = sensor.nextState("0.1s");
351
+ nextStatePromise.then(() => {
352
+ promiseResolved = true;
353
+ });
354
+
355
+ // Remove all listeners before timeout completes - should kill the timer
356
+ sensor.removeAllListeners();
357
+
358
+ // Advance time past the timeout - promise should not resolve
359
+ await vi.advanceTimersByTimeAsync(100);
360
+
361
+ // Give a small delay to check if promise resolved
362
+ await vi.advanceTimersByTimeAsync(1);
363
+
364
+ // The promise should not resolve because done was set to undefined and timer was killed
365
+ expect(promiseResolved).toBe(false);
366
+ });
367
+ });
368
+ vi.useRealTimers();
369
+ });
370
+
371
+ it("nextState complete function checks if done exists before calling", async () => {
372
+ expect.assertions(1);
373
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
374
+ lifecycle.onReady(async () => {
375
+ const sensor = hass.refBy.id("sensor.magic");
376
+ let promiseResolved = false;
377
+ // Call nextState without timeout
378
+ const nextStatePromise = sensor.nextState();
379
+ nextStatePromise.then(() => {
380
+ promiseResolved = true;
381
+ });
382
+
383
+ // Remove all listeners - this sets done = undefined
384
+ sensor.removeAllListeners();
385
+
386
+ // Emit an update - complete function should check if done exists
387
+ // Since done is undefined, it should not call done()
388
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
389
+ state: "updated",
390
+ });
391
+
392
+ // Give a small delay to check if promise resolved
393
+ await new Promise(resolve => setTimeout(resolve, 10));
394
+
395
+ // The promise should not resolve because done was set to undefined
396
+ // and the complete function checks if (done) before calling it
397
+ expect(promiseResolved).toBe(false);
398
+ });
399
+ });
400
+ });
401
+
402
+ it("returns previous entity state", async () => {
403
+ expect.assertions(3);
404
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
405
+ lifecycle.onReady(async () => {
406
+ const entity_id = "sensor.magic";
407
+ const entity = hass.refBy.id(entity_id);
408
+ const startState = entity.state;
409
+
410
+ // Emit an update
411
+ await mock_assistant.events.emitEntityUpdate(entity_id, {
412
+ state: "updated",
413
+ });
414
+
415
+ const updatedState = entity.state;
416
+ const previousState = entity.previous;
417
+
418
+ expect(updatedState).toBe("updated");
419
+ expect(startState).not.toBe("updated");
420
+ expect(previousState.state).toBe(startState);
421
+ });
422
+ });
423
+ });
424
+ });
425
+
426
+ describe("waitForState", () => {
427
+ it("resolves when state matches", async () => {
428
+ expect.assertions(2);
429
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
430
+ lifecycle.onReady(async () => {
431
+ const entity = hass.refBy.id("sensor.magic");
432
+ const waitPromise = entity.waitForState("target", "1s");
433
+
434
+ // Emit update with matching state
435
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
436
+ state: "target",
437
+ });
438
+
439
+ const result = await waitPromise;
440
+ expect(result?.state).toBe("target");
441
+ expect(result).toBeDefined();
442
+ });
443
+ });
444
+ });
445
+
446
+ it("does not resolve when state does not match", async () => {
447
+ expect.assertions(1);
448
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
449
+ lifecycle.onReady(async () => {
450
+ const entity = hass.refBy.id("sensor.magic");
451
+ let promiseResolved = false;
452
+ // Call waitForState without timeout - should wait indefinitely
453
+ const waitPromise = entity.waitForState("target");
454
+ waitPromise.then(() => {
455
+ promiseResolved = true;
456
+ });
457
+
458
+ // Emit update with non-matching state
459
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
460
+ state: "other",
461
+ });
462
+
463
+ // Give a small delay to check if promise resolved
464
+ await new Promise(resolve => setTimeout(resolve, 10));
465
+
466
+ // Promise should not resolve because state didn't match
467
+ expect(promiseResolved).toBe(false);
468
+ });
469
+ });
470
+ });
471
+
472
+ it("resolves with undefined when timeout expires", async () => {
473
+ expect.assertions(1);
474
+ vi.useFakeTimers();
475
+ await hassTestRunner.run(({ lifecycle, hass }) => {
476
+ lifecycle.onReady(async () => {
477
+ const entity = hass.refBy.id("sensor.magic");
478
+ const waitPromise = entity.waitForState("target", "0.1s");
479
+
480
+ // Advance time past timeout without matching state
481
+ await vi.advanceTimersByTimeAsync(100);
482
+
483
+ const result = await waitPromise;
484
+ expect(result).toBeUndefined();
485
+ });
486
+ });
487
+ vi.useRealTimers();
488
+ });
489
+
490
+ it("returns early when timeout is undefined", async () => {
491
+ expect.assertions(1);
492
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
493
+ lifecycle.onReady(async () => {
494
+ const entity = hass.refBy.id("sensor.magic");
495
+ // Call waitForState without timeout - should wait indefinitely
496
+ const waitPromise = entity.waitForState("target");
497
+
498
+ // Emit update with matching state - promise should resolve
499
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
500
+ state: "target",
501
+ });
502
+
503
+ const result = await waitPromise;
504
+ expect(result?.state).toBe("target");
505
+ });
506
+ });
507
+ });
508
+
509
+ it("works with numeric state values", async () => {
510
+ expect.assertions(1);
511
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
512
+ lifecycle.onReady(async () => {
513
+ const entity = hass.refBy.id("sensor.magic");
514
+ const waitPromise = entity.waitForState("42", "1s");
515
+
516
+ // Emit update with matching numeric state
517
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
518
+ state: "42",
519
+ });
520
+
521
+ const result = await waitPromise;
522
+ expect(result?.state).toBe("42");
523
+ });
524
+ });
525
+ });
526
+
527
+ it("kills timer when removed before timeout", async () => {
528
+ expect.assertions(1);
529
+ vi.useFakeTimers();
530
+ await hassTestRunner.run(({ lifecycle, hass }) => {
531
+ lifecycle.onReady(async () => {
532
+ const entity = hass.refBy.id("sensor.magic");
533
+ let promiseResolved = false;
534
+ const waitPromise = entity.waitForState("target", "0.1s");
535
+ waitPromise.then(() => {
536
+ promiseResolved = true;
537
+ });
538
+
539
+ // Remove all listeners before timeout completes - should kill the timer
540
+ entity.removeAllListeners();
541
+
542
+ // Advance time past the timeout - promise should not resolve
543
+ await vi.advanceTimersByTimeAsync(100);
544
+
545
+ // Give a small delay to check if promise resolved
546
+ await vi.advanceTimersByTimeAsync(1);
547
+
548
+ // The promise should not resolve because done was set to undefined and timer was killed
549
+ expect(promiseResolved).toBe(false);
550
+ });
551
+ });
552
+ vi.useRealTimers();
553
+ });
554
+
555
+ it("complete function checks if done exists before calling", async () => {
556
+ expect.assertions(1);
557
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
558
+ lifecycle.onReady(async () => {
559
+ const entity = hass.refBy.id("sensor.magic");
560
+ let promiseResolved = false;
561
+ const waitPromise = entity.waitForState("target", "1s");
562
+ waitPromise.then(() => {
563
+ promiseResolved = true;
564
+ });
565
+
566
+ // Remove all listeners - this sets done = undefined
567
+ entity.removeAllListeners();
568
+
569
+ // Emit an update with matching state - complete function should check if done exists
570
+ // Since done is undefined, it should not call done()
571
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
572
+ state: "target",
573
+ });
574
+
575
+ // Give a small delay to check if promise resolved
576
+ await new Promise(resolve => setTimeout(resolve, 10));
577
+
578
+ // The promise should not resolve because done was set to undefined
579
+ // and the complete function checks if (done) before calling it
580
+ expect(promiseResolved).toBe(false);
581
+ });
582
+ });
583
+ });
584
+
585
+ it("only resolves when state matches, ignores non-matching updates", async () => {
586
+ expect.assertions(2);
587
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
588
+ lifecycle.onReady(async () => {
589
+ const entity = hass.refBy.id("sensor.magic");
590
+ const waitPromise = entity.waitForState("target", "1s");
591
+
592
+ // Emit update with non-matching state - should not resolve
593
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
594
+ state: "other1",
595
+ });
596
+
597
+ // Emit another non-matching update - should not resolve
598
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
599
+ state: "other2",
600
+ });
601
+
602
+ // Emit update with matching state - should resolve
603
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
604
+ state: "target",
605
+ });
606
+
607
+ const result = await waitPromise;
608
+ expect(result?.state).toBe("target");
609
+ expect(result).toBeDefined();
610
+ });
611
+ });
612
+ });
613
+ });
614
+
615
+ describe("listener management", () => {
616
+ it("addListener registers a remove callback", async () => {
617
+ expect.assertions(1);
618
+ await hassTestRunner.run(({ lifecycle, hass }) => {
619
+ lifecycle.onReady(() => {
620
+ const entity = hass.refBy.id("sensor.magic");
621
+ const removeCallback = Object.assign(vi.fn(), { remove: vi.fn() });
622
+
623
+ entity.addListener(removeCallback);
624
+
625
+ // Call removeAllListeners - should call the registered callback
626
+ entity.removeAllListeners();
627
+
628
+ expect(removeCallback).toHaveBeenCalledTimes(1);
629
+ });
630
+ });
631
+ });
632
+
633
+ it("removeAllListeners calls all registered callbacks", async () => {
634
+ expect.assertions(3);
635
+ await hassTestRunner.run(({ lifecycle, hass }) => {
636
+ lifecycle.onReady(() => {
637
+ const entity = hass.refBy.id("sensor.magic");
638
+ const removeCallback1 = Object.assign(vi.fn(), { remove: vi.fn() });
639
+ const removeCallback2 = Object.assign(vi.fn(), { remove: vi.fn() });
640
+ const removeCallback3 = Object.assign(vi.fn(), { remove: vi.fn() });
641
+
642
+ entity.addListener(removeCallback1);
643
+ entity.addListener(removeCallback2);
644
+ entity.addListener(removeCallback3);
645
+
646
+ entity.removeAllListeners();
647
+
648
+ expect(removeCallback1).toHaveBeenCalledTimes(1);
649
+ expect(removeCallback2).toHaveBeenCalledTimes(1);
650
+ expect(removeCallback3).toHaveBeenCalledTimes(1);
651
+ });
652
+ });
653
+ });
654
+
655
+ it("removeAllListeners cleans up onUpdate listeners", async () => {
656
+ expect.assertions(1);
657
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
658
+ lifecycle.onReady(async () => {
659
+ const entity = hass.refBy.id("sensor.magic");
660
+ const callback = vi.fn();
661
+
662
+ entity.onUpdate(callback);
663
+
664
+ // Remove all listeners
665
+ entity.removeAllListeners();
666
+
667
+ // Emit an update - callback should not be called
668
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
669
+ state: "updated",
670
+ });
671
+
672
+ expect(callback).not.toHaveBeenCalled();
673
+ });
674
+ });
675
+ });
676
+
677
+ it("removeAllListeners cleans up once listeners", async () => {
678
+ expect.assertions(1);
679
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
680
+ lifecycle.onReady(async () => {
681
+ const entity = hass.refBy.id("sensor.magic");
682
+ const callback = vi.fn();
683
+
684
+ entity.once(callback);
685
+
686
+ // Remove all listeners
687
+ entity.removeAllListeners();
688
+
689
+ // Emit an update - callback should not be called
690
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
691
+ state: "updated",
692
+ });
693
+
694
+ expect(callback).not.toHaveBeenCalled();
695
+ });
696
+ });
697
+ });
698
+
699
+ it("removeAllListeners cleans up onStateFor listeners", async () => {
700
+ expect.assertions(1);
701
+ vi.useFakeTimers();
702
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
703
+ lifecycle.onReady(async () => {
704
+ const entity = hass.refBy.id("sensor.magic");
705
+ const callback = vi.fn();
706
+
707
+ entity.onStateFor({
708
+ context,
709
+ exec: callback,
710
+ for: "0.1s",
711
+ state: "on",
712
+ });
713
+
714
+ // Remove all listeners
715
+ entity.removeAllListeners();
716
+
717
+ // Change state to match
718
+ const updatePromise = mock_assistant.events.emitEntityUpdate("sensor.magic", {
719
+ state: "on",
720
+ });
721
+ await vi.advanceTimersByTimeAsync(1);
722
+ await updatePromise;
723
+ await vi.advanceTimersByTimeAsync(100);
724
+
725
+ expect(callback).not.toHaveBeenCalled();
726
+ });
727
+ });
728
+ vi.useRealTimers();
729
+ });
730
+
731
+ it("removeAllListeners cleans up both addListener and built-in listeners", async () => {
732
+ expect.assertions(2);
733
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
734
+ lifecycle.onReady(async () => {
735
+ const entity = hass.refBy.id("sensor.magic");
736
+ const addListenerCallback = Object.assign(vi.fn(), { remove: vi.fn() });
737
+ const onUpdateCallback = vi.fn();
738
+
739
+ entity.addListener(addListenerCallback);
740
+ entity.onUpdate(onUpdateCallback);
741
+
742
+ // Remove all listeners
743
+ entity.removeAllListeners();
744
+
745
+ // Emit an update - callbacks should not be called
746
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
747
+ state: "updated",
748
+ });
749
+
750
+ expect(addListenerCallback).toHaveBeenCalledTimes(1);
751
+ expect(onUpdateCallback).not.toHaveBeenCalled();
752
+ });
753
+ });
754
+ });
755
+ });
756
+
757
+ describe("once", () => {
758
+ it("calls callback once on next entity update", async () => {
759
+ expect.assertions(2);
760
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
761
+ lifecycle.onReady(async () => {
762
+ const entity = hass.refBy.id("sensor.magic");
763
+ const callback = vi.fn();
764
+
765
+ entity.once(callback);
766
+
767
+ // Emit first update - callback should be called
768
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
769
+ state: "first",
770
+ });
771
+
772
+ expect(callback).toHaveBeenCalledTimes(1);
773
+
774
+ // Emit second update - callback should not be called again
775
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
776
+ state: "second",
777
+ });
778
+
779
+ expect(callback).toHaveBeenCalledTimes(1);
780
+ });
781
+ });
782
+ });
783
+
784
+ it("passes new_state and old_state to callback", async () => {
785
+ expect.assertions(3);
786
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
787
+ lifecycle.onReady(async () => {
788
+ const entity = hass.refBy.id("sensor.magic");
789
+ const callback = vi.fn();
790
+
791
+ entity.once(callback);
792
+
793
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
794
+ state: "updated",
795
+ });
796
+
797
+ expect(callback).toHaveBeenCalledTimes(1);
798
+ expect(callback).toHaveBeenCalledWith(
799
+ expect.objectContaining({ state: "updated" }),
800
+ expect.any(Object),
801
+ );
802
+ expect(callback.mock.calls[0]).toHaveLength(2);
803
+ });
804
+ });
805
+ });
806
+
807
+ it("removes listener after callback is called", async () => {
808
+ expect.assertions(2);
809
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
810
+ lifecycle.onReady(async () => {
811
+ const entity = hass.refBy.id("sensor.magic");
812
+ const callback = vi.fn();
813
+
814
+ entity.once(callback);
815
+
816
+ // First update - callback should be called
817
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
818
+ state: "first",
819
+ });
820
+
821
+ expect(callback).toHaveBeenCalledTimes(1);
822
+
823
+ // Second update - callback should not be called (listener removed)
824
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
825
+ state: "second",
826
+ });
827
+
828
+ expect(callback).toHaveBeenCalledTimes(1);
829
+ });
830
+ });
831
+ });
832
+
833
+ it("remove function prevents callback from being called", async () => {
834
+ expect.assertions(1);
835
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
836
+ lifecycle.onReady(async () => {
837
+ const entity = hass.refBy.id("sensor.magic");
838
+ const callback = vi.fn();
839
+
840
+ const remove = entity.once(callback);
841
+
842
+ // Remove the listener before update
843
+ remove();
844
+
845
+ // Emit update - callback should not be called
846
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
847
+ state: "updated",
848
+ });
849
+
850
+ expect(callback).not.toHaveBeenCalled();
851
+ });
852
+ });
853
+ });
854
+
855
+ it("handles multiple once listeners independently", async () => {
856
+ expect.assertions(4);
857
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
858
+ lifecycle.onReady(async () => {
859
+ const entity = hass.refBy.id("sensor.magic");
860
+ const callback1 = vi.fn();
861
+ const callback2 = vi.fn();
862
+
863
+ entity.once(callback1);
864
+ entity.once(callback2);
865
+
866
+ // First update - both callbacks should be called
867
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
868
+ state: "first",
869
+ });
870
+
871
+ expect(callback1).toHaveBeenCalledTimes(1);
872
+ expect(callback2).toHaveBeenCalledTimes(1);
873
+
874
+ // Second update - neither callback should be called
875
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
876
+ state: "second",
877
+ });
878
+
879
+ expect(callback1).toHaveBeenCalledTimes(1);
880
+ expect(callback2).toHaveBeenCalledTimes(1);
881
+ });
882
+ });
883
+ });
884
+ });
885
+
886
+ describe("onStateFor", () => {
887
+ it("executes callback when state matches for duration", async () => {
888
+ expect.assertions(1);
889
+ vi.useFakeTimers();
890
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
891
+ lifecycle.onReady(async () => {
892
+ const entity = hass.refBy.id("sensor.magic");
893
+ const callback = vi.fn();
894
+
895
+ entity.onStateFor({
896
+ context,
897
+ exec: callback,
898
+ for: "0.1s",
899
+ state: "on",
900
+ });
901
+
902
+ // Change state to match
903
+ const updatePromise = mock_assistant.events.emitEntityUpdate("sensor.magic", {
904
+ state: "on",
905
+ });
906
+
907
+ // Advance timers to let the sleep(1ms) inside emitEntityUpdate complete
908
+ await vi.advanceTimersByTimeAsync(1);
909
+
910
+ // Now await the emitEntityUpdate to complete
911
+ await updatePromise;
912
+
913
+ // Advance timers to trigger the callback (0.1s = 100ms)
914
+ await vi.advanceTimersByTimeAsync(100);
915
+
916
+ expect(callback).toHaveBeenCalledTimes(1);
917
+ });
918
+ });
919
+ vi.useRealTimers();
920
+ });
921
+
922
+ it("does not execute callback if state changes before duration", async () => {
923
+ expect.assertions(1);
924
+ vi.useFakeTimers();
925
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
926
+ lifecycle.onReady(async () => {
927
+ const entity = hass.refBy.id("sensor.magic");
928
+ const callback = vi.fn();
929
+
930
+ entity.onStateFor({
931
+ context,
932
+ exec: callback,
933
+ for: "0.1s",
934
+ state: "on",
935
+ });
936
+
937
+ // Change state to match
938
+ const updatePromise1 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
939
+ state: "on",
940
+ });
941
+ await vi.advanceTimersByTimeAsync(1);
942
+ await updatePromise1;
943
+ await vi.advanceTimersByTimeAsync(50);
944
+
945
+ // Change state away before timer completes
946
+ const updatePromise2 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
947
+ state: "off",
948
+ });
949
+ await vi.advanceTimersByTimeAsync(1);
950
+ await updatePromise2;
951
+ await vi.advanceTimersByTimeAsync(100);
952
+
953
+ expect(callback).not.toHaveBeenCalled();
954
+ });
955
+ });
956
+ vi.useRealTimers();
957
+ });
958
+
959
+ it("restarts timer when state changes back to matching", async () => {
960
+ expect.assertions(2);
961
+ vi.useFakeTimers();
962
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
963
+ lifecycle.onReady(async () => {
964
+ const entity = hass.refBy.id("sensor.magic");
965
+ const callback = vi.fn();
966
+
967
+ entity.onStateFor({
968
+ context,
969
+ exec: callback,
970
+ for: "0.1s",
971
+ state: "on",
972
+ });
973
+
974
+ // Change state to match
975
+ const updatePromise1 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
976
+ state: "on",
977
+ });
978
+ await vi.advanceTimersByTimeAsync(1);
979
+ await updatePromise1;
980
+ await vi.advanceTimersByTimeAsync(50);
981
+
982
+ // Change state away
983
+ const updatePromise2 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
984
+ state: "off",
985
+ });
986
+ await vi.advanceTimersByTimeAsync(1);
987
+ await updatePromise2;
988
+ await vi.advanceTimersByTimeAsync(50);
989
+
990
+ // Change back to matching - should start new timer
991
+ const updatePromise3 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
992
+ state: "on",
993
+ });
994
+ await vi.advanceTimersByTimeAsync(1);
995
+ await updatePromise3;
996
+ await vi.advanceTimersByTimeAsync(50);
997
+ expect(callback).not.toHaveBeenCalled();
998
+
999
+ // Complete the timer
1000
+ await vi.advanceTimersByTimeAsync(50);
1001
+ expect(callback).toHaveBeenCalledTimes(1);
1002
+ });
1003
+ });
1004
+ vi.useRealTimers();
1005
+ });
1006
+
1007
+ it("uses custom matches function when provided", async () => {
1008
+ expect.assertions(3);
1009
+ vi.useFakeTimers();
1010
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
1011
+ lifecycle.onReady(async () => {
1012
+ const entity = hass.refBy.id("sensor.magic");
1013
+ const callback = vi.fn();
1014
+ const matches = vi.fn((new_state, old_state) => {
1015
+ return new_state.state === "target" && old_state.state !== "target";
1016
+ });
1017
+
1018
+ entity.onStateFor({
1019
+ context,
1020
+ exec: callback,
1021
+ for: "0.1s",
1022
+ matches,
1023
+ });
1024
+
1025
+ // Change to non-matching state - matches should return false, so no timer
1026
+ const updatePromise1 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
1027
+ state: "other",
1028
+ });
1029
+ await vi.advanceTimersByTimeAsync(1);
1030
+ await updatePromise1;
1031
+ // Verify matches was called and returned false (no timer should be set)
1032
+ expect(matches).toHaveBeenCalled();
1033
+ // Advance timers - callback should not be called
1034
+ await vi.advanceTimersByTimeAsync(100);
1035
+ expect(callback).not.toHaveBeenCalled();
1036
+
1037
+ // Change to matching state (target from different state) - matches should return true
1038
+ const updatePromise2 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
1039
+ state: "target",
1040
+ });
1041
+ await vi.advanceTimersByTimeAsync(1);
1042
+ await updatePromise2;
1043
+ // Advance timers to trigger the callback
1044
+ await vi.advanceTimersByTimeAsync(100);
1045
+ expect(callback).toHaveBeenCalledTimes(1);
1046
+ });
1047
+ });
1048
+ vi.useRealTimers();
1049
+ });
1050
+
1051
+ it("prevents duplicate timers when state matches multiple times", async () => {
1052
+ expect.assertions(1);
1053
+ vi.useFakeTimers();
1054
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
1055
+ lifecycle.onReady(async () => {
1056
+ const entity = hass.refBy.id("sensor.magic");
1057
+ const callback = vi.fn();
1058
+
1059
+ entity.onStateFor({
1060
+ context,
1061
+ exec: callback,
1062
+ for: "0.1s",
1063
+ state: "on",
1064
+ });
1065
+
1066
+ // Change state to match
1067
+ const updatePromise1 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
1068
+ state: "on",
1069
+ });
1070
+ await vi.advanceTimersByTimeAsync(1);
1071
+ await updatePromise1;
1072
+ await vi.advanceTimersByTimeAsync(50);
1073
+
1074
+ // Change to same matching state again - should not start new timer
1075
+ const updatePromise2 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
1076
+ state: "on",
1077
+ });
1078
+ await vi.advanceTimersByTimeAsync(1);
1079
+ await updatePromise2;
1080
+ await vi.advanceTimersByTimeAsync(50);
1081
+
1082
+ // Complete original timer
1083
+ await vi.advanceTimersByTimeAsync(50);
1084
+
1085
+ // Should only be called once
1086
+ expect(callback).toHaveBeenCalledTimes(1);
1087
+ });
1088
+ });
1089
+ vi.useRealTimers();
1090
+ });
1091
+
1092
+ it("cleans up timer and listener when remove is called", async () => {
1093
+ expect.assertions(1);
1094
+ vi.useFakeTimers();
1095
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
1096
+ lifecycle.onReady(async () => {
1097
+ const entity = hass.refBy.id("sensor.magic");
1098
+ const callback = vi.fn();
1099
+
1100
+ const remove = entity.onStateFor({
1101
+ context,
1102
+ exec: callback,
1103
+ for: "0.1s",
1104
+ state: "on",
1105
+ });
1106
+
1107
+ // Change state to match
1108
+ const updatePromise = mock_assistant.events.emitEntityUpdate("sensor.magic", {
1109
+ state: "on",
1110
+ });
1111
+ await vi.advanceTimersByTimeAsync(1);
1112
+ await updatePromise;
1113
+ await vi.advanceTimersByTimeAsync(50);
1114
+
1115
+ // Remove the listener
1116
+ remove();
1117
+
1118
+ // Advance time past when timer would have fired
1119
+ await vi.advanceTimersByTimeAsync(100);
1120
+
1121
+ // Callback should not have been called
1122
+ expect(callback).not.toHaveBeenCalled();
1123
+ });
1124
+ });
1125
+ vi.useRealTimers();
1126
+ });
1127
+
1128
+ it("handles cleanup when timer is already running", async () => {
1129
+ expect.assertions(1);
1130
+ vi.useFakeTimers();
1131
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
1132
+ lifecycle.onReady(async () => {
1133
+ const entity = hass.refBy.id("sensor.magic");
1134
+ const callback = vi.fn();
1135
+
1136
+ const remove = entity.onStateFor({
1137
+ context,
1138
+ exec: callback,
1139
+ for: "0.1s",
1140
+ state: "on",
1141
+ });
1142
+
1143
+ // Change state to match - starts timer
1144
+ const updatePromise = mock_assistant.events.emitEntityUpdate("sensor.magic", {
1145
+ state: "on",
1146
+ });
1147
+ await vi.advanceTimersByTimeAsync(1);
1148
+ await updatePromise;
1149
+ await vi.advanceTimersByTimeAsync(50);
1150
+
1151
+ // Remove should clean up the running timer
1152
+ remove();
1153
+
1154
+ // Advance time past when timer would have fired
1155
+ await vi.advanceTimersByTimeAsync(100);
1156
+
1157
+ expect(callback).not.toHaveBeenCalled();
1158
+ });
1159
+ });
1160
+ vi.useRealTimers();
1161
+ });
1162
+
1163
+ it("works with numeric state values", async () => {
1164
+ expect.assertions(1);
1165
+ vi.useFakeTimers();
1166
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
1167
+ lifecycle.onReady(async () => {
1168
+ const entity = hass.refBy.id("sensor.magic");
1169
+ const callback = vi.fn();
1170
+
1171
+ entity.onStateFor({
1172
+ context,
1173
+ exec: callback,
1174
+ for: "0.1s",
1175
+ state: "42",
1176
+ });
1177
+
1178
+ // Change state to match numeric value
1179
+ const updatePromise = mock_assistant.events.emitEntityUpdate("sensor.magic", {
1180
+ state: "42",
1181
+ });
1182
+ await vi.advanceTimersByTimeAsync(1);
1183
+ await updatePromise;
1184
+ await vi.advanceTimersByTimeAsync(100);
1185
+
1186
+ expect(callback).toHaveBeenCalledTimes(1);
1187
+ });
1188
+ });
1189
+ vi.useRealTimers();
1190
+ });
1191
+
1192
+ it("passes proxy to exec callback", async () => {
1193
+ expect.assertions(3);
1194
+ vi.useFakeTimers();
1195
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
1196
+ lifecycle.onReady(async () => {
1197
+ const entity = hass.refBy.id("sensor.magic");
1198
+ const callback = vi.fn(proxy => {
1199
+ expect(proxy.entity_id).toBe("sensor.magic");
1200
+ expect(proxy.state).toBe("on");
1201
+ });
1202
+
1203
+ entity.onStateFor({
1204
+ context,
1205
+ exec: callback,
1206
+ for: "0.1s",
1207
+ state: "on",
1208
+ });
1209
+
1210
+ const updatePromise = mock_assistant.events.emitEntityUpdate("sensor.magic", {
1211
+ state: "on",
1212
+ });
1213
+ await vi.advanceTimersByTimeAsync(1);
1214
+ await updatePromise;
1215
+ await vi.advanceTimersByTimeAsync(100);
1216
+
1217
+ expect(callback).toHaveBeenCalledTimes(1);
1218
+ });
1219
+ });
1220
+ vi.useRealTimers();
1221
+ });
1222
+
1223
+ it("handles multiple onStateFor listeners on same entity", async () => {
1224
+ expect.assertions(2);
1225
+ vi.useFakeTimers();
1226
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
1227
+ lifecycle.onReady(async () => {
1228
+ const entity = hass.refBy.id("sensor.magic");
1229
+ const callback1 = vi.fn();
1230
+ const callback2 = vi.fn();
1231
+
1232
+ entity.onStateFor({
1233
+ context,
1234
+ exec: callback1,
1235
+ for: "0.1s",
1236
+ state: "on",
1237
+ });
1238
+
1239
+ entity.onStateFor({
1240
+ context,
1241
+ exec: callback2,
1242
+ for: ".15s",
1243
+ state: "on",
1244
+ });
1245
+
1246
+ const updatePromise = mock_assistant.events.emitEntityUpdate("sensor.magic", {
1247
+ state: "on",
1248
+ });
1249
+ await vi.advanceTimersByTimeAsync(1);
1250
+ await updatePromise;
1251
+ await vi.advanceTimersByTimeAsync(100);
1252
+ expect(callback1).toHaveBeenCalledTimes(1);
1253
+
1254
+ await vi.advanceTimersByTimeAsync(50);
1255
+ expect(callback2).toHaveBeenCalledTimes(1);
1256
+ });
1257
+ });
1258
+ vi.useRealTimers();
1259
+ });
299
1260
  });
300
1261
  });
301
1262
  });