diff --git a/lib/statifier/actions/assign_action.ex b/lib/statifier/actions/assign_action.ex index 165fdd2..6a04886 100644 --- a/lib/statifier/actions/assign_action.ex +++ b/lib/statifier/actions/assign_action.ex @@ -27,7 +27,7 @@ defmodule Statifier.Actions.AssignAction do """ - alias Statifier.{Evaluator, StateChart} + alias Statifier.{Evaluator, Event, StateChart} alias Statifier.Logging.LogManager require LogManager @@ -93,9 +93,21 @@ defmodule Statifier.Actions.AssignAction do %{state_chart | datamodel: updated_datamodel} {:error, reason} -> - # Log the error and continue without modification - LogManager.error( - state_chart, + # Create error.execution event per SCXML specification + error_event = %Event{ + name: "error.execution", + data: %{ + "reason" => inspect(reason), + "type" => "assign.execution", + "location" => assign_action.location, + "expr" => assign_action.expr + }, + origin: :internal + } + + # Log the error and generate error.execution event per SCXML spec + state_chart + |> LogManager.error( "Assign action failed: #{inspect(reason)}", %{ action_type: "assign_action", @@ -104,6 +116,7 @@ defmodule Statifier.Actions.AssignAction do error: inspect(reason) } ) + |> StateChart.enqueue_event(error_event) end end end diff --git a/lib/statifier/datamodel.ex b/lib/statifier/datamodel.ex index 4aa48c7..acfde52 100644 --- a/lib/statifier/datamodel.ex +++ b/lib/statifier/datamodel.ex @@ -123,11 +123,19 @@ defmodule Statifier.Datamodel do end def put_in_path(map, [key | rest], value) when is_map(map) do - nested_map = Map.get(map, key, %{}) + case Map.get(map, key) do + nil -> + # Key doesn't exist - cannot assign to nested path on nil + {:error, "Cannot assign to nested path: '#{key}' does not exist"} + + nested_map when is_map(nested_map) -> + case put_in_path(nested_map, rest, value) do + {:ok, updated_nested} -> {:ok, Map.put(map, key, updated_nested)} + error -> error + end - case put_in_path(nested_map, rest, value) do - {:ok, updated_nested} -> {:ok, Map.put(map, key, updated_nested)} - error -> error + _non_map -> + {:error, "Cannot assign to nested path: '#{key}' is not a map"} end end diff --git a/lib/statifier/event.ex b/lib/statifier/event.ex index c577308..7fce8b8 100644 --- a/lib/statifier/event.ex +++ b/lib/statifier/event.ex @@ -48,12 +48,57 @@ defmodule Statifier.Event do @doc """ Check if this event matches a transition's event specification. - For now, only supports exact string matching. + Supports SCXML event matching patterns: + - Universal wildcard: "*" matches any event + - Prefix matching: "foo" matches "foo", "foo.bar", "foo.bar.baz" + - Multiple descriptors: "foo bar" matches "foo" OR "bar" (and their prefixes) + - Wildcard suffix: "foo.*" matches "foo.bar", "foo.baz" (but not "foo") """ @spec matches?(t(), String.t() | nil) :: boolean() def matches?(%__MODULE__{}, nil), do: false + # Universal wildcard matches any event + def matches?(%__MODULE__{name: _name}, "*"), do: true + def matches?(%__MODULE__{name: name}, name), do: true + def matches?(%__MODULE__{name: name}, event_spec) when is_binary(event_spec) do - name == event_spec + # Split event descriptor into space-separated alternatives + descriptors = String.split(event_spec, " ") + event_tokens = String.split(name, ".") + + # Event matches if ANY descriptor matches + Enum.any?(descriptors, fn descriptor -> + if String.ends_with?(descriptor, ".*") do + # Wildcard pattern: "foo.*" matches "foo.bar" but not "foo" + prefix = String.slice(descriptor, 0, String.length(descriptor) - 2) + prefix_tokens = String.split(prefix, ".") + matches_wildcard_prefix?(event_tokens, prefix_tokens) + else + # Regular prefix matching: "foo" matches "foo", "foo.bar", etc. + descriptor_tokens = String.split(descriptor, ".") + matches_prefix?(event_tokens, descriptor_tokens) + end + end) + end + + # Check if event tokens match spec tokens as prefix + defp matches_prefix?(event_tokens, spec_tokens) + when length(spec_tokens) <= length(event_tokens) do + spec_length = length(spec_tokens) + event_prefix = Enum.take(event_tokens, spec_length) + event_prefix == spec_tokens end + + defp matches_prefix?(_event_tokens, _spec_tokens), do: false + + # Check wildcard prefix patterns like "foo.*" + # Requires event to have MORE tokens than prefix (wildcard means additional tokens) + defp matches_wildcard_prefix?(event_tokens, prefix_tokens) + when length(event_tokens) > length(prefix_tokens) do + prefix_length = length(prefix_tokens) + event_prefix = Enum.take(event_tokens, prefix_length) + event_prefix == prefix_tokens + end + + defp matches_wildcard_prefix?(_event_tokens, _prefix_tokens), do: false end diff --git a/test/passing_tests.json b/test/passing_tests.json index 9d9c801..8e59237 100644 --- a/test/passing_tests.json +++ b/test/passing_tests.json @@ -17,6 +17,7 @@ "test/scion_tests/actionSend/send8_test.exs", "test/scion_tests/actionSend/send8b_test.exs", "test/scion_tests/actionSend/send9_test.exs", + "test/scion_tests/assign/assign_invalid_test.exs", "test/scion_tests/assign/assign_obj_literal_test.exs", "test/scion_tests/assign_current_small_step/test1_test.exs", "test/scion_tests/assign_current_small_step/test2_test.exs", @@ -55,6 +56,7 @@ "test/scion_tests/more_parallel/test2b_test.exs", "test/scion_tests/more_parallel/test4_test.exs", "test/scion_tests/more_parallel/test9_test.exs", + "test/scion_tests/multiple_events_per_transition/test1_test.exs", "test/scion_tests/parallel/test0_test.exs", "test/scion_tests/parallel/test1_test.exs", "test/scion_tests/parallel/test2_test.exs", @@ -86,6 +88,9 @@ "test/scion_tests/parallel_interrupt/test7_test.exs", "test/scion_tests/parallel_interrupt/test8_test.exs", "test/scion_tests/parallel_interrupt/test9_test.exs", + "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_internal/test0_test.exs", "test/scion_tests/targetless_transition/test0_test.exs" ], @@ -101,11 +106,12 @@ "test/scxml_tests/mandatory/data/test280_test.exs", "test/scxml_tests/mandatory/data/test550_test.exs", "test/scxml_tests/mandatory/events/test396_test.exs", + "test/scxml_tests/mandatory/events/test399_test.exs", + "test/scxml_tests/mandatory/events/test402_test.exs", "test/scxml_tests/mandatory/foreach/test152_test.exs", "test/scxml_tests/mandatory/foreach/test153_test.exs", "test/scxml_tests/mandatory/foreach/test155_test.exs", "test/scxml_tests/mandatory/foreach/test525_test.exs", - "test/scxml_tests/mandatory/history/test387_test.exs", "test/scxml_tests/mandatory/if/test147_test.exs", "test/scxml_tests/mandatory/if/test148_test.exs", "test/scxml_tests/mandatory/if/test149_test.exs", diff --git a/test/statifier/actions/assign_action_test.exs b/test/statifier/actions/assign_action_test.exs index 05aff81..1d08ba6 100644 --- a/test/statifier/actions/assign_action_test.exs +++ b/test/statifier/actions/assign_action_test.exs @@ -42,7 +42,26 @@ defmodule Statifier.Actions.AssignActionTest do assert %StateChart{datamodel: %{"userName" => "John Doe"}} = result end - test "executes nested assignment", %{state_chart: state_chart} do + test "fails nested assignment when intermediate structures don't exist", %{ + state_chart: state_chart + } do + action = AssignAction.new("user.profile.name", "'Jane Smith'") + + result = AssignAction.execute(state_chart, action) + + # Should fail and generate error.execution event + assert result.internal_queue |> length() == 1 + [error_event] = result.internal_queue + assert error_event.name == "error.execution" + assert error_event.data["type"] == "assign.execution" + assert error_event.data["location"] == "user.profile.name" + end + + test "executes nested assignment when intermediate structures exist", %{ + state_chart: state_chart + } do + # Set up intermediate structures first + state_chart = %{state_chart | datamodel: %{"user" => %{"profile" => %{}}}} action = AssignAction.new("user.profile.name", "'Jane Smith'") result = AssignAction.execute(state_chart, action) @@ -60,12 +79,29 @@ defmodule Statifier.Actions.AssignActionTest do assert %StateChart{datamodel: %{"counter" => 8}} = result end - test "executes assignment with mixed notation", %{state_chart: state_chart} do + test "fails assignment with mixed notation when intermediate structures don't exist", %{ + state_chart: state_chart + } do state_chart = %{state_chart | datamodel: %{"users" => %{}}} action = AssignAction.new("users['john'].active", "true") result = AssignAction.execute(state_chart, action) + # Should fail and generate error.execution event because users['john'] doesn't exist + assert result.internal_queue |> length() == 1 + [error_event] = result.internal_queue + assert error_event.name == "error.execution" + assert error_event.data["type"] == "assign.execution" + end + + test "executes assignment with mixed notation when intermediate structures exist", %{ + state_chart: state_chart + } do + state_chart = %{state_chart | datamodel: %{"users" => %{"john" => %{}}}} + action = AssignAction.new("users['john'].active", "true") + + result = AssignAction.execute(state_chart, action) + expected_data = %{"users" => %{"john" => %{"active" => true}}} assert %StateChart{datamodel: ^expected_data} = result end @@ -152,9 +188,27 @@ defmodule Statifier.Actions.AssignActionTest do assert log_entry.metadata.location == "result" end - test "assigns complex data structures", %{state_chart: state_chart} do - # This would work with enhanced expression evaluation that supports object literals - # For now, we test with a simple string that predictor can handle + test "fails to assign complex data structures when intermediate structures don't exist", %{ + state_chart: state_chart + } do + # Assignment to config.settings should fail because config doesn't exist + action = AssignAction.new("config.settings", "'complex_value'") + + result = AssignAction.execute(state_chart, action) + + # Should fail and generate error.execution event + assert result.internal_queue |> length() == 1 + [error_event] = result.internal_queue + assert error_event.name == "error.execution" + assert error_event.data["type"] == "assign.execution" + assert error_event.data["location"] == "config.settings" + end + + test "assigns complex data structures when intermediate structures exist", %{ + state_chart: state_chart + } do + # Set up intermediate structure first + state_chart = %{state_chart | datamodel: %{"config" => %{}}} action = AssignAction.new("config.settings", "'complex_value'") result = AssignAction.execute(state_chart, action) @@ -187,20 +241,24 @@ defmodule Statifier.Actions.AssignActionTest do assert action.expr == "'John Doe'" end - test "expressions work correctly with validation-time compilation", %{ - state_chart: state_chart - } do + test "expressions work correctly with validation-time compilation - fails when intermediate structures don't exist", + %{ + state_chart: state_chart + } do action = AssignAction.new("user.settings.theme", "'dark'") # Verify expression is not compiled during creation assert is_nil(action.compiled_expr) - # Execute should work with runtime compilation as fallback + # Execute should fail because user doesn't exist result = AssignAction.execute(state_chart, action) - # Verify result is correct - expected_data = %{"user" => %{"settings" => %{"theme" => "dark"}}} - assert %StateChart{datamodel: ^expected_data} = result + # Should fail and generate error.execution event + assert result.internal_queue |> length() == 1 + [error_event] = result.internal_queue + assert error_event.name == "error.execution" + assert error_event.data["type"] == "assign.execution" + assert error_event.data["location"] == "user.settings.theme" end end end diff --git a/test/statifier/datamodel_test.exs b/test/statifier/datamodel_test.exs index 528c0b6..9e8041b 100644 --- a/test/statifier/datamodel_test.exs +++ b/test/statifier/datamodel_test.exs @@ -340,14 +340,22 @@ defmodule Statifier.DatamodelTest do {:ok, result1} = Datamodel.put_in_path(datamodel, ["key"], "value") assert result1 == %{"key" => "value"} - # Test nested path - {:ok, result2} = Datamodel.put_in_path(datamodel, ["user", "profile", "name"], "John") - assert result2 == %{"user" => %{"profile" => %{"name" => "John"}}} + # Test nested path fails when intermediate structures don't exist + {:error, error_msg} = Datamodel.put_in_path(datamodel, ["user", "profile", "name"], "John") + assert error_msg == "Cannot assign to nested path: 'user' does not exist" # Test updating existing nested structure existing = %{"user" => %{"age" => 30}} {:ok, result3} = Datamodel.put_in_path(existing, ["user", "name"], "Jane") assert result3 == %{"user" => %{"age" => 30, "name" => "Jane"}} + + # Test creating nested path only when intermediate structures exist + existing_with_profile = %{"user" => %{"profile" => %{}}} + + {:ok, result4} = + Datamodel.put_in_path(existing_with_profile, ["user", "profile", "name"], "John") + + assert result4 == %{"user" => %{"profile" => %{"name" => "John"}}} end test "handles non-map structures with error" do @@ -355,7 +363,7 @@ defmodule Statifier.DatamodelTest do datamodel = %{"user" => "not_a_map"} {:error, msg} = Datamodel.put_in_path(datamodel, ["user", "name"], "John") - assert msg == "Cannot assign to non-map structure" + assert msg == "Cannot assign to nested path: 'user' is not a map" # Try to assign to primitive value {:error, msg2} = Datamodel.put_in_path("string", ["key"], "value") diff --git a/test/statifier/evaluator_test.exs b/test/statifier/evaluator_test.exs index e8c63ee..1a08976 100644 --- a/test/statifier/evaluator_test.exs +++ b/test/statifier/evaluator_test.exs @@ -322,17 +322,17 @@ defmodule Statifier.EvaluatorTest do Evaluator.assign_value(["user"], "John", datamodel) end - test "assigns to nested path" do + test "fails to assign to nested path when intermediate structure doesn't exist" do datamodel = %{} - assert {:ok, %{"user" => %{"name" => "John"}}} = + assert {:error, "Cannot assign to nested path: 'user' does not exist"} = Evaluator.assign_value(["user", "name"], "John", datamodel) end - test "assigns to deeply nested path" do + test "fails to assign to deeply nested path when intermediate structures don't exist" do datamodel = %{} - assert {:ok, %{"user" => %{"profile" => %{"settings" => %{"theme" => "dark"}}}}} = + assert {:error, "Cannot assign to nested path: 'user' does not exist"} = Evaluator.assign_value( ["user", "profile", "settings", "theme"], "dark", @@ -371,13 +371,13 @@ defmodule Statifier.EvaluatorTest do Evaluator.evaluate_and_assign("result", "counter * 2", state_chart) end - test "works with nested assignments" do + test "fails with nested assignments when intermediate structures don't exist" do state_chart = %StateChart{ configuration: Configuration.new([]), datamodel: %{"name" => "John"} } - assert {:ok, %{"user" => %{"profile" => %{"name" => "John"}}}} = + assert {:error, "Cannot assign to nested path: 'user' does not exist"} = Evaluator.evaluate_and_assign("user.profile.name", "name", state_chart) end diff --git a/test/statifier/event_test.exs b/test/statifier/event_test.exs index fc92047..c4da3fd 100644 --- a/test/statifier/event_test.exs +++ b/test/statifier/event_test.exs @@ -88,5 +88,83 @@ defmodule Statifier.EventTest do assert Event.matches?(event, "timeout") refute Event.matches?(event, "other_event") end + + test "supports universal wildcard '*'" do + event1 = Event.new("any_event") + event2 = Event.new("foo.bar.baz") + event3 = Event.internal("internal_event") + + # Universal wildcard should match any event + assert Event.matches?(event1, "*") + assert Event.matches?(event2, "*") + assert Event.matches?(event3, "*") + end + + test "supports prefix matching" do + event = Event.new("foo.bar.baz") + + # Prefix matching - spec tokens must be prefix of event tokens + # foo matches foo.bar.baz + assert Event.matches?(event, "foo") + # foo.bar matches foo.bar.baz + assert Event.matches?(event, "foo.bar") + # exact match + assert Event.matches?(event, "foo.bar.baz") + + # Should not match if spec is longer than event + refute Event.matches?(event, "foo.bar.baz.qux") + + # Should not match different prefixes + refute Event.matches?(event, "bar") + refute Event.matches?(event, "foo.different") + end + + test "supports multiple descriptors with OR logic" do + event = Event.new("user.login") + + # Should match if ANY descriptor matches + # matches "user" + assert Event.matches?(event, "user admin") + # matches "user" + assert Event.matches?(event, "admin user") + # matches "user.login" + assert Event.matches?(event, "foo user.login bar") + + # Should not match if NO descriptor matches + refute Event.matches?(event, "admin system") + refute Event.matches?(event, "foo bar baz") + end + + test "supports wildcard suffix patterns" do + # Test foo.* pattern matching + # matches foo.bar + assert Event.matches?(Event.new("foo.bar"), "foo.*") + # matches foo.baz + assert Event.matches?(Event.new("foo.baz"), "foo.*") + # matches foo.bar.qux + assert Event.matches?(Event.new("foo.bar.qux"), "foo.*") + + # Should not match just "foo" (wildcard requires additional tokens) + refute Event.matches?(Event.new("foo"), "foo.*") + + # Should not match different prefixes + refute Event.matches?(Event.new("bar.baz"), "foo.*") + + # Test more complex wildcard patterns + assert Event.matches?(Event.new("user.profile.updated"), "user.profile.*") + refute Event.matches?(Event.new("user.profile"), "user.profile.*") + end + + test "combines multiple patterns correctly" do + event = Event.new("system.error.critical") + + # Multiple patterns with wildcards and prefixes + # matches system.* + assert Event.matches?(event, "system.* user.*") + # matches system prefix + assert Event.matches?(event, "system user.*") + # matches neither + refute Event.matches?(event, "admin.* user.*") + end end end