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