Skip to content

Commit 75c192f

Browse files
authored
Fix/add more tests (#145)
* rename _Websocket to _WebsocketService to match convention, remove previous websocket tests * unreviewed codex written tests for websocket service * have codex make tests easier to read across the board * remove usless core test * unreviewed codex written tests for core * unreviewed codex written tests for bus and predicates * never use from future import annotations * fix core tests * add post_to_loop method * fix tests by using post_to_loop method * update changelog
1 parent dc6c271 commit 75c192f

19 files changed

Lines changed: 964 additions & 235 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- Add validation for filename extension in AppManifest - add `.py` if no suffix, raise error if not `.py`
1212
- Bus handlers can now accept args and kwargs to be passed to the callback when the event is fired
1313
- `tasks.py` renamed to `task_bucket.py` to follow naming conventions
14+
- `post_to_loop` method added to `TaskBucket` to allow posting callables to the event loop from other threads
1415

1516
### Changed
1617
- **Breaking:** - Renamed `async_utils.py` to `func_utils.py`, added `callable_name` and `callable_short_name` utility functions

src/hassette/core/core.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from .services.health_service import _HealthService
2424
from .services.scheduler_service import _SchedulerService
2525
from .services.service_watcher import _ServiceWatcher
26-
from .services.websocket_service import _Websocket
26+
from .services.websocket_service import _WebsocketService
2727

2828
if typing.TYPE_CHECKING:
2929
from hassette.events import Event
@@ -71,7 +71,7 @@ def __init__(self, config: HassetteConfig) -> None:
7171
self._bus_service = self.add_child(_BusService, stream=self._receive_stream.clone())
7272

7373
self._service_watcher = self.add_child(_ServiceWatcher)
74-
self._websocket = self.add_child(_Websocket)
74+
self._websocket = self.add_child(_WebsocketService)
7575
self._health_service = self.add_child(_HealthService)
7676
self._file_watcher = self.add_child(_FileWatcher)
7777
self._app_handler = self.add_child(_AppHandler)

src/hassette/core/resources/task_bucket.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ def _call() -> R:
126126

127127
return asyncio.to_thread(_call)
128128

129+
def post_to_loop(self, fn, *args, **kwargs) -> None:
130+
"""Schedule a callable on the event loop from any thread."""
131+
self.hassette.loop.call_soon_threadsafe(fn, *args, **kwargs)
132+
129133
@overload
130134
def make_async_adapter(self, fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: ...
131135
@overload

src/hassette/core/services/websocket_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
)
5353

5454

55-
class _Websocket(Service): # pyright: ignore[reportUnusedClass]
55+
class _WebsocketService(Service): # pyright: ignore[reportUnusedClass]
5656
url: str
5757
"""WebSocket URL to connect to."""
5858

src/hassette/test_utils/harness.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from hassette.core.services.bus_service import _BusService
2525
from hassette.core.services.file_watcher import _FileWatcher
2626
from hassette.core.services.scheduler_service import _SchedulerService
27-
from hassette.core.services.websocket_service import _Websocket
27+
from hassette.core.services.websocket_service import _WebsocketService
2828
from hassette.enums import ResourceStatus
2929
from hassette.events import Event
3030
from hassette.test_utils.test_server import SimpleTestServer
@@ -85,7 +85,7 @@ def __init__(self, *, config: HassetteConfig) -> None:
8585
self._scheduler: Scheduler | None = None
8686
self._file_watcher: _FileWatcher | None = None
8787
self._app_handler: _AppHandler | None = None
88-
self._websocket: _Websocket | None = None
88+
self._websocket: _WebsocketService | None = None
8989

9090
async def send_event(self, topic: str, event: Event[Any]) -> None:
9191
if not self._send_stream:
@@ -293,7 +293,7 @@ async def _start_api_mock(self) -> None:
293293
self._exit_stack.enter_context(rest_url_patch)
294294
self._exit_stack.enter_context(headers_patch)
295295

296-
self.hassette._websocket = Mock(spec=_Websocket)
296+
self.hassette._websocket = Mock(spec=_WebsocketService)
297297
self.hassette._websocket.ready_event = asyncio.Event()
298298
self.hassette._websocket.ready_event.set()
299299

tests/test_api.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,39 +7,43 @@
77

88

99
async def test_api_rest_request_sets_body_and_headers(hassette_with_mock_api: tuple[Api, SimpleTestServer]):
10-
api, mock = hassette_with_mock_api
10+
"""POST requests include JSON body and expected headers."""
11+
api_client, mock_server = hassette_with_mock_api
1112

12-
mock.expect("POST", "/api/thing", "", json={"a": 1}, status=200)
13+
mock_server.expect("POST", "/api/thing", "", json={"a": 1}, status=200)
1314

14-
resp = await api.rest_request("POST", "/api/thing", data={"a": 1})
15-
resp_data = await resp.json()
15+
response = await api_client.rest_request("POST", "/api/thing", data={"a": 1})
16+
payload = await response.json()
1617

17-
assert "application/json" in resp.headers.get("Content-Type", ""), "Expected JSON response"
18-
assert resp_data == {"a": 1}, f"Expected echoed JSON, got {resp_data}"
18+
assert "application/json" in response.headers.get("Content-Type", ""), "Expected JSON response"
19+
assert payload == {"a": 1}, f"Expected echoed JSON, got {payload}"
1920

2021

2122
async def test_api_rest_request_cleans_params(hassette_with_mock_api: tuple[Api, SimpleTestServer]):
22-
api, mock = hassette_with_mock_api
23+
"""Query parameters are cleaned before sending a GET request."""
24+
api_client, mock_server = hassette_with_mock_api
2325

24-
params = {"keep": "x", "none": None, "empty": " ", "flag": False}
26+
request_params = {"keep": "x", "none": None, "empty": " ", "flag": False}
2527

26-
mock.expect("GET", "/api/thing", "keep=x&flag=false", status=200)
28+
mock_server.expect("GET", "/api/thing", "keep=x&flag=false", status=200)
2729

28-
resp = await api.rest_request("GET", "/api/thing", params=params)
30+
response = await api_client.rest_request("GET", "/api/thing", params=request_params)
2931

30-
assert resp.status == 200, f"Expected 200 OK, got {resp.status}"
32+
assert response.status == 200, f"Expected 200 OK, got {response.status}"
3133

32-
assert dict(resp.request_info.url.query) == {"keep": "x", "flag": "false"}, (
33-
f"Unexpected query params: {resp.request_info.url.query}"
34+
assert dict(response.request_info.url.query) == {"keep": "x", "flag": "false"}, (
35+
f"Unexpected query params: {response.request_info.url.query}"
3436
)
3537

3638

3739
def test_clean_kwargs_basic():
38-
out = clean_kwargs(a=None, b=False, c=" ", d="x", e=5)
39-
assert out == {"b": "false", "d": "x", "e": 5}, f"Unexpected cleaned kwargs: {out}"
40+
"""clean_kwargs drops empty values and normalises booleans."""
41+
cleaned_kwargs = clean_kwargs(a=None, b=False, c=" ", d="x", e=5)
42+
assert cleaned_kwargs == {"b": "false", "d": "x", "e": 5}, f"Unexpected cleaned kwargs: {cleaned_kwargs}"
4043

4144

4245
def test_sync_parity():
46+
"""Sync facade exposes the same public methods as the async API."""
4347
api_methods = inspect.getmembers(Api, predicate=inspect.isfunction)
4448
api_sync_methods = inspect.getmembers(ApiSyncFacade, predicate=inspect.isfunction)
4549

tests/test_apps.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ async def test_handle_changes_disables_app(self) -> None:
6363
event = asyncio.Event()
6464

6565
async def handler(*args, **kwargs): # noqa
66-
event.set()
66+
self.hassette.task_bucket.post_to_loop(event.set)
6767

6868
self.hassette._bus_service.add_listener(
6969
Listener(
@@ -103,7 +103,7 @@ async def test_handle_changes_enables_app(self) -> None:
103103
event = asyncio.Event()
104104

105105
async def handler(*args, **kwargs): # noqa
106-
event.set()
106+
self.hassette.task_bucket.post_to_loop(event.set)
107107

108108
self.hassette._bus_service.add_listener(
109109
Listener(

tests/test_bus.py

Lines changed: 146 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,176 @@
11
# pyright: reportInvalidTypeArguments=none, reportArgumentType=none
22

3+
34
import asyncio
5+
import typing
46
from 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
614
from 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

9135
async 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

33160
async 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

53181
async 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+
)

tests/test_config.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@
66

77

88
def test_overrides_are_used(env_file_path: Path, test_config: HassetteConfig) -> None:
9-
"""
10-
Test that the overrides in the HassetteConfig are used correctly.
11-
"""
9+
"""Configuration values honour overrides from the test TOML and .env."""
1210

1311
expected_token = dotenv.get_key(env_file_path, "hassette__token")
1412

@@ -21,9 +19,9 @@ def test_overrides_are_used(env_file_path: Path, test_config: HassetteConfig) ->
2119

2220

2321
def test_env_overrides_are_used(test_config_class, monkeypatch):
24-
"""
25-
Test that environment variable overrides are used correctly.
26-
"""
22+
"""Environment overrides win when constructing a HassetteConfig."""
2723
monkeypatch.setenv("hassette__app_dir", "/custom/apps")
28-
config = test_config_class()
29-
assert config.app_dir == Path("/custom/apps"), f"Expected /custom/apps, got {config.app_dir}"
24+
config_with_env_override = test_config_class()
25+
assert config_with_env_override.app_dir == Path("/custom/apps"), (
26+
f"Expected /custom/apps, got {config_with_env_override.app_dir}"
27+
)

0 commit comments

Comments
 (0)