11# pyright: reportInvalidTypeArguments=none, reportArgumentType=none
22
3+
34import asyncio
5+ import typing
46from types import SimpleNamespace
7+ from unittest .mock import AsyncMock , Mock
8+
9+ import pytest
510
11+ from hassette .core .resources .bus .listeners import Subscription
12+ from hassette .core .resources .bus .predicates import AllOf , AttrChanged , EntityMatches , Guard , StateChanged
13+ from hassette .core .resources .bus .predicates .event import CallServiceEventWrapper , KeyValueMatches
614from hassette .events .base import Event
715
16+ if typing .TYPE_CHECKING :
17+ from hassette .core .core import Hassette
18+ from hassette .core .resources .bus .bus import Bus
19+
20+
21+ @pytest .fixture
22+ def bus_instance (hassette_with_bus : "Hassette" ) -> "Bus" :
23+ """Return the Bus resource for the running Hassette harness."""
24+ return hassette_with_bus ._bus
25+
26+
27+ async def test_on_registers_listener_and_supports_unsubscribe (bus_instance : "Bus" ) -> None :
28+ """Bus.on wraps handlers, normalises predicates, and wires subscription cleanup."""
29+
30+ async def handler (event ): # noqa
31+ await asyncio .sleep (0 )
32+
33+ add_listener_mock = Mock ()
34+ remove_listener_mock = Mock ()
35+ original_service = bus_instance .bus_service
36+ original_remove = bus_instance .remove_listener
37+ bus_instance .bus_service = Mock (add_listener = add_listener_mock ) # type: ignore[assignment]
38+ bus_instance .remove_listener = remove_listener_mock # type: ignore[assignment]
39+
40+ try :
41+ subscription = bus_instance .on (
42+ topic = "demo.topic" ,
43+ handler = handler ,
44+ where = [lambda _ : True ],
45+ args = ("prefix" ,),
46+ kwargs = {"suffix" : "!" },
47+ once = True ,
48+ debounce = 0.1 ,
49+ throttle = 0.2 ,
50+ )
51+
52+ assert isinstance (subscription , Subscription )
53+ add_listener_mock .assert_called_once ()
54+ listener = add_listener_mock .call_args .args [0 ]
55+
56+ assert listener .topic == "demo.topic"
57+ assert listener .orig_handler is handler
58+ assert asyncio .iscoroutinefunction (listener .handler )
59+ assert listener .args == ("prefix" ,)
60+ assert listener .kwargs == {"suffix" : "!" }
61+ assert listener .once is True
62+ assert listener .debounce == 0.1
63+ assert listener .throttle == 0.2
64+ assert isinstance (listener .predicate , AllOf )
65+
66+ subscription .unsubscribe ()
67+ remove_listener_mock .assert_called_once_with (listener )
68+ finally :
69+ bus_instance .bus_service = original_service # type: ignore[assignment]
70+ bus_instance .remove_listener = original_remove # type: ignore[assignment]
71+
72+
73+ async def test_on_state_change_builds_predicates (bus_instance : "Bus" ) -> None :
74+ """on_state_change composes entity, state, and extra predicates."""
75+ extra_guard = Guard (lambda event : event .payload .topic == "any" )
76+
77+ subscription = bus_instance .on_state_change (
78+ "sensor.kitchen" ,
79+ handler = AsyncMock (),
80+ changed_from = "off" ,
81+ changed_to = "on" ,
82+ where = extra_guard ,
83+ args = (),
84+ kwargs = None ,
85+ )
86+
87+ listener = subscription .listener
88+ assert isinstance (listener .predicate , AllOf )
89+ predicate_types = {type (pred ) for pred in listener .predicate .predicates }
90+ assert EntityMatches in predicate_types
91+ assert StateChanged in predicate_types
92+ assert extra_guard in listener .predicate .predicates
93+
94+
95+ async def test_on_attribute_change_targets_attribute (bus_instance : "Bus" ) -> None :
96+ """on_attribute_change adds AttrChanged predicate for the supplied attribute."""
97+ subscription = bus_instance .on_attribute_change (
98+ "light.office" ,
99+ "brightness" ,
100+ handler = AsyncMock (),
101+ changed_from = 100 ,
102+ changed_to = 200 ,
103+ )
104+
105+ listener = subscription .listener
106+ assert isinstance (listener .predicate , AllOf )
107+ attr_predicates = [pred for pred in listener .predicate .predicates if isinstance (pred , AttrChanged )]
108+ assert attr_predicates , "Expected AttrChanged predicate to be included"
109+ attr_pred = attr_predicates [0 ]
110+ assert attr_pred .name == "brightness"
111+ assert attr_pred .from_ == 100
112+ assert attr_pred .to == 200
113+
114+
115+ async def test_on_call_service_handles_mapping_predicates (bus_instance : "Bus" ) -> None :
116+ """on_call_service wraps mapping filters as KeyValueMatches within a CallServiceEventWrapper."""
117+ subscription = bus_instance .on_call_service (
118+ domain = "light" ,
119+ service = "turn_on" ,
120+ handler = AsyncMock (),
121+ where = [{"entity_id" : "light.kitchen" }, lambda data : data .get ("brightness" , 0 ) > 150 ],
122+ )
123+
124+ listener = subscription .listener
125+ assert isinstance (listener .predicate , AllOf )
126+
127+ predicate_types = {type (pred ) for pred in listener .predicate .predicates }
128+ assert Guard in predicate_types , "Expected domain/service guards"
129+ assert CallServiceEventWrapper in predicate_types , "Expected wrapper for mapping predicates"
130+
131+ wrapper = next (pred for pred in listener .predicate .predicates if isinstance (pred , CallServiceEventWrapper ))
132+ assert any (isinstance (pred , KeyValueMatches ) for pred in wrapper .predicates )
133+
8134
9135async def test_once_listener_removed (hassette_with_bus ) -> None :
136+ """Listeners registered with once=True are removed after the first invocation."""
10137 hassette = hassette_with_bus
11138
12- payloads : list [int ] = []
13- first_fired = asyncio .Event ()
139+ received_payloads : list [int ] = []
140+ first_invocation = asyncio .Event ()
14141
15142 async def handler (event : Event [SimpleNamespace ]) -> None :
16- payloads .append (event .payload .value )
17- first_fired . set ( )
143+ received_payloads .append (event .payload .value )
144+ hassette_with_bus . task_bucket . post_to_loop ( first_invocation . set )
18145
19146 hassette ._bus .on (topic = "custom.once" , handler = handler , once = True )
20147
21148 await hassette .send_event ("custom.once" , Event (topic = "custom.once" , payload = SimpleNamespace (value = 1 )))
22149
23- await asyncio .wait_for (first_fired .wait (), timeout = 1 )
150+ await asyncio .wait_for (first_invocation .wait (), timeout = 1 )
24151 await asyncio .sleep (0.05 )
25152
26153 await hassette .send_event ("custom.once" , Event (topic = "custom.once" , payload = SimpleNamespace (value = 2 )))
27154
28155 await asyncio .sleep (0.1 )
29156
30- assert payloads == [1 ], f"Expected handler to fire once with payload 1, got { payloads } "
157+ assert received_payloads == [1 ], f"Expected handler to fire once with payload 1, got { received_payloads } "
31158
32159
33160async def test_bus_background_tasks_cleanup (hassette_with_bus ) -> None :
161+ """Bus cleans up background tasks after a once handler completes."""
34162 hassette = hassette_with_bus
35163
36- fired = asyncio .Event ()
164+ event_received = asyncio .Event ()
37165
38166 async def handler (event : Event [SimpleNamespace ]) -> None : # noqa
39- fired . set ( )
167+ hassette_with_bus . task_bucket . post_to_loop ( event_received . set )
40168
41169 hassette ._bus .on (topic = "custom.cleanup" , handler = handler , once = True )
42170
43171 await hassette .send_event ("custom.cleanup" , Event (topic = "custom.cleanup" , payload = SimpleNamespace (value = 9 )))
44172
45- await asyncio .wait_for (fired .wait (), timeout = 1 )
173+ await asyncio .wait_for (event_received .wait (), timeout = 1 )
46174 await asyncio .sleep (0.1 )
47175
48176 assert len (hassette ._bus .task_bucket ) == 0 , (
@@ -51,19 +179,22 @@ async def handler(event: Event[SimpleNamespace]) -> None: # noqa
51179
52180
53181async def test_bus_uses_args_kwargs (hassette_with_bus ) -> None :
182+ """Handlers receive configured args and kwargs when invoked."""
54183 hassette = hassette_with_bus
55184
56- received : list [str ] = []
57- fired = asyncio .Event ()
185+ formatted_messages : list [str ] = []
186+ event_processed = asyncio .Event ()
58187
59188 def handler (event : Event [SimpleNamespace ], prefix : str , suffix : str ) -> None :
60- received .append (f"{ prefix } { event .payload .value } { suffix } " )
61- fired . set ( )
189+ formatted_messages .append (f"{ prefix } { event .payload .value } { suffix } " )
190+ hassette_with_bus . task_bucket . post_to_loop ( event_processed . set )
62191
63192 hassette ._bus .on (topic = "custom.args" , handler = handler , args = ("Value: " ,), kwargs = {"suffix" : "!" })
64193
65194 await hassette .send_event ("custom.args" , Event (topic = "custom.args" , payload = SimpleNamespace (value = "Test" )))
66195
67- await asyncio .wait_for (fired .wait (), timeout = 1 )
196+ await asyncio .wait_for (event_processed .wait (), timeout = 1 )
68197
69- assert received == ["Value: Test!" ], f"Expected handler to receive formatted value, got { received } "
198+ assert formatted_messages == ["Value: Test!" ], (
199+ f"Expected handler to receive formatted value, got { formatted_messages } "
200+ )
0 commit comments