Skip to content

Commit 381a2c7

Browse files
committed
feat: run SCXML test suite with both sync and async engines
Add async SCXML test variant using a minimal AsyncListener to trigger AsyncEngine selection, keeping SCXMLProcessor free of async concerns. Fix __initial__ handling in AsyncEngine (break instead of continue in phase 3) to ensure internal events are processed before external ones.
1 parent 21161ba commit 381a2c7

4 files changed

Lines changed: 69 additions & 12 deletions

File tree

statemachine/engines/async_.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,13 +324,16 @@ async def processing_loop(self): # noqa: C901
324324

325325
logger.debug("External event: %s", external_event.event)
326326

327-
# Handle lazy initial state activation
327+
# Handle lazy initial state activation.
328+
# Break out of phase 3 so the outer loop restarts from phase 1
329+
# (eventless/internal), ensuring internal events queued during
330+
# initial entry are processed before any external events.
328331
if external_event.event == "__initial__":
329332
transitions = self._initial_transitions(external_event)
330333
await self._enter_states(
331334
transitions, external_event, OrderedSet(), OrderedSet()
332335
)
333-
continue
336+
break
334337

335338
enabled_transitions = await self.select_transitions(external_event)
336339
logger.debug("Enabled transitions: %s", enabled_transitions)

statemachine/io/scxml/processor.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,7 @@ def _process_transitions(self, transitions: List[Transition]):
207207

208208
# Process actions
209209
if transition.on and not transition.on.is_empty:
210-
callable = ExecuteBlock(transition.on)
211-
transition_dict["on"] = callable
210+
transition_dict["on"] = ExecuteBlock(transition.on)
212211

213212
result.append(transition_dict)
214213
return result

tests/scxml/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def pytest_generate_tests(metafunc):
4545
id=str(testcase_path.relative_to(TESTCASES_DIR)),
4646
marks=compute_testcase_marks(testcase_path),
4747
)
48-
for testcase_path in TESTCASES_DIR.glob("**/*.scxml")
48+
for testcase_path in sorted(TESTCASES_DIR.glob("**/*.scxml"))
4949
if "sub" not in testcase_path.name
5050
],
5151
)

tests/scxml/test_scxml_cases.py

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pathlib import Path
55
from typing import Any
66

7+
import pytest
78
from statemachine.event import Event
89
from statemachine.io.scxml.processor import SCXMLProcessor
910

@@ -60,6 +61,13 @@ def on_enter_state(self, event: Event, state: State, event_data):
6061
)
6162

6263

64+
class AsyncListener:
65+
"""No-op async listener to trigger AsyncEngine selection."""
66+
67+
async def on_enter_state(self, **kwargs):
68+
pass
69+
70+
6371
@dataclass
6472
class FailedMark:
6573
reason: str
@@ -126,26 +134,38 @@ def write_fail_markdown(self, testcase_path: Path):
126134
fail_file.write(report)
127135

128136

129-
def test_scxml_usecase(
130-
testcase_path: Path, update_fail_mark, should_generate_debug_diagram, caplog
131-
):
137+
def _run_scxml_testcase(
138+
testcase_path: Path,
139+
update_fail_mark,
140+
should_generate_debug_diagram,
141+
caplog,
142+
*,
143+
async_mode: bool = False,
144+
) -> StateChart:
145+
"""Shared logic for sync and async SCXML test variants.
146+
147+
Parses the SCXML file, starts the state machine, and asserts the final
148+
configuration contains ``pass``. Returns the SM instance.
149+
"""
132150
from statemachine.contrib.diagram import DotGraphMachine
133151

134152
sm: "StateChart | None" = None
135153
try:
136154
debug = DebugListener()
155+
listeners: list = [debug]
156+
if async_mode:
157+
listeners.append(AsyncListener())
137158
processor = SCXMLProcessor()
138159
processor.parse_scxml_file(testcase_path)
139160

140-
sm = processor.start(listeners=[debug])
161+
sm = processor.start(listeners=listeners)
141162
if should_generate_debug_diagram:
142163
DotGraphMachine(sm).get_graph().write_png(
143164
testcase_path.parent / f"{testcase_path.stem}.png"
144165
)
145-
assert isinstance(sm, StateChart)
146-
assert "pass" in {s.id for s in sm.configuration}, debug
166+
assert sm is not None
167+
return sm
147168
except Exception as e:
148-
# Import necessary module
149169
if update_fail_mark:
150170
reason = f"{e.__class__.__name__}: {e.__class__.__doc__}"
151171
is_assertion_error = isinstance(e, AssertionError)
@@ -159,3 +179,38 @@ def test_scxml_usecase(
159179
)
160180
fail_mark.write_fail_markdown(testcase_path)
161181
raise
182+
183+
184+
def _assert_passed(sm: StateChart, debug: "DebugListener | None" = None):
185+
assert isinstance(sm, StateChart)
186+
assert "pass" in {s.id for s in sm.configuration}, debug
187+
188+
189+
def test_scxml_usecase_sync(
190+
testcase_path: Path, update_fail_mark, should_generate_debug_diagram, caplog
191+
):
192+
sm = _run_scxml_testcase(
193+
testcase_path,
194+
update_fail_mark,
195+
should_generate_debug_diagram,
196+
caplog,
197+
async_mode=False,
198+
)
199+
_assert_passed(sm)
200+
201+
202+
@pytest.mark.asyncio()
203+
async def test_scxml_usecase_async(
204+
testcase_path: Path, update_fail_mark, should_generate_debug_diagram, caplog
205+
):
206+
sm = _run_scxml_testcase(
207+
testcase_path,
208+
update_fail_mark,
209+
should_generate_debug_diagram,
210+
caplog,
211+
async_mode=True,
212+
)
213+
# In async context, the engine only queued __initial__ during __init__.
214+
# Activate now within the running event loop.
215+
await sm.activate_initial_state()
216+
_assert_passed(sm)

0 commit comments

Comments
 (0)