-
Notifications
You must be signed in to change notification settings - Fork 0
feat: state tags + has_tag()/hasTag() (0.7.0) #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -72,6 +72,25 @@ def __init__( | |
| self.status = "active" | ||
| self.output = None | ||
|
|
||
| @property | ||
| def tags(self) -> frozenset[str]: | ||
| """Union of the ``tags`` declared on every active state node. | ||
|
|
||
| XState v5 surfaces ``snapshot.tags`` as the set of tags across the | ||
| current configuration; querying it is the idiomatic way to ask "is the | ||
| machine loading / busy / editable" without enumerating state values. | ||
| """ | ||
| return frozenset( | ||
| tag for node in self.configuration for tag in node.tags | ||
| ) | ||
|
Comment on lines
+83
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Comment on lines
+83
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the machine root declares Useful? React with 👍 / 👎. |
||
|
|
||
| def has_tag(self, tag: str) -> bool: | ||
| """Return True if any active state node declares *tag* (v5 ``hasTag``).""" | ||
| return any(tag in node.tags for node in self.configuration) | ||
|
|
||
| # XState v5 spells this ``hasTag``; expose both for JS-parity ergonomics. | ||
| hasTag = has_tag | ||
|
|
||
| def can(self, event: Any) -> bool: | ||
| """Return True if any enabled transition exists for *event* right now. | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -36,6 +36,7 @@ class StateNode: | |||||
| after: list[tuple[Any, str]] = field(default_factory=list) | ||||||
| invoke: list[dict[str, Any]] = field(default_factory=list) | ||||||
| initial_transition: Transition | None = None | ||||||
| tags: tuple[str, ...] = field(default_factory=tuple) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using a
Suggested change
|
||||||
|
|
||||||
| @property | ||||||
| def history_states(self) -> list[StateNode]: | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| """Tests for state tags + has_tag()/hasTag() (0.7.0). | ||
|
|
||
| XState v5 lets a state declare ``tags: ["loading"]`` and query the running | ||
| snapshot with ``state.hasTag("loading")``. Tags aggregate across the whole | ||
| active configuration (compound ancestors + parallel regions). | ||
| """ | ||
|
|
||
| import pytest | ||
|
|
||
| from xstate import Machine, create_actor | ||
| from xstate.exceptions import InvalidConfigError | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Parsing | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| def test_tags_as_list(): | ||
| machine = Machine( | ||
| { | ||
| "id": "m", | ||
| "initial": "loading", | ||
| "states": { | ||
| "loading": {"tags": ["busy", "network"]}, | ||
| "idle": {}, | ||
| }, | ||
| } | ||
| ) | ||
| assert machine.initial_state.tags == frozenset({"busy", "network"}) | ||
|
|
||
|
|
||
| def test_tags_as_single_string(): | ||
| machine = Machine( | ||
| { | ||
| "id": "m", | ||
| "initial": "loading", | ||
| "states": { | ||
| "loading": {"tags": "busy"}, | ||
| "idle": {}, | ||
| }, | ||
| } | ||
| ) | ||
| assert machine.initial_state.tags == frozenset({"busy"}) | ||
|
|
||
|
|
||
| def test_no_tags_is_empty_frozenset(): | ||
| machine = Machine( | ||
| {"id": "m", "initial": "idle", "states": {"idle": {}}} | ||
| ) | ||
| assert machine.initial_state.tags == frozenset() | ||
|
|
||
|
|
||
| def test_non_string_tag_raises(): | ||
| with pytest.raises(InvalidConfigError, match="every tag must be a string"): | ||
| Machine( | ||
| { | ||
| "id": "m", | ||
| "initial": "a", | ||
| "states": {"a": {"tags": ["ok", 123]}}, | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| def test_invalid_tags_type_raises(): | ||
| with pytest.raises(InvalidConfigError, match="must be a string or a list"): | ||
| Machine( | ||
| { | ||
| "id": "m", | ||
| "initial": "a", | ||
| "states": {"a": {"tags": {"not": "valid"}}}, | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # has_tag / hasTag query | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| def test_has_tag_true(): | ||
| machine = Machine( | ||
| { | ||
| "id": "m", | ||
| "initial": "loading", | ||
| "states": {"loading": {"tags": ["busy"]}, "idle": {}}, | ||
| } | ||
| ) | ||
| assert machine.initial_state.has_tag("busy") is True | ||
|
|
||
|
|
||
| def test_has_tag_false(): | ||
| machine = Machine( | ||
| { | ||
| "id": "m", | ||
| "initial": "loading", | ||
| "states": {"loading": {"tags": ["busy"]}, "idle": {}}, | ||
| } | ||
| ) | ||
| assert machine.initial_state.has_tag("idle") is False | ||
|
|
||
|
|
||
| def test_hasTag_camelcase_alias(): | ||
| machine = Machine( | ||
| { | ||
| "id": "m", | ||
| "initial": "loading", | ||
| "states": {"loading": {"tags": ["busy"]}, "idle": {}}, | ||
| } | ||
| ) | ||
| assert machine.initial_state.hasTag("busy") is True | ||
| assert machine.initial_state.hasTag("nope") is False | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Tags update across transitions | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| def test_tags_change_on_transition(): | ||
| machine = Machine( | ||
| { | ||
| "id": "m", | ||
| "initial": "loading", | ||
| "states": { | ||
| "loading": {"tags": ["busy"], "on": {"DONE": "ready"}}, | ||
| "ready": {"tags": ["interactive"]}, | ||
| }, | ||
| } | ||
| ) | ||
| state = machine.initial_state | ||
| assert state.has_tag("busy") | ||
|
|
||
| state = machine.transition(state, "DONE") | ||
| assert state.has_tag("interactive") | ||
| assert not state.has_tag("busy") | ||
|
|
||
|
|
||
| def test_tags_via_actor(): | ||
| machine = Machine( | ||
| { | ||
| "id": "m", | ||
| "initial": "loading", | ||
| "states": { | ||
| "loading": {"tags": ["busy"], "on": {"DONE": "ready"}}, | ||
| "ready": {}, | ||
| }, | ||
| } | ||
| ) | ||
| actor = create_actor(machine).start() | ||
| assert actor.get_snapshot().has_tag("busy") | ||
| actor.send("DONE") | ||
| assert not actor.get_snapshot().has_tag("busy") | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Aggregation across compound ancestors and parallel regions | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| def test_tags_aggregate_from_compound_ancestor(): | ||
| machine = Machine( | ||
| { | ||
| "id": "m", | ||
| "initial": "parent", | ||
| "states": { | ||
| "parent": { | ||
| "tags": ["outer"], | ||
| "initial": "child", | ||
| "states": { | ||
| "child": {"tags": ["inner"]}, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| ) | ||
| state = machine.initial_state | ||
| # Both the active compound ancestor and the leaf contribute tags. | ||
| assert state.tags == frozenset({"outer", "inner"}) | ||
| assert state.has_tag("outer") | ||
| assert state.has_tag("inner") | ||
|
|
||
|
|
||
| def test_tags_aggregate_across_parallel_regions(): | ||
| machine = Machine( | ||
| { | ||
| "id": "m", | ||
| "type": "parallel", | ||
| "states": { | ||
| "a": { | ||
| "initial": "a1", | ||
| "states": {"a1": {"tags": ["region-a"]}}, | ||
| }, | ||
| "b": { | ||
| "initial": "b1", | ||
| "states": {"b1": {"tags": ["region-b"]}}, | ||
| }, | ||
| }, | ||
| } | ||
| ) | ||
| state = machine.initial_state | ||
| assert state.has_tag("region-a") | ||
| assert state.has_tag("region-b") | ||
| assert {"region-a", "region-b"} <= state.tags |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update
_build_tagsto return afrozenset[str]to align with the suggestedfrozensettype forStateNode.tags. This also deduplicates any duplicate tags provided in the configuration.