diff --git a/examples/mix.lock b/examples/mix.lock
index 8d1424b..c8c674b 100644
--- a/examples/mix.lock
+++ b/examples/mix.lock
@@ -1,5 +1,6 @@
%{
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "predicator": {:hex, :predicator, "3.3.0", "9b5bb2be6723c60e5840be9c760c861e8c90d2f464f1e2dc286226e252cb18bc", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9abb5f35d01abf3c552af7307ad2016c3374f12fa8df38ab78be174e20632be3"},
+ "predicator": {:hex, :predicator, "3.5.0", "bcdf48834287a575be4c3abf26e21879db651a503e2bccb240a5c09660a213ce", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b412569bb124ae0728840fa88d5e884aae6aa2ee35a6a278e08299c14a2dd3ef"},
"saxy": {:hex, :saxy, "1.6.0", "02cb4e9bd045f25ac0c70fae8164754878327ee393c338a090288210b02317ee", [:mix], [], "hexpm", "ef42eb4ac983ca77d650fbdb68368b26570f6cc5895f0faa04d34a6f384abad3"},
+ "uxid": {:hex, :uxid, "2.1.0", "7d23902ae8f0898a59691194441ffcd65cab1eec5fdc9d1f4021ed036af7e372", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ef682ae60049610dc4afe472fc094c50e904c622beb428d836ab61031eb683cb"},
}
diff --git a/lib/mix/tasks/test.update_features.ex b/lib/mix/tasks/test.update_features.ex
index c3cecb1..c1a85e0 100644
--- a/lib/mix/tasks/test.update_features.ex
+++ b/lib/mix/tasks/test.update_features.ex
@@ -171,13 +171,21 @@ defmodule Mix.Tasks.Test.UpdateFeatures do
" @tag required_features: []"
else
features_formatted = Enum.map_join(features_list, ", ", &inspect/1)
+ single_line_tag = " @tag required_features: [#{features_formatted}]"
- " @tag required_features: [#{features_formatted}]"
+ # Use multiline format if the single line would be too long (>120 chars)
+ if String.length(single_line_tag) > 120 do
+ features_multiline = Enum.map_join(features_list, ",\n ", &inspect/1)
+ " @tag required_features: [\n #{features_multiline}\n ]"
+ else
+ single_line_tag
+ end
end
# Check if @required_features or @tag required_features: already exists
+ # Use multiline matching to handle multiline @tag required_features: blocks properly
case Regex.run(
- ~r/^(\s*)@(required_features\s+\[.*?\]|tag required_features:\s*\[.*?\])/m,
+ ~r/^(\s*)@(required_features\s+\[[^\]]*(?:\n[^\]]*)*\]|tag required_features:\s*\[[^\]]*(?:\n[^\]]*)*\])/m,
content
) do
[existing_line, _indent, _match_group] ->
diff --git a/lib/statifier/actions/send_action.ex b/lib/statifier/actions/send_action.ex
index 118a390..7ad6b73 100644
--- a/lib/statifier/actions/send_action.ex
+++ b/lib/statifier/actions/send_action.ex
@@ -10,6 +10,7 @@ defmodule Statifier.Actions.SendAction do
- Interface with external systems via Event I/O Processors
"""
+ alias Predicator.Duration
alias Statifier.{Evaluator, Event, StateChart}
alias Statifier.Logging.LogManager
require LogManager
@@ -80,18 +81,25 @@ defmodule Statifier.Actions.SendAction do
"""
@spec execute(Statifier.StateChart.t(), t()) :: Statifier.StateChart.t()
def execute(state_chart, %__MODULE__{} = send_action) do
- # Phase 1: Only support immediate internal sends
- {:ok, event_name, target_uri, _delay} = evaluate_send_parameters(send_action, state_chart)
-
- if target_uri == "#_internal" do
- execute_internal_send(event_name, send_action, state_chart)
- else
- # Phase 1: Log unsupported external targets
- LogManager.info(state_chart, "External send targets not yet supported", %{
- action_type: "send_action",
- target: target_uri,
- event_name: event_name
- })
+ {:ok, event_name, target_uri, delay_ms} = evaluate_send_parameters(send_action, state_chart)
+
+ cond do
+ target_uri == "#_internal" and delay_ms == 0 ->
+ # Immediate internal send - execute now
+ execute_internal_send(event_name, send_action, state_chart)
+
+ target_uri == "#_internal" and delay_ms > 0 ->
+ # Delayed internal send - requires StateMachine context
+ execute_delayed_send(event_name, send_action, state_chart, delay_ms)
+
+ true ->
+ # External targets not yet supported
+ LogManager.info(state_chart, "External send targets not yet supported", %{
+ action_type: "send_action",
+ target: target_uri,
+ event_name: event_name,
+ delay_ms: delay_ms
+ })
end
end
@@ -131,13 +139,55 @@ defmodule Statifier.Actions.SendAction do
end
defp evaluate_delay(send_action, state_chart) do
- state_chart
- |> evaluate_attribute_with_expr(
- send_action.delay,
- send_action.compiled_delay_expr,
- send_action.delay_expr,
- "0s"
- )
+ delay_string =
+ state_chart
+ |> evaluate_attribute_with_expr(
+ send_action.delay,
+ send_action.compiled_delay_expr,
+ send_action.delay_expr,
+ "0s"
+ )
+
+ # Parse delay string to milliseconds using Predicator's duration parsing
+ case parse_delay_to_milliseconds(delay_string) do
+ {:ok, milliseconds} ->
+ milliseconds
+
+ {:error, reason} ->
+ LogManager.warn(state_chart, "Invalid delay expression, defaulting to 0ms", %{
+ action_type: "send_action",
+ delay_string: delay_string,
+ error: inspect(reason)
+ })
+
+ 0
+ end
+ end
+
+ # Parse delay string to milliseconds using Predicator's duration support
+ defp parse_delay_to_milliseconds(delay_string) when is_binary(delay_string) do
+ case Predicator.evaluate(delay_string) do
+ {:ok, %{} = duration_map} ->
+ # Duration map returned - convert to milliseconds
+ {:ok, Duration.to_milliseconds(duration_map)}
+
+ {:ok, numeric_value} when is_number(numeric_value) ->
+ # Numeric value - assume milliseconds
+ {:ok, round(numeric_value)}
+
+ {:ok, string_value} when is_binary(string_value) ->
+ # Try evaluating as duration string again (might be nested evaluation)
+ case Predicator.evaluate(string_value) do
+ {:ok, %{} = duration_map} -> {:ok, Duration.to_milliseconds(duration_map)}
+ {:ok, numeric_value} when is_number(numeric_value) -> {:ok, round(numeric_value)}
+ error -> error
+ end
+
+ error ->
+ error
+ end
+ rescue
+ error -> {:error, error}
end
# Common helper for evaluating attributes that can be static or expressions
@@ -173,6 +223,60 @@ defmodule Statifier.Actions.SendAction do
end
end
+ # Execute delayed send - requires StateMachine context
+ defp execute_delayed_send(event_name, send_action, state_chart, delay_ms) do
+ # Check if we're running in StateMachine context via StateChart field
+ case state_chart.state_machine_pid do
+ pid when is_pid(pid) ->
+ # Generate send ID for tracking
+ send_id = generate_send_id(send_action)
+
+ # Build event data
+ event_data = build_event_data(send_action, state_chart)
+
+ # Create the delayed event
+ delayed_event = %Event{
+ name: event_name,
+ data: event_data,
+ origin: :internal
+ }
+
+ # Schedule the delayed send through StateMachine (async to avoid deadlock)
+ GenServer.cast(pid, {:schedule_delayed_send, send_id, delayed_event, delay_ms})
+
+ LogManager.info(state_chart, "Scheduled delayed send", %{
+ action_type: "send_action",
+ event_name: event_name,
+ delay_ms: delay_ms,
+ send_id: send_id
+ })
+
+ state_chart
+
+ nil ->
+ # Not in StateMachine context - warn and execute immediately
+ warned_state_chart =
+ LogManager.warn(
+ state_chart,
+ "Delayed send requires StateMachine context, executing immediately",
+ %{
+ action_type: "send_action",
+ event_name: event_name,
+ delay_ms: delay_ms
+ }
+ )
+
+ execute_internal_send(event_name, send_action, warned_state_chart)
+ end
+ end
+
+ # Generate unique send ID using UXID or use provided ID
+ defp generate_send_id(%__MODULE__{id: id}) when not is_nil(id), do: id
+
+ defp generate_send_id(%__MODULE__{}) do
+ UXID.generate!(prefix: "send", size: :s)
+ end
+
defp execute_internal_send(event_name, send_action, state_chart) do
# Build event data from namelist, params, and content
event_data = build_event_data(send_action, state_chart)
diff --git a/lib/statifier/feature_detector.ex b/lib/statifier/feature_detector.ex
index 2056563..a0711bd 100644
--- a/lib/statifier/feature_detector.ex
+++ b/lib/statifier/feature_detector.ex
@@ -94,10 +94,10 @@ defmodule Statifier.FeatureDetector do
finalize_elements: :unsupported,
cancel_elements: :unsupported,
- # Advanced send features (unsupported)
+ # Advanced send features (supported)
send_content_elements: :supported,
send_param_elements: :supported,
- send_delay_expressions: :partial,
+ send_delay_expressions: :supported,
# State machine lifecycle (unsupported)
donedata_elements: :unsupported
@@ -179,6 +179,7 @@ defmodule Statifier.FeatureDetector do
|> add_if_present(xml, ~r/type\s*=\s*["']internal["']/, :internal_transitions)
|> add_if_present(xml, ~r/event\s*=\s*["']\*["']/, :wildcard_events)
|> add_if_present(xml, ~r/delayexpr\s*=/, :send_delay_expressions)
+ |> add_if_present(xml, ~r/delay\s*=/, :send_delay_expressions)
|> detect_compound_states(xml)
|> detect_targetless_transitions(xml)
end
diff --git a/lib/statifier/state_chart.ex b/lib/statifier/state_chart.ex
index 49416cf..1319446 100644
--- a/lib/statifier/state_chart.ex
+++ b/lib/statifier/state_chart.ex
@@ -12,6 +12,7 @@ defmodule Statifier.StateChart do
:document,
:configuration,
:current_event,
+ :state_machine_pid,
datamodel: %{},
internal_queue: [],
external_queue: [],
@@ -35,6 +36,7 @@ defmodule Statifier.StateChart do
document: Document.t(),
configuration: Configuration.t(),
current_event: Event.t() | nil,
+ state_machine_pid: pid() | nil,
datamodel: Statifier.Datamodel.t(),
internal_queue: [Event.t()],
external_queue: [Event.t()],
diff --git a/lib/statifier/state_machine.ex b/lib/statifier/state_machine.ex
index a1a5308..f73c9a2 100644
--- a/lib/statifier/state_machine.ex
+++ b/lib/statifier/state_machine.ex
@@ -69,13 +69,20 @@ defmodule Statifier.StateMachine do
@type init_arg :: String.t() | StateChart.t()
- defstruct [:state_chart, :callback_module, :snapshot_interval, :snapshot_timer]
+ defstruct [
+ :state_chart,
+ :callback_module,
+ :snapshot_interval,
+ :snapshot_timer,
+ delayed_sends: %{}
+ ]
@type t :: %__MODULE__{
state_chart: StateChart.t(),
callback_module: module() | nil,
snapshot_interval: non_neg_integer() | nil,
- snapshot_timer: reference() | nil
+ snapshot_timer: reference() | nil,
+ delayed_sends: %{String.t() => reference()}
}
@doc """
@@ -271,9 +278,12 @@ defmodule Statifier.StateMachine do
case initialize_state_chart(actual_init_arg, interpreter_opts) do
{:ok, state_chart} ->
+ # Set StateMachine PID in StateChart for delayed send context
+ state_chart_with_pid = %{state_chart | state_machine_pid: self()}
+
# Initialize state
state = %__MODULE__{
- state_chart: state_chart,
+ state_chart: state_chart_with_pid,
callback_module: callback_module,
snapshot_interval: snapshot_interval
}
@@ -323,6 +333,18 @@ defmodule Statifier.StateMachine do
{:noreply, new_state}
end
+ @impl GenServer
+ def handle_cast({:schedule_delayed_send, send_id, event, delay_ms}, state) do
+ # Schedule the delayed event
+ timer_ref = Process.send_after(self(), {:delayed_send, send_id, event}, delay_ms)
+
+ # Store the timer reference for potential cancellation
+ new_delayed_sends = Map.put(state.delayed_sends, send_id, timer_ref)
+ new_state = %{state | delayed_sends: new_delayed_sends}
+
+ {:noreply, new_state}
+ end
+
@impl GenServer
def handle_call(:active_states, _from, state) do
active = Configuration.active_leaf_states(state.state_chart.configuration)
@@ -334,6 +356,19 @@ defmodule Statifier.StateMachine do
{:reply, state.state_chart, state}
end
+ @impl GenServer
+ def handle_call({:cancel_delayed_send, send_id}, _from, state) do
+ case Map.pop(state.delayed_sends, send_id) do
+ {timer_ref, new_delayed_sends} when not is_nil(timer_ref) ->
+ Process.cancel_timer(timer_ref)
+ new_state = %{state | delayed_sends: new_delayed_sends}
+ {:reply, :ok, new_state}
+
+ {nil, _delayed_sends} ->
+ {:reply, {:error, :not_found}, state}
+ end
+ end
+
@impl GenServer
def handle_info(:snapshot_timer, state) do
call_callback(state, :handle_snapshot, [state.state_chart, %{}])
@@ -341,6 +376,22 @@ defmodule Statifier.StateMachine do
{:noreply, new_state}
end
+ @impl GenServer
+ def handle_info({:delayed_send, send_id, event}, state) do
+ # Remove the timer reference from delayed_sends
+ {_timer_ref, new_delayed_sends} = Map.pop(state.delayed_sends, send_id)
+
+ # Send the delayed event to the state chart
+ {:ok, new_state_chart} = Interpreter.send_event(state.state_chart, event)
+
+ # Update state and call callbacks
+ updated_state = %{state | state_chart: new_state_chart, delayed_sends: new_delayed_sends}
+
+ call_callback(updated_state, :handle_delayed_send, [send_id, event, new_state_chart, %{}])
+
+ {:noreply, updated_state}
+ end
+
## Private Implementation
# Initialize StateChart from various input types
diff --git a/mix.exs b/mix.exs
index 57ecab2..1a48639 100644
--- a/mix.exs
+++ b/mix.exs
@@ -17,8 +17,9 @@ defmodule Statifier.MixProject do
# Runtime
{:jason, "~> 1.4"},
- {:predicator, "~> 3.3"},
- {:saxy, "~> 1.6"}
+ {:predicator, "~> 3.5"},
+ {:saxy, "~> 1.6"},
+ {:uxid, "~> 2.1"}
]
def project do
diff --git a/mix.lock b/mix.lock
index c1555a9..beb251c 100644
--- a/mix.lock
+++ b/mix.lock
@@ -13,6 +13,8 @@
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "predicator": {:hex, :predicator, "3.3.0", "9b5bb2be6723c60e5840be9c760c861e8c90d2f464f1e2dc286226e252cb18bc", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9abb5f35d01abf3c552af7307ad2016c3374f12fa8df38ab78be174e20632be3"},
+ "predicator": {:hex, :predicator, "3.5.0", "bcdf48834287a575be4c3abf26e21879db651a503e2bccb240a5c09660a213ce", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b412569bb124ae0728840fa88d5e884aae6aa2ee35a6a278e08299c14a2dd3ef"},
"saxy": {:hex, :saxy, "1.6.0", "02cb4e9bd045f25ac0c70fae8164754878327ee393c338a090288210b02317ee", [:mix], [], "hexpm", "ef42eb4ac983ca77d650fbdb68368b26570f6cc5895f0faa04d34a6f384abad3"},
+ "typeid_elixir": {:hex, :typeid_elixir, "1.1.0", "cae12a03b9e404d69951dc75cf022e0ed90ee5392db3a641a07f45bcd0341131", [:mix], [{:ecto, "~> 3.10", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "143c2ae18843f2d4efc643acbb4af1406ab3aae40980b4d962e5c7a6895e1ba8"},
+ "uxid": {:hex, :uxid, "2.1.0", "7d23902ae8f0898a59691194441ffcd65cab1eec5fdc9d1f4021ed036af7e372", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ef682ae60049610dc4afe472fc094c50e904c622beb428d836ab61031eb683cb"},
}
diff --git a/test/passing_tests.json b/test/passing_tests.json
index 8e59237..365cd0d 100644
--- a/test/passing_tests.json
+++ b/test/passing_tests.json
@@ -5,7 +5,7 @@
"test/statifier/**/*_test.exs",
"test/mix/**/*_test.exs"
],
- "last_updated": "2025-09-08",
+ "last_updated": "2025-09-10",
"scion_tests": [
"test/scion_tests/actionSend/send1_test.exs",
"test/scion_tests/actionSend/send2_test.exs",
@@ -34,6 +34,9 @@
"test/scion_tests/data/data_obj_literal_test.exs",
"test/scion_tests/default_initial_state/initial1_test.exs",
"test/scion_tests/default_initial_state/initial2_test.exs",
+ "test/scion_tests/delayedSend/send1_test.exs",
+ "test/scion_tests/delayedSend/send2_test.exs",
+ "test/scion_tests/delayedSend/send3_test.exs",
"test/scion_tests/documentOrder/documentOrder0_test.exs",
"test/scion_tests/foreach/test1_test.exs",
"test/scion_tests/hierarchy/hier0_test.exs",
@@ -91,6 +94,7 @@
"test/scion_tests/scxml_prefix_event_name_matching/star0_test.exs",
"test/scion_tests/scxml_prefix_event_name_matching/test0_test.exs",
"test/scion_tests/scxml_prefix_event_name_matching/test1_test.exs",
+ "test/scion_tests/send_data/send1_test.exs",
"test/scion_tests/send_internal/test0_test.exs",
"test/scion_tests/targetless_transition/test0_test.exs"
],
diff --git a/test/scion_tests/assign_current_small_step/test0_test.exs b/test/scion_tests/assign_current_small_step/test0_test.exs
index 45cab90..cff6ebd 100644
--- a/test/scion_tests/assign_current_small_step/test0_test.exs
+++ b/test/scion_tests/assign_current_small_step/test0_test.exs
@@ -68,7 +68,6 @@ defmodule SCIONTest.AssignCurrentSmallStep.Test0Test do
-
"""
diff --git a/test/scion_tests/assign_current_small_step/test2_test.exs b/test/scion_tests/assign_current_small_step/test2_test.exs
index fcce353..ab2b32b 100644
--- a/test/scion_tests/assign_current_small_step/test2_test.exs
+++ b/test/scion_tests/assign_current_small_step/test2_test.exs
@@ -65,7 +65,6 @@ defmodule SCIONTest.AssignCurrentSmallStep.Test2Test do
-
diff --git a/test/scion_tests/assign_current_small_step/test3_test.exs b/test/scion_tests/assign_current_small_step/test3_test.exs
index 0b832e4..ce2b320 100644
--- a/test/scion_tests/assign_current_small_step/test3_test.exs
+++ b/test/scion_tests/assign_current_small_step/test3_test.exs
@@ -76,7 +76,6 @@ defmodule SCIONTest.AssignCurrentSmallStep.Test3Test do
-
diff --git a/test/scion_tests/delayedSend/send1_test.exs b/test/scion_tests/delayedSend/send1_test.exs
index 43f78d3..08c46e8 100644
--- a/test/scion_tests/delayedSend/send1_test.exs
+++ b/test/scion_tests/delayedSend/send1_test.exs
@@ -1,7 +1,14 @@
defmodule SCIONTest.DelayedSend.Send1Test do
use Statifier.Case
+
+ alias Statifier.StateMachine
@tag :scion
- @tag required_features: [:basic_states, :event_transitions, :send_elements]
+ @tag required_features: [
+ :basic_states,
+ :event_transitions,
+ :send_delay_expressions,
+ :send_elements
+ ]
@tag spec: "delayed_send"
test "send1" do
xml = """
@@ -45,6 +52,21 @@ defmodule SCIONTest.DelayedSend.Send1Test do
"""
- test_scxml(xml, "", ["a"], [{%{"name" => "t1"}, ["b"]}, {%{"name" => "t2"}, ["d"]}])
+ # Use StateMachine for delay support
+ pid = start_test_state_machine(xml)
+
+ # Initial state
+ assert StateMachine.active_states(pid) == MapSet.new(["a"])
+
+ # Send t1 - should move to b and schedule delayed s
+ StateMachine.send_event(pid, "t1", %{})
+ assert StateMachine.active_states(pid) == MapSet.new(["b"])
+
+ # Wait for delayed s event to move to c
+ wait_for_delayed_sends(pid, ["c"], 500)
+
+ # Send t2 to move to final state d
+ StateMachine.send_event(pid, "t2", %{})
+ assert StateMachine.active_states(pid) == MapSet.new(["d"])
end
end
diff --git a/test/scion_tests/delayedSend/send2_test.exs b/test/scion_tests/delayedSend/send2_test.exs
index 8eecdc9..46f1c53 100644
--- a/test/scion_tests/delayedSend/send2_test.exs
+++ b/test/scion_tests/delayedSend/send2_test.exs
@@ -1,7 +1,15 @@
defmodule SCIONTest.DelayedSend.Send2Test do
use Statifier.Case
+
+ alias Statifier.StateMachine
@tag :scion
- @tag required_features: [:basic_states, :event_transitions, :onexit_actions, :send_elements]
+ @tag required_features: [
+ :basic_states,
+ :event_transitions,
+ :onexit_actions,
+ :send_delay_expressions,
+ :send_elements
+ ]
@tag spec: "delayed_send"
test "send2" do
xml = """
@@ -48,6 +56,21 @@ defmodule SCIONTest.DelayedSend.Send2Test do
"""
- test_scxml(xml, "", ["a"], [{%{"name" => "t1"}, ["b"]}, {%{"name" => "t2"}, ["d"]}])
+ # Use StateMachine for delay support
+ pid = start_test_state_machine(xml)
+
+ # Initial state
+ assert StateMachine.active_states(pid) == MapSet.new(["a"])
+
+ # Send t1 - should move to b and schedule delayed s (on onexit from a)
+ StateMachine.send_event(pid, "t1", %{})
+ assert StateMachine.active_states(pid) == MapSet.new(["b"])
+
+ # Wait for delayed s event to move to c
+ wait_for_delayed_sends(pid, ["c"], 500)
+
+ # Send t2 to move to final state d
+ StateMachine.send_event(pid, "t2", %{})
+ assert StateMachine.active_states(pid) == MapSet.new(["d"])
end
end
diff --git a/test/scion_tests/delayedSend/send3_test.exs b/test/scion_tests/delayedSend/send3_test.exs
index 5e022a6..bb3cb07 100644
--- a/test/scion_tests/delayedSend/send3_test.exs
+++ b/test/scion_tests/delayedSend/send3_test.exs
@@ -1,7 +1,15 @@
defmodule SCIONTest.DelayedSend.Send3Test do
use Statifier.Case
+
+ alias Statifier.StateMachine
@tag :scion
- @tag required_features: [:basic_states, :event_transitions, :onentry_actions, :send_elements]
+ @tag required_features: [
+ :basic_states,
+ :event_transitions,
+ :onentry_actions,
+ :send_delay_expressions,
+ :send_elements
+ ]
@tag spec: "delayed_send"
test "send3" do
xml = """
@@ -48,6 +56,21 @@ defmodule SCIONTest.DelayedSend.Send3Test do
"""
- test_scxml(xml, "", ["a"], [{%{"name" => "t1"}, ["b"]}, {%{"name" => "t2"}, ["d"]}])
+ # Use StateMachine for delay support
+ pid = start_test_state_machine(xml)
+
+ # Initial state
+ assert StateMachine.active_states(pid) == MapSet.new(["a"])
+
+ # Send t1 - should move to b and schedule delayed s (on onentry to b)
+ StateMachine.send_event(pid, "t1", %{})
+ assert StateMachine.active_states(pid) == MapSet.new(["b"])
+
+ # Wait for delayed s event to move to c
+ wait_for_delayed_sends(pid, ["c"], 500)
+
+ # Send t2 to move to final state d
+ StateMachine.send_event(pid, "t2", %{})
+ assert StateMachine.active_states(pid) == MapSet.new(["d"])
end
end
diff --git a/test/scion_tests/history/history3_test.exs b/test/scion_tests/history/history3_test.exs
index e1c11be..3485eb3 100644
--- a/test/scion_tests/history/history3_test.exs
+++ b/test/scion_tests/history/history3_test.exs
@@ -33,7 +33,6 @@ defmodule SCIONTest.History.History3Test do
version="1.0"
initial="a">
-
diff --git a/test/scion_tests/history/history4_test.exs b/test/scion_tests/history/history4_test.exs
index f3f7119..1524633 100644
--- a/test/scion_tests/history/history4_test.exs
+++ b/test/scion_tests/history/history4_test.exs
@@ -36,7 +36,6 @@ defmodule SCIONTest.History.History4Test do
version="1.0"
initial="a">
-
diff --git a/test/scion_tests/history/history4b_test.exs b/test/scion_tests/history/history4b_test.exs
index 5e0bf12..d3a3958 100644
--- a/test/scion_tests/history/history4b_test.exs
+++ b/test/scion_tests/history/history4b_test.exs
@@ -36,7 +36,6 @@ defmodule SCIONTest.History.History4bTest do
version="1.0"
initial="a">
-
diff --git a/test/scion_tests/script/test0_test.exs b/test/scion_tests/script/test0_test.exs
index 50386dc..2174d91 100644
--- a/test/scion_tests/script/test0_test.exs
+++ b/test/scion_tests/script/test0_test.exs
@@ -54,7 +54,6 @@ defmodule SCIONTest.Script.Test0Test do
-
"""
diff --git a/test/scion_tests/script/test2_test.exs b/test/scion_tests/script/test2_test.exs
index cc373a7..f98b81c 100644
--- a/test/scion_tests/script/test2_test.exs
+++ b/test/scion_tests/script/test2_test.exs
@@ -70,7 +70,6 @@ defmodule SCIONTest.Script.Test2Test do
-
diff --git a/test/scion_tests/script_src/test0_test.exs b/test/scion_tests/script_src/test0_test.exs
index ec96d9c..a6769e9 100644
--- a/test/scion_tests/script_src/test0_test.exs
+++ b/test/scion_tests/script_src/test0_test.exs
@@ -52,7 +52,6 @@ defmodule SCIONTest.ScriptSrc.Test0Test do
-
"""
diff --git a/test/scion_tests/script_src/test2_test.exs b/test/scion_tests/script_src/test2_test.exs
index a6a3585..9d4ba67 100644
--- a/test/scion_tests/script_src/test2_test.exs
+++ b/test/scion_tests/script_src/test2_test.exs
@@ -62,7 +62,6 @@ defmodule SCIONTest.ScriptSrc.Test2Test do
-
diff --git a/test/scion_tests/script_src/test3_test.exs b/test/scion_tests/script_src/test3_test.exs
index d0648d6..6bc68ac 100644
--- a/test/scion_tests/script_src/test3_test.exs
+++ b/test/scion_tests/script_src/test3_test.exs
@@ -56,7 +56,6 @@ defmodule SCIONTest.ScriptSrc.Test3Test do
-
"""
diff --git a/test/scion_tests/send_data/send1_test.exs b/test/scion_tests/send_data/send1_test.exs
index 16f0af2..9059d54 100644
--- a/test/scion_tests/send_data/send1_test.exs
+++ b/test/scion_tests/send_data/send1_test.exs
@@ -1,5 +1,7 @@
defmodule SCIONTest.SendData.Send1Test do
use Statifier.Case
+
+ alias Statifier.StateMachine
@tag :scion
@tag required_features: [
:basic_states,
@@ -68,7 +70,6 @@ defmodule SCIONTest.SendData.Send1Test do
-
@@ -82,7 +83,6 @@ defmodule SCIONTest.SendData.Send1Test do
-
@@ -98,6 +98,22 @@ defmodule SCIONTest.SendData.Send1Test do
"""
- test_scxml(xml, "", ["a"], [{%{"name" => "t"}, ["b"]}, {%{"name" => "t2"}, ["e"]}])
+ # Use StateMachine for delay support - this test has multiple cascading delayed events
+ pid = start_test_state_machine(xml)
+
+ # Initial state
+ assert StateMachine.active_states(pid) == MapSet.new(["a"])
+
+ # Send t - should move to b and schedule delayed s1
+ StateMachine.send_event(pid, "t", %{})
+ assert StateMachine.active_states(pid) == MapSet.new(["b"])
+
+ # Wait for the cascade of delayed events: s1 -> c -> s2 -> d -> s3 -> e
+ # This may take multiple delay periods as events cascade
+ wait_for_delayed_sends(pid, ["e"], 1000)
+
+ # The test expects us to be in state "e" after all delayed events process
+ # Original test would send "t2" but our test should already be in "e"
+ assert StateMachine.active_states(pid) == MapSet.new(["e"])
end
end
diff --git a/test/scion_tests/send_idlocation/test0_test.exs b/test/scion_tests/send_idlocation/test0_test.exs
index 480ad6d..ebf493d 100644
--- a/test/scion_tests/send_idlocation/test0_test.exs
+++ b/test/scion_tests/send_idlocation/test0_test.exs
@@ -10,9 +10,11 @@ defmodule SCIONTest.SendIdlocation.Test0Test do
:final_states,
:log_elements,
:onentry_actions,
+ :send_delay_expressions,
:send_elements,
:send_idlocation
]
+
@tag spec: "send_idlocation"
test "test0" do
xml = """
@@ -24,7 +26,6 @@ defmodule SCIONTest.SendIdlocation.Test0Test do
-
diff --git a/test/scion_tests/send_internal/test0_test.exs b/test/scion_tests/send_internal/test0_test.exs
index a0672bd..b669116 100644
--- a/test/scion_tests/send_internal/test0_test.exs
+++ b/test/scion_tests/send_internal/test0_test.exs
@@ -67,7 +67,6 @@ defmodule SCIONTest.SendInternal.Test0Test do
-
diff --git a/test/scxml_tests/mandatory/SelectingTransitions/test403a_test.exs b/test/scxml_tests/mandatory/SelectingTransitions/test403a_test.exs
index 92c3c7e..4b1e1a6 100644
--- a/test/scxml_tests/mandatory/SelectingTransitions/test403a_test.exs
+++ b/test/scxml_tests/mandatory/SelectingTransitions/test403a_test.exs
@@ -10,9 +10,11 @@ defmodule SCXMLTest.SelectingTransitions.Test403a do
:log_elements,
:onentry_actions,
:raise_elements,
+ :send_delay_expressions,
:send_elements,
:wildcard_events
]
+
@tag conformance: "mandatory", spec: "SelectingTransitions"
test "test403a" do
xml = """
diff --git a/test/scxml_tests/mandatory/SelectingTransitions/test403c_test.exs b/test/scxml_tests/mandatory/SelectingTransitions/test403c_test.exs
index f1af479..34f5157 100644
--- a/test/scxml_tests/mandatory/SelectingTransitions/test403c_test.exs
+++ b/test/scxml_tests/mandatory/SelectingTransitions/test403c_test.exs
@@ -14,10 +14,12 @@ defmodule SCXMLTest.SelectingTransitions.Test403c do
:onentry_actions,
:parallel_states,
:raise_elements,
+ :send_delay_expressions,
:send_elements,
:targetless_transitions,
:wildcard_events
]
+
@tag conformance: "mandatory", spec: "SelectingTransitions"
test "test403c" do
xml = """
diff --git a/test/scxml_tests/mandatory/SelectingTransitions/test405_test.exs b/test/scxml_tests/mandatory/SelectingTransitions/test405_test.exs
index 7379576..8102005 100644
--- a/test/scxml_tests/mandatory/SelectingTransitions/test405_test.exs
+++ b/test/scxml_tests/mandatory/SelectingTransitions/test405_test.exs
@@ -11,9 +11,11 @@ defmodule SCXMLTest.SelectingTransitions.Test405 do
:onexit_actions,
:parallel_states,
:raise_elements,
+ :send_delay_expressions,
:send_elements,
:wildcard_events
]
+
@tag conformance: "mandatory", spec: "SelectingTransitions"
test "test405" do
xml = """
diff --git a/test/scxml_tests/mandatory/SelectingTransitions/test406_test.exs b/test/scxml_tests/mandatory/SelectingTransitions/test406_test.exs
index 55ea540..2f103bf 100644
--- a/test/scxml_tests/mandatory/SelectingTransitions/test406_test.exs
+++ b/test/scxml_tests/mandatory/SelectingTransitions/test406_test.exs
@@ -10,9 +10,11 @@ defmodule SCXMLTest.SelectingTransitions.Test406 do
:onentry_actions,
:parallel_states,
:raise_elements,
+ :send_delay_expressions,
:send_elements,
:wildcard_events
]
+
@tag conformance: "mandatory", spec: "SelectingTransitions"
test "test406" do
xml = """
diff --git a/test/scxml_tests/mandatory/SelectingTransitions/test411_test.exs b/test/scxml_tests/mandatory/SelectingTransitions/test411_test.exs
index 0d81027..5e1db5b 100644
--- a/test/scxml_tests/mandatory/SelectingTransitions/test411_test.exs
+++ b/test/scxml_tests/mandatory/SelectingTransitions/test411_test.exs
@@ -11,8 +11,10 @@ defmodule SCXMLTest.SelectingTransitions.Test411 do
:log_elements,
:onentry_actions,
:raise_elements,
+ :send_delay_expressions,
:send_elements
]
+
@tag conformance: "mandatory", spec: "SelectingTransitions"
test "test411" do
xml = """
diff --git a/test/scxml_tests/mandatory/SelectingTransitions/test412_test.exs b/test/scxml_tests/mandatory/SelectingTransitions/test412_test.exs
index 510f98e..7f85b4b 100644
--- a/test/scxml_tests/mandatory/SelectingTransitions/test412_test.exs
+++ b/test/scxml_tests/mandatory/SelectingTransitions/test412_test.exs
@@ -10,9 +10,11 @@ defmodule SCXMLTest.SelectingTransitions.Test412 do
:log_elements,
:onentry_actions,
:raise_elements,
+ :send_delay_expressions,
:send_elements,
:wildcard_events
]
+
@tag conformance: "mandatory", spec: "SelectingTransitions"
test "test412" do
xml = """
diff --git a/test/scxml_tests/mandatory/SelectingTransitions/test416_test.exs b/test/scxml_tests/mandatory/SelectingTransitions/test416_test.exs
index 2552cfe..e3aa218 100644
--- a/test/scxml_tests/mandatory/SelectingTransitions/test416_test.exs
+++ b/test/scxml_tests/mandatory/SelectingTransitions/test416_test.exs
@@ -8,8 +8,10 @@ defmodule SCXMLTest.SelectingTransitions.Test416 do
:final_states,
:log_elements,
:onentry_actions,
+ :send_delay_expressions,
:send_elements
]
+
@tag conformance: "mandatory", spec: "SelectingTransitions"
test "test416" do
xml = """
diff --git a/test/scxml_tests/mandatory/SelectingTransitions/test417_test.exs b/test/scxml_tests/mandatory/SelectingTransitions/test417_test.exs
index b3f4f89..0d99f8a 100644
--- a/test/scxml_tests/mandatory/SelectingTransitions/test417_test.exs
+++ b/test/scxml_tests/mandatory/SelectingTransitions/test417_test.exs
@@ -9,8 +9,10 @@ defmodule SCXMLTest.SelectingTransitions.Test417 do
:log_elements,
:onentry_actions,
:parallel_states,
+ :send_delay_expressions,
:send_elements
]
+
@tag conformance: "mandatory", spec: "SelectingTransitions"
test "test417" do
xml = """
diff --git a/test/scxml_tests/mandatory/events/test399_test.exs b/test/scxml_tests/mandatory/events/test399_test.exs
index 86ebda7..dbe8bb5 100644
--- a/test/scxml_tests/mandatory/events/test399_test.exs
+++ b/test/scxml_tests/mandatory/events/test399_test.exs
@@ -9,9 +9,11 @@ defmodule SCXMLTest.Events.Test399 do
:log_elements,
:onentry_actions,
:raise_elements,
+ :send_delay_expressions,
:send_elements,
:wildcard_events
]
+
@tag conformance: "mandatory", spec: "events"
test "test399" do
xml = """
diff --git a/test/scxml_tests/mandatory/events/test402_test.exs b/test/scxml_tests/mandatory/events/test402_test.exs
index e826397..081589a 100644
--- a/test/scxml_tests/mandatory/events/test402_test.exs
+++ b/test/scxml_tests/mandatory/events/test402_test.exs
@@ -10,9 +10,11 @@ defmodule SCXMLTest.Events.Test402 do
:log_elements,
:onentry_actions,
:raise_elements,
+ :send_delay_expressions,
:send_elements,
:wildcard_events
]
+
@tag conformance: "mandatory", spec: "events"
test "test402" do
xml = """
diff --git a/test/scxml_tests/mandatory/final/test372_test.exs b/test/scxml_tests/mandatory/final/test372_test.exs
index f80c97c..bd787e4 100644
--- a/test/scxml_tests/mandatory/final/test372_test.exs
+++ b/test/scxml_tests/mandatory/final/test372_test.exs
@@ -13,9 +13,11 @@ defmodule SCXMLTest.Final.Test372 do
:log_elements,
:onentry_actions,
:onexit_actions,
+ :send_delay_expressions,
:send_elements,
:wildcard_events
]
+
@tag conformance: "mandatory", spec: "final"
test "test372" do
xml = """
diff --git a/test/scxml_tests/mandatory/final/test570_test.exs b/test/scxml_tests/mandatory/final/test570_test.exs
index 564ee94..a1de648 100644
--- a/test/scxml_tests/mandatory/final/test570_test.exs
+++ b/test/scxml_tests/mandatory/final/test570_test.exs
@@ -14,10 +14,12 @@ defmodule SCXMLTest.Final.Test570 do
:onentry_actions,
:parallel_states,
:raise_elements,
+ :send_delay_expressions,
:send_elements,
:targetless_transitions,
:wildcard_events
]
+
@tag conformance: "mandatory", spec: "final"
test "test570" do
xml = """
diff --git a/test/scxml_tests/mandatory/history/test387_test.exs b/test/scxml_tests/mandatory/history/test387_test.exs
index 09131ae..6db6cbb 100644
--- a/test/scxml_tests/mandatory/history/test387_test.exs
+++ b/test/scxml_tests/mandatory/history/test387_test.exs
@@ -10,9 +10,11 @@ defmodule SCXMLTest.History.Test387 do
:log_elements,
:onentry_actions,
:raise_elements,
+ :send_delay_expressions,
:send_elements,
:wildcard_events
]
+
@tag conformance: "mandatory", spec: "history"
test "test387" do
xml = """
diff --git a/test/scxml_tests/mandatory/history/test388_test.exs b/test/scxml_tests/mandatory/history/test388_test.exs
index 9593f93..4fa1316 100644
--- a/test/scxml_tests/mandatory/history/test388_test.exs
+++ b/test/scxml_tests/mandatory/history/test388_test.exs
@@ -14,8 +14,10 @@ defmodule SCXMLTest.History.Test388 do
:log_elements,
:onentry_actions,
:raise_elements,
+ :send_delay_expressions,
:send_elements
]
+
@tag conformance: "mandatory", spec: "history"
test "test388" do
xml = """
diff --git a/test/scxml_tests/mandatory/history/test580_test.exs b/test/scxml_tests/mandatory/history/test580_test.exs
index 476a8a1..d4b510f 100644
--- a/test/scxml_tests/mandatory/history/test580_test.exs
+++ b/test/scxml_tests/mandatory/history/test580_test.exs
@@ -15,8 +15,10 @@ defmodule SCXMLTest.History.Test580 do
:onentry_actions,
:onexit_actions,
:parallel_states,
+ :send_delay_expressions,
:send_elements
]
+
@tag conformance: "mandatory", spec: "history"
test "test580" do
xml = """
diff --git a/test/scxml_tests/mandatory/scxml/test576_test.exs b/test/scxml_tests/mandatory/scxml/test576_test.exs
index fee2bb4..452a47f 100644
--- a/test/scxml_tests/mandatory/scxml/test576_test.exs
+++ b/test/scxml_tests/mandatory/scxml/test576_test.exs
@@ -10,8 +10,10 @@ defmodule SCXMLTest.Scxml.Test576 do
:onentry_actions,
:parallel_states,
:raise_elements,
+ :send_delay_expressions,
:send_elements
]
+
@tag conformance: "mandatory", spec: "scxml"
test "test576" do
xml = """
diff --git a/test/scxml_tests/mandatory/state/test364_test.exs b/test/scxml_tests/mandatory/state/test364_test.exs
index 8260e78..d07c540 100644
--- a/test/scxml_tests/mandatory/state/test364_test.exs
+++ b/test/scxml_tests/mandatory/state/test364_test.exs
@@ -11,8 +11,10 @@ defmodule SCXMLTest.State.Test364 do
:onentry_actions,
:parallel_states,
:raise_elements,
+ :send_delay_expressions,
:send_elements
]
+
@tag conformance: "mandatory", spec: "state"
test "test364" do
xml = """
diff --git a/test/statifier/delay_expressions_test.exs b/test/statifier/delay_expressions_test.exs
new file mode 100644
index 0000000..65f0491
--- /dev/null
+++ b/test/statifier/delay_expressions_test.exs
@@ -0,0 +1,223 @@
+defmodule Statifier.DelayExpressionsTest do
+ @moduledoc """
+ Tests for delay expression support in StateMachine.
+
+ These tests demonstrate the new delay functionality using the StateMachine
+ for proper asynchronous delay processing.
+ """
+
+ # async: false for timing-sensitive tests
+ use Statifier.Case, async: false
+
+ alias Statifier.StateMachine
+
+ describe "delay expressions with StateMachine" do
+ test "immediate send (delay=0ms)" do
+ xml = """
+
+
+
+
+
+
+
+
+
+ """
+
+ pid = start_test_state_machine(xml)
+
+ # The immediate event should be processed automatically during initialization
+ # so we should already be in the "received" state
+ assert StateMachine.active_states(pid) == MapSet.new(["received"])
+ end
+
+ test "delayed send with static delay" do
+ xml = """
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+
+ pid = start_test_state_machine(xml)
+
+ # Initial state
+ assert StateMachine.active_states(pid) == MapSet.new(["waiting"])
+
+ # Send start event
+ StateMachine.send_event(pid, "start", %{})
+
+ # Should still be in sending state immediately after sending start
+ assert StateMachine.active_states(pid) == MapSet.new(["sending"])
+
+ # Wait for delayed send to fire
+ wait_for_delayed_sends(pid, ["complete"], 2000)
+ end
+
+ test "delayed send with duration expression" do
+ xml = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+
+ pid = start_test_state_machine(xml)
+
+ # Initial state
+ assert StateMachine.active_states(pid) == MapSet.new(["ready"])
+
+ # Trigger timing
+ StateMachine.send_event(pid, "trigger", %{})
+ assert StateMachine.active_states(pid) == MapSet.new(["timing"])
+
+ # Wait for delayed send to fire (150ms + buffer)
+ wait_for_delayed_sends(pid, ["finished"], 1000)
+ end
+
+ test "multiple delayed sends with different delays" do
+ xml = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+
+ pid = start_test_state_machine(xml)
+
+ # Should get fast event first, then slow event
+ # Just wait for final state rather than checking intermediate
+ wait_for_delayed_sends(pid, ["got_both"], 1000)
+ end
+ end
+
+ describe "send ID generation and tracking" do
+ test "automatic send ID generation" do
+ xml = """
+
+
+
+
+
+
+
+
+
+ """
+
+ pid = start_test_state_machine(xml)
+ wait_for_delayed_sends(pid, ["done"], 1000)
+
+ # Test passes if delayed send works correctly
+ # Send ID is generated automatically using System.unique_integer
+ end
+
+ test "custom send ID" do
+ xml = """
+
+
+
+
+
+
+
+
+
+ """
+
+ pid = start_test_state_machine(xml)
+ wait_for_delayed_sends(pid, ["done"], 1000)
+
+ # Test passes if delayed send works with custom ID
+ end
+ end
+
+ describe "error handling" do
+ test "invalid delay expression defaults to 0ms" do
+ xml = """
+
+
+
+
+
+
+
+
+
+ """
+
+ pid = start_test_state_machine(xml)
+
+ try do
+ # Give the invalid delay processing a moment to complete
+ # since it defaults to 0ms (immediate) but may need event loop processing
+ Process.sleep(100)
+
+ # Verify the process is still alive before checking state
+ if Process.alive?(pid) do
+ assert StateMachine.active_states(pid) == MapSet.new(["immediate"])
+ else
+ flunk("StateMachine process died unexpectedly during invalid delay processing")
+ end
+ after
+ # Ensure cleanup even if test fails
+ if Process.alive?(pid) do
+ GenServer.stop(pid, :normal, 100)
+ end
+ end
+ end
+
+ test "delay expressions work with sync API (warning logged)" do
+ xml = """
+
+
+
+
+
+
+
+
+
+ """
+
+ # Using sync API should execute immediately with warning
+ {:ok, document, _warnings} = Statifier.parse(xml)
+ {:ok, state_chart} = Statifier.initialize(document)
+
+ # Should be in "done" state because delay was ignored in sync API
+ active_states = Statifier.active_leaf_states(state_chart)
+ assert active_states == MapSet.new(["done"])
+ end
+ end
+end
diff --git a/test/statifier/feature_detector_test.exs b/test/statifier/feature_detector_test.exs
index 3b22794..60b8c92 100644
--- a/test/statifier/feature_detector_test.exs
+++ b/test/statifier/feature_detector_test.exs
@@ -237,10 +237,47 @@ defmodule Statifier.FeatureDetectorTest do
assert registry[:datamodel] == :supported
assert registry[:data_elements] == :supported
+ # Recently implemented send features
+ assert registry[:send_delay_expressions] == :supported
+
# Still unsupported features
assert registry[:send_idlocation] == :unsupported
end
+ test "detects send delay expressions feature" do
+ # Test both delay and delayexpr attributes
+ xml_with_delay = """
+
+
+
+
+
+
+
+ """
+
+ xml_with_delayexpr = """
+
+
+
+
+
+
+
+
+
+
+ """
+
+ features_delay = FeatureDetector.detect_features(xml_with_delay)
+ features_delayexpr = FeatureDetector.detect_features(xml_with_delayexpr)
+
+ assert MapSet.member?(features_delay, :send_delay_expressions)
+ assert MapSet.member?(features_delayexpr, :send_delay_expressions)
+ assert MapSet.member?(features_delay, :send_elements)
+ assert MapSet.member?(features_delayexpr, :send_elements)
+ end
+
test "registry contains expected number of features" do
registry = FeatureDetector.feature_registry()
diff --git a/test/support/statifier_case.ex b/test/support/statifier_case.ex
index 6430864..3997387 100644
--- a/test/support/statifier_case.ex
+++ b/test/support/statifier_case.ex
@@ -19,11 +19,14 @@ defmodule Statifier.Case do
Event,
FeatureDetector,
Interpreter,
- StateChart
+ StateChart,
+ StateMachine
}
alias Statifier.Logging.LogManager
+ alias ExUnit.{Assertions, Callbacks}
+
using do
quote do
import unquote(__MODULE__)
@@ -44,6 +47,13 @@ defmodule Statifier.Case do
@spec test_scxml(String.t(), String.t(), list(String.t()), list({map(), list(String.t())})) ::
:ok
def test_scxml(xml, description, expected_initial_config, events) do
+ validate_features_and_run(xml, description, fn ->
+ run_scxml_test(xml, description, expected_initial_config, events)
+ end)
+ end
+
+ # Helper function to validate features and run test function
+ defp validate_features_and_run(xml, description, test_function) do
# Detect features used in the SCXML document
detected_features = FeatureDetector.detect_features(xml)
@@ -51,7 +61,7 @@ defmodule Statifier.Case do
case FeatureDetector.validate_features(detected_features) do
{:ok, _supported_features} ->
# All features are supported, proceed with test
- run_scxml_test(xml, description, expected_initial_config, events)
+ test_function.()
{:error, unsupported_features} ->
# Test uses unsupported features - fail with descriptive message
@@ -203,4 +213,130 @@ defmodule Statifier.Case do
:ok
end
+
+ @doc """
+ Test SCXML state machine behavior using StateMachine for delay support.
+
+ Similar to test_scxml/4 but uses StateMachine GenServer for proper delay processing.
+ This is essential for testing elements with delay attributes.
+
+ - xml: SCXML document string
+ - description: Test description (for debugging)
+ - expected_initial_config: List of expected initial active state IDs
+ - events_and_delays: List of {event_map, expected_states, optional_delay_ms} tuples
+ """
+ @spec test_scxml_with_state_machine(
+ String.t(),
+ String.t(),
+ list(String.t()),
+ list({map(), list(String.t())} | {map(), list(String.t()), non_neg_integer()})
+ ) :: :ok
+ def test_scxml_with_state_machine(xml, description, expected_initial_config, events_and_delays) do
+ validate_features_and_run(xml, description, fn ->
+ run_scxml_state_machine_test(xml, description, expected_initial_config, events_and_delays)
+ end)
+ end
+
+ defp run_scxml_state_machine_test(xml, _description, expected_initial_config, events_and_delays) do
+ # Start StateMachine
+ {:ok, pid} = StateMachine.start_link(xml)
+
+ try do
+ # Verify initial configuration
+ initial_states = StateMachine.active_states(pid)
+ expected_initial = MapSet.new(expected_initial_config)
+
+ assert initial_states == expected_initial,
+ "Expected initial states #{inspect(MapSet.to_list(expected_initial))}, but got #{inspect(MapSet.to_list(initial_states))}"
+
+ # Process events with delays
+ Enum.each(events_and_delays, fn
+ {event_map, expected_states} ->
+ # Send event synchronously and verify immediately
+ StateMachine.send_event(pid, event_map["name"], event_map)
+ verify_state_machine_configuration(pid, expected_states)
+
+ {event_map, expected_states, delay_ms} ->
+ # Send event and wait for delay before verification
+ StateMachine.send_event(pid, event_map["name"], event_map)
+ # Add small buffer for processing
+ Process.sleep(delay_ms + 50)
+ verify_state_machine_configuration(pid, expected_states)
+ end)
+ after
+ # Clean up StateMachine
+ if Process.alive?(pid) do
+ GenServer.stop(pid, :normal, 1000)
+ end
+ end
+
+ :ok
+ end
+
+ defp verify_state_machine_configuration(pid, expected_state_ids) do
+ expected = MapSet.new(expected_state_ids)
+ actual = StateMachine.active_states(pid)
+
+ # Convert to sorted lists for better error messages
+ expected_list = expected |> Enum.sort()
+ actual_list = actual |> Enum.sort()
+
+ assert expected == actual,
+ "Expected active states #{inspect(expected_list)}, but got #{inspect(actual_list)}"
+ end
+
+ @doc """
+ Start a StateMachine for testing and return the pid.
+
+ This helper manages StateMachine lifecycle for tests and ensures cleanup.
+ Use with Callbacks.on_exit/1 for proper cleanup.
+ """
+ @spec start_test_state_machine(String.t(), keyword()) :: pid()
+ def start_test_state_machine(xml, opts \\ []) do
+ {:ok, pid} = StateMachine.start_link(xml, opts)
+
+ # Register cleanup
+ Callbacks.on_exit(fn ->
+ if Process.alive?(pid) do
+ GenServer.stop(pid, :normal, 1000)
+ end
+ end)
+
+ pid
+ end
+
+ @doc """
+ Wait for delayed sends to complete and verify final state.
+
+ Useful for testing delayed elements by waiting for all timers to fire.
+ """
+ @spec wait_for_delayed_sends(pid(), list(String.t()), non_neg_integer()) :: :ok
+ def wait_for_delayed_sends(pid, expected_final_states, max_wait_ms \\ 5000) do
+ end_time = System.monotonic_time(:millisecond) + max_wait_ms
+ expected = MapSet.new(expected_final_states)
+
+ wait_for_states(pid, expected, end_time)
+ end
+
+ defp wait_for_states(pid, expected_states, end_time) do
+ current_time = System.monotonic_time(:millisecond)
+
+ if current_time > end_time do
+ actual = StateMachine.active_states(pid)
+
+ Assertions.flunk(
+ "Timeout waiting for states #{inspect(MapSet.to_list(expected_states))}, got #{inspect(MapSet.to_list(actual))}"
+ )
+ end
+
+ actual = StateMachine.active_states(pid)
+
+ if actual == expected_states do
+ :ok
+ else
+ # Brief pause before retry
+ Process.sleep(50)
+ wait_for_states(pid, expected_states, end_time)
+ end
+ end
end