@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
@@ -9,9 +9,15 @@ describe("References", () => {
9
9
  describe("refBy.id", () => {
10
10
  it("can grab references by id", async () => {
11
11
  expect.assertions(2);
12
- await hassTestRunner.run(({ lifecycle, hass }) => {
12
+ await hassTestRunner.run(({ lifecycle, hass, logger, context }) => {
13
13
  lifecycle.onReady(() => {
14
14
  const sensor = hass.refBy.id("sensor.magic");
15
+ sensor.onStateFor({
16
+ context,
17
+ exec: () => logger.info("HIT"),
18
+ for: "5h",
19
+ matches: new_state => new_state.state === "test",
20
+ });
15
21
  expect(sensor).toBeDefined();
16
22
  expect(sensor.state).toBe("unavailable");
17
23
  });
@@ -148,7 +154,7 @@ describe("References", () => {
148
154
  describe("functionality", () => {
149
155
  describe("operators", () => {
150
156
  it("has", async () => {
151
- expect.assertions(15);
157
+ expect.assertions(16);
152
158
  await hassTestRunner.run(({ lifecycle, hass }) => {
153
159
  lifecycle.onReady(() => {
154
160
  const entity = hass.refBy.id("switch.bedroom_lamp");
@@ -161,6 +167,7 @@ describe("References", () => {
161
167
  "last",
162
168
  "nextState",
163
169
  "once",
170
+ "onStateFor",
164
171
  "onUpdate",
165
172
  "previous",
166
173
  "removeAllListeners",
@@ -192,6 +199,7 @@ describe("References", () => {
192
199
  "last",
193
200
  "nextState",
194
201
  "once",
202
+ "onStateFor",
195
203
  "onUpdate",
196
204
  "previous",
197
205
  "removeAllListeners",
@@ -268,6 +276,798 @@ describe("References", () => {
268
276
  });
269
277
  });
270
278
  });
279
+ it("returns undefined for non-string property access", async () => {
280
+ expect.assertions(1);
281
+ await hassTestRunner.run(({ lifecycle, hass }) => {
282
+ lifecycle.onReady(() => {
283
+ const sensor = hass.refBy.id("sensor.magic");
284
+ const symbolProperty = Symbol("test");
285
+ // Accessing with a Symbol should return undefined
286
+ // This tests the proxyGetLogic non-string check
287
+ expect(Reflect.get(sensor, symbolProperty)).toBeUndefined();
288
+ });
289
+ });
290
+ });
291
+ it("nextState returns early when timeout is undefined", async () => {
292
+ expect.assertions(1);
293
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
294
+ lifecycle.onReady(async () => {
295
+ const sensor = hass.refBy.id("sensor.magic");
296
+ // Call nextState without timeout - should wait indefinitely for state change
297
+ const nextStatePromise = sensor.nextState();
298
+ // Emit a state update - promise should resolve with the new state
299
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
300
+ state: "updated",
301
+ });
302
+ const result = await nextStatePromise;
303
+ // Should resolve with the updated state when timeout is undefined
304
+ expect(result?.state).toBe("updated");
305
+ });
306
+ });
307
+ });
308
+ it("nextState kills timer when removed before timeout", async () => {
309
+ expect.assertions(1);
310
+ vi.useFakeTimers();
311
+ await hassTestRunner.run(({ lifecycle, hass }) => {
312
+ lifecycle.onReady(async () => {
313
+ const sensor = hass.refBy.id("sensor.magic");
314
+ let promiseResolved = false;
315
+ // Call nextState with timeout
316
+ const nextStatePromise = sensor.nextState("0.1s");
317
+ nextStatePromise.then(() => {
318
+ promiseResolved = true;
319
+ });
320
+ // Remove all listeners before timeout completes - should kill the timer
321
+ sensor.removeAllListeners();
322
+ // Advance time past the timeout - promise should not resolve
323
+ await vi.advanceTimersByTimeAsync(100);
324
+ // Give a small delay to check if promise resolved
325
+ await vi.advanceTimersByTimeAsync(1);
326
+ // The promise should not resolve because done was set to undefined and timer was killed
327
+ expect(promiseResolved).toBe(false);
328
+ });
329
+ });
330
+ vi.useRealTimers();
331
+ });
332
+ it("nextState complete function checks if done exists before calling", async () => {
333
+ expect.assertions(1);
334
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
335
+ lifecycle.onReady(async () => {
336
+ const sensor = hass.refBy.id("sensor.magic");
337
+ let promiseResolved = false;
338
+ // Call nextState without timeout
339
+ const nextStatePromise = sensor.nextState();
340
+ nextStatePromise.then(() => {
341
+ promiseResolved = true;
342
+ });
343
+ // Remove all listeners - this sets done = undefined
344
+ sensor.removeAllListeners();
345
+ // Emit an update - complete function should check if done exists
346
+ // Since done is undefined, it should not call done()
347
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
348
+ state: "updated",
349
+ });
350
+ // Give a small delay to check if promise resolved
351
+ await new Promise(resolve => setTimeout(resolve, 10));
352
+ // The promise should not resolve because done was set to undefined
353
+ // and the complete function checks if (done) before calling it
354
+ expect(promiseResolved).toBe(false);
355
+ });
356
+ });
357
+ });
358
+ it("returns previous entity state", async () => {
359
+ expect.assertions(3);
360
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
361
+ lifecycle.onReady(async () => {
362
+ const entity_id = "sensor.magic";
363
+ const entity = hass.refBy.id(entity_id);
364
+ const startState = entity.state;
365
+ // Emit an update
366
+ await mock_assistant.events.emitEntityUpdate(entity_id, {
367
+ state: "updated",
368
+ });
369
+ const updatedState = entity.state;
370
+ const previousState = entity.previous;
371
+ expect(updatedState).toBe("updated");
372
+ expect(startState).not.toBe("updated");
373
+ expect(previousState.state).toBe(startState);
374
+ });
375
+ });
376
+ });
377
+ });
378
+ describe("waitForState", () => {
379
+ it("resolves when state matches", async () => {
380
+ expect.assertions(2);
381
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
382
+ lifecycle.onReady(async () => {
383
+ const entity = hass.refBy.id("sensor.magic");
384
+ const waitPromise = entity.waitForState("target", "1s");
385
+ // Emit update with matching state
386
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
387
+ state: "target",
388
+ });
389
+ const result = await waitPromise;
390
+ expect(result?.state).toBe("target");
391
+ expect(result).toBeDefined();
392
+ });
393
+ });
394
+ });
395
+ it("does not resolve when state does not match", async () => {
396
+ expect.assertions(1);
397
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
398
+ lifecycle.onReady(async () => {
399
+ const entity = hass.refBy.id("sensor.magic");
400
+ let promiseResolved = false;
401
+ // Call waitForState without timeout - should wait indefinitely
402
+ const waitPromise = entity.waitForState("target");
403
+ waitPromise.then(() => {
404
+ promiseResolved = true;
405
+ });
406
+ // Emit update with non-matching state
407
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
408
+ state: "other",
409
+ });
410
+ // Give a small delay to check if promise resolved
411
+ await new Promise(resolve => setTimeout(resolve, 10));
412
+ // Promise should not resolve because state didn't match
413
+ expect(promiseResolved).toBe(false);
414
+ });
415
+ });
416
+ });
417
+ it("resolves with undefined when timeout expires", async () => {
418
+ expect.assertions(1);
419
+ vi.useFakeTimers();
420
+ await hassTestRunner.run(({ lifecycle, hass }) => {
421
+ lifecycle.onReady(async () => {
422
+ const entity = hass.refBy.id("sensor.magic");
423
+ const waitPromise = entity.waitForState("target", "0.1s");
424
+ // Advance time past timeout without matching state
425
+ await vi.advanceTimersByTimeAsync(100);
426
+ const result = await waitPromise;
427
+ expect(result).toBeUndefined();
428
+ });
429
+ });
430
+ vi.useRealTimers();
431
+ });
432
+ it("returns early when timeout is undefined", async () => {
433
+ expect.assertions(1);
434
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
435
+ lifecycle.onReady(async () => {
436
+ const entity = hass.refBy.id("sensor.magic");
437
+ // Call waitForState without timeout - should wait indefinitely
438
+ const waitPromise = entity.waitForState("target");
439
+ // Emit update with matching state - promise should resolve
440
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
441
+ state: "target",
442
+ });
443
+ const result = await waitPromise;
444
+ expect(result?.state).toBe("target");
445
+ });
446
+ });
447
+ });
448
+ it("works with numeric state values", async () => {
449
+ expect.assertions(1);
450
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
451
+ lifecycle.onReady(async () => {
452
+ const entity = hass.refBy.id("sensor.magic");
453
+ const waitPromise = entity.waitForState("42", "1s");
454
+ // Emit update with matching numeric state
455
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
456
+ state: "42",
457
+ });
458
+ const result = await waitPromise;
459
+ expect(result?.state).toBe("42");
460
+ });
461
+ });
462
+ });
463
+ it("kills timer when removed before timeout", async () => {
464
+ expect.assertions(1);
465
+ vi.useFakeTimers();
466
+ await hassTestRunner.run(({ lifecycle, hass }) => {
467
+ lifecycle.onReady(async () => {
468
+ const entity = hass.refBy.id("sensor.magic");
469
+ let promiseResolved = false;
470
+ const waitPromise = entity.waitForState("target", "0.1s");
471
+ waitPromise.then(() => {
472
+ promiseResolved = true;
473
+ });
474
+ // Remove all listeners before timeout completes - should kill the timer
475
+ entity.removeAllListeners();
476
+ // Advance time past the timeout - promise should not resolve
477
+ await vi.advanceTimersByTimeAsync(100);
478
+ // Give a small delay to check if promise resolved
479
+ await vi.advanceTimersByTimeAsync(1);
480
+ // The promise should not resolve because done was set to undefined and timer was killed
481
+ expect(promiseResolved).toBe(false);
482
+ });
483
+ });
484
+ vi.useRealTimers();
485
+ });
486
+ it("complete function checks if done exists before calling", async () => {
487
+ expect.assertions(1);
488
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
489
+ lifecycle.onReady(async () => {
490
+ const entity = hass.refBy.id("sensor.magic");
491
+ let promiseResolved = false;
492
+ const waitPromise = entity.waitForState("target", "1s");
493
+ waitPromise.then(() => {
494
+ promiseResolved = true;
495
+ });
496
+ // Remove all listeners - this sets done = undefined
497
+ entity.removeAllListeners();
498
+ // Emit an update with matching state - complete function should check if done exists
499
+ // Since done is undefined, it should not call done()
500
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
501
+ state: "target",
502
+ });
503
+ // Give a small delay to check if promise resolved
504
+ await new Promise(resolve => setTimeout(resolve, 10));
505
+ // The promise should not resolve because done was set to undefined
506
+ // and the complete function checks if (done) before calling it
507
+ expect(promiseResolved).toBe(false);
508
+ });
509
+ });
510
+ });
511
+ it("only resolves when state matches, ignores non-matching updates", async () => {
512
+ expect.assertions(2);
513
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
514
+ lifecycle.onReady(async () => {
515
+ const entity = hass.refBy.id("sensor.magic");
516
+ const waitPromise = entity.waitForState("target", "1s");
517
+ // Emit update with non-matching state - should not resolve
518
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
519
+ state: "other1",
520
+ });
521
+ // Emit another non-matching update - should not resolve
522
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
523
+ state: "other2",
524
+ });
525
+ // Emit update with matching state - should resolve
526
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
527
+ state: "target",
528
+ });
529
+ const result = await waitPromise;
530
+ expect(result?.state).toBe("target");
531
+ expect(result).toBeDefined();
532
+ });
533
+ });
534
+ });
535
+ });
536
+ describe("listener management", () => {
537
+ it("addListener registers a remove callback", async () => {
538
+ expect.assertions(1);
539
+ await hassTestRunner.run(({ lifecycle, hass }) => {
540
+ lifecycle.onReady(() => {
541
+ const entity = hass.refBy.id("sensor.magic");
542
+ const removeCallback = Object.assign(vi.fn(), { remove: vi.fn() });
543
+ entity.addListener(removeCallback);
544
+ // Call removeAllListeners - should call the registered callback
545
+ entity.removeAllListeners();
546
+ expect(removeCallback).toHaveBeenCalledTimes(1);
547
+ });
548
+ });
549
+ });
550
+ it("removeAllListeners calls all registered callbacks", async () => {
551
+ expect.assertions(3);
552
+ await hassTestRunner.run(({ lifecycle, hass }) => {
553
+ lifecycle.onReady(() => {
554
+ const entity = hass.refBy.id("sensor.magic");
555
+ const removeCallback1 = Object.assign(vi.fn(), { remove: vi.fn() });
556
+ const removeCallback2 = Object.assign(vi.fn(), { remove: vi.fn() });
557
+ const removeCallback3 = Object.assign(vi.fn(), { remove: vi.fn() });
558
+ entity.addListener(removeCallback1);
559
+ entity.addListener(removeCallback2);
560
+ entity.addListener(removeCallback3);
561
+ entity.removeAllListeners();
562
+ expect(removeCallback1).toHaveBeenCalledTimes(1);
563
+ expect(removeCallback2).toHaveBeenCalledTimes(1);
564
+ expect(removeCallback3).toHaveBeenCalledTimes(1);
565
+ });
566
+ });
567
+ });
568
+ it("removeAllListeners cleans up onUpdate listeners", async () => {
569
+ expect.assertions(1);
570
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
571
+ lifecycle.onReady(async () => {
572
+ const entity = hass.refBy.id("sensor.magic");
573
+ const callback = vi.fn();
574
+ entity.onUpdate(callback);
575
+ // Remove all listeners
576
+ entity.removeAllListeners();
577
+ // Emit an update - callback should not be called
578
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
579
+ state: "updated",
580
+ });
581
+ expect(callback).not.toHaveBeenCalled();
582
+ });
583
+ });
584
+ });
585
+ it("removeAllListeners cleans up once listeners", async () => {
586
+ expect.assertions(1);
587
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
588
+ lifecycle.onReady(async () => {
589
+ const entity = hass.refBy.id("sensor.magic");
590
+ const callback = vi.fn();
591
+ entity.once(callback);
592
+ // Remove all listeners
593
+ entity.removeAllListeners();
594
+ // Emit an update - callback should not be called
595
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
596
+ state: "updated",
597
+ });
598
+ expect(callback).not.toHaveBeenCalled();
599
+ });
600
+ });
601
+ });
602
+ it("removeAllListeners cleans up onStateFor listeners", async () => {
603
+ expect.assertions(1);
604
+ vi.useFakeTimers();
605
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
606
+ lifecycle.onReady(async () => {
607
+ const entity = hass.refBy.id("sensor.magic");
608
+ const callback = vi.fn();
609
+ entity.onStateFor({
610
+ context,
611
+ exec: callback,
612
+ for: "0.1s",
613
+ state: "on",
614
+ });
615
+ // Remove all listeners
616
+ entity.removeAllListeners();
617
+ // Change state to match
618
+ const updatePromise = mock_assistant.events.emitEntityUpdate("sensor.magic", {
619
+ state: "on",
620
+ });
621
+ await vi.advanceTimersByTimeAsync(1);
622
+ await updatePromise;
623
+ await vi.advanceTimersByTimeAsync(100);
624
+ expect(callback).not.toHaveBeenCalled();
625
+ });
626
+ });
627
+ vi.useRealTimers();
628
+ });
629
+ it("removeAllListeners cleans up both addListener and built-in listeners", async () => {
630
+ expect.assertions(2);
631
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
632
+ lifecycle.onReady(async () => {
633
+ const entity = hass.refBy.id("sensor.magic");
634
+ const addListenerCallback = Object.assign(vi.fn(), { remove: vi.fn() });
635
+ const onUpdateCallback = vi.fn();
636
+ entity.addListener(addListenerCallback);
637
+ entity.onUpdate(onUpdateCallback);
638
+ // Remove all listeners
639
+ entity.removeAllListeners();
640
+ // Emit an update - callbacks should not be called
641
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
642
+ state: "updated",
643
+ });
644
+ expect(addListenerCallback).toHaveBeenCalledTimes(1);
645
+ expect(onUpdateCallback).not.toHaveBeenCalled();
646
+ });
647
+ });
648
+ });
649
+ });
650
+ describe("once", () => {
651
+ it("calls callback once on next entity update", async () => {
652
+ expect.assertions(2);
653
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
654
+ lifecycle.onReady(async () => {
655
+ const entity = hass.refBy.id("sensor.magic");
656
+ const callback = vi.fn();
657
+ entity.once(callback);
658
+ // Emit first update - callback should be called
659
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
660
+ state: "first",
661
+ });
662
+ expect(callback).toHaveBeenCalledTimes(1);
663
+ // Emit second update - callback should not be called again
664
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
665
+ state: "second",
666
+ });
667
+ expect(callback).toHaveBeenCalledTimes(1);
668
+ });
669
+ });
670
+ });
671
+ it("passes new_state and old_state to callback", async () => {
672
+ expect.assertions(3);
673
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
674
+ lifecycle.onReady(async () => {
675
+ const entity = hass.refBy.id("sensor.magic");
676
+ const callback = vi.fn();
677
+ entity.once(callback);
678
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
679
+ state: "updated",
680
+ });
681
+ expect(callback).toHaveBeenCalledTimes(1);
682
+ expect(callback).toHaveBeenCalledWith(expect.objectContaining({ state: "updated" }), expect.any(Object));
683
+ expect(callback.mock.calls[0]).toHaveLength(2);
684
+ });
685
+ });
686
+ });
687
+ it("removes listener after callback is called", async () => {
688
+ expect.assertions(2);
689
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
690
+ lifecycle.onReady(async () => {
691
+ const entity = hass.refBy.id("sensor.magic");
692
+ const callback = vi.fn();
693
+ entity.once(callback);
694
+ // First update - callback should be called
695
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
696
+ state: "first",
697
+ });
698
+ expect(callback).toHaveBeenCalledTimes(1);
699
+ // Second update - callback should not be called (listener removed)
700
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
701
+ state: "second",
702
+ });
703
+ expect(callback).toHaveBeenCalledTimes(1);
704
+ });
705
+ });
706
+ });
707
+ it("remove function prevents callback from being called", async () => {
708
+ expect.assertions(1);
709
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
710
+ lifecycle.onReady(async () => {
711
+ const entity = hass.refBy.id("sensor.magic");
712
+ const callback = vi.fn();
713
+ const remove = entity.once(callback);
714
+ // Remove the listener before update
715
+ remove();
716
+ // Emit update - callback should not be called
717
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
718
+ state: "updated",
719
+ });
720
+ expect(callback).not.toHaveBeenCalled();
721
+ });
722
+ });
723
+ });
724
+ it("handles multiple once listeners independently", async () => {
725
+ expect.assertions(4);
726
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
727
+ lifecycle.onReady(async () => {
728
+ const entity = hass.refBy.id("sensor.magic");
729
+ const callback1 = vi.fn();
730
+ const callback2 = vi.fn();
731
+ entity.once(callback1);
732
+ entity.once(callback2);
733
+ // First update - both callbacks should be called
734
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
735
+ state: "first",
736
+ });
737
+ expect(callback1).toHaveBeenCalledTimes(1);
738
+ expect(callback2).toHaveBeenCalledTimes(1);
739
+ // Second update - neither callback should be called
740
+ await mock_assistant.events.emitEntityUpdate("sensor.magic", {
741
+ state: "second",
742
+ });
743
+ expect(callback1).toHaveBeenCalledTimes(1);
744
+ expect(callback2).toHaveBeenCalledTimes(1);
745
+ });
746
+ });
747
+ });
748
+ });
749
+ describe("onStateFor", () => {
750
+ it("executes callback when state matches for duration", async () => {
751
+ expect.assertions(1);
752
+ vi.useFakeTimers();
753
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
754
+ lifecycle.onReady(async () => {
755
+ const entity = hass.refBy.id("sensor.magic");
756
+ const callback = vi.fn();
757
+ entity.onStateFor({
758
+ context,
759
+ exec: callback,
760
+ for: "0.1s",
761
+ state: "on",
762
+ });
763
+ // Change state to match
764
+ const updatePromise = mock_assistant.events.emitEntityUpdate("sensor.magic", {
765
+ state: "on",
766
+ });
767
+ // Advance timers to let the sleep(1ms) inside emitEntityUpdate complete
768
+ await vi.advanceTimersByTimeAsync(1);
769
+ // Now await the emitEntityUpdate to complete
770
+ await updatePromise;
771
+ // Advance timers to trigger the callback (0.1s = 100ms)
772
+ await vi.advanceTimersByTimeAsync(100);
773
+ expect(callback).toHaveBeenCalledTimes(1);
774
+ });
775
+ });
776
+ vi.useRealTimers();
777
+ });
778
+ it("does not execute callback if state changes before duration", async () => {
779
+ expect.assertions(1);
780
+ vi.useFakeTimers();
781
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
782
+ lifecycle.onReady(async () => {
783
+ const entity = hass.refBy.id("sensor.magic");
784
+ const callback = vi.fn();
785
+ entity.onStateFor({
786
+ context,
787
+ exec: callback,
788
+ for: "0.1s",
789
+ state: "on",
790
+ });
791
+ // Change state to match
792
+ const updatePromise1 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
793
+ state: "on",
794
+ });
795
+ await vi.advanceTimersByTimeAsync(1);
796
+ await updatePromise1;
797
+ await vi.advanceTimersByTimeAsync(50);
798
+ // Change state away before timer completes
799
+ const updatePromise2 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
800
+ state: "off",
801
+ });
802
+ await vi.advanceTimersByTimeAsync(1);
803
+ await updatePromise2;
804
+ await vi.advanceTimersByTimeAsync(100);
805
+ expect(callback).not.toHaveBeenCalled();
806
+ });
807
+ });
808
+ vi.useRealTimers();
809
+ });
810
+ it("restarts timer when state changes back to matching", async () => {
811
+ expect.assertions(2);
812
+ vi.useFakeTimers();
813
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
814
+ lifecycle.onReady(async () => {
815
+ const entity = hass.refBy.id("sensor.magic");
816
+ const callback = vi.fn();
817
+ entity.onStateFor({
818
+ context,
819
+ exec: callback,
820
+ for: "0.1s",
821
+ state: "on",
822
+ });
823
+ // Change state to match
824
+ const updatePromise1 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
825
+ state: "on",
826
+ });
827
+ await vi.advanceTimersByTimeAsync(1);
828
+ await updatePromise1;
829
+ await vi.advanceTimersByTimeAsync(50);
830
+ // Change state away
831
+ const updatePromise2 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
832
+ state: "off",
833
+ });
834
+ await vi.advanceTimersByTimeAsync(1);
835
+ await updatePromise2;
836
+ await vi.advanceTimersByTimeAsync(50);
837
+ // Change back to matching - should start new timer
838
+ const updatePromise3 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
839
+ state: "on",
840
+ });
841
+ await vi.advanceTimersByTimeAsync(1);
842
+ await updatePromise3;
843
+ await vi.advanceTimersByTimeAsync(50);
844
+ expect(callback).not.toHaveBeenCalled();
845
+ // Complete the timer
846
+ await vi.advanceTimersByTimeAsync(50);
847
+ expect(callback).toHaveBeenCalledTimes(1);
848
+ });
849
+ });
850
+ vi.useRealTimers();
851
+ });
852
+ it("uses custom matches function when provided", async () => {
853
+ expect.assertions(3);
854
+ vi.useFakeTimers();
855
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
856
+ lifecycle.onReady(async () => {
857
+ const entity = hass.refBy.id("sensor.magic");
858
+ const callback = vi.fn();
859
+ const matches = vi.fn((new_state, old_state) => {
860
+ return new_state.state === "target" && old_state.state !== "target";
861
+ });
862
+ entity.onStateFor({
863
+ context,
864
+ exec: callback,
865
+ for: "0.1s",
866
+ matches,
867
+ });
868
+ // Change to non-matching state - matches should return false, so no timer
869
+ const updatePromise1 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
870
+ state: "other",
871
+ });
872
+ await vi.advanceTimersByTimeAsync(1);
873
+ await updatePromise1;
874
+ // Verify matches was called and returned false (no timer should be set)
875
+ expect(matches).toHaveBeenCalled();
876
+ // Advance timers - callback should not be called
877
+ await vi.advanceTimersByTimeAsync(100);
878
+ expect(callback).not.toHaveBeenCalled();
879
+ // Change to matching state (target from different state) - matches should return true
880
+ const updatePromise2 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
881
+ state: "target",
882
+ });
883
+ await vi.advanceTimersByTimeAsync(1);
884
+ await updatePromise2;
885
+ // Advance timers to trigger the callback
886
+ await vi.advanceTimersByTimeAsync(100);
887
+ expect(callback).toHaveBeenCalledTimes(1);
888
+ });
889
+ });
890
+ vi.useRealTimers();
891
+ });
892
+ it("prevents duplicate timers when state matches multiple times", async () => {
893
+ expect.assertions(1);
894
+ vi.useFakeTimers();
895
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
896
+ lifecycle.onReady(async () => {
897
+ const entity = hass.refBy.id("sensor.magic");
898
+ const callback = vi.fn();
899
+ entity.onStateFor({
900
+ context,
901
+ exec: callback,
902
+ for: "0.1s",
903
+ state: "on",
904
+ });
905
+ // Change state to match
906
+ const updatePromise1 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
907
+ state: "on",
908
+ });
909
+ await vi.advanceTimersByTimeAsync(1);
910
+ await updatePromise1;
911
+ await vi.advanceTimersByTimeAsync(50);
912
+ // Change to same matching state again - should not start new timer
913
+ const updatePromise2 = mock_assistant.events.emitEntityUpdate("sensor.magic", {
914
+ state: "on",
915
+ });
916
+ await vi.advanceTimersByTimeAsync(1);
917
+ await updatePromise2;
918
+ await vi.advanceTimersByTimeAsync(50);
919
+ // Complete original timer
920
+ await vi.advanceTimersByTimeAsync(50);
921
+ // Should only be called once
922
+ expect(callback).toHaveBeenCalledTimes(1);
923
+ });
924
+ });
925
+ vi.useRealTimers();
926
+ });
927
+ it("cleans up timer and listener when remove is called", async () => {
928
+ expect.assertions(1);
929
+ vi.useFakeTimers();
930
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
931
+ lifecycle.onReady(async () => {
932
+ const entity = hass.refBy.id("sensor.magic");
933
+ const callback = vi.fn();
934
+ const remove = entity.onStateFor({
935
+ context,
936
+ exec: callback,
937
+ for: "0.1s",
938
+ state: "on",
939
+ });
940
+ // Change state to match
941
+ const updatePromise = mock_assistant.events.emitEntityUpdate("sensor.magic", {
942
+ state: "on",
943
+ });
944
+ await vi.advanceTimersByTimeAsync(1);
945
+ await updatePromise;
946
+ await vi.advanceTimersByTimeAsync(50);
947
+ // Remove the listener
948
+ remove();
949
+ // Advance time past when timer would have fired
950
+ await vi.advanceTimersByTimeAsync(100);
951
+ // Callback should not have been called
952
+ expect(callback).not.toHaveBeenCalled();
953
+ });
954
+ });
955
+ vi.useRealTimers();
956
+ });
957
+ it("handles cleanup when timer is already running", async () => {
958
+ expect.assertions(1);
959
+ vi.useFakeTimers();
960
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
961
+ lifecycle.onReady(async () => {
962
+ const entity = hass.refBy.id("sensor.magic");
963
+ const callback = vi.fn();
964
+ const remove = entity.onStateFor({
965
+ context,
966
+ exec: callback,
967
+ for: "0.1s",
968
+ state: "on",
969
+ });
970
+ // Change state to match - starts timer
971
+ const updatePromise = mock_assistant.events.emitEntityUpdate("sensor.magic", {
972
+ state: "on",
973
+ });
974
+ await vi.advanceTimersByTimeAsync(1);
975
+ await updatePromise;
976
+ await vi.advanceTimersByTimeAsync(50);
977
+ // Remove should clean up the running timer
978
+ remove();
979
+ // Advance time past when timer would have fired
980
+ await vi.advanceTimersByTimeAsync(100);
981
+ expect(callback).not.toHaveBeenCalled();
982
+ });
983
+ });
984
+ vi.useRealTimers();
985
+ });
986
+ it("works with numeric state values", async () => {
987
+ expect.assertions(1);
988
+ vi.useFakeTimers();
989
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
990
+ lifecycle.onReady(async () => {
991
+ const entity = hass.refBy.id("sensor.magic");
992
+ const callback = vi.fn();
993
+ entity.onStateFor({
994
+ context,
995
+ exec: callback,
996
+ for: "0.1s",
997
+ state: "42",
998
+ });
999
+ // Change state to match numeric value
1000
+ const updatePromise = mock_assistant.events.emitEntityUpdate("sensor.magic", {
1001
+ state: "42",
1002
+ });
1003
+ await vi.advanceTimersByTimeAsync(1);
1004
+ await updatePromise;
1005
+ await vi.advanceTimersByTimeAsync(100);
1006
+ expect(callback).toHaveBeenCalledTimes(1);
1007
+ });
1008
+ });
1009
+ vi.useRealTimers();
1010
+ });
1011
+ it("passes proxy to exec callback", async () => {
1012
+ expect.assertions(3);
1013
+ vi.useFakeTimers();
1014
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
1015
+ lifecycle.onReady(async () => {
1016
+ const entity = hass.refBy.id("sensor.magic");
1017
+ const callback = vi.fn(proxy => {
1018
+ expect(proxy.entity_id).toBe("sensor.magic");
1019
+ expect(proxy.state).toBe("on");
1020
+ });
1021
+ entity.onStateFor({
1022
+ context,
1023
+ exec: callback,
1024
+ for: "0.1s",
1025
+ state: "on",
1026
+ });
1027
+ const updatePromise = mock_assistant.events.emitEntityUpdate("sensor.magic", {
1028
+ state: "on",
1029
+ });
1030
+ await vi.advanceTimersByTimeAsync(1);
1031
+ await updatePromise;
1032
+ await vi.advanceTimersByTimeAsync(100);
1033
+ expect(callback).toHaveBeenCalledTimes(1);
1034
+ });
1035
+ });
1036
+ vi.useRealTimers();
1037
+ });
1038
+ it("handles multiple onStateFor listeners on same entity", async () => {
1039
+ expect.assertions(2);
1040
+ vi.useFakeTimers();
1041
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant, context }) => {
1042
+ lifecycle.onReady(async () => {
1043
+ const entity = hass.refBy.id("sensor.magic");
1044
+ const callback1 = vi.fn();
1045
+ const callback2 = vi.fn();
1046
+ entity.onStateFor({
1047
+ context,
1048
+ exec: callback1,
1049
+ for: "0.1s",
1050
+ state: "on",
1051
+ });
1052
+ entity.onStateFor({
1053
+ context,
1054
+ exec: callback2,
1055
+ for: ".15s",
1056
+ state: "on",
1057
+ });
1058
+ const updatePromise = mock_assistant.events.emitEntityUpdate("sensor.magic", {
1059
+ state: "on",
1060
+ });
1061
+ await vi.advanceTimersByTimeAsync(1);
1062
+ await updatePromise;
1063
+ await vi.advanceTimersByTimeAsync(100);
1064
+ expect(callback1).toHaveBeenCalledTimes(1);
1065
+ await vi.advanceTimersByTimeAsync(50);
1066
+ expect(callback2).toHaveBeenCalledTimes(1);
1067
+ });
1068
+ });
1069
+ vi.useRealTimers();
1070
+ });
271
1071
  });
272
1072
  });
273
1073
  });