Repository: pytransitions/transitions

Issue:
may_ function in parallel state does not report correct value
Thank you for taking the time to report a bug! Your support is essential for the maintenance of this project. Please fill out the following fields to ease bug hunting and resolving this issue as soon as possible:

**Describe the bug**
In parallel state of a hierarchical machine, the may_ function for a trigger returns False, yet it should return True, since the trigger indeed return True  

**I have a simple statemachine which runs a task for each month in quarter 1 parallelly, then run each month in quarter 2 parallelly. When it reach the parallell state of run_qtr_1, may_ function of any trigger in quater1 should return True, yet it returns False. And I indeed can run the trigger function**

```python
from transitions.extensions import HierarchicalMachine
        from transitions.extensions.nesting import NestedState
        class Model:
            def __init__(self):
                pass

            def qtr1_completed(self, event):
                # check embedding_[1-3]_succeeded state has been reached
                return True

            def qtr2_completed(self, event):
                # check embedding_[1-3]_succeeded state has been reached
                return True

        model = Model()
        states = [
            'workflow_started',
            {
                'name': 'running_qtr_1',
                'parallel': [
                    {
                        'name': 'running_jan',
                        'children': [
                            'jan_started', 'jan_succeeded', 'jan_failed'
                        ],
                        'initial': 'jan_started',
                        'transitions': [
                            ['end_success_jan', 'jan_started', 'jan_succeeded'],
                            ['end_failure_jan', 'jan_started', 'jan_failed']
                        ]
                    },
                    {
                        'name': 'running_feb',
                        'children': [
                            'feb_started', 'feb_succeeded', 'feb_failed'
                        ],
                        'initial': 'feb_started',
                        'transitions': [
                            ['end_success_feb', 'feb_started', 'feb_succeeded'],
                            ['end_failure_feb', 'feb_started', 'feb_failed']
                        ]
                    },
                    {
                        'name': 'running_mar',
                        'children': [
                            'mar_started', 'mar_succeeded', 'mar_failed'
                        ],
                        'initial': 'mar_started',
                        'transitions': [
                            ['end_success_mar', 'mar_started', 'mar_succeeded'],
                            ['end_failure_mar', 'mar_started', 'mar_failed']
                        ]
                    },
                ]
            },
            'completed_qtr_1',
            {
                'name': 'running_qtr_2',
                'parallel': [
                    {
                        'name': 'running_apr',
                        'children': [
                            'apr_started', 'apr_succeeded', 'apr_failed'
                        ],
                        'initial': 'apr_started',
                        'transitions': [
                            ['end_success_apr', 'apr_started', 'apr_succeeded'],
                            ['end_failure_apr', 'apr_started', 'apr_failed']
                        ]
                    },
                    {
                        'name': 'running_may',
                        'children': [
                            'may_started', 'may_succeeded', 'may_failed'
                        ],
                        'initial': 'may_started',
                        'transitions': [
                            ['end_success_may', 'may_started', 'may_succeeded'],
                            ['end_failure_may', 'may_started', 'may_failed']
                        ]
                    },
                    {
                        'name': 'running_jun',
                        'children': [
                            'jun_started', 'jun_succeeded', 'jun_failed'
                        ],
                        'initial': 'jun_started',
                        'transitions': [
                            ['end_success_jun', 'jun_started', 'jun_succeeded'],
                            ['end_failure_jun', 'jun_started', 'jun_failed']
                        ]
                    },
                ],
            },
            'completed_qtr_2',
            'workflow_succeeded', 'workflow_failed'
        ]
        transitions = [
                {'trigger': 'next', 'source': 'workflow_started', 'dest': 'running_qtr_1'},
                {'trigger': 'next', 'source': 'running_qtr_1~running_jan~jan_succeeded', 'dest': 'completed_qtr_1',
                 'conditions': 'qtr1_completed'},
                {'trigger': 'next', 'source': 'running_qtr_1~running_feb~feb_succeeded', 'dest': 'completed_qtr_1',
                 'conditions': 'qtr1_completed'},
                {'trigger': 'next', 'source': 'running_qtr_1~running_mar~mar_succeeded', 'dest': 'completed_qtr_1',
                 'conditions': 'qtr1_completed'},
                {'trigger': 'next', 'source': 'completed_qtr_1', 'dest': 'running_qtr_2'},
                {'trigger': 'next', 'source': 'running_qtr_2~running_apr~apr_succeeded', 'dest': 'completed_qtr_2',
                 'conditions': 'qtr2_completed'},
                {'trigger': 'next', 'source': 'running_qtr_2~running_may~may_succeeded', 'dest': 'completed_qtr_2',
                 'conditions': 'qtr2_completed'},
                {'trigger': 'next', 'source': 'running_qtr_2~running_jun~jun_succeeded', 'dest': 'completed_qtr_2',
                 'conditions': 'qtr2_completed'},
                {'trigger': 'next', 'source': 'completed_qtr_2', 'dest': 'workflow_succeeded'},
                {'trigger': 'next',
                 'source': [
                     'running_qtr_1~running_jan~jan_failed',
                     'running_qtr_1~running_feb~feb_failed',
                     'running_qtr_1~running_mar~mar_failed',
                     'running_qtr_2~running_apr~apr_failed',
                     'running_qtr_2~running_may~may_failed',
                     'running_qtr_2~running_jun~jun_failed'
                 ],
                 'dest': 'workflow_failed'
                 },
            ]
        initial_state = 'workflow_started'
        machine = HierarchicalMachine(
            model=model,
            states=states,
            transitions=transitions,
            initial=initial_state
        )
        print(f"current_state is: {machine.model.state}")
        machine.model.next()
        print(f"current_state is: {machine.model.state}")
        # BUG HERE??? may_ function returns False
        print(f"may_end_success_feb: {machine.model.may_end_success_feb()} <<--- BUG HERE???, Should be True")
        # but indeed I can run the trigger
        machine.model.end_success_feb()
        # you see the parallel state has transitioned to "running_qtr_1~running_feb~feb_succeeded" as expected
        print(f"current_state is: {machine.model.state}")
```

**Expected behavior**
may_ function for the triggers in parallel states should return True

**Additional context**
Output of above code:

```
current_state is: workflow_started
current_state is: ['running_qtr_1~running_jan~jan_started', 'running_qtr_1~running_feb~feb_started', 'running_qtr_1~running_mar~mar_started']
may_end_success_feb: False  <<--- BUG HERE??? Should be True
current_state is ['running_qtr_1~running_jan~jan_started', 'running_qtr_1~running_feb~feb_succeeded', 'running_qtr_1~running_mar~mar_started']
```



Discussion:

Patch:
diff --git a/Changelog.md b/Changelog.md
index 4e89d8a..85bb4f8 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -3,6 +3,8 @@
 ## 0.9.2 ()
 
 - Bug #610: Decorate models appropriately when `HierarchicalMachine` is passed to `add_state` (thanks @e0lithic)
+- Bug #647: Let `may_<trigger>` check all parallel states in processing order (thanks @spearsear)
+- Bug: `HSM.is_state` works with parallel states now 
 
 ## 0.9.1 (May 2024)
 
diff --git a/transitions/extensions/nesting.py b/transitions/extensions/nesting.py
index 3fdb10b..66301b1 100644
--- a/transitions/extensions/nesting.py
+++ b/transitions/extensions/nesting.py
@@ -759,9 +759,11 @@ class HierarchicalMachine(Machine):
     def _can_trigger(self, model, trigger, *args, **kwargs):
         state_tree = self.build_state_tree(getattr(model, self.model_attribute), self.state_cls.separator)
         ordered_states = resolve_order(state_tree)
-        for state_path in ordered_states:
-            with self():
-                return self._can_trigger_nested(model, trigger, state_path, *args, **kwargs)
+        with self():
+            return any(
+                self._can_trigger_nested(model, trigger, state_path, *args, **kwargs)
+                for state_path in ordered_states
+            )
 
     def _can_trigger_nested(self, model, trigger, path, *args, **kwargs):
         if trigger in self.events:
@@ -822,14 +824,15 @@ class HierarchicalMachine(Machine):
         return trigger in state.events or any(self.has_trigger(trigger, sta) for sta in state.states.values())
 
     def is_state(self, state, model, allow_substates=False):
-        if allow_substates:
-            current = getattr(model, self.model_attribute)
-            current_name = self.state_cls.separator.join(self._get_enum_path(current))\
-                if isinstance(current, Enum) else current
-            state_name = self.state_cls.separator.join(self._get_enum_path(state))\
-                if isinstance(state, Enum) else state
-            return current_name.startswith(state_name)
-        return getattr(model, self.model_attribute) == state
+        tree = self.build_state_tree(listify(getattr(model, self.model_attribute)),
+                                     self.state_cls.separator)
+
+        path = self._get_enum_path(state) if isinstance(state, Enum) else state.split(self.state_cls.separator)
+        for elem in path:
+            if elem not in tree:
+                return False
+            tree = tree[elem]
+        return len(tree) == 0 or allow_substates
 
     def on_enter(self, state_name, callback):
         """Helper function to add callbacks to states in case a custom state separator is used.


Test patch:
diff --git a/tests/test_parallel.py b/tests/test_parallel.py
index 62fd061..e2142b5 100644
--- a/tests/test_parallel.py
+++ b/tests/test_parallel.py
@@ -216,6 +216,53 @@ class TestParallel(TestNested):
         self.assertEqual(tree, m.build_state_tree(states, sep))
         self.assertEqual(states, _build_state_list(tree, sep))
 
+    def test_may_transition_with_parallel(self):
+        states = ['A',
+                  {'name': 'P',
+                   'parallel': [
+                       {'name': '1', 'states': ["a", "b"], "initial": 'a'},
+                       {'name': '2', 'states': ["a", "b"], "initial": 'a',
+                        'transitions': [['valid', 'a', 'b']]}
+                   ]}]
+        m = self.machine_cls(states=states, initial="A")
+        assert not m.may_valid()
+        assert m.to_P()
+        assert m.is_P(allow_substates=True)
+        assert m.is_P_1_a()
+        assert m.is_P_2_a()
+        assert m.may_valid()
+        assert m.valid()
+        assert m.is_P_1_a()
+        assert not m.is_P_2_a()
+        assert m.is_P_2_b()
+
+    def test_is_state_parallel(self):
+        states = ['A',
+                  {'name': 'P',
+                   'parallel': [
+                       '1',
+                       {'name': '2', 'parallel': [
+                           {'name': 'a'},
+                           {'name': 'b', 'parallel': [
+                               {'name': 'x', 'parallel': ['1', '2']}, 'y'
+                           ]}
+                       ]},
+                   ]}]
+        m = self.machine_cls(states=states, initial="A")
+        assert m.is_A()
+        assert not m.is_P_2()
+        assert not m.is_P_2_a()
+        assert not m.is_P_2_b()
+        assert not m.is_P_2_b_x()
+        assert not m.is_P(allow_substates=True)
+        m.to_P()
+        assert m.is_P_1()
+        assert m.is_P_2_a()
+        assert not m.is_P_2()
+        assert m.is_P(allow_substates=True)
+        assert m.is_P_2(allow_substates=True)
+        assert not m.is_A(allow_substates=True)
+
 
 @skipIf(pgv is None, "pygraphviz is not available")
 class TestParallelWithPyGraphviz(TestParallel):