|
| 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" |
0 commit comments