From d00974e8ea03d84ce47fcc39bcfa4595b3876301 Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Tue, 30 Jun 2026 04:42:45 +0900 Subject: [PATCH] cli: standard command surface + Mermaid export; full suite passing Build-order steps 15-16 (issue #3). The CLI conformance case (cli/01) passes, completing the definition of done: all 22 engine cases + conformance/cli/01. - Mermaid export (SPEC 12) behind a pluggable exporter interface: static structure (composites as `state S { ... }`, orthogonal regions separated by `--`, `[*] --> initial`, `final --> [*]`, `S --> T : event [guard]`, `after(d)`), plus current-state highlighting via `classDef active` when given a state_config. - Standard CLI (SPEC 13): `validate` / `export` / `new` / `send` / `advance` / `env` / `state` / `list` / `snapshot` / `restore` over a file-backed store (`--store`, default $HAREL_STORE or ./.harel), with the normative `--json` shapes and exit codes (0/2/3/4/5/1) and the virtual clock. State-changing commands run all instances to quiescence and persist atomically. - A CLI harness runs each `conformance/cli/*` case against a fresh temp store (exit + structural-JSON assertions). --- README.md | 10 +- pyproject.toml | 3 + src/harel/cli.py | 386 ++++++++++++++++++++++++++++++++++++++ src/harel/export.py | 115 ++++++++++++ src/harel/store.py | 63 +++++++ tests/harness.py | 55 ++++++ tests/test_conformance.py | 6 + tests/test_export.py | 52 +++++ 8 files changed, 687 insertions(+), 3 deletions(-) create mode 100644 src/harel/cli.py create mode 100644 src/harel/export.py create mode 100644 src/harel/store.py create mode 100644 tests/test_export.py diff --git a/README.md b/README.md index 36d1416..fa5c1d4 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,13 @@ The normative `SPEC.md`, the JSON Schema for machine YAML, and the cross-languag **conformance suite** live in the spec repo. This repository implements that spec in Python and is correct **iff it passes the conformance suite**. -Status: **in progress** — YAML 1.2 loading + machine validation (SPEC §2/§4) are -implemented and gated against the full conformance suite. The engine is being -built up the build order in [issue #3][issue]. +Status: **passing the full conformance suite** — all 22 engine cases +(`conformance/01`–`22`) plus `conformance/cli/01`. Implements YAML 1.2 loading ++ validation, the full statechart semantics (RTC dispatch, hierarchy, orthogonal +regions + `done`, shallow/deep history, esvs, CEL guards, structured actions, +active objects + bus, defer, timers, faults), static contracts, snapshot +round-trip + safe-point migration, Mermaid `export`, and the §13 CLI. Built up +the build order in [issue #3][issue]. [issue]: https://github.com/fruwehq/harel-python/issues/3 diff --git a/pyproject.toml b/pyproject.toml index 678b6bb..e6a9ab0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,9 @@ dev = [ "types-jsonschema", ] +[project.scripts] +harel = "harel.cli:main" + [tool.hatch.build.targets.wheel] packages = ["src/harel"] diff --git a/src/harel/cli.py b/src/harel/cli.py new file mode 100644 index 0000000..695ffd5 --- /dev/null +++ b/src/harel/cli.py @@ -0,0 +1,386 @@ +"""Standard CLI (SPEC §13). + +Every implementation exposes the same command surface so operators and tests +interact with any language's engine identically. State persists in a +file-backed store; a state-changing command loads the affected instances, runs +all to quiescence, and persists. Diagnostics go to stderr; the result to stdout. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any, cast + +from . import collect_errors, load_definitions +from . import export as export_mod +from .contracts import load_contract, validate_contracts +from .engine import Host +from .errors import HarelError +from .instance import Instance, Status +from .model import Machine +from .store import Store, StoreState + +# Exit codes (SPEC §13.2). +EXIT_OK = 0 +EXIT_OTHER = 1 +EXIT_USAGE = 2 +EXIT_VALIDATION = 3 +EXIT_NOT_FOUND = 4 +EXIT_FAULTED = 5 + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + store_dir = args.store or os.environ.get("HAREL_STORE", "./.harel") + try: + return int(args.cmd(args, Store(store_dir))) + except HarelError as exc: + print(str(exc), file=sys.stderr) + return EXIT_OTHER + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="harel", description="harel statechart engine") + p.add_argument("--store", default=None, help="store directory (default ./.harel)") + p.add_argument( + "--version", action="version", version=f"harel {_pkg_version()}" + ) + sub = p.add_subparsers(dest="command", required=True) + + # `--json` is accepted per-subcommand (after the positionals). + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--json", action="store_true", help="machine-readable output") + + def add(cmd: str, **kw: Any) -> argparse.ArgumentParser: + return sub.add_parser(cmd, parents=[common], **kw) + + v = add("validate") + v.add_argument("machine") + v.set_defaults(cmd=cmd_validate) + + e = add("export") + e.add_argument("machine") + e.add_argument("--format", default="mermaid") + e.add_argument("--state", default=None) + e.set_defaults(cmd=cmd_export) + + n = add("new") + n.add_argument("id") + n.add_argument("machine") + n.add_argument("--external", action="append", default=[]) + n.set_defaults(cmd=cmd_new) + + s = add("send") + s.add_argument("instance") + s.add_argument("event") + s.add_argument("--payload", action="append", default=[]) + s.add_argument("--payload-json", default=None) + s.set_defaults(cmd=cmd_send) + + a = add("advance") + a.add_argument("duration") + a.set_defaults(cmd=cmd_advance) + + env = add("env") + env.add_argument("instance") + env.add_argument("--changed", required=True) + env.set_defaults(cmd=cmd_env) + + st = add("state") + st.add_argument("instance") + st.set_defaults(cmd=cmd_state) + + add("list").set_defaults(cmd=cmd_list) + + snap = add("snapshot") + snap.add_argument("instance") + snap.set_defaults(cmd=cmd_snapshot) + + r = add("restore") + r.add_argument("snapshot") + r.set_defaults(cmd=cmd_restore) + return p + + +# --- host (de)serialization ------------------------------------------------- +def _build_host(state: StoreState) -> Host: + host = Host() + host.now = state.now + host._spawn_counters = dict(state.spawn_counters) # noqa: SLF001 + for text in state.defs.values(): + host.register_all(load_definitions(text)) + host.restore_all(state.instances) + return host + + +def _persist(store: Store, state: StoreState, host: Host) -> None: + state.instances = host.snapshot_all() + state.now = host.now + state.spawn_counters = dict(host._spawn_counters) # noqa: SLF001 + store.save(state) + + +def _resolve_machine_path(arg: str) -> Path: + return Path(arg) + + +# --- commands --------------------------------------------------------------- +def cmd_validate(args: argparse.Namespace, store: Store) -> int: + raw_text = _resolve_machine_path(args.machine).read_text(encoding="utf-8") + defs = load_definitions(raw_text) + root = defs[0] + errors = list(collect_errors(root.raw)) + cdir = _resolve_machine_path(args.machine).parent / "contracts" + if cdir.exists(): + contracts = {} + for cf in sorted(cdir.glob("*.yaml")): + c = load_contract(cf.read_text(encoding="utf-8")) + contracts[c["id"]] = c + errors.extend(validate_contracts(root.raw, contracts)) + valid = not errors + if args.json: + print( + json.dumps( + { + "valid": valid, + "errors": [{"path": e["path"], "message": e["message"]} for e in errors], + } + ) + ) + elif not valid: + for e in errors: + print(f"{e['path']}: {e['message']}", file=sys.stderr) + return EXIT_OK if valid else EXIT_VALIDATION + + +def cmd_new(args: argparse.Namespace, store: Store) -> int: + state = store.load() + if any(s["id"] == args.id for s in state.instances): + print(f"instance '{args.id}' already exists", file=sys.stderr) + return EXIT_USAGE + text = _resolve_machine_path(args.machine).read_text(encoding="utf-8") + defs = load_definitions(text) + key = f"{defs[0].id}@{defs[0].version}" + state.defs[key] = text + host = _build_host(state) + external = _parse_kv(args.external, _external_types(host.machines[defs[0].id])) + host.create_root(host.machines[defs[0].id], args.id, external=external) + host.run_to_quiescence() + inst = host.instances[args.id] + _print_state(args, host, inst) + _persist(store, state, host) + return EXIT_OK + + +def cmd_send(args: argparse.Namespace, store: Store) -> int: + state = store.load() + host = _build_host(state) + inst = host.instances.get(args.instance) + if inst is None: + print(f"no such instance: {args.instance}", file=sys.stderr) + return EXIT_NOT_FOUND + payload = _build_payload(args, inst.machine) + before = len(host.published) + if not host.deliver(args.instance, args.event, payload): + print(f"rejected: {args.event}", file=sys.stderr) + return EXIT_VALIDATION + host.run_to_quiescence() + if args.json: + obj = _state_json(host, host.instances[args.instance]) + obj["published"] = host.published[before:] + print(json.dumps(obj)) + _persist(store, state, host) + inst = host.instances.get(args.instance) + if inst is not None and inst.status is Status.FAULTED: + return EXIT_FAULTED + return EXIT_OK + + +def cmd_advance(args: argparse.Namespace, store: Store) -> int: + state = store.load() + host = _build_host(state) + host.advance(args.duration) + host.run_to_quiescence() + if args.json: + print(json.dumps({"now": host.now})) + _persist(store, state, host) + return EXIT_OK + + +def cmd_env(args: argparse.Namespace, store: Store) -> int: + state = store.load() + host = _build_host(state) + if args.instance not in host.instances: + print(f"no such instance: {args.instance}", file=sys.stderr) + return EXIT_NOT_FOUND + changed = _parse_csv_kv(args.changed) + host.deliver(args.instance, "env", {"changed": changed}) + host.run_to_quiescence() + _print_state(args, host, host.instances[args.instance]) + _persist(store, state, host) + return EXIT_OK + + +def cmd_state(args: argparse.Namespace, store: Store) -> int: + state = store.load() + host = _build_host(state) + inst = host.instances.get(args.instance) + if inst is None: + print(f"no such instance: {args.instance}", file=sys.stderr) + return EXIT_NOT_FOUND + _print_state(args, host, inst) + return EXIT_OK + + +def cmd_list(args: argparse.Namespace, store: Store) -> int: + state = store.load() + host = _build_host(state) + if args.json: + rows = [ + { + "id": i.id, + "def": f"{i.machine.id}@{i.machine.version}", + "parent": i.parent_id, + "status": i.status.value, + "config": i.active_leaf_names(), + } + for i in sorted(host.instances.values(), key=lambda x: x.id) + ] + print(json.dumps(rows)) + else: + for i in sorted(host.instances.values(), key=lambda x: x.id): + print(f"{i.id}\t{i.status.value}\t{i.active_leaf_names()}") + return EXIT_OK + + +def cmd_snapshot(args: argparse.Namespace, store: Store) -> int: + state = store.load() + host = _build_host(state) + inst = host.instances.get(args.instance) + if inst is None: + print(f"no such instance: {args.instance}", file=sys.stderr) + return EXIT_NOT_FOUND + print(json.dumps(inst.to_snapshot())) + return EXIT_OK + + +def cmd_restore(args: argparse.Namespace, store: Store) -> int: + state = store.load() + snap = json.loads(_resolve_machine_path(args.snapshot).read_text(encoding="utf-8")) + host = _build_host(state) + machine = host.versions.get((snap["def_id"], snap["def_version"])) + if machine is None: + print(f"unknown definition: {snap['def_id']}@{snap['def_version']}", file=sys.stderr) + return EXIT_NOT_FOUND + inst = Instance(machine, snap["id"], snap["parent_id"], host, auto_enter=False) + inst.load_snapshot(snap) + host.instances[snap["id"]] = inst + _persist(store, state, host) + return EXIT_OK + + +def cmd_export(args: argparse.Namespace, store: Store) -> int: + defs = load_definitions(_resolve_machine_path(args.machine).read_text(encoding="utf-8")) + machine = Machine(defs[0]) + state_config = None + if args.state: + st = store.load() + host = _build_host(st) + inst = host.instances.get(args.state) + if inst is None: + print(f"no such instance: {args.state}", file=sys.stderr) + return EXIT_NOT_FOUND + state_config = sorted(inst.config) + print(export_mod.export(machine, format=args.format, state_config=state_config)) + return EXIT_OK + + +# --- output helpers --------------------------------------------------------- +def _state_json(host: Host, inst: Instance) -> dict[str, Any]: + return { + "instance": inst.id, + "def": f"{inst.machine.id}@{inst.machine.version}", + "status": inst.status.value, + "config": inst.active_leaf_names(), + "esvs": inst.resolved_esvs(), + } + + +def _print_state(args: argparse.Namespace, host: Host, inst: Instance) -> None: + if args.json: + print(json.dumps(_state_json(host, inst))) + + +def _build_payload(args: argparse.Namespace, machine: Machine) -> dict[str, Any] | None: + if args.payload_json: + return cast(dict[str, Any], json.loads(args.payload_json)) + if not args.payload: + return None + types = _event_payload_types(machine, args.event) + return _parse_kv(args.payload, types) + + +def _event_payload_types(machine: Machine, event: str) -> dict[str, str]: + decl = (machine.definition.raw.get("events") or {}).get(event) + if not isinstance(decl, dict): + return {} + return {k: v["type"] for k, v in (decl.get("payload") or {}).items()} + + +def _external_types(machine: Machine) -> dict[str, str]: + types: dict[str, str] = {} + for var, decl in (machine.top.raw.get("esvs") or {}).items(): + if decl.get("external"): + types[var] = decl["type"] + return types + + +def _parse_kv(items: list[str], types: dict[str, str]) -> dict[str, Any]: + out: dict[str, Any] = {} + for item in items: + if "=" not in item: + continue + k, v = item.split("=", 1) + out[k] = _coerce(v, types.get(k)) + return out + + +def _parse_csv_kv(items: str) -> dict[str, Any]: + out: dict[str, Any] = {} + for part in items.split(","): + if "=" in part: + k, v = part.split("=", 1) + out[k] = _coerce(v, None) + return out + + +def _coerce(value: str, type_name: str | None) -> Any: + if type_name == "int": + return int(value) + if type_name == "float": + return float(value) + if type_name == "bool": + return value.lower() in {"true", "yes", "1"} + if type_name == "list": + return json.loads(value) + if type_name == "map": + return json.loads(value) + if type_name is None: + for caster in (int, float): + try: + return caster(value) + except ValueError: + continue + if value.lower() in {"true", "false"}: + return value.lower() == "true" + return value + + +def _pkg_version() -> str: + from . import __version__ + + return __version__ diff --git a/src/harel/export.py b/src/harel/export.py new file mode 100644 index 0000000..eeb3c31 --- /dev/null +++ b/src/harel/export.py @@ -0,0 +1,115 @@ +"""Machine/instance visualization (SPEC §12, informative). + +Exporters are pluggable by format; ``mermaid`` (``stateDiagram-v2``) is the +built-in default. Without a state config the static structure is rendered; with +one (from a snapshot/observer) the active leaves and their ancestors are +highlighted for current-state visualization. +""" + +from __future__ import annotations + +from .model import Machine, State + + +def export( + machine: Machine, + format: str = "mermaid", + state_config: list[str] | None = None, +) -> str: + """Render ``machine`` (optionally highlighting ``state_config``).""" + if format != "mermaid": + raise ValueError(f"unsupported export format: {format}") + return _to_mermaid(machine, state_config) + + +def _to_mermaid(machine: Machine, state_config: list[str] | None) -> str: + lines: list[str] = ["stateDiagram-v2"] + _emit_state(machine, machine.top, lines, indent=1, in_root=True) + if state_config: + lines.append(" classDef active fill:#9f9,stroke:#3a3") + for name in sorted(_active_names(machine, state_config)): + lines.append(f" class {name} active") + return "\n".join(lines) + "\n" + + +def _emit_state( + machine: Machine, + state: State, + lines: list[str], + indent: int, + in_root: bool, +) -> None: + pad = " " * indent + composite = state.type in ("composite", "orthogonal") + if in_root: + # top is the diagram root; its initial and transitions emit at top level. + _emit_initial(state, lines, indent) + _emit_transitions(state, lines, indent) + for child in state.children.values(): + _emit_state(machine, child, lines, indent, in_root=False) + return + if composite: + lines.append(f"{pad}state {state.name} {{") + _emit_initial(state, lines, indent + 1) + _emit_transitions(state, lines, indent + 1) + for child in state.children.values(): + _emit_state(machine, child, lines, indent + 1, in_root=False) + if state.type == "orthogonal": + regions = state.raw.get("regions") or [] + for _ in range(len(regions) - 1): + lines.append(f"{pad} --") + lines.append(f"{pad}}}") + else: + _emit_transitions(state, lines, indent) + if state.type == "final": + lines.append(f"{pad}{state.name} --> [*]") + + +def _emit_initial(state: State, lines: list[str], indent: int) -> None: + initial = state.raw.get("initial") + if not isinstance(initial, dict): + return + target = _short(initial["transition_to"]) + label = _label(None, initial.get("guard")) + pad = " " * indent + lines.append(f"{pad}[*] --> {target}{label}") + + +def _emit_transitions(state: State, lines: list[str], indent: int) -> None: + pad = " " * indent + for event, spec in (state.raw.get("on_events") or {}).items(): + transitions = spec if isinstance(spec, list) else [spec] + for t in transitions: + target = t.get("transition_to") + if target is None: + continue # internal transition: no edge + lines.append(f"{pad}{state.name} --> {_short(target)}{_label(event, t.get('guard'))}") + for after in state.raw.get("after") or []: + target = after.get("transition_to") + if target is None: + continue + lines.append(f"{pad}{state.name} --> {_short(target)} : after({after['duration']})") + + +def _label(event: str | None, guard: str | None) -> str: + """Edge label `` : event [guard]`` (§12).""" + text = event or "" + if guard: + text = f"{text} [{guard}]" if text else f"[{guard}]" + return f" : {text}" if text else "" + + +def _short(ref: str) -> str: + """A transition_to ref -> the final (leaf-most) component name.""" + return ref.split(".")[-1] + + +def _active_names(machine: Machine, state_config: list[str]) -> set[str]: + """Names of the active leaves and their ancestors (excluding the root `top`).""" + names: set[str] = set() + for path in state_config: + cur = machine.by_path.get(path) + while cur is not None and cur.parent is not None: + names.add(cur.name) + cur = cur.parent + return names diff --git a/src/harel/store.py b/src/harel/store.py new file mode 100644 index 0000000..9a04482 --- /dev/null +++ b/src/harel/store.py @@ -0,0 +1,63 @@ +"""File-backed store for the CLI (SPEC §13.1). + +A store is a directory (``--store ``, default ``$HAREL_STORE`` or +``./.harel``) holding the registered definitions, instance snapshots, and the +virtual clock. The on-disk layout is an implementation detail; the normative +contract is CLI behaviour + the JSON I/O (§13.4). State-changing commands load +the affected instances, run all instances to quiescence, and persist atomically. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass +class StoreState: + defs: dict[str, str] = field(default_factory=dict) # "id@version" -> yaml text + instances: list[dict[str, Any]] = field(default_factory=list) + now: int = 0 + spawn_counters: dict[str, int] = field(default_factory=dict) + + +class Store: + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + + def _ensure(self) -> None: + self.path.mkdir(parents=True, exist_ok=True) + + def _read_json(self, name: str) -> Any: + p = self.path / name + if not p.exists(): + return None + return json.loads(p.read_text(encoding="utf-8")) + + def load(self) -> StoreState: + defs = self._read_json("defs.json") or {} + instances = self._read_json("instances.json") or [] + meta = self._read_json("meta.json") or {} + return StoreState( + defs=defs, + instances=instances, + now=int(meta.get("now", 0)), + spawn_counters=dict(meta.get("spawn_counters") or {}), + ) + + def save(self, state: StoreState) -> None: + self._ensure() + (self.path / "defs.json").write_text( + json.dumps(state.defs, indent=2), encoding="utf-8" + ) + (self.path / "instances.json").write_text( + json.dumps(state.instances, indent=2), encoding="utf-8" + ) + (self.path / "meta.json").write_text( + json.dumps( + {"now": state.now, "spawn_counters": state.spawn_counters}, indent=2 + ), + encoding="utf-8", + ) diff --git a/tests/harness.py b/tests/harness.py index 1354bd9..04207a2 100644 --- a/tests/harness.py +++ b/tests/harness.py @@ -8,6 +8,7 @@ from __future__ import annotations +import json from dataclasses import dataclass from pathlib import Path from typing import Any @@ -98,6 +99,60 @@ def cli_cases() -> list[Path]: return sorted(p for p in cli_dir.iterdir() if p.is_dir()) +# --- CLI case runner (SPEC §13.6) ------------------------------------------ +def run_cli_case(case_dir: Path) -> None: + """Run a CLI case's steps against a fresh temp store; assert exit + output.""" + import io + import tempfile + from contextlib import redirect_stdout + + from harel import cli + + spec = _load_yaml(case_dir / "cli.yaml") + tmpstore = tempfile.mkdtemp(prefix="harel-cli-") + for i, step in enumerate(spec.get("steps", [])): + argv = [_resolve_arg(case_dir, a) for a in step["run"]] + buf = io.StringIO() + with redirect_stdout(buf): + rc = cli.main(["--store", tmpstore, *argv]) + _check_cli_expect(case_dir.name, i, rc, buf.getvalue(), step.get("expect") or {}) + + +def _resolve_arg(case_dir: Path, arg: str) -> str: + # A bare filename that exists in the case dir (e.g. machine.yaml) is resolved. + if "/" not in arg and (case_dir / arg).exists(): + return str(case_dir / arg) + return arg + + +def _check_cli_expect( + name: str, i: int, rc: int, stdout: str, expect: dict[str, Any] +) -> None: + label = f"cli/{name} step {i}" + if "exit" in expect: + assert rc == expect["exit"], f"{label}: exit {rc} != {expect['exit']}" + if "json" in expect: + actual = json.loads(stdout) if stdout.strip() else None + _assert_subset(actual, expect["json"], label) + elif "stdout" in expect: + assert stdout == expect["stdout"], f"{label}: stdout mismatch" + + +def _assert_subset(actual: Any, expected: Any, label: str) -> None: + if isinstance(expected, dict): + assert isinstance(actual, dict), f"{label}: expected object, got {type(actual)}" + for k, v in expected.items(): + assert k in actual, f"{label}: missing key '{k}'" + _assert_subset(actual[k], v, label) + elif isinstance(expected, list): + assert isinstance(actual, list), f"{label}: expected list" + assert len(actual) == len(expected), f"{label}: list length" + for a, e in zip(actual, expected, strict=False): + _assert_subset(a, e, label) + else: + assert actual == expected, f"{label}: {actual!r} != {expected!r}" + + # --- engine case runner ----------------------------------------------------- def run_engine_case(case: EngineCase) -> None: """Execute one engine conformance case, asserting every ``expect`` (SPEC §9).""" diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 529b559..f9db508 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -25,6 +25,7 @@ SUPPORTED, cli_cases, engine_cases, + run_cli_case, run_engine_case, ) @@ -66,3 +67,8 @@ def test_engine_case(case) -> None: # type: ignore[no-untyped-def] if case.name not in SUPPORTED: pytest.skip(f"not yet supported: {case.name}") run_engine_case(case) + + +@pytest.mark.parametrize("case", cli_cases(), ids=lambda c: f"cli/{c.name}") +def test_cli_case(case) -> None: # type: ignore[no-untyped-def] + run_cli_case(case) diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..7217a8c --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,52 @@ +"""Mermaid export tests (SPEC §12).""" + +from __future__ import annotations + +from harel import load_definition +from harel.export import export +from harel.model import Machine + +TURNSTILE = """ +id: turnstile +events: + coin: { payload: { amount: { type: int, required: true } } } + push: {} +top: + esvs: + fare: { type: int, init: 50 } + initial: { transition_to: locked } + states: + locked: + on_events: + coin: { transition_to: unlocked, guard: "amount >= fare" } + unlocked: + on_events: + push: { transition_to: locked } +""" + + +def test_static_structure() -> None: + machine = Machine(load_definition(TURNSTILE)) + out = export(machine) + assert out.startswith("stateDiagram-v2") + assert "[*] --> locked" in out + assert "locked --> unlocked : coin [amount >= fare]" in out + assert "unlocked --> locked : push" in out + + +def test_current_state_highlight() -> None: + machine = Machine(load_definition(TURNSTILE)) + # active leaf `unlocked`; its ancestor is `top`. + leaf = machine.by_path["top.unlocked"] + config = [leaf.path] + out = export(machine, state_config=config) + assert "classDef active fill:#9f9,stroke:#3a3" in out + assert "class unlocked active" in out + + +def test_unsupported_format_raises() -> None: + import pytest + + machine = Machine(load_definition(TURNSTILE)) + with pytest.raises(ValueError): + export(machine, format="plantuml")