Skip to content

Commit bf8689b

Browse files
committed
test: add Python-syntax StateChart tests (55 tests)
Comprehensive test coverage for StateChart features using Python class syntax: compound states, parallel states, history states, eventless transitions, donedata, delayed events, error.execution, and In() conditions.
1 parent 6d95ced commit bf8689b

8 files changed

Lines changed: 1411 additions & 0 deletions

tests/test_statechart_compound.py

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
"""Compound state behavior using Python class syntax.
2+
3+
Tests exercise entering/exiting compound states, nested compounds, cross-compound
4+
transitions, done.state events from final children, callback ordering, and discovery
5+
of methods defined inside State.Compound class bodies.
6+
7+
Theme: Fellowship journey through Middle-earth.
8+
"""
9+
10+
import pytest
11+
12+
from statemachine import Event
13+
from statemachine import State
14+
from statemachine import StateChart
15+
16+
17+
@pytest.mark.timeout(5)
18+
class TestCompoundStates:
19+
def test_enter_compound_activates_initial_child(self):
20+
"""Entering a compound activates both parent and the initial child."""
21+
22+
class ShireToRivendell(StateChart):
23+
class shire(State.Compound):
24+
bag_end = State(initial=True)
25+
green_dragon = State()
26+
27+
visit_pub = bag_end.to(green_dragon)
28+
29+
road = State(final=True)
30+
depart = shire.to(road)
31+
32+
sm = ShireToRivendell()
33+
assert {"shire", "bag_end"} == set(sm.configuration_values)
34+
35+
def test_transition_within_compound(self):
36+
"""Inner state changes while parent stays active."""
37+
38+
class ShireToRivendell(StateChart):
39+
class shire(State.Compound):
40+
bag_end = State(initial=True)
41+
green_dragon = State()
42+
43+
visit_pub = bag_end.to(green_dragon)
44+
45+
road = State(final=True)
46+
depart = shire.to(road)
47+
48+
sm = ShireToRivendell()
49+
sm.send("visit_pub")
50+
assert "shire" in sm.configuration_values
51+
assert "green_dragon" in sm.configuration_values
52+
assert "bag_end" not in sm.configuration_values
53+
54+
def test_exit_compound_removes_all_descendants(self):
55+
"""Leaving a compound removes the parent and all children."""
56+
57+
class ShireToRivendell(StateChart):
58+
class shire(State.Compound):
59+
bag_end = State(initial=True)
60+
green_dragon = State()
61+
62+
visit_pub = bag_end.to(green_dragon)
63+
64+
road = State(final=True)
65+
depart = shire.to(road)
66+
67+
sm = ShireToRivendell()
68+
sm.send("depart")
69+
assert {"road"} == set(sm.configuration_values)
70+
71+
def test_nested_compound_two_levels(self):
72+
"""Three-level nesting: outer > middle > leaf."""
73+
74+
class MoriaExpedition(StateChart):
75+
class moria(State.Compound):
76+
class upper_halls(State.Compound):
77+
entrance = State(initial=True)
78+
bridge = State(final=True)
79+
80+
cross = entrance.to(bridge)
81+
82+
assert isinstance(upper_halls, State)
83+
depths = State(final=True)
84+
descend = upper_halls.to(depths)
85+
86+
sm = MoriaExpedition()
87+
assert {"moria", "upper_halls", "entrance"} == set(sm.configuration_values)
88+
89+
def test_transition_from_inner_to_outer(self):
90+
"""A deep child can transition to an outer state."""
91+
92+
class MoriaExpedition(StateChart):
93+
class moria(State.Compound):
94+
class upper_halls(State.Compound):
95+
entrance = State(initial=True)
96+
bridge = State()
97+
98+
cross = entrance.to(bridge)
99+
100+
assert isinstance(upper_halls, State)
101+
depths = State(final=True)
102+
descend = upper_halls.to(depths)
103+
104+
daylight = State(final=True)
105+
escape = moria.to(daylight)
106+
107+
sm = MoriaExpedition()
108+
sm.send("escape")
109+
assert {"daylight"} == set(sm.configuration_values)
110+
111+
def test_cross_compound_transition(self):
112+
"""Transition from one compound to another removes old children."""
113+
114+
class MiddleEarthJourney(StateChart):
115+
validate_disconnected_states = False
116+
117+
class rivendell(State.Compound):
118+
council = State(initial=True)
119+
preparing = State()
120+
121+
get_ready = council.to(preparing)
122+
123+
class moria(State.Compound):
124+
gates = State(initial=True)
125+
bridge = State(final=True)
126+
127+
cross = gates.to(bridge)
128+
129+
class lothlorien(State.Compound):
130+
mirror = State(initial=True)
131+
departure = State(final=True)
132+
133+
leave = mirror.to(departure)
134+
135+
march_to_moria = rivendell.to(moria)
136+
march_to_lorien = moria.to(lothlorien)
137+
138+
sm = MiddleEarthJourney()
139+
assert "rivendell" in sm.configuration_values
140+
assert "council" in sm.configuration_values
141+
142+
sm.send("march_to_moria")
143+
assert "moria" in sm.configuration_values
144+
assert "gates" in sm.configuration_values
145+
assert "rivendell" not in sm.configuration_values
146+
assert "council" not in sm.configuration_values
147+
148+
def test_enter_compound_lands_on_initial(self):
149+
"""Entering a compound from outside lands on the initial child."""
150+
151+
class MiddleEarthJourney(StateChart):
152+
validate_disconnected_states = False
153+
154+
class rivendell(State.Compound):
155+
council = State(initial=True)
156+
preparing = State()
157+
158+
get_ready = council.to(preparing)
159+
160+
class moria(State.Compound):
161+
gates = State(initial=True)
162+
bridge = State(final=True)
163+
164+
cross = gates.to(bridge)
165+
166+
march_to_moria = rivendell.to(moria)
167+
168+
sm = MiddleEarthJourney()
169+
sm.send("march_to_moria")
170+
# Should land on the initial child 'gates'
171+
assert "gates" in sm.configuration_values
172+
assert "moria" in sm.configuration_values
173+
174+
def test_final_child_fires_done_state(self):
175+
"""Reaching a final child triggers done.state.{parent_id}."""
176+
177+
class QuestForErebor(StateChart):
178+
class lonely_mountain(State.Compound):
179+
approach = State(initial=True)
180+
inside = State(final=True)
181+
182+
enter_mountain = approach.to(inside)
183+
184+
victory = State(final=True)
185+
done_state_lonely_mountain = Event(
186+
lonely_mountain.to(victory), id="done.state.lonely_mountain"
187+
)
188+
189+
sm = QuestForErebor()
190+
assert "approach" in sm.configuration_values
191+
192+
sm.send("enter_mountain")
193+
assert {"victory"} == set(sm.configuration_values)
194+
195+
def test_multiple_compound_sequential_traversal(self):
196+
"""Traverse all three compounds sequentially."""
197+
198+
class MiddleEarthJourney(StateChart):
199+
validate_disconnected_states = False
200+
201+
class rivendell(State.Compound):
202+
council = State(initial=True)
203+
preparing = State(final=True)
204+
205+
get_ready = council.to(preparing)
206+
207+
class moria(State.Compound):
208+
gates = State(initial=True)
209+
bridge = State(final=True)
210+
211+
cross = gates.to(bridge)
212+
213+
class lothlorien(State.Compound):
214+
mirror = State(initial=True)
215+
departure = State(final=True)
216+
217+
leave = mirror.to(departure)
218+
219+
march_to_moria = rivendell.to(moria)
220+
march_to_lorien = moria.to(lothlorien)
221+
222+
sm = MiddleEarthJourney()
223+
sm.send("march_to_moria")
224+
assert "moria" in sm.configuration_values
225+
226+
sm.send("march_to_lorien")
227+
assert "lothlorien" in sm.configuration_values
228+
assert "mirror" in sm.configuration_values
229+
assert "moria" not in sm.configuration_values
230+
231+
def test_entry_exit_action_ordering(self):
232+
"""on_exit fires before on_enter (verified via log)."""
233+
log = []
234+
235+
class ActionOrderTracker(StateChart):
236+
class realm(State.Compound):
237+
day = State(initial=True)
238+
night = State()
239+
240+
sunset = day.to(night)
241+
242+
outside = State(final=True)
243+
leave = realm.to(outside)
244+
245+
def on_exit_day(self):
246+
log.append("exit_day")
247+
248+
def on_exit_realm(self):
249+
log.append("exit_realm")
250+
251+
def on_enter_outside(self):
252+
log.append("enter_outside")
253+
254+
sm = ActionOrderTracker()
255+
sm.send("leave")
256+
assert log == ["exit_day", "exit_realm", "enter_outside"]
257+
258+
def test_callbacks_inside_compound_class(self):
259+
"""Methods defined inside the State.Compound class body are discovered."""
260+
log = []
261+
262+
class CallbackDiscovery(StateChart):
263+
class realm(State.Compound):
264+
peaceful = State(initial=True)
265+
troubled = State()
266+
267+
darken = peaceful.to(troubled)
268+
269+
def on_enter_troubled(self):
270+
log.append("entered troubled times")
271+
272+
end = State(final=True)
273+
conclude = realm.to(end)
274+
275+
sm = CallbackDiscovery()
276+
sm.send("darken")
277+
assert log == ["entered troubled times"]
278+
279+
def test_compound_state_name_attribute(self):
280+
"""The name= kwarg in class syntax sets the state name."""
281+
282+
class NamedCompound(StateChart):
283+
class shire(State.Compound, name="The Shire"):
284+
home = State(initial=True, final=True)
285+
286+
sm = NamedCompound()
287+
assert sm.shire.name == "The Shire"

