From c46e9327df8646a8323ce2b44875628e3742b7ae Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Wed, 27 Nov 2024 20:03:29 -0300 Subject: [PATCH] feat: Delayed events; Support for SCXML tag --- pyproject.toml | 2 +- statemachine/engines/async_.py | 10 +- statemachine/engines/base.py | 7 +- statemachine/engines/sync.py | 12 +- statemachine/event.py | 36 +++-- statemachine/event_data.py | 34 +++- statemachine/io/scxml.py | 160 +++++++++++++++++-- statemachine/statemachine.py | 22 ++- tests/w3c_tests/test_testcases.py | 8 +- tests/w3c_tests/testcases/test158.scxml | 31 ++++ tests/w3c_tests/testcases/test159.scxml | 26 +++ tests/w3c_tests/testcases/test172.scxml | 25 +++ tests/w3c_tests/testcases/test173.scxml | 26 +++ tests/w3c_tests/testcases/test174.scxml | 26 +++ tests/w3c_tests/testcases/test175.scxml | 34 ++++ tests/w3c_tests/testcases/test176.scxml | 36 +++++ tests/w3c_tests/testcases/test179.scxml | 24 +++ tests/w3c_tests/testcases/test183.scxml | 26 +++ tests/w3c_tests/testcases/test185.scxml | 27 ++++ tests/w3c_tests/testcases/test186.scxml | 39 +++++ tests/w3c_tests/testcases/test187-fail.scxml | 40 +++++ tests/w3c_tests/testcases/test189-fail.scxml | 29 ++++ tests/w3c_tests/testcases/test190-fail.scxml | 45 ++++++ tests/w3c_tests/testcases/test191-fail.scxml | 40 +++++ tests/w3c_tests/testcases/test192-fail.scxml | 52 ++++++ 25 files changed, 773 insertions(+), 44 deletions(-) create mode 100644 tests/w3c_tests/testcases/test158.scxml create mode 100644 tests/w3c_tests/testcases/test159.scxml create mode 100644 tests/w3c_tests/testcases/test172.scxml create mode 100644 tests/w3c_tests/testcases/test173.scxml create mode 100644 tests/w3c_tests/testcases/test174.scxml create mode 100644 tests/w3c_tests/testcases/test175.scxml create mode 100644 tests/w3c_tests/testcases/test176.scxml create mode 100644 tests/w3c_tests/testcases/test179.scxml create mode 100644 tests/w3c_tests/testcases/test183.scxml create mode 100644 tests/w3c_tests/testcases/test185.scxml create mode 100644 tests/w3c_tests/testcases/test186.scxml create mode 100644 tests/w3c_tests/testcases/test187-fail.scxml create mode 100644 tests/w3c_tests/testcases/test189-fail.scxml create mode 100644 tests/w3c_tests/testcases/test190-fail.scxml create mode 100644 tests/w3c_tests/testcases/test191-fail.scxml create mode 100644 tests/w3c_tests/testcases/test192-fail.scxml diff --git a/pyproject.toml b/pyproject.toml index 197bb74..31b52e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -178,7 +178,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" "tests/examples/**.py" = ["B018"] [tool.ruff.lint.mccabe] -max-complexity = 6 +max-complexity = 10 [tool.ruff.lint.isort] force-single-line = true diff --git a/statemachine/engines/async_.py b/statemachine/engines/async_.py index 9d2b3f9..4e8fd05 100644 --- a/statemachine/engines/async_.py +++ b/statemachine/engines/async_.py @@ -1,3 +1,6 @@ +import asyncio +import heapq +from time import time from typing import TYPE_CHECKING from ..event_data import EventData @@ -61,7 +64,12 @@ async def processing_loop(self): try: # Execute the triggers in the queue in FIFO order until the queue is empty while self._external_queue: - trigger_data = self._external_queue.popleft() + trigger_data = heapq.heappop(self._external_queue) + current_time = time() + if trigger_data.execution_time > current_time: + self.put(trigger_data) + await asyncio.sleep(0.001) + continue try: result = await self._trigger(trigger_data) if first_result is self._sentinel: diff --git a/statemachine/engines/base.py b/statemachine/engines/base.py index dbfb282..bb447f9 100644 --- a/statemachine/engines/base.py +++ b/statemachine/engines/base.py @@ -1,4 +1,4 @@ -from collections import deque +import heapq from threading import Lock from typing import TYPE_CHECKING from weakref import proxy @@ -17,7 +17,7 @@ class BaseEngine: def __init__(self, sm: "StateMachine", rtc: bool = True): self.sm: StateMachine = proxy(sm) - self._external_queue: deque = deque() + self._external_queue: list = [] self._sentinel = object() self._rtc = rtc self._processing = Lock() @@ -27,7 +27,8 @@ def put(self, trigger_data: TriggerData): """Put the trigger on the queue without blocking the caller.""" if not self._running and not self.sm.allow_event_without_transition: raise TransitionNotAllowed(trigger_data.event, self.sm.current_state) - self._external_queue.append(trigger_data) + + heapq.heappush(self._external_queue, trigger_data) def start(self): if self.sm.current_state_value is not None: diff --git a/statemachine/engines/sync.py b/statemachine/engines/sync.py index fe09678..89b8afb 100644 --- a/statemachine/engines/sync.py +++ b/statemachine/engines/sync.py @@ -1,3 +1,6 @@ +import heapq +from time import sleep +from time import time from typing import TYPE_CHECKING from ..event_data import EventData @@ -47,7 +50,7 @@ def processing_loop(self): """ if not self._rtc: # The machine is in "synchronous" mode - trigger_data = self._external_queue.popleft() + trigger_data = heapq.heappop(self._external_queue) return self._trigger(trigger_data) # We make sure that only the first event enters the processing critical section, @@ -62,7 +65,12 @@ def processing_loop(self): try: # Execute the triggers in the queue in FIFO order until the queue is empty while self._running and self._external_queue: - trigger_data = self._external_queue.popleft() + trigger_data = heapq.heappop(self._external_queue) + current_time = time() + if trigger_data.execution_time > current_time: + self.put(trigger_data) + sleep(0.001) + continue try: result = self._trigger(trigger_data) if first_result is self._sentinel: diff --git a/statemachine/event.py b/statemachine/event.py index f80f811..a5c5369 100644 --- a/statemachine/event.py +++ b/statemachine/event.py @@ -1,10 +1,7 @@ -from inspect import isawaitable from typing import TYPE_CHECKING from typing import List from uuid import uuid4 -from statemachine.utils import run_async_from_sync - from .event_data import TriggerData from .i18n import _ @@ -42,6 +39,9 @@ class Event(str): name: str """The event name.""" + delay: float = 0 + """The delay in milliseconds before the event is triggered. Default is 0.""" + _sm: "StateMachine | None" = None """The state machine instance.""" @@ -53,6 +53,7 @@ def __new__( transitions: "str | TransitionList | None" = None, id: "str | None" = None, name: "str | None" = None, + delay: float = 0, _sm: "StateMachine | None" = None, ): if isinstance(transitions, str): @@ -64,6 +65,7 @@ def __new__( instance = super().__new__(cls, id) instance.id = id + instance.delay = delay if name: instance.name = name elif _has_real_id: @@ -92,19 +94,13 @@ def __get__(self, instance, owner): """ if instance is None: return self - return BoundEvent(id=self.id, name=self.name, _sm=instance) - - def __call__(self, *args, **kwargs): - """Send this event to the current state machine. + return BoundEvent(id=self.id, name=self.name, delay=self.delay, _sm=instance) - Triggering an event on a state machine means invoking or sending a signal, initiating the - process that may result in executing a transition. - """ + def put(self, *args, machine: "StateMachine", **kwargs): # The `__call__` is declared here to help IDEs knowing that an `Event` # can be called as a method. But it is not meant to be called without # an SM instance. Such SM instance is provided by `__get__` method when # used as a property descriptor. - machine = self._sm if machine is None: raise RuntimeError(_("Event {} cannot be called without a SM instance").format(self)) @@ -116,10 +112,20 @@ def __call__(self, *args, **kwargs): kwargs=kwargs, ) machine._put_nonblocking(trigger_data) - result = machine._processing_loop() - if not isawaitable(result): - return result - return run_async_from_sync(result) + + def __call__(self, *args, **kwargs): + """Send this event to the current state machine. + + Triggering an event on a state machine means invoking or sending a signal, initiating the + process that may result in executing a transition. + """ + # The `__call__` is declared here to help IDEs knowing that an `Event` + # can be called as a method. But it is not meant to be called without + # an SM instance. Such SM instance is provided by `__get__` method when + # used as a property descriptor. + machine = self._sm + self.put(*args, machine=machine, **kwargs) + return machine._processing_loop() def split( # type: ignore[override] self, sep: "str | None" = None, maxsplit: int = -1 diff --git a/statemachine/event_data.py b/statemachine/event_data.py index 8187e14..7b27600 100644 --- a/statemachine/event_data.py +++ b/statemachine/event_data.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from dataclasses import field +from time import time from typing import TYPE_CHECKING from typing import Any @@ -11,23 +12,36 @@ @dataclass +class _Data: + kwargs: dict + + def __getattr__(self, name): + return self.kwargs.get(name, None) + + +@dataclass(order=True) class TriggerData: - machine: "StateMachine" + machine: "StateMachine" = field(compare=False) - event: "Event | None" + event: "Event | None" = field(compare=False) """The Event that was triggered.""" - model: Any = field(init=False) + execution_time: float = field(default=0.0) + """The time at which the :ref:`Event` should run.""" + + model: Any = field(init=False, compare=False) """A reference to the underlying model that holds the current :ref:`State`.""" - args: tuple = field(default_factory=tuple) + args: tuple = field(default_factory=tuple, compare=False) """All positional arguments provided on the :ref:`Event`.""" - kwargs: dict = field(default_factory=dict) + kwargs: dict = field(default_factory=dict, compare=False) """All keyword arguments provided on the :ref:`Event`.""" def __post_init__(self): self.model = self.machine.model + delay = self.event.delay if self.event and self.event.delay else 0 + self.execution_time = time() + (delay / 1000) @dataclass @@ -77,3 +91,13 @@ def extended_kwargs(self): kwargs["source"] = self.source kwargs["target"] = self.target return kwargs + + @property + def data(self): + "Property used by the SCXML namespace" + if self.trigger_data.kwargs: + return _Data(self.trigger_data.kwargs) + elif self.trigger_data.args and len(self.trigger_data.args) == 1: + return self.trigger_data.args[0] + else: + return self.trigger_data.args diff --git a/statemachine/io/scxml.py b/statemachine/io/scxml.py index 9d60e54..82f1e57 100644 --- a/statemachine/io/scxml.py +++ b/statemachine/io/scxml.py @@ -8,13 +8,25 @@ from typing import Any from typing import Dict from typing import List +from uuid import uuid4 + +from statemachine.event import Event from ..model import Model from ..statemachine import StateMachine -def parse_onentry(element): - """Parses the XML into a callable.""" +def _eval(expr: str, **kwargs) -> Any: + if "machine" in kwargs: + kwargs.update(kwargs["machine"].model.__dict__) + if "event_data" in kwargs: + kwargs["_event"] = kwargs["event_data"] + + return eval(expr, {}, kwargs) + + +def parse_executable_content(element): + """Parses the children as content XML into a callable.""" actions = [parse_element(child) for child in element] def execute_block(*args, **kwargs): @@ -22,9 +34,13 @@ def execute_block(*args, **kwargs): try: for action in actions: action(*args, **kwargs) - except Exception: - machine.send("error.execution") + machine._processing_loop() + except Exception as e: + machine.send("error.execution", error=e) + + if not actions: + return None return execute_block @@ -41,6 +57,8 @@ def parse_element(element): return parse_log(element) elif tag == "if": return parse_if(element) + elif tag == "send": + return parse_send(element) else: raise ValueError(f"Unknown tag: {tag}") @@ -64,7 +82,7 @@ def parse_log(element): def raise_log(*args, **kwargs): machine = kwargs["machine"] kwargs.update(machine.model.__dict__) - value = eval(expr, {}, kwargs) + value = _eval(expr, **kwargs) print(f"{label}: {value!r}") return raise_log @@ -77,7 +95,7 @@ def parse_assign(element): def assign_action(*args, **kwargs): machine = kwargs["machine"] - value = eval(expr, {}, {"machine": machine, **machine.model.__dict__}) + value = _eval(expr, **kwargs) setattr(machine.model, location, value) return assign_action @@ -173,8 +191,7 @@ def parse_cond(cond): return None def cond_action(*args, **kwargs): - machine = kwargs["machine"] - return eval(cond, {}, {"machine": machine, **machine.model.__dict__}) + return _eval(cond, **kwargs) cond_action.cond = cond @@ -282,6 +299,124 @@ def data_initializer(model): return data_initializer +class ParseTime: + pattern = re.compile(r"(\d+)?(\.\d+)?(s|ms)") + + @classmethod + def replace(cls, expr: str) -> str: + def rep(match): + return str(cls.time_in_ms(match.group(0))) + + return cls.pattern.sub(rep, expr) + + @classmethod + def time_in_ms(cls, expr: str) -> float: + """ + Convert a CSS2 time expression to milliseconds. + + Args: + time (str): A string representing the time, e.g., '1.5s' or '150ms'. + + Returns: + float: The time in milliseconds. + + Raises: + ValueError: If the input is not a valid CSS2 time expression. + """ + if expr.endswith("ms"): + try: + return float(expr[:-2]) + except ValueError as e: + raise ValueError(f"Invalid time value: {expr}") from e + elif expr.endswith("s"): + try: + return float(expr[:-1]) * 1000 + except ValueError as e: + raise ValueError(f"Invalid time value: {expr}") from e + else: + try: + return float(expr) + except ValueError as e: + raise ValueError(f"Invalid time unit in: {expr}") from e + + +def parse_send(element): # noqa: C901 + """ + Parses the element into a callable that dispatches events. + + Attributes: + - `event`: The name of the event to send (required). + - `target`: The target to which the event is sent (optional). + - `type`: The type of the event (optional). + - `id`: A unique identifier for this send action (optional). + - `delay`: The delay before sending the event (optional). + - `namelist`: A space-separated list of data model variables to include in the event (optional) + - `params`: A dictionary of parameters to include in the event (optional). + - `content`: Content to include in the event (optional). + """ + event_attr = element.attrib.get("event") + event_expr = element.attrib.get("eventexpr") + if not (event_attr or event_expr): + raise ValueError(" must have an 'event' or `eventexpr` attribute") + + target_expr = element.attrib.get("target") + type_expr = element.attrib.get("type") + id_attr = element.attrib.get("id") + idlocation = element.attrib.get("idlocation") + delay_attr = element.attrib.get("delay") + delay_expr = element.attrib.get("delayexpr") + namelist_expr = element.attrib.get("namelist") + + # Parse and child elements + params = {} + content = () + for child in element: + if child.tag == "param": + name = child.attrib.get("name") + expr = child.attrib.get("expr") + if name and expr: + params[name] = expr + elif child.tag == "content": + content = (_eval(child.text),) + + def send_action(*args, **kwargs): + machine = kwargs["machine"] + context = {**machine.model.__dict__} + + # Evaluate expressions + event = event_attr or eval(event_expr, {}, context) + _target = eval(target_expr, {}, context) if target_expr else None + _event_type = eval(type_expr, {}, context) if type_expr else None + + if id_attr: + send_id = id_attr + else: + send_id = uuid4().hex + if idlocation: + setattr(machine.model, idlocation, send_id) + + if delay_attr: + delay = ParseTime.time_in_ms(delay_attr) + elif delay_expr: + delay_expr_expanded = ParseTime.replace(delay_expr) + delay = ParseTime.time_in_ms(eval(delay_expr_expanded, {}, context)) + else: + delay = 0 + + params_values = {} + if namelist_expr: + for name in namelist_expr.split(): + if name in context: + params_values[name] = context[name] + + for name, expr in params.items(): + params_values[name] = eval(expr, {}, context) + + Event(id=event, name=event, delay=delay).put(*content, machine=machine, **params_values) + + return send_action + + def strip_namespaces(tree): """Remove all namespaces from tags and attributes in place. @@ -335,9 +470,13 @@ def _parse_state(state_elem, final=False): # noqa: C901 event = trans_elem.get("event") or None target = trans_elem.get("target") cond = parse_cond(trans_elem.get("cond")) + content_action = parse_executable_content(trans_elem) if target: state = states[state_id] + + # This "on" represents the events handled by the state + # there's also a possibility of "on" as an action if "on" not in state: state["on"] = {} @@ -350,13 +489,16 @@ def _parse_state(state_elem, final=False): # noqa: C901 if cond: transition["cond"] = cond + if content_action: + transition["on"] = content_action + transitions.append(transition) if target not in states: states[target] = {} for onentry_elem in state_elem.findall("onentry"): - entry_action = parse_onentry(onentry_elem) + entry_action = parse_executable_content(onentry_elem) state = states[state_id] if "enter" not in state: state["enter"] = [] diff --git a/statemachine/statemachine.py b/statemachine/statemachine.py index a4d51fe..3f1e45b 100644 --- a/statemachine/statemachine.py +++ b/statemachine/statemachine.py @@ -111,7 +111,10 @@ def activate_initial_state(self): return run_async_from_sync(result) def _processing_loop(self): - return self._engine.processing_loop() + result = self._engine.processing_loop() + if not isawaitable(result): + return result + return run_async_from_sync(result) def __init_subclass__(cls, strict_states: bool = False): cls._strict_states = strict_states @@ -303,17 +306,24 @@ def _put_nonblocking(self, trigger_data: TriggerData): """Put the trigger on the queue without blocking the caller.""" self._engine.put(trigger_data) - def send(self, event: str, *args, **kwargs): + def send(self, event: str, *args, delay: float = 0, **kwargs): """Send an :ref:`Event` to the state machine. + :param event: The trigger for the state machine, specified as an event id string. + :param args: Additional positional arguments to pass to the event. + :param delay: A time delay in milliseconds to process the event. Default is 0. + :param kwargs: Additional keyword arguments to pass to the event. + .. seealso:: See: :ref:`triggering events`. - """ - event_instance: BoundEvent = getattr( - self, event, BoundEvent(id=event, name=event, _sm=self) - ) + know_event = getattr(self, event, None) + event_name = know_event.name if know_event else event + delay = ( + delay if delay else know_event and know_event.delay or 0 + ) # first the param, then the event, or 0 + event_instance = BoundEvent(id=event, name=event_name, delay=delay, _sm=self) result = event_instance(*args, **kwargs) if not isawaitable(result): return result diff --git a/tests/w3c_tests/test_testcases.py b/tests/w3c_tests/test_testcases.py index 0fabb85..773487e 100644 --- a/tests/w3c_tests/test_testcases.py +++ b/tests/w3c_tests/test_testcases.py @@ -20,8 +20,12 @@ class DebugListener: events: list = field(default_factory=list) - def on_transition(self, event: Event, source: State, target: State): - self.events.append(f"{source and source.id} --({event and event.id})--> {target.id}") + def on_transition(self, event: Event, source: State, target: State, event_data): + self.events.append( + f"{source and source.id} -- " + f"{event and event.id}{event_data.trigger_data.kwargs} --> " + f"{target.id}" + ) def test_usecase(testcase_path, sm_class): diff --git a/tests/w3c_tests/testcases/test158.scxml b/tests/w3c_tests/testcases/test158.scxml new file mode 100644 index 0000000..a989fc3 --- /dev/null +++ b/tests/w3c_tests/testcases/test158.scxml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/w3c_tests/testcases/test159.scxml b/tests/w3c_tests/testcases/test159.scxml new file mode 100644 index 0000000..b17e5f5 --- /dev/null +++ b/tests/w3c_tests/testcases/test159.scxml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/w3c_tests/testcases/test172.scxml b/tests/w3c_tests/testcases/test172.scxml new file mode 100644 index 0000000..5194a97 --- /dev/null +++ b/tests/w3c_tests/testcases/test172.scxml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/w3c_tests/testcases/test173.scxml b/tests/w3c_tests/testcases/test173.scxml new file mode 100644 index 0000000..b3694a6 --- /dev/null +++ b/tests/w3c_tests/testcases/test173.scxml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/w3c_tests/testcases/test174.scxml b/tests/w3c_tests/testcases/test174.scxml new file mode 100644 index 0000000..227b787 --- /dev/null +++ b/tests/w3c_tests/testcases/test174.scxml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/w3c_tests/testcases/test175.scxml b/tests/w3c_tests/testcases/test175.scxml new file mode 100644 index 0000000..c5ada22 --- /dev/null +++ b/tests/w3c_tests/testcases/test175.scxml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/w3c_tests/testcases/test176.scxml b/tests/w3c_tests/testcases/test176.scxml new file mode 100644 index 0000000..f337561 --- /dev/null +++ b/tests/w3c_tests/testcases/test176.scxml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/w3c_tests/testcases/test179.scxml b/tests/w3c_tests/testcases/test179.scxml new file mode 100644 index 0000000..64168fe --- /dev/null +++ b/tests/w3c_tests/testcases/test179.scxml @@ -0,0 +1,24 @@ + + + + + + + 123 + + + + + + + + + + + + + + + + diff --git a/tests/w3c_tests/testcases/test183.scxml b/tests/w3c_tests/testcases/test183.scxml new file mode 100644 index 0000000..ba28159 --- /dev/null +++ b/tests/w3c_tests/testcases/test183.scxml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/w3c_tests/testcases/test185.scxml b/tests/w3c_tests/testcases/test185.scxml new file mode 100644 index 0000000..46bb4eb --- /dev/null +++ b/tests/w3c_tests/testcases/test185.scxml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/w3c_tests/testcases/test186.scxml b/tests/w3c_tests/testcases/test186.scxml new file mode 100644 index 0000000..395b177 --- /dev/null +++ b/tests/w3c_tests/testcases/test186.scxml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/w3c_tests/testcases/test187-fail.scxml b/tests/w3c_tests/testcases/test187-fail.scxml new file mode 100644 index 0000000..b6fa91b --- /dev/null +++ b/tests/w3c_tests/testcases/test187-fail.scxml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/w3c_tests/testcases/test189-fail.scxml b/tests/w3c_tests/testcases/test189-fail.scxml new file mode 100644 index 0000000..916a9cd --- /dev/null +++ b/tests/w3c_tests/testcases/test189-fail.scxml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/w3c_tests/testcases/test190-fail.scxml b/tests/w3c_tests/testcases/test190-fail.scxml new file mode 100644 index 0000000..c1728a6 --- /dev/null +++ b/tests/w3c_tests/testcases/test190-fail.scxml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/w3c_tests/testcases/test191-fail.scxml b/tests/w3c_tests/testcases/test191-fail.scxml new file mode 100644 index 0000000..50e226e --- /dev/null +++ b/tests/w3c_tests/testcases/test191-fail.scxml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/w3c_tests/testcases/test192-fail.scxml b/tests/w3c_tests/testcases/test192-fail.scxml new file mode 100644 index 0000000..e445874 --- /dev/null +++ b/tests/w3c_tests/testcases/test192-fail.scxml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +