Skip to content

Commit bbb8112

Browse files
committed
test: improve coverage for invoke, SCXML actions, and async engine
Add 19 tests covering uncovered lines in modified files: - EventDataWrapper edge cases (no args, __getattr__, name via trigger_data) - _send_to_parent namelist errors and param expr=None skip - _send_to_invoke param without expr skip - invoke_init idempotent behavior - SCXMLInvoker: on_event exception, non-string content, param location - Parser: <assign> child XML/text, <invoke> text content - InvokeManager: send_to_child not found / no on_event, null event - _stop_child_machine exception handling - BaseEngine.__del__ cancel_all exception - Async engine error in before callbacks Also fix _make_invoker to not hardcode /tmp, and document coverage report commands in AGENTS.md.
1 parent 7f0595f commit bbb8112

4 files changed

Lines changed: 376 additions & 2 deletions

File tree

AGENTS.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,19 @@ timeout 120 uv run pytest -n 4
127127

128128
Testes normally run under 60s (~40s on average), so take a closer look if they take longer, it can be a regression.
129129

130-
Coverage is enabled by default.
130+
Coverage is enabled by default (`--cov` is in `pyproject.toml`'s `addopts`). To generate a
131+
coverage report to a file, pass `--cov-report` **in addition to** `--cov`:
132+
133+
```bash
134+
# JSON report (machine-readable, includes missing_lines per file)
135+
timeout 120 uv run pytest -n auto --cov=statemachine --cov-report=json:cov.json
136+
137+
# Terminal report with missing lines
138+
timeout 120 uv run pytest -n auto --cov=statemachine --cov-report=term-missing
139+
```
140+
141+
Note: `--cov=statemachine` is required to activate coverage collection; `--cov-report`
142+
alone only changes the output format.
131143

132144
### Testing both sync and async engines
133145

tests/test_async.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,29 @@ def after_go(self, **kwargs):
273273
assert sm.configuration == {sm.error_state}
274274

275275

276+
@pytest.mark.timeout(5)
277+
async def test_async_error_on_execution_in_before():
278+
"""Async engine catches errors in before callbacks with error_on_execution."""
279+
280+
class SM(StateChart):
281+
s1 = State(initial=True)
282+
error_state = State(final=True)
283+
284+
go = s1.to(s1)
285+
error_execution = s1.to(error_state)
286+
287+
def before_go(self, **kwargs):
288+
raise RuntimeError("Before boom")
289+
290+
async def on_enter_state(self, **kwargs):
291+
"""Async callback to force the async engine."""
292+
293+
sm = SM()
294+
await sm.activate_initial_state()
295+
await sm.go()
296+
assert sm.configuration == {sm.error_state}
297+
298+
276299
@pytest.mark.timeout(5)
277300
async def test_async_invalid_definition_in_transition_propagates():
278301
"""InvalidDefinition in async transition propagates."""

tests/test_invoke.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -990,3 +990,83 @@ def on_invoke_loading(self, ctx=None, **kwargs):
990990
await sm_runner.processing_loop(sm)
991991

992992
assert "loading" in sm.configuration_values
993+
994+
995+
class TestInvokeManagerUnit:
996+
"""Unit tests for InvokeManager methods not exercised by integration tests."""
997+
998+
def test_send_to_child_not_found(self):
999+
"""send_to_child returns False when invokeid is not in _active."""
1000+
from unittest.mock import Mock
1001+
1002+
from statemachine.invoke import InvokeManager
1003+
1004+
engine = Mock()
1005+
manager = InvokeManager(engine)
1006+
1007+
assert manager.send_to_child("nonexistent", "event") is False
1008+
1009+
def test_send_to_child_handler_without_on_event(self):
1010+
"""send_to_child returns False when handler has no on_event."""
1011+
from unittest.mock import Mock
1012+
1013+
from statemachine.invoke import Invocation
1014+
from statemachine.invoke import InvokeContext
1015+
from statemachine.invoke import InvokeManager
1016+
1017+
engine = Mock()
1018+
manager = InvokeManager(engine)
1019+
1020+
handler = Mock(spec=[]) # no on_event
1021+
ctx = InvokeContext(invokeid="test_id", state_id="s1", send=Mock(), machine=Mock())
1022+
inv = Invocation(invokeid="test_id", state_id="s1", ctx=ctx, _handler=handler)
1023+
manager._active["test_id"] = inv
1024+
1025+
assert manager.send_to_child("test_id", "event") is False
1026+
1027+
def test_handle_external_event_none_event(self):
1028+
"""handle_external_event returns early when event is None."""
1029+
from unittest.mock import Mock
1030+
1031+
from statemachine.invoke import InvokeManager
1032+
1033+
engine = Mock()
1034+
manager = InvokeManager(engine)
1035+
1036+
trigger_data = Mock(event=None)
1037+
# Should not raise
1038+
manager.handle_external_event(trigger_data)
1039+
1040+
1041+
class TestStopChildMachine:
1042+
"""Tests for _stop_child_machine."""
1043+
1044+
def test_stop_child_machine_exception_swallowed(self):
1045+
"""_stop_child_machine swallows exceptions during stop."""
1046+
from unittest.mock import Mock
1047+
1048+
from statemachine.invoke import _stop_child_machine
1049+
1050+
child = Mock()
1051+
child._engine.running = True
1052+
child._engine._invoke_manager.cancel_all.side_effect = RuntimeError("boom")
1053+
1054+
# Should not raise
1055+
_stop_child_machine(child)
1056+
1057+
1058+
class TestEngineDelCleanup:
1059+
"""Test BaseEngine.__del__ cancel_all exception handling."""
1060+
1061+
def test_del_swallows_cancel_all_exception(self):
1062+
"""__del__ swallows exceptions from cancel_all."""
1063+
1064+
class SM(StateChart):
1065+
s1 = State(initial=True, final=True)
1066+
1067+
sm = SM()
1068+
engine = sm._engine
1069+
engine._invoke_manager.cancel_all = lambda: (_ for _ in ()).throw(RuntimeError("boom"))
1070+
1071+
# Should not raise
1072+
engine.__del__()

0 commit comments

Comments
 (0)