tests/test_statechart_delayed.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Delayed event sends and cancellations.
2+
3+
Tests exercise queuing events with a delay (fires after elapsed time),
4+
cancelling delayed events before they fire, zero-delay immediate firing,
5+
and the Event(delay=...) definition syntax.
6+
7+
Theme: Beacons of Gondor — signal fires propagate with timing.
8+
"""
9+
10+
import time
11+
12+
import pytest
13+
from statemachine.event import BoundEvent
14+
15+
from statemachine import Event
16+
from statemachine import State
17+
from statemachine import StateChart
18+
19+
20+
@pytest.mark.timeout(10)
21+
class TestDelayedEvents:
22+
def test_delayed_event_fires_after_delay(self):
23+
"""Queuing a delayed event does not fire immediately; processing after delay does."""
24+
25+
class BeaconsOfGondor(StateChart):
26+
dark = State(initial=True)
27+
first_lit = State()
28+
all_lit = State(final=True)
29+
30+
light_first = dark.to(first_lit)
31+
light_all = first_lit.to(all_lit)
32+
33+
sm = BeaconsOfGondor()
34+
sm.send("light_first")
35+
assert "first_lit" in sm.configuration_values
36+
37+
# Queue the event with delay without triggering the processing loop
38+
event = BoundEvent(id="light_all", name="Light all", delay=50, _sm=sm)
39+
event.put()
40+
41+
# Not yet processed
42+
assert "first_lit" in sm.configuration_values
43+
44+
time.sleep(0.1)
45+
sm._processing_loop()
46+
assert "all_lit" in sm.configuration_values
47+
48+
def test_cancel_delayed_event(self):
49+
"""Cancelled delayed events do not fire."""
50+
51+
class BeaconsOfGondor(StateChart):
52+
dark = State(initial=True)
53+
lit = State(final=True)
54+
55+
light = dark.to(lit)
56+
57+
sm = BeaconsOfGondor()
58+
# Queue delayed event
59+
event = BoundEvent(id="light", name="Light", delay=500, _sm=sm)
60+
event.put(send_id="beacon_signal")
61+
62+
sm.cancel_event("beacon_signal")
63+
64+
time.sleep(0.1)
65+
sm._processing_loop()
66+
assert "dark" in sm.configuration_values
67+
68+
def test_zero_delay_fires_immediately(self):
69+
"""delay=0 fires immediately."""
70+
71+
class BeaconsOfGondor(StateChart):
72+
dark = State(initial=True)
73+
lit = State(final=True)
74+
75+
light = dark.to(lit)
76+
77+
sm = BeaconsOfGondor()
78+
sm.send("light", delay=0)
79+
assert "lit" in sm.configuration_values
80+
81+
def test_delayed_event_on_event_definition(self):
82+
"""Event(transitions, delay=100) syntax queues with a delay."""
83+
84+
class BeaconsOfGondor(StateChart):
85+
dark = State(initial=True)
86+
lit = State(final=True)
87+
88+
light = Event(dark.to(lit), delay=50)
89+
90+
sm = BeaconsOfGondor()
91+
# Queue via BoundEvent.put() to avoid blocking in processing_loop
92+
event = BoundEvent(id="light", name="Light", delay=50, _sm=sm)
93+
event.put()
94+
95+
# Not yet processed
96+
assert "dark" in sm.configuration_values
97+
98+
time.sleep(0.1)
99+
sm._processing_loop()
100+
assert "lit" in sm.configuration_values

0 commit comments

Comments
 (0)