diff --git a/docs/transitions.md b/docs/transitions.md index aa1809b5..7b482772 100644 --- a/docs/transitions.md +++ b/docs/transitions.md @@ -184,6 +184,40 @@ The compound approach scales better as you add more states — no need to remember to include each new state in a `from_()` list. +### Multiple targets: the UML fork + +Pass a list of states to `to()` to enter them all in a single transition. This +is the UML **fork**: one event activates every target at once, so the machine +ends up in more than one region at the same time: + +```py +>>> class ForkMachine(StateChart): +... started = State(initial=True) +... +... class active(State.Parallel): +... class region1(State.Compound): +... step1 = State(initial=True) +... done1 = State(final=True) +... finish1 = step1.to(done1) +... +... class region2(State.Compound): +... step2 = State(initial=True) +... done2 = State(final=True) +... finish2 = step2.to(done2) +... +... fork = started.to([active.region1.step1, active.region2.step2]) + +>>> sm = ForkMachine() +>>> sm.send("fork") +>>> set(sm.configuration_values) == {"active", "region1", "step1", "region2", "step2"} +True + +``` + +The graph checks follow every target of a transition, so a state that is only +entered by a fork doesn't need another incoming transition to count as reachable. + + (self-transition)= (self transition)= diff --git a/statemachine/graph.py b/statemachine/graph.py index 25521624..bffe0246 100644 --- a/statemachine/graph.py +++ b/statemachine/graph.py @@ -17,7 +17,7 @@ def visit_connected_states(state: "State"): continue already_visited.add(state) yield state - visit.extend(t.target for t in state.transitions if t.target) + visit.extend(target for t in state.transitions for target in t.targets) # Traverse the state hierarchy: entering a compound/parallel state # implicitly enters its initial children (all children for parallel). for child in state.states: diff --git a/statemachine/state.py b/statemachine/state.py index 7f48b7d7..215e71c0 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -34,9 +34,10 @@ def __call__(self, *states: "State", **kwargs): class _ToState(_TransitionBuilder): - def __call__(self, *states: "State | NestedStateFactory | None", **kwargs): + def __call__(self, *states: "State | NestedStateFactory | list[State] | None", **kwargs): transitions = TransitionList( - Transition(self._state, cast("State | None", state), **kwargs) for state in states + Transition(self._state, cast("State | list[State] | None", state), **kwargs) + for state in states ) self._state.transitions.add_transitions(transitions) return transitions diff --git a/tests/test_statemachine.py b/tests/test_statemachine.py index 1946a083..f69eb5f6 100644 --- a/tests/test_statemachine.py +++ b/tests/test_statemachine.py @@ -444,6 +444,29 @@ class compound(State.Compound): assert "a" in HistoryMachine.states_map +def test_fork_multi_target_transition_is_reachable(): + """A fork targets several states at once; the reachability walk follows all of them.""" + + class ForkMachine(StateChart): + started = State(initial=True) + + class active(State.Parallel): + class r1(State.Compound): + x1 = State(initial=True) + x2 = State(final=True) + + class r2(State.Compound): + y1 = State(initial=True) + y2 = State(final=True) + + fork = started.to([active.r1.x2, active.r2.y2]) + + sm = ForkMachine() + sm.send("fork") + + assert set(sm.configuration_values) == {"active", "r1", "r2", "x2", "y2"} + + def test_state_value_is_correct(): STATE_NEW = 0 STATE_DRAFT = 1