@digital-alchemy/hass 25.11.16 → 25.11.17-beta.0

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