diff --git a/AGENTS.md b/AGENTS.md index f2b53b4..d279c3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,9 @@ Working: Known gaps: -- Full SCXML conformance still has known `more-parallel` failures. +- The configured 54-case SCXML suite passes, including the enabled + `more-parallel` cases, but broader W3C datamodel and executable-content + conformance is not claimed. - Graphviz export and graph/test helper APIs beyond Mermaid are future work. - SCXML condition support is intentionally not a general JavaScript evaluator. diff --git a/CHANGELOG.md b/CHANGELOG.md index 14bf45a..1c12e7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ All notable changes to this project will be documented here. - Updated PyPI-facing installation docs to use `pip install xstate` for the released package. +### Fixed + +- Corrected SCXML transition-domain resolution so external transitions inside + parallel states re-enter every affected region and resolve conflicts in + document order. + ## 0.6.0 - 2026-06-28 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2cd73e9..4f26f44 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,9 +48,8 @@ git submodule update --init poetry run python -m pytest tests/test_scxml.py ``` -The current known full-SCXML gap is the `more-parallel` conformance group. -Do not treat those existing failures as caused by unrelated docs or tooling -changes. +The configured SCXML suite must pass in full. It is a focused subset and does +not yet claim broad W3C datamodel or executable-content conformance. ## Pull Requests diff --git a/README.md b/README.md index 907cd0a..bc81c08 100644 --- a/README.md +++ b/README.md @@ -427,9 +427,10 @@ git submodule update --init poetry run python -m pytest tests/test_scxml.py ``` -Current branch result: `45 passed`, `8 failed`; the remaining failures are the -known `more-parallel` conformance cases. The `cond-js` subset passes with the -safe Boolean evaluator. +Current configured-suite result: `54 passed`, `0 failed`, including all enabled +`more-parallel` cases. This is a focused SCXML subset rather than a claim of full +W3C conformance; broader datamodel and executable-content coverage remains +future work. The `cond-js` subset passes with the safe Boolean evaluator. ## Developing @@ -470,7 +471,7 @@ release target needs to be checked against different refs. | Snapshot queries | `tags`, `meta`, `has_tag`/`hasTag`, and `state_in`/`stateIn` are present | | Diagrams | Dependency-free Mermaid export is present | | Async | `AsyncInterpreter`, async actors, `from_observable`, and `to_promise` are present | -| SCXML | XML import works; safe Boolean cond subset works; `more-parallel` conformance remains open | +| SCXML | Configured 54-case suite passes; broader W3C datamodel and executable-content coverage remains open | | Persistence | Snapshot serialization and restore helpers are present | ## Related Projects diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 46b0519..f62bda1 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -36,7 +36,7 @@ bridge between XState's JSON ecosystem and Python backends. | Actor model | `create_actor`, `ActorSystem`, spawn, parent/child tree, `send_parent`, `send_to` | | Actor logic | `from_promise`, `from_callback`, `from_observable`, `to_promise` | | Invoke | Child actor lifetime reconciliation with `done.invoke.*` and `error.platform.*` events | -| SCXML XML import | Present; safe Boolean cond subset; known `more-parallel` failures remain | +| SCXML XML import | Present; safe Boolean cond subset; configured 54-case suite passes | ## What Works Today @@ -57,7 +57,8 @@ bridge between XState's JSON ecosystem and Python backends. `MachineSnapshot`, `create_actor`, and `setup`. - Primary suite passes in current Python 3.13/3.14 CI. - SCXML `cond-js` subset result: `4 passed`. -- Full SCXML result: `45 passed`, `8 failed` in known `more-parallel` cases. +- Configured SCXML result: `54 passed`, `0 failed`, including all enabled + `more-parallel` cases. ## Competitive Position @@ -77,7 +78,7 @@ JavaScript and wanting the same machine shape in Python services. |---|---| | High | Publish 0.7.0 to PyPI as `xstate` | | High | Document the public API by concept: machines, guards/actions, context, interpreter, actors, async, SCXML | -| High | Fix the remaining SCXML `more-parallel` conformance cases | +| High | Expand SCXML coverage beyond the configured subset without adding JavaScript evaluation | | Medium | Document snapshot persistence and restore helpers | | Medium | Inspector protocol compatibility | | Medium | More XState v5 utilities: `provide`, action helpers, graph/test helpers | @@ -89,7 +90,7 @@ JavaScript and wanting the same machine shape in Python services. |---|---| | PyPI release | `pip install xstate` works after the 0.7.0 GitHub Release | | Primary test count | Maintain 300+ focused tests | -| SCXML pass rate | Resolve known `more-parallel` group | +| SCXML pass rate | Keep the configured suite green while expanding supported coverage | | Docs | README plus concept docs for the main public APIs | | Examples | JSON, sync, async, actor, and integration examples | diff --git a/docs/comparison.md b/docs/comparison.md index cd1a680..8686c87 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -13,7 +13,7 @@ release-readiness work. | XState JSON | No | No | **Yes, native** | Yes | No | No | | SCXML algorithm | No | Yes | **Yes, partial conformance** | Partial | Yes | No | | Hierarchical states | Yes, extension | Yes | Yes | Yes | Yes | No | -| Parallel states | Yes, extension | Yes | Yes, with known SCXML `more-parallel` gaps | Yes | Yes | No | +| Parallel states | Yes, extension | Yes | Yes; configured `more-parallel` cases pass | Yes | Yes | No | | Guards/actions | Yes | Yes | Yes, named or inline | Yes | Yes | Limited | | Context/extended state | No native XState-style context | Yes | Yes, with `assign` and snapshot isolation | Yes | Yes | No | | Eventless transitions | No | Yes | Yes, `always` and legacy empty-event form | Yes | Yes | No | @@ -72,7 +72,7 @@ structure with a JavaScript/XState frontend or a Stately-authored design. | Gap | Notes | |---|---| | PyPI release | 0.7.0 packaging metadata and release workflow are ready; publish via GitHub Release. | -| SCXML conformance | Current full SCXML run is `45 passed`, `8 failed`; remaining failures are the known `more-parallel` cases. | +| SCXML conformance | Configured SCXML suite is `54 passed`, `0 failed`; broader W3C coverage is not yet claimed. | | Full ECMAScript cond support | Intentionally not implemented; unsupported SCXML expressions raise `InvalidConfigError`. | | Graph/test utilities | Mermaid export exists; no graph traversal/test-path helpers yet. | | Inspector protocol | No `@statelyai/inspect` compatibility yet. | diff --git a/src/xstate/algorithm.py b/src/xstate/algorithm.py index 605b857..0ec366a 100644 --- a/src/xstate/algorithm.py +++ b/src/xstate/algorithm.py @@ -225,8 +225,11 @@ def get_transition_domain( def find_lcca(state_list: list[StateNode]) -> StateNode | None: + """Return the least common compound ancestor for SCXML transition domains.""" for anc in get_proper_ancestors(state_list[0], state2=None): - if all(is_descendent(s, state2=anc) for s in state_list[1:]): + if is_compound_state(anc) and all( + is_descendent(s, state2=anc) for s in state_list[1:] + ): return anc return None diff --git a/tests/test_parallel.py b/tests/test_parallel.py index ca663ea..204ab4c 100644 --- a/tests/test_parallel.py +++ b/tests/test_parallel.py @@ -1,6 +1,8 @@ """Tests for parallel (orthogonal) states (0.2.0).""" -from xstate import Machine +import pytest + +from xstate import Machine, interpret def make_word(): @@ -131,3 +133,153 @@ def test_parallel_on_done_when_all_regions_final(): # Both regions final -> done.state.running -> onDone. state = machine.transition(state, "FINISH_B") assert state.value == "done" + + +def test_external_region_transition_reenters_parallel_siblings(): + calls: list[str] = [] + machine = Machine( + { + "id": "workflow", + "initial": "running", + "states": { + "running": { + "type": "parallel", + "states": { + "a": { + "initial": "a1", + "on": {"RESET_A": "#a1"}, + "states": { + "a1": {"id": "a1"}, + "a2": {}, + }, + }, + "b": { + "entry": "enterB", + "exit": "exitB", + "initial": "b1", + "states": { + "b1": {"on": {"ADVANCE_B": "b2"}}, + "b2": {}, + }, + }, + }, + } + }, + }, + actions={ + "enterB": lambda: calls.append("enterB"), + "exitB": lambda: calls.append("exitB"), + }, + ) + service = interpret(machine).start() + calls.clear() + + service.send("ADVANCE_B") + assert service.state.value == {"running": {"a": "a1", "b": "b2"}} + + service.send("RESET_A") + + assert service.state.value == {"running": {"a": "a1", "b": "b1"}} + assert calls == ["exitB", "enterB"] + service.stop() + + +def _parallel_conflict_machine(first_region: str) -> Machine: + a = { + "initial": "a1", + "on": {"T": "#a2"}, + "states": { + "a1": {}, + "a2": {"id": "a2"}, + }, + } + b = { + "initial": "b1", + "states": { + "b1": {"on": {"T": "b2"}}, + "b2": {}, + }, + } + regions = {"a": a, "b": b} if first_region == "a" else {"b": b, "a": a} + return Machine( + { + "id": "conflict", + "initial": "running", + "states": { + "running": { + "type": "parallel", + "states": regions, + } + }, + } + ) + + +@pytest.mark.parametrize( + ("first_region", "expected"), + [ + ("a", {"running": {"a": "a2", "b": "b1"}}), + ("b", {"running": {"a": "a1", "b": "b2"}}), + ], +) +def test_parallel_conflicts_follow_document_order(first_region, expected): + machine = _parallel_conflict_machine(first_region) + + state = machine.transition(machine.initial_state, "T") + + assert state.value == expected + + +def test_parallel_conflict_with_nested_target_reenters_sibling_initial_state(): + machine = Machine( + { + "id": "nested-conflict", + "initial": "running", + "states": { + "running": { + "type": "parallel", + "states": { + "a": { + "initial": "a1", + "on": {"T": "#a22"}, + "states": { + "a1": { + "initial": "a11", + "states": {"a11": {}, "a12": {}}, + }, + "a2": { + "initial": "a21", + "states": { + "a21": {}, + "a22": {"id": "a22"}, + }, + }, + }, + }, + "b": { + "initial": "b1", + "states": { + "b1": { + "initial": "b11", + "states": { + "b11": {"on": {"T": "b12"}}, + "b12": {}, + }, + }, + "b2": {}, + }, + }, + }, + } + }, + } + ) + + state = machine.transition(machine.initial_state, "T") + + assert state.value == { + "running": { + "a": {"a2": "a22"}, + "b": {"b1": "b11"}, + } + } diff --git a/tests/test_scxml.py b/tests/test_scxml.py index d051ea9..aa9c042 100644 --- a/tests/test_scxml.py +++ b/tests/test_scxml.py @@ -73,6 +73,7 @@ "hierarchy+documentOrder", "parallel", "parallel+interrupt", + "more-parallel", } test_files = [