diff --git a/docs/events.md b/docs/events.md index d3414d98..19012dfa 100644 --- a/docs/events.md +++ b/docs/events.md @@ -353,6 +353,13 @@ True ``` +The same event declaration rules apply inside each compound or parallel body. +An explicit `id` does not replace the Python attribute: both names can be used +to refer to the event. A transition-less `Event` is also retained in the +machine event catalog, including its `name`, `delay`, and `internal` metadata. +Callback methods declared in sibling regions are resolved in their owning +state's scope, so equal method names do not use last-writer-wins lookup. + (donedata)= #### DoneData diff --git a/docs/processing_model.md b/docs/processing_model.md index d8181316..3037802c 100644 --- a/docs/processing_model.md +++ b/docs/processing_model.md @@ -73,6 +73,21 @@ Within a single macrostep, the engine repeats: After the macrostep completes, the engine picks the next event from the **external queue** (placed by `send()`) and starts a new macrostep. +## Callback lookup in nested state bodies + +Callbacks declared inside `State.Compound` and `State.Parallel` bodies are +bound when the owning `StateChart` is assembled, but resolved against the real +machine instance when an event is processed. This preserves dynamic model and +property values and keeps synchronous and asynchronous callbacks on their +normal engine paths. + +When more than one listener supplies the same callback name, lookup follows +the existing order: the machine method, model, class listeners, then runtime +listeners. A callback declared in a nested body is scoped to its owning state, +so sibling regions can use the same name without replacing each other's +handler. Inherited state-chart classes reuse the declaration without consuming +it from the base or another subclass. + ### Event queues diff --git a/docs/releases/3.2.2.md b/docs/releases/3.2.2.md index b0a6405e..3d818ce2 100644 --- a/docs/releases/3.2.2.md +++ b/docs/releases/3.2.2.md @@ -67,15 +67,37 @@ True ``` -Two differences from the top-level form remain, both because a nested body is evaluated before -the owning class exists: an explicit `id` that differs from the attribute name does not also -bind the attribute name, and an `Event` with no transitions is dropped instead of becoming a -class attribute. +Nested declarations now use the same supported event forms as top-level declarations. An +explicit `id` is preserved while the Python attribute remains available as an alias, and an +`Event` without transitions is still registered in the machine event catalog with its `name`, +`delay`, and `internal` metadata. This also applies inside `State.Parallel` regions. Reported by [@Dolecor](https://github.com/Dolecor). [#643](https://github.com/fgmacedo/python-statemachine/issues/643). +### Nested callback ownership and inheritance + +Nested compound and parallel bodies keep callback declarations associated with +the owning state. Two sibling regions may use the same callback name without +one region's method replacing the other. Reusing a state-chart class through +inheritance also keeps the base declaration available to child and sibling +classes; constructing one class does not consume callback metadata needed by +another. + +Callback lookup remains instance-time. Machine methods, model callbacks, class +listeners, and runtime listeners continue to run in that order. This covers +[#646](https://github.com/fgmacedo/python-statemachine/issues/646) and +[#653](https://github.com/fgmacedo/python-statemachine/issues/653). + +### Explicit IDs and transition-less nested events + +An event declared inside a nested state body can use an explicit dotted ID that +differs from its Python attribute name. Both identities remain usable, and a +transition-less `Event` remains in `StateChart.events`. These declarations +preserve display names and scheduling metadata. See +[#656](https://github.com/fgmacedo/python-statemachine/issues/656). + ### `delay` and `internal` dropped from an explicit `Event` `Event(dark.to(lit), delay=50)` rebuilt the event without its `delay`, so diff --git a/docs/statechart.md b/docs/statechart.md index 5b92d6a3..40920b88 100644 --- a/docs/statechart.md +++ b/docs/statechart.md @@ -239,6 +239,18 @@ True Use `is_terminated` instead of checking individual states — it handles arbitrarily nested structures for you. +Nested state bodies support the same declaration forms as a top-level +`StateChart`: `State`, `States.from_enum()`, transitions and transition lists, +`Event`, decorated event callbacks, and ordinary callback methods. The nested +body may be a `State.Compound` or a `State.Parallel`; each parallel region +keeps its callbacks associated with its own owning state, so equal method names +in sibling regions do not overwrite one another. + +An explicit event ID and the Python attribute name are separate identities. Both +remain available when they differ, and an event with no transitions is still +listed in `StateChart.events` with its display name, delay, and `internal` +metadata. + **`final_states`** lists all top-level states marked as `final`: ```py diff --git a/statemachine/contrib/diagram/extract.py b/statemachine/contrib/diagram/extract.py index ca5986f9..d162adfd 100644 --- a/statemachine/contrib/diagram/extract.py +++ b/statemachine/contrib/diagram/extract.py @@ -44,6 +44,14 @@ def getter(grouper): # pyright: ignore[reportRedeclaration] def getter(grouper): all_names = set(dir(machine)) + scope = machine._callback_scopes.get(id(grouper.list)) + if scope is not None: + body = scope[1] + all_names.update(body) + for value in body.values(): + attr_name = getattr(value, "attr_name", None) + if attr_name: + all_names.add(attr_name) return ", ".join(str(c) for c in grouper if not c.is_convention or c.func in all_names) return getter diff --git a/statemachine/dispatcher.py b/statemachine/dispatcher.py index 8f9bc369..e6a4c24b 100644 --- a/statemachine/dispatcher.py +++ b/statemachine/dispatcher.py @@ -37,19 +37,39 @@ class Listener: obj: object all_attrs: set[str] resolver_id: str + local_scope: dict[str, Any] | None = None + scope_id: str | None = None @classmethod - def from_obj(cls, obj, skip_attrs=None) -> "Listener": + def from_obj(cls, obj, skip_attrs=None, local_scope=None, scope_id=None) -> "Listener": if isinstance(obj, Listener): return obj else: if skip_attrs is None: skip_attrs = set() all_attrs = set(dir(obj)) - skip_attrs - return cls(obj, all_attrs, str(id(obj))) + if local_scope is not None: + all_attrs = set(local_scope) + return cls(obj, all_attrs, str(id(obj)), local_scope, scope_id) def build_key(self, attr_name) -> str: - return f"{attr_name}@{self.resolver_id}" + suffix = self.resolver_id + if self.scope_id is not None: + suffix = f"{suffix}:{self.scope_id}" + return f"{attr_name}@{suffix}" + + def get(self, name): + if self.local_scope is None: + return getattr(self.obj, name) + value = self.local_scope[name] + if isinstance(value, property): + return value.__get__(self.obj, type(self.obj)) + descriptor = getattr(type(self.obj), name, None) + if descriptor is value and hasattr(value, "__get__"): + return value.__get__(self.obj, type(self.obj)) + if hasattr(value, "__get__") and callable(value): + return value.__get__(self.obj, type(self.obj)) + return value @dataclass @@ -149,11 +169,19 @@ def _search_property(self, spec): if attr_name not in self.all_attrs: return for listener in self.items: - func = getattr(type(listener.obj), attr_name, None) + if listener.local_scope is not None: + func = listener.local_scope.get(attr_name) + else: + func = getattr(type(listener.obj), attr_name, None) if func is not None and func is spec.func: + builder = ( + partial(listener_attr_method, listener, attr_name) + if listener.local_scope is not None + else partial(attr_method, attr_name, listener.obj) + ) yield ( listener.build_key(attr_name), - partial(attr_method, attr_name, listener.obj), + builder, ) return @@ -162,7 +190,12 @@ def _search_callable(self, spec): # on the self if not spec.is_bounded: for listener in self.items: - func = getattr(listener.obj, spec.attr_name, None) + if listener.local_scope is not None: + func = listener.local_scope.get(spec.attr_name) + if func is not None and hasattr(func, "__get__"): + func = func.__get__(listener.obj, type(listener.obj)) + else: + func = getattr(listener.obj, spec.attr_name, None) # ``getattr`` may return a non-method that happens to share the name # (e.g. a model attribute named like a compiled guard); it is not the # unbounded method we are rebinding, so skip it instead of accessing @@ -179,9 +212,12 @@ def search_name(self, name): continue key = listener.build_key(name) - func = getattr(listener.obj, name) + func = listener.get(name) if not callable(func): - yield key, partial(attr_method, name, listener.obj) + if listener.local_scope is not None: + yield key, partial(listener_attr_method, listener, name) + else: + yield key, partial(attr_method, name, listener.obj) continue if isinstance(func, Event): @@ -225,6 +261,16 @@ def method(*args, **kwargs): return method +def listener_attr_method(listener: Listener, attribute: str) -> Callable: + """Read a listener member at invocation time, preserving scoped descriptors.""" + + def method(*args, **kwargs): + return listener.get(attribute) + + method.__name__ = attribute + return method + + def event_method(func) -> Callable: def method(*args, **kwargs): kwargs.pop("machine", None) diff --git a/statemachine/factory.py b/statemachine/factory.py index 33f66e28..80680503 100644 --- a/statemachine/factory.py +++ b/statemachine/factory.py @@ -1,3 +1,4 @@ +import builtins import re from typing import Any @@ -9,7 +10,6 @@ from .event import _expand_event_id from .exceptions import InvalidDefinition from .graph import disconnected_states -from .graph import iterate_states from .graph import iterate_states_and_transitions from .graph import states_without_path_to_final_states from .i18n import _ @@ -52,6 +52,7 @@ def __init__( cls._events: dict[Event, None] = {} # used Dict to preserve order and avoid duplicates cls._protected_attrs: set = set() cls._events_to_update: dict[Event, Event | None] = {} + cls._callback_scopes: dict[int, tuple[State, dict[str, Any]]] = {} cls._specs = CallbackSpecList() cls.prepare = cls._specs.grouper(CallbackGroup.PREPARE).add( "prepare_event", priority=CallbackPriority.GENERIC, is_convention=True @@ -164,13 +165,9 @@ def _initials_by_document_order( # noqa: C901 parent.to(state, initial=True) # pragma: no cover def _unpack_builders_callbacks(cls): - callbacks = {} - for state in iterate_states(cls.states): - if state._callbacks: - callbacks.update(state._callbacks) - del state._callbacks - for key, value in callbacks.items(): - setattr(cls, key, value) + # Kept as a compatibility no-op. Nested callback bodies are retained on + # their State and resolved through the scoped machine listener. + return None def _check(cls): has_states = bool(cls.states) @@ -304,27 +301,10 @@ def add_from_attributes(cls, attrs): # noqa: C901 cls._add_states_from_dict(value) if isinstance(value, State): cls.add_state(key, value) - elif isinstance(value, (Transition, TransitionList)): - event_id = _expand_event_id(key) - cls.add_event(event=Event(transitions=value, id=event_id)) - elif isinstance(value, (Event,)): - if value._has_real_id: - event_id = value.id - else: - event_id = _expand_event_id(key) - new_event = Event( - transitions=value._transitions, - id=event_id, - name=value.name, - delay=value.delay, - internal=value.internal, - ) - cls.add_event(event=new_event, old_event=value) - # Ensure the event is accessible by the Python attribute name - if event_id != key: - setattr(cls, key, new_event) + elif isinstance(value, (Transition, TransitionList, Event)): + cls._read_body({key: value}) elif getattr(value, "attr_name", None): - cls._add_unbounded_callback(key, value) + cls._read_body({key: value}) def _add_states_from_dict(cls, states): for state_id, state in states.items(): @@ -346,13 +326,77 @@ def add_state(cls, id, state: State): if not hasattr(cls, id): setattr(cls, id, state) + cls._read_body(state._body, owner=state) + body_owner = state + while not body_owner._body and body_owner.parent is not None: + body_owner = body_owner.parent + cls._callback_scopes[builtins.id(state._specs)] = (body_owner, body_owner._body) + # also register all events associated directly with transitions for event in state.transitions.unique_events: cls.add_event(event) + for transition in state.transitions: + cls._callback_scopes[builtins.id(transition._specs)] = (body_owner, body_owner._body) + for substate in state.states: cls.add_state(substate.id, substate) + def _read_body(cls, body, owner=None): # noqa: C901 + """Read behavioural declarations with an optional owning State.""" + if not body: + return + for key, value in body.items(): + if key.startswith("__"): + continue + # Structural declarations were consumed by NestedStateFactory. + if isinstance(value, (States, State)): + continue + if isinstance(value, TransitionList): + event_id = _expand_event_id(key) + if owner is None: + cls.add_event(event=Event(transitions=value, id=event_id)) + else: + value.add_event(event_id) + continue + if isinstance(value, Transition): + event_id = _expand_event_id(key) + if owner is None: + cls.add_event(event=Event(transitions=value, id=event_id)) + else: + value.add_event(event_id) + continue + if isinstance(value, Event): + event_id = value.id if value._has_real_id else _expand_event_id(key) + new_event = Event( + transitions=value._transitions, + id=event_id, + name=value.name, + delay=value.delay, + internal=value.internal, + ) + if owner is None or value._transitions is None: + cls.add_event(event=new_event, old_event=value) + else: + value._transitions._on_event_defined( + event=new_event, + states=list(cls.states), + ) + if event_id != key: + setattr(cls, key, new_event) + continue + if getattr(value, "attr_name", None): + if value.is_event and value._transitions is not None: + value._transitions.add_event(key) + cls.add_event(event=Event(value._transitions, id=key)) + # Event-decorator callbacks need their private callable name on + # the class; ordinary local callbacks stay in the owner scope. + if owner is None: + cls._add_unbounded_callback(key, value) + continue + if callable(value) and owner is None: + cls._add_unbounded_callback(key, value) + def add_event( cls, event: Event, diff --git a/statemachine/state.py b/statemachine/state.py index 02268083..fb7c4525 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -8,8 +8,6 @@ from .callbacks import CallbackGroup from .callbacks import CallbackPriority from .callbacks import CallbackSpecList -from .event import Event -from .event import _expand_event_id from .exceptions import InvalidDefinition from .i18n import _ from .invoke import normalize_invoke_callbacks @@ -76,7 +74,10 @@ def __new__( # type: ignore [misc] # noqa: C901 states = [] history = [] - callbacks = {} + # Keep the complete body for the owning state-machine metaclass. Nested + # construction is deliberately structural; behavioural declarations are + # consumed later with the real owner state available. + body = dict(attrs) # Order is significant: a ``HistoryState`` is a ``State``, and an ``Event`` is a # callable ``str``, so both would be captured by a later branch. for key, value in attrs.items(): @@ -90,29 +91,8 @@ def __new__( # type: ignore [misc] # noqa: C901 elif isinstance(value, State): value._set_id(key) states.append(value) - elif isinstance(value, TransitionList): - value.add_event(_expand_event_id(key)) - elif isinstance(value, Event): - if value._transitions is not None: - event_id = value.id if value._has_real_id else _expand_event_id(key) - value._transitions.add_event( - Event( - id=event_id, - name=value.name, - delay=value.delay, - internal=value.internal, - ) - ) - elif getattr(value, "attr_name", None): - if value.is_event: - value._transitions.add_event(key) - callbacks[value.attr_name] = value - elif callable(value): - callbacks[key] = value - - return State( - name=name, states=states, history=history, _callbacks=callbacks, **inherited_kwargs - ) + + return State(name=name, states=states, history=history, _body=body, **inherited_kwargs) @classmethod def to(cls, *args: "State | NestedStateFactory", **kwargs) -> "_ToState": # pragma: no cover @@ -238,6 +218,7 @@ def __init__( invoke: Any = None, donedata: Any = None, _callbacks: Any = None, + _body: dict[str, Any] | None = None, ): self.name = name self.value = value @@ -250,6 +231,7 @@ def __init__( self.is_active = False self._id: str = "" self._callbacks = _callbacks + self._body = _body or {} self.parent: "State | None" = None self.transitions = TransitionList() self._specs = CallbackSpecList() diff --git a/statemachine/statemachine.py b/statemachine/statemachine.py index 5277c4fc..d1510ac4 100644 --- a/statemachine/statemachine.py +++ b/statemachine/statemachine.py @@ -298,11 +298,32 @@ def bind_events_to(self, *targets): continue setattr(target, event, trigger) - def _add_listener(self, listeners: "Listeners", allowed_references: SpecReference = SPECS_ALL): + def _add_listener( + self, + listeners: "Listeners", + allowed_references: SpecReference = SPECS_ALL, + include_body_scopes: bool = False, + ): registry = self._callbacks listeners.resolve(self._specs, registry=registry, allowed_references=allowed_references) for visited in iterate_states_and_transitions(self.states): - listeners.resolve( + scoped = listeners + if include_body_scopes: + scope = self.__class__._callback_scopes.get(id(visited._specs)) + if scope is not None: + _, body = scope + body = self._scope_members(body) + scoped = Listeners.from_listeners( + ( + Listener.from_obj( + self, + local_scope=body, + scope_id=str(id(scope[0])), + ), + *listeners.items, + ) + ) + scoped.resolve( visited._specs, registry=registry, allowed_references=allowed_references, @@ -310,6 +331,15 @@ def _add_listener(self, listeners: "Listeners", allowed_references: SpecReferenc return self + @staticmethod + def _scope_members(body): + scope = dict(body) + for _name, value in body.items(): + attr_name = getattr(value, "attr_name", None) + if attr_name: + scope.setdefault(attr_name, value) + return scope + def _register_callbacks(self, listeners: list[object]): self._listeners.update({id(listener): listener for listener in listeners}) self._add_listener( @@ -319,7 +349,8 @@ def _register_callbacks(self, listeners: list[object]): Listener.from_obj(self.model, skip_attrs={self.state_field}), *(Listener.from_obj(listener) for listener in listeners), ) - ) + ), + include_body_scopes=True, ) check_callbacks = self._callbacks.check @@ -356,6 +387,7 @@ def add_listener(self, *listeners): return self._add_listener( Listeners.from_listeners(Listener.from_obj(listener) for listener in listeners), allowed_references=SPECS_SAFE, + include_body_scopes=False, ) def _repr_html_(self): diff --git a/tests/test_shared_body_reader.py b/tests/test_shared_body_reader.py new file mode 100644 index 00000000..41e4a8c0 --- /dev/null +++ b/tests/test_shared_body_reader.py @@ -0,0 +1,250 @@ +"""Regression coverage for the shared nested state-body declaration reader. + +These tests describe the public declaration contract from issue #657. They +exercise observable graph, event, callback, and inheritance behavior rather +than private implementation details. +""" + +from enum import Enum + +import pytest +from statemachine.states import States + +from statemachine import Event +from statemachine import State +from statemachine import StateChart + + +def assert_parallel_leaf_values(chart_cls): + expected = {"left-idle", "left-active", "right-idle", "right-active"} + assert expected <= set(chart_cls.states_map) + + +def test_nested_event_forms_keep_aliases_and_metadata(): + class Journey(StateChart): + class route(State.Compound): + start = State(initial=True) + finish = State(final=True) + arrive = start.to(finish) + explicit = Event(arrive, id="route.arrive", name="Arrive", internal=True) + floating = Event(id="route.ping", name="Ping", delay=0.25, internal=True) + + @start.to(finish) + def revisit(self): + return None + + done = State(final=True) + complete = route.to(done) + + assert Journey.explicit.id == "route.arrive" + assert Journey.explicit.name == "Arrive" + assert Journey.explicit.internal is True + assert Journey.floating.id == "route.ping" + assert Journey.floating.name == "Ping" + assert Journey.floating.delay == pytest.approx(0.25) + assert Journey.floating.internal is True + assert {"route.arrive", "route.ping"} <= {event.id for event in Journey.events} + assert Journey.explicit is not None + assert Journey.revisit is not None + + +def test_nested_alias_families_and_enum_states_remain_usable(): + class Mode(Enum): + IDLE = 1 + DONE = 2 + + class Workflow(StateChart): + class process(State.Compound): + states = States.from_enum(Mode, initial=Mode.IDLE, final=Mode.DONE) + begin = states.IDLE.to(states.DONE) + + error_execution = states.IDLE.to(states.DONE) + done_state_process = states.IDLE.to(states.DONE) + done_invoke_process = states.IDLE.to(states.DONE) + + finished = State(final=True) + leave = process.to(finished) + + ids = {event.id for event in Workflow.events} + assert "error.execution" in ids + assert "done.state.process" in ids + assert "done.invoke.process" in ids + assert Mode.IDLE in Workflow.states_map + assert Mode.DONE in Workflow.states_map + + +@pytest.mark.asyncio() +async def test_parallel_same_named_callbacks_keep_owner_scope(sm_runner): + calls = [] + + class ParallelChart(StateChart): + class branches(State.Parallel): + class left(State.Compound): + idle = State(name="Left idle", value="left-idle", initial=True) + active = State(name="Left active", value="left-active") + go = idle.to(active) + + def on_enter_active(self): + calls.append("left") + + class right(State.Compound): + idle = State(name="Right idle", value="right-idle", initial=True) + active = State(name="Right active", value="right-active") + go = idle.to(active) + + def on_enter_active(self): + calls.append("right") + + start = State(initial=True) + enter = start.to(branches) + + assert_parallel_leaf_values(ParallelChart) + sm = await sm_runner.start(ParallelChart) + await sm_runner.send(sm, "enter") + await sm_runner.send(sm, "go") + + assert sorted(calls) == ["left", "right"] + + +@pytest.mark.asyncio() +async def test_parallel_callable_callbacks_keep_owner_scope(sm_runner): + calls = [] + + class ParallelChart(StateChart): + class branches(State.Parallel): + class left(State.Compound): + idle = State(name="Left idle", value="left-idle", initial=True) + + def enter_shared(self): + calls.append("left-callable") + + active = State(name="Left active", value="left-active", enter=enter_shared) + go = idle.to(active) + + class right(State.Compound): + idle = State(name="Right idle", value="right-idle", initial=True) + + def enter_shared(self): + calls.append("right-callable") + + active = State(name="Right active", value="right-active", enter=enter_shared) + go = idle.to(active) + + start = State(initial=True) + enter = start.to(branches) + + assert_parallel_leaf_values(ParallelChart) + sm = await sm_runner.start(ParallelChart) + await sm_runner.send(sm, "enter") + await sm_runner.send(sm, "go") + + assert sorted(calls) == ["left-callable", "right-callable"] + + +def test_nested_callbacks_are_reusable_across_base_child_and_sibling(): + calls = [] + + class Base(StateChart): + class flow(State.Compound): + idle = State(initial=True) + done = State() + finish = idle.to(done) + + def on_enter_done(self): + calls.append("base") + + stop = State(final=True) + leave = flow.to(stop) + + class Child(Base): + pass + + class Sibling(Base): + pass + + Base().send("finish") + Child().send("finish") + Sibling().send("finish") + + assert calls == ["base", "base", "base"] + + +def test_nested_guard_reads_live_machine_property(): + class Model: + allow = False + + class Guarded(StateChart): + class flow(State.Compound): + waiting = State(initial=True) + accepted = State() + property_accepted = State() + constant_accepted = State(final=True) + constant_allowed = False + + @property + def allowed(self): + return self.enabled + + @property + def direct_allowed(self): + return self.enabled + + approve = waiting.to(accepted, cond="allowed") + approve_property = accepted.to(property_accepted, cond=direct_allowed) + approve_constant = property_accepted.to(constant_accepted, cond="constant_allowed") + approve_expression = property_accepted.to( + constant_accepted, cond="direct_allowed and allow" + ) + + # The local false value must win over the outer true value. + constant_allowed = True + + def __init__(self, model=None): + self.enabled = False + super().__init__(model=model) + + model = Model() + machine = Guarded(model=model) + machine.send("approve") + assert set(machine.configuration_values) == {"flow", "waiting"} + + machine.enabled = True + machine.send("approve") + assert set(machine.configuration_values) == {"flow", "accepted"} + machine.send("approve_property") + assert set(machine.configuration_values) == {"flow", "property_accepted"} + machine.send("approve_constant") + assert set(machine.configuration_values) == {"flow", "property_accepted"} + model.allow = True + machine.send("approve_expression") + assert set(machine.configuration_values) == {"flow", "constant_accepted"} + + +def test_machine_model_and_listener_callbacks_coexist_in_declared_order(): + calls = [] + + class Model: + def on_enter_ready(self): + calls.append("model") + + class Chart(StateChart): + start = State(initial=True) + ready = State(final=True) + go = start.to(ready) + + def on_enter_ready(self): + calls.append("machine") + + class Listener: + def on_enter_ready(self): + calls.append("listener") + + class Runtime: + def on_enter_ready(self): + calls.append("runtime") + + machine = Chart(model=Model(), listeners=[Listener()]) + machine.add_listener(Runtime()) + machine.send("go") + + assert calls == ["machine", "model", "listener", "runtime"] diff --git a/tests/test_statechart_compound.py b/tests/test_statechart_compound.py index de1c52f3..eba708d7 100644 --- a/tests/test_statechart_compound.py +++ b/tests/test_statechart_compound.py @@ -425,8 +425,8 @@ class battle(State.Compound): await sm_runner.send(sm, "victory") assert {"war", "quest", "end", "battle", "won"} == set(sm.configuration_values) - def test_transition_less_event_declares_nothing(self): - """A nested ``Event`` carries only its id, so with no transitions it is dropped.""" + def test_transition_less_event_registers_catalog_and_alias(self): + """A nested transition-less ``Event`` keeps its public identity and metadata.""" class Placeholder(StateChart): class shire(State.Compound): @@ -434,10 +434,13 @@ class shire(State.Compound): green_dragon = State(final=True) visit_pub = bag_end.to(green_dragon) - knock = Event(name="Knock on the door") + knock = Event(name="Knock on the door", delay=25, internal=True) - assert [event.id for event in Placeholder.events] == ["visit_pub"] - assert not hasattr(Placeholder, "knock") + assert {event.id for event in Placeholder.events} == {"visit_pub", "knock"} + assert Placeholder.knock.id == "knock" + assert Placeholder.knock.name == "Knock on the door" + assert Placeholder.knock.delay == 25 + assert Placeholder.knock.internal is True @pytest.mark.timeout(5)