Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/transitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)=

Expand Down
2 changes: 1 addition & 1 deletion statemachine/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions statemachine/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions tests/test_statemachine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down