Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions lib/statifier/actions/assign_action.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ defmodule Statifier.Actions.AssignAction do

"""

alias Statifier.{Evaluator, StateChart}
alias Statifier.{Evaluator, Event, StateChart}
alias Statifier.Logging.LogManager
require LogManager

Expand Down Expand Up @@ -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",
Expand All @@ -104,6 +116,7 @@ defmodule Statifier.Actions.AssignAction do
error: inspect(reason)
}
)
|> StateChart.enqueue_event(error_event)
end
end
end
16 changes: 12 additions & 4 deletions lib/statifier/datamodel.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
49 changes: 47 additions & 2 deletions lib/statifier/event.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion test/passing_tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
],
Expand All @@ -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",
Expand Down
82 changes: 70 additions & 12 deletions test/statifier/actions/assign_action_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
16 changes: 12 additions & 4 deletions test/statifier/datamodel_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -340,22 +340,30 @@ 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
# Try to assign to a non-map value
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")
Expand Down
12 changes: 6 additions & 6 deletions test/statifier/evaluator_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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

Expand Down
Loading