Skip to content

Commit a0bd732

Browse files
committed
test: run StateChart tests on both sync and async engines
Add SMRunner fixture parametrized over sync/async to conftest.py. Engine-dependent tests (53) now run on both engines via sm_runner, producing 108 test cases total. Definition-only tests (2) remain sync.
1 parent 22b3d85 commit a0bd732

9 files changed

Lines changed: 243 additions & 204 deletions

tests/conftest.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,64 @@ def engine(request):
226226
return SyncEngine
227227
else:
228228
return AsyncEngine
229+
230+
231+
class _AsyncListener:
232+
"""No-op async listener that triggers AsyncEngine selection."""
233+
234+
async def on_enter_state(self, **kwargs):
235+
pass
236+
237+
238+
class SMRunner:
239+
"""Helper for running state machine tests on both sync and async engines.
240+
241+
Usage in tests::
242+
243+
async def test_something(self, sm_runner):
244+
sm = await sm_runner.start(MyStateChart)
245+
await sm_runner.send(sm, "some_event")
246+
assert "expected_state" in sm.configuration_values
247+
"""
248+
249+
def __init__(self, is_async: bool):
250+
self.is_async = is_async
251+
252+
async def start(self, cls, **kwargs):
253+
"""Create and activate a state machine instance."""
254+
from inspect import isawaitable
255+
256+
if self.is_async:
257+
listeners = list(kwargs.pop("listeners", []))
258+
listeners.append(_AsyncListener())
259+
sm = cls(listeners=listeners, **kwargs)
260+
result = sm.activate_initial_state()
261+
if isawaitable(result):
262+
await result
263+
else:
264+
sm = cls(**kwargs)
265+
return sm
266+
267+
async def send(self, sm, event, **kwargs):
268+
"""Send an event to the state machine."""
269+
from inspect import isawaitable
270+
271+
result = sm.send(event, **kwargs)
272+
if isawaitable(result):
273+
return await result
274+
return result
275+
276+
async def processing_loop(self, sm):
277+
"""Run the processing loop (for delayed event tests)."""
278+
from inspect import isawaitable
279+
280+
result = sm._processing_loop()
281+
if isawaitable(result):
282+
return await result
283+
return result
284+
285+
286+
@pytest.fixture(params=["sync", "async"])
287+
def sm_runner(request):
288+
"""Fixture that runs tests on both sync and async engines."""
289+
return SMRunner(is_async=request.param == "async")

tests/test_statechart_compound.py

Lines changed: 32 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
@pytest.mark.timeout(5)
1818
class TestCompoundStates:
19-
def test_enter_compound_activates_initial_child(self):
19+
async def test_enter_compound_activates_initial_child(self, sm_runner):
2020
"""Entering a compound activates both parent and the initial child."""
2121

2222
class ShireToRivendell(StateChart):
@@ -29,10 +29,10 @@ class shire(State.Compound):
2929
road = State(final=True)
3030
depart = shire.to(road)
3131

32-
sm = ShireToRivendell()
32+
sm = await sm_runner.start(ShireToRivendell)
3333
assert {"shire", "bag_end"} == set(sm.configuration_values)
3434

35-
def test_transition_within_compound(self):
35+
async def test_transition_within_compound(self, sm_runner):
3636
"""Inner state changes while parent stays active."""
3737

3838
class ShireToRivendell(StateChart):
@@ -45,13 +45,13 @@ class shire(State.Compound):
4545
road = State(final=True)
4646
depart = shire.to(road)
4747

48-
sm = ShireToRivendell()
49-
sm.send("visit_pub")
48+
sm = await sm_runner.start(ShireToRivendell)
49+
await sm_runner.send(sm, "visit_pub")
5050
assert "shire" in sm.configuration_values
5151
assert "green_dragon" in sm.configuration_values
5252
assert "bag_end" not in sm.configuration_values
5353

54-
def test_exit_compound_removes_all_descendants(self):
54+
async def test_exit_compound_removes_all_descendants(self, sm_runner):
5555
"""Leaving a compound removes the parent and all children."""
5656

5757
class ShireToRivendell(StateChart):
@@ -64,11 +64,11 @@ class shire(State.Compound):
6464
road = State(final=True)
6565
depart = shire.to(road)
6666

67-
sm = ShireToRivendell()
68-
sm.send("depart")
67+
sm = await sm_runner.start(ShireToRivendell)
68+
await sm_runner.send(sm, "depart")
6969
assert {"road"} == set(sm.configuration_values)
7070

71-
def test_nested_compound_two_levels(self):
71+
async def test_nested_compound_two_levels(self, sm_runner):
7272
"""Three-level nesting: outer > middle > leaf."""
7373

7474
class MoriaExpedition(StateChart):
@@ -83,10 +83,10 @@ class upper_halls(State.Compound):
8383
depths = State(final=True)
8484
descend = upper_halls.to(depths)
8585

86-
sm = MoriaExpedition()
86+
sm = await sm_runner.start(MoriaExpedition)
8787
assert {"moria", "upper_halls", "entrance"} == set(sm.configuration_values)
8888

89-
def test_transition_from_inner_to_outer(self):
89+
async def test_transition_from_inner_to_outer(self, sm_runner):
9090
"""A deep child can transition to an outer state."""
9191

9292
class MoriaExpedition(StateChart):
@@ -104,11 +104,11 @@ class upper_halls(State.Compound):
104104
daylight = State(final=True)
105105
escape = moria.to(daylight)
106106

107-
sm = MoriaExpedition()
108-
sm.send("escape")
107+
sm = await sm_runner.start(MoriaExpedition)
108+
await sm_runner.send(sm, "escape")
109109
assert {"daylight"} == set(sm.configuration_values)
110110

111-
def test_cross_compound_transition(self):
111+
async def test_cross_compound_transition(self, sm_runner):
112112
"""Transition from one compound to another removes old children."""
113113

114114
class MiddleEarthJourney(StateChart):
@@ -135,17 +135,17 @@ class lothlorien(State.Compound):
135135
march_to_moria = rivendell.to(moria)
136136
march_to_lorien = moria.to(lothlorien)
137137

138-
sm = MiddleEarthJourney()
138+
sm = await sm_runner.start(MiddleEarthJourney)
139139
assert "rivendell" in sm.configuration_values
140140
assert "council" in sm.configuration_values
141141

142-
sm.send("march_to_moria")
142+
await sm_runner.send(sm, "march_to_moria")
143143
assert "moria" in sm.configuration_values
144144
assert "gates" in sm.configuration_values
145145
assert "rivendell" not in sm.configuration_values
146146
assert "council" not in sm.configuration_values
147147

148-
def test_enter_compound_lands_on_initial(self):
148+
async def test_enter_compound_lands_on_initial(self, sm_runner):
149149
"""Entering a compound from outside lands on the initial child."""
150150

151151
class MiddleEarthJourney(StateChart):
@@ -165,13 +165,12 @@ class moria(State.Compound):
165165

166166
march_to_moria = rivendell.to(moria)
167167

168-
sm = MiddleEarthJourney()
169-
sm.send("march_to_moria")
170-
# Should land on the initial child 'gates'
168+
sm = await sm_runner.start(MiddleEarthJourney)
169+
await sm_runner.send(sm, "march_to_moria")
171170
assert "gates" in sm.configuration_values
172171
assert "moria" in sm.configuration_values
173172

174-
def test_final_child_fires_done_state(self):
173+
async def test_final_child_fires_done_state(self, sm_runner):
175174
"""Reaching a final child triggers done.state.{parent_id}."""
176175

177176
class QuestForErebor(StateChart):
@@ -186,13 +185,13 @@ class lonely_mountain(State.Compound):
186185
lonely_mountain.to(victory), id="done.state.lonely_mountain"
187186
)
188187

189-
sm = QuestForErebor()
188+
sm = await sm_runner.start(QuestForErebor)
190189
assert "approach" in sm.configuration_values
191190

192-
sm.send("enter_mountain")
191+
await sm_runner.send(sm, "enter_mountain")
193192
assert {"victory"} == set(sm.configuration_values)
194193

195-
def test_multiple_compound_sequential_traversal(self):
194+
async def test_multiple_compound_sequential_traversal(self, sm_runner):
196195
"""Traverse all three compounds sequentially."""
197196

198197
class MiddleEarthJourney(StateChart):
@@ -219,16 +218,16 @@ class lothlorien(State.Compound):
219218
march_to_moria = rivendell.to(moria)
220219
march_to_lorien = moria.to(lothlorien)
221220

222-
sm = MiddleEarthJourney()
223-
sm.send("march_to_moria")
221+
sm = await sm_runner.start(MiddleEarthJourney)
222+
await sm_runner.send(sm, "march_to_moria")
224223
assert "moria" in sm.configuration_values
225224

226-
sm.send("march_to_lorien")
225+
await sm_runner.send(sm, "march_to_lorien")
227226
assert "lothlorien" in sm.configuration_values
228227
assert "mirror" in sm.configuration_values
229228
assert "moria" not in sm.configuration_values
230229

231-
def test_entry_exit_action_ordering(self):
230+
async def test_entry_exit_action_ordering(self, sm_runner):
232231
"""on_exit fires before on_enter (verified via log)."""
233232
log = []
234233

@@ -251,11 +250,11 @@ def on_exit_realm(self):
251250
def on_enter_outside(self):
252251
log.append("enter_outside")
253252

254-
sm = ActionOrderTracker()
255-
sm.send("leave")
253+
sm = await sm_runner.start(ActionOrderTracker)
254+
await sm_runner.send(sm, "leave")
256255
assert log == ["exit_day", "exit_realm", "enter_outside"]
257256

258-
def test_callbacks_inside_compound_class(self):
257+
async def test_callbacks_inside_compound_class(self, sm_runner):
259258
"""Methods defined inside the State.Compound class body are discovered."""
260259
log = []
261260

@@ -272,8 +271,8 @@ def on_enter_troubled(self):
272271
end = State(final=True)
273272
conclude = realm.to(end)
274273

275-
sm = CallbackDiscovery()
276-
sm.send("darken")
274+
sm = await sm_runner.start(CallbackDiscovery)
275+
await sm_runner.send(sm, "darken")
277276
assert log == ["entered troubled times"]
278277

279278
def test_compound_state_name_attribute(self):

tests/test_statechart_delayed.py

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
Theme: Beacons of Gondor — signal fires propagate with timing.
88
"""
99

10-
import time
10+
import asyncio
1111

1212
import pytest
1313
from statemachine.event import BoundEvent
@@ -19,7 +19,7 @@
1919

2020
@pytest.mark.timeout(10)
2121
class TestDelayedEvents:
22-
def test_delayed_event_fires_after_delay(self):
22+
async def test_delayed_event_fires_after_delay(self, sm_runner):
2323
"""Queuing a delayed event does not fire immediately; processing after delay does."""
2424

2525
class BeaconsOfGondor(StateChart):
@@ -30,8 +30,8 @@ class BeaconsOfGondor(StateChart):
3030
light_first = dark.to(first_lit)
3131
light_all = first_lit.to(all_lit)
3232

33-
sm = BeaconsOfGondor()
34-
sm.send("light_first")
33+
sm = await sm_runner.start(BeaconsOfGondor)
34+
await sm_runner.send(sm, "light_first")
3535
assert "first_lit" in sm.configuration_values
3636

3737
# Queue the event with delay without triggering the processing loop
@@ -41,11 +41,11 @@ class BeaconsOfGondor(StateChart):
4141
# Not yet processed
4242
assert "first_lit" in sm.configuration_values
4343

44-
time.sleep(0.1)
45-
sm._processing_loop()
44+
await asyncio.sleep(0.1)
45+
await sm_runner.processing_loop(sm)
4646
assert "all_lit" in sm.configuration_values
4747

48-
def test_cancel_delayed_event(self):
48+
async def test_cancel_delayed_event(self, sm_runner):
4949
"""Cancelled delayed events do not fire."""
5050

5151
class BeaconsOfGondor(StateChart):
@@ -54,18 +54,18 @@ class BeaconsOfGondor(StateChart):
5454

5555
light = dark.to(lit)
5656

57-
sm = BeaconsOfGondor()
57+
sm = await sm_runner.start(BeaconsOfGondor)
5858
# Queue delayed event
5959
event = BoundEvent(id="light", name="Light", delay=500, _sm=sm)
6060
event.put(send_id="beacon_signal")
6161

6262
sm.cancel_event("beacon_signal")
6363

64-
time.sleep(0.1)
65-
sm._processing_loop()
64+
await asyncio.sleep(0.1)
65+
await sm_runner.processing_loop(sm)
6666
assert "dark" in sm.configuration_values
6767

68-
def test_zero_delay_fires_immediately(self):
68+
async def test_zero_delay_fires_immediately(self, sm_runner):
6969
"""delay=0 fires immediately."""
7070

7171
class BeaconsOfGondor(StateChart):
@@ -74,11 +74,11 @@ class BeaconsOfGondor(StateChart):
7474

7575
light = dark.to(lit)
7676

77-
sm = BeaconsOfGondor()
78-
sm.send("light", delay=0)
77+
sm = await sm_runner.start(BeaconsOfGondor)
78+
await sm_runner.send(sm, "light", delay=0)
7979
assert "lit" in sm.configuration_values
8080

81-
def test_delayed_event_on_event_definition(self):
81+
async def test_delayed_event_on_event_definition(self, sm_runner):
8282
"""Event(transitions, delay=100) syntax queues with a delay."""
8383

8484
class BeaconsOfGondor(StateChart):
@@ -87,14 +87,14 @@ class BeaconsOfGondor(StateChart):
8787

8888
light = Event(dark.to(lit), delay=50)
8989

90-
sm = BeaconsOfGondor()
90+
sm = await sm_runner.start(BeaconsOfGondor)
9191
# Queue via BoundEvent.put() to avoid blocking in processing_loop
9292
event = BoundEvent(id="light", name="Light", delay=50, _sm=sm)
9393
event.put()
9494

9595
# Not yet processed
9696
assert "dark" in sm.configuration_values
9797

98-
time.sleep(0.1)
99-
sm._processing_loop()
98+
await asyncio.sleep(0.1)
99+
await sm_runner.processing_loop(sm)
100100
assert "lit" in sm.configuration_values

0 commit comments

Comments
 (0)