diff --git a/lib/statifier/actions/foreach_action.ex b/lib/statifier/actions/foreach_action.ex index 6b6e2c8..39c1fdd 100644 --- a/lib/statifier/actions/foreach_action.ex +++ b/lib/statifier/actions/foreach_action.ex @@ -101,27 +101,68 @@ defmodule Statifier.Actions.ForeachAction do """ @spec execute(StateChart.t(), t()) :: StateChart.t() def execute(%StateChart{} = state_chart, %__MODULE__{} = foreach_action) do - # Step 1: Evaluate array expression - case evaluate_array(foreach_action, state_chart) do - {:ok, collection} when is_list(collection) -> - # Step 2: Execute iteration with proper variable scoping - execute_iteration(foreach_action, collection, state_chart) - - {:ok, _non_list} -> - # Not an iterable collection - raise error.execution - raise_execution_error( - state_chart, - "Array expression did not evaluate to an iterable collection" - ) + # Step 1: Validate item and index parameters are legal variable names + case validate_variable_names(foreach_action) do + :ok -> + # Step 2: Evaluate array expression + case evaluate_array(foreach_action, state_chart) do + {:ok, collection} when is_list(collection) -> + # Step 3: Execute iteration with proper variable scoping + execute_iteration(foreach_action, collection, state_chart) + + {:ok, _non_list} -> + # Not an iterable collection - raise error.execution + raise_execution_error( + state_chart, + "Array expression did not evaluate to an iterable collection" + ) + + {:error, reason} -> + # Array evaluation failed - raise error.execution + raise_execution_error(state_chart, "Array evaluation failed: #{inspect(reason)}") + end {:error, reason} -> - # Array evaluation failed - raise error.execution - raise_execution_error(state_chart, "Array evaluation failed: #{inspect(reason)}") + # Invalid variable names - raise error.execution + raise_execution_error(state_chart, reason) end end # Private functions + # Validate that item and index parameters are legal variable names + defp validate_variable_names(foreach_action) do + # Validate item parameter (required) + case validate_single_variable_name(foreach_action.item, "item") do + :ok -> + # Validate index parameter (optional) + if foreach_action.index do + validate_single_variable_name(foreach_action.index, "index") + else + :ok + end + + error -> + error + end + end + + # Validate a single variable name using the Evaluator location validation + defp validate_single_variable_name(variable_name, param_type) do + case Evaluator.resolve_location(variable_name) do + {:ok, _location} -> + :ok + + {:error, %{type: :not_assignable}} -> + {:error, + "#{param_type} parameter '#{variable_name}' is not a legal variable name - cannot assign to literals"} + + {:error, reason} -> + {:error, + "#{param_type} parameter '#{variable_name}' is not a legal variable name: #{inspect(reason)}"} + end + end + # Evaluate the array expression to get the collection defp evaluate_array(%{compiled_array: compiled_array, array: array_expr}, state_chart) do case Evaluator.evaluate_value(compiled_array || array_expr, state_chart) do diff --git a/lib/statifier/data.ex b/lib/statifier/data.ex index 3b9a923..ae93f4d 100644 --- a/lib/statifier/data.ex +++ b/lib/statifier/data.ex @@ -3,30 +3,35 @@ defmodule Statifier.Data do Represents a data element in an SCXML datamodel. Corresponds to SCXML `` elements which define variables in the state machine's datamodel. - Each data element has an `id` (required) and optional `expr` or `src` for initialization. + Each data element has an `id` (required) and optional `expr`, `src`, or child content for initialization. + Per SCXML specification, precedence is: `expr` attribute > child content > `src` attribute. """ defstruct [ :id, :expr, :src, + :child_content, # Document order for deterministic processing document_order: nil, # Location information for validation source_location: nil, id_location: nil, expr_location: nil, - src_location: nil + src_location: nil, + child_content_location: nil ] @type t :: %__MODULE__{ id: String.t(), expr: String.t() | nil, src: String.t() | nil, + child_content: String.t() | nil, document_order: integer() | nil, source_location: map() | nil, id_location: map() | nil, expr_location: map() | nil, - src_location: map() | nil + src_location: map() | nil, + child_content_location: map() | nil } end diff --git a/lib/statifier/datamodel.ex b/lib/statifier/datamodel.ex index a4b1aa3..4aa48c7 100644 --- a/lib/statifier/datamodel.ex +++ b/lib/statifier/datamodel.ex @@ -17,17 +17,17 @@ defmodule Statifier.Datamodel do %Statifier.Data{id: "counter", expr: "0"}, %Statifier.Data{id: "name", expr: "'John'"} ] - datamodel = Statifier.Datamodel.initialize(data_elements, state_chart) + state_chart = Statifier.Datamodel.initialize(state_chart, data_elements) # Access variables - value = Statifier.Datamodel.get(datamodel, "counter") + value = Statifier.Datamodel.get(state_chart.datamodel, "counter") # => 0 # Update variables datamodel = Statifier.Datamodel.set(datamodel, "counter", 1) """ - alias Statifier.{Configuration, Evaluator} + alias Statifier.{Configuration, Evaluator, Event, StateChart} alias Statifier.Logging.LogManager require LogManager @@ -45,13 +45,19 @@ defmodule Statifier.Datamodel do Initialize a datamodel from a list of data elements. Processes each `` element, evaluates its expression (if any), - and stores the result in the datamodel. + and stores the result in the datamodel. Returns the updated StateChart + with the initialized datamodel and any error events that were generated. """ - @spec initialize(list(Statifier.Data.t()), Statifier.StateChart.t()) :: t() - def initialize(data_elements, state_chart) when is_list(data_elements) do - Enum.reduce(data_elements, new(), fn data_element, model -> - initialize_variable(data_element, model, state_chart) - end) + @spec initialize(Statifier.StateChart.t(), list(Statifier.Data.t())) :: + Statifier.StateChart.t() + def initialize(state_chart, data_elements) when is_list(data_elements) do + {datamodel, updated_state_chart} = + Enum.reduce(data_elements, {new(), state_chart}, fn data_element, {model, sc} -> + initialize_variable(sc, data_element, model) + end) + + # Return StateChart with updated datamodel + %{updated_state_chart | datamodel: datamodel} end @doc """ @@ -132,11 +138,11 @@ defmodule Statifier.Datamodel do @doc """ Build evaluation context for Predicator expressions. - Takes the datamodel and state_chart, returns a context ready for evaluation. + Takes the state_chart, extracts its datamodel, and returns a context ready for evaluation. This is the single source of truth for context preparation. """ - @spec build_evaluation_context(t(), Statifier.StateChart.t()) :: map() - def build_evaluation_context(datamodel, state_chart) do + @spec build_evaluation_context(Statifier.StateChart.t()) :: map() + def build_evaluation_context(%Statifier.StateChart{datamodel: datamodel} = state_chart) do %{} # Start with datamodel variables as base |> Map.merge(datamodel) @@ -194,56 +200,107 @@ defmodule Statifier.Datamodel do |> Map.put("_ioprocessors", []) end - defp initialize_variable(%{id: id, expr: expr}, model, state_chart) + defp initialize_variable(state_chart, %{id: id} = data_element, model) when is_binary(id) do # Build context for expression evaluation using the simplified approach # Create a temporary state chart with current datamodel for evaluation temp_state_chart = %{state_chart | datamodel: model} - # Evaluate the expression or use nil as default - value = evaluate_initial_expression(expr, temp_state_chart) + # Evaluate the data value using SCXML precedence: expr > child_content > src + case determine_data_value(temp_state_chart, data_element) do + {:ok, value, updated_state_chart} -> + # Store in model and return updated state chart + {Map.put(model, id, value), updated_state_chart} + + {:error, reason, updated_state_chart} -> + # Per SCXML spec: create empty variable and generate error.execution event + LogManager.debug(updated_state_chart, "Data element initialization failed", %{ + action_type: "datamodel_error", + data_id: id, + error: inspect(reason) + }) + + # Create error.execution event + error_event = %Event{ + name: "error.execution", + data: %{"reason" => reason, "type" => "datamodel.initialization", "data_id" => id}, + origin: :internal + } - # Store in model - Map.put(model, id, value) + # Add event to internal queue and create empty variable + final_state_chart = StateChart.enqueue_event(updated_state_chart, error_event) + {Map.put(model, id, nil), final_state_chart} + end end - defp initialize_variable(_data_element, model, _state_chart) do + defp initialize_variable(state_chart, _data_element, model) do # Skip data elements without valid id - model + {model, state_chart} end - defp evaluate_initial_expression(nil, _state_chart), do: nil - defp evaluate_initial_expression("", _state_chart), do: nil + # Implement SCXML data value precedence: expr attribute > child content > src attribute + defp determine_data_value(state_chart, %{expr: expr, child_content: child_content, src: src}) do + cond do + # 1. expr attribute has highest precedence + expr && expr != "" -> + evaluate_initial_expression_with_errors(state_chart, expr) + + # 2. child content has second precedence + child_content && child_content != "" -> + evaluate_child_content_with_errors(state_chart, child_content) + + # 3. src attribute has lowest precedence (not implemented yet) + src && src != "" -> + # NOTE: src loading planned for future implementation phase + LogManager.debug(state_chart, "src attribute not yet supported for data elements", %{ + action_type: "datamodel_src_loading", + src: src + }) + + {:ok, nil, state_chart} - defp evaluate_initial_expression(expr_string, state_chart) do + # 4. Default to nil if no value source specified + true -> + {:ok, nil, state_chart} + end + end + + # Error-aware version of evaluate_initial_expression for proper error event generation + defp evaluate_initial_expression_with_errors(state_chart, expr_string) do case Evaluator.compile_expression(expr_string) do {:ok, compiled} -> case Evaluator.evaluate_value(compiled, state_chart) do - {:ok, val} -> - val + {:ok, value} -> + {:ok, value, state_chart} {:error, reason} -> - # Log the error but continue with fallback - LogManager.debug(state_chart, "Failed to evaluate datamodel expression", %{ - action_type: "datamodel_evaluation", - expr_string: expr_string, - error: inspect(reason) - }) - - # For now, default to the literal string if evaluation fails - # This handles cases like object literals that Predicator can't parse - expr_string + # Expression evaluation failed - should generate error.execution + {:error, "Expression evaluation failed: #{inspect(reason)}", state_chart} end {:error, reason} -> - LogManager.debug(state_chart, "Failed to compile datamodel expression", %{ - action_type: "datamodel_compilation", - expr_string: expr_string, - error: inspect(reason) - }) + # Compilation failed - should generate error.execution + {:error, "Expression compilation failed: #{inspect(reason)}", state_chart} + end + end + + # Error-aware version of evaluate_child_content using Predicator/Evaluator + defp evaluate_child_content_with_errors(state_chart, child_content) do + # Use Evaluator to handle all expression types including literals, arrays, objects + case Evaluator.compile_expression(child_content) do + {:ok, compiled} -> + case Evaluator.evaluate_value(compiled, state_chart) do + {:ok, value} -> + {:ok, value, state_chart} - # Default to literal string if compilation fails - expr_string + {:error, reason} -> + # Expression evaluation failed - this is an error for child content + {:error, "Child content evaluation failed: #{inspect(reason)}", state_chart} + end + + {:error, reason} -> + # Compilation failed - this is an error for child content + {:error, "Child content compilation failed: #{inspect(reason)}", state_chart} end end diff --git a/lib/statifier/document.ex b/lib/statifier/document.ex index 0b5c324..b847279 100644 --- a/lib/statifier/document.ex +++ b/lib/statifier/document.ex @@ -8,6 +8,7 @@ defmodule Statifier.Document do :datamodel, :version, :xmlns, + :binding, initial: [], states: [], datamodel_elements: [], @@ -25,7 +26,8 @@ defmodule Statifier.Document do name_location: nil, initial_location: nil, datamodel_location: nil, - version_location: nil + version_location: nil, + binding_location: nil ] @type t :: %__MODULE__{ @@ -33,6 +35,7 @@ defmodule Statifier.Document do datamodel: String.t() | nil, version: String.t() | nil, xmlns: String.t() | nil, + binding: String.t() | nil, initial: [String.t()], states: [Statifier.State.t()], datamodel_elements: [Statifier.Data.t()], diff --git a/lib/statifier/evaluator.ex b/lib/statifier/evaluator.ex index 6af9769..45153d4 100644 --- a/lib/statifier/evaluator.ex +++ b/lib/statifier/evaluator.ex @@ -65,7 +65,7 @@ defmodule Statifier.Evaluator do def evaluate_condition(compiled_cond, state_chart) do # Build context once using the unified approach - context = Datamodel.build_evaluation_context(state_chart.datamodel, state_chart) + context = Datamodel.build_evaluation_context(state_chart) functions = Datamodel.build_predicator_functions(state_chart.configuration) case Predicator.evaluate(compiled_cond, context, functions: functions) do @@ -88,7 +88,7 @@ defmodule Statifier.Evaluator do def evaluate_value(compiled_expr, state_chart) do # Build context once using the unified approach - context = Datamodel.build_evaluation_context(state_chart.datamodel, state_chart) + context = Datamodel.build_evaluation_context(state_chart) functions = Datamodel.build_predicator_functions(state_chart.configuration) case Predicator.evaluate(compiled_expr, context, functions: functions) do @@ -129,7 +129,7 @@ defmodule Statifier.Evaluator do {:ok, [String.t()]} | {:error, term()} def resolve_location(location_expr, state_chart) when is_binary(location_expr) do # Build evaluation context for location resolution - context = Datamodel.build_evaluation_context(state_chart.datamodel, state_chart) + context = Datamodel.build_evaluation_context(state_chart) # Note: context_location doesn't need functions parameter case Predicator.context_location(location_expr, context) do @@ -195,7 +195,7 @@ defmodule Statifier.Evaluator do # Evaluate using predicator with proper SCXML context and functions defp evaluate_with_predicator(expression_or_instructions, state_chart) do - context = Datamodel.build_evaluation_context(state_chart.datamodel, state_chart) + context = Datamodel.build_evaluation_context(state_chart) functions = Datamodel.build_predicator_functions(state_chart.configuration) case Predicator.evaluate(expression_or_instructions, context, functions: functions) do diff --git a/lib/statifier/interpreter.ex b/lib/statifier/interpreter.ex index 5beb947..d6bb6f9 100644 --- a/lib/statifier/interpreter.ex +++ b/lib/statifier/interpreter.ex @@ -89,17 +89,13 @@ defmodule Statifier.Interpreter do |> Configuration.active_leaf_states() |> MapSet.to_list() - state_chart = StateChart.new(optimized_document, initial_config) - - # Initialize data model from datamodel_elements - datamodel = Datamodel.initialize(optimized_document.datamodel_elements, state_chart) - - # Extract invoke handlers from options + # Extract invoke handlers from options for pipelining invoke_handlers = Keyword.get(opts, :invoke_handlers, %{}) state_chart = - state_chart - |> StateChart.update_datamodel(datamodel) + StateChart.new(optimized_document, initial_config) + # Initialize data model from datamodel_elements + |> Datamodel.initialize(optimized_document.datamodel_elements) # Configure invoke handlers |> Map.put(:invoke_handlers, invoke_handlers) # Configure logging based on options or defaults @@ -108,17 +104,8 @@ defmodule Statifier.Interpreter do |> ActionExecutor.execute_onentry_actions(initial_leaf_states) # Execute microsteps (eventless transitions and internal events) after initialization |> execute_microsteps() - - # Log warnings if any using proper logging infrastructure - state_chart = - if warnings != [] do - LogManager.warn(state_chart, "Document validation warnings", %{ - warning_count: length(warnings), - warnings: warnings - }) - else - state_chart - end + # Log warnings if any using proper logging infrastructure + |> log_validation_warnings(warnings) {:ok, state_chart} end @@ -777,4 +764,14 @@ defmodule Statifier.Interpreter do end end) end + + # Helper function to conditionally log validation warnings + defp log_validation_warnings(state_chart, []), do: state_chart + + defp log_validation_warnings(state_chart, warnings) do + LogManager.warn(state_chart, "Document validation warnings", %{ + warning_count: length(warnings), + warnings: warnings + }) + end end diff --git a/lib/statifier/parser/scxml/element_builder.ex b/lib/statifier/parser/scxml/element_builder.ex index b2ef60f..2f018fe 100644 --- a/lib/statifier/parser/scxml/element_builder.ex +++ b/lib/statifier/parser/scxml/element_builder.ex @@ -39,12 +39,16 @@ defmodule Statifier.Parser.SCXML.ElementBuilder do version_location = LocationTracker.attribute_location(xml_string, "version", location) + binding_location = + LocationTracker.attribute_location(xml_string, "binding", location) + %Statifier.Document{ name: get_attr_value(attrs_map, "name"), initial: parse_initial_attribute(get_attr_value(attrs_map, "initial")), datamodel: get_attr_value(attrs_map, "datamodel"), version: get_attr_value(attrs_map, "version"), xmlns: get_attr_value(attrs_map, "xmlns"), + binding: get_attr_value(attrs_map, "binding"), states: [], datamodel_elements: [], document_order: document_order, @@ -53,7 +57,8 @@ defmodule Statifier.Parser.SCXML.ElementBuilder do name_location: name_location, initial_location: initial_location, datamodel_location: datamodel_location, - version_location: version_location + version_location: version_location, + binding_location: binding_location } end @@ -270,12 +275,14 @@ defmodule Statifier.Parser.SCXML.ElementBuilder do id: get_attr_value(attrs_map, "id"), expr: get_attr_value(attrs_map, "expr"), src: get_attr_value(attrs_map, "src"), + child_content: nil, document_order: document_order, # Location information source_location: location, id_location: id_location, expr_location: expr_location, - src_location: src_location + src_location: src_location, + child_content_location: nil } end diff --git a/lib/statifier/parser/scxml/state_stack.ex b/lib/statifier/parser/scxml/state_stack.ex index f2556cd..f40e8fc 100644 --- a/lib/statifier/parser/scxml/state_stack.ex +++ b/lib/statifier/parser/scxml/state_stack.ex @@ -169,6 +169,7 @@ defmodule Statifier.Parser.SCXML.StateStack do case parent_stack do [{"datamodel", _datamodel_placeholder} | [{"scxml", document} | rest]] -> + # Document-level datamodel: add to document and continue updated_document = %{ document | datamodel_elements: document.datamodel_elements ++ [data_element] @@ -182,11 +183,66 @@ defmodule Statifier.Parser.SCXML.StateStack do {:ok, updated_state} + [{"datamodel", _datamodel_placeholder} | [{"state", state_data} | rest]] -> + # State-level datamodel: check binding and add to document if early binding + case find_document_in_stack([{"state", state_data} | rest]) do + {document, updated_stack} when document.binding == "early" -> + # Early binding: add state-level data element to document's datamodel_elements + updated_document = %{ + document + | datamodel_elements: document.datamodel_elements ++ [data_element] + } + + # Update the document in the stack + final_stack = replace_document_in_stack(updated_stack, updated_document) + + updated_state = %{ + state + | result: updated_document, + stack: [{"datamodel", nil} | final_stack] + } + + {:ok, updated_state} + + _binding -> + # Late binding or no binding: just remove from stack (state-level data not implemented yet) + {:ok, %{state | stack: [{"datamodel", nil}, {"state", state_data} | rest]}} + end + _other_parent -> {:ok, %{state | stack: parent_stack}} end end + # Helper function to find document in the stack and return it with the remaining stack + defp find_document_in_stack([{"scxml", document} | rest]) do + {document, [{"scxml", document} | rest]} + end + + defp find_document_in_stack([other | rest]) do + case find_document_in_stack(rest) do + {document, updated_rest} -> {document, [other | updated_rest]} + nil -> nil + end + end + + defp find_document_in_stack([]) do + nil + end + + # Helper function to replace document in the stack + defp replace_document_in_stack([{"scxml", _old_document} | rest], new_document) do + [{"scxml", new_document} | rest] + end + + defp replace_document_in_stack([other | rest], new_document) do + [other | replace_document_in_stack(rest, new_document)] + end + + defp replace_document_in_stack([], _new_document) do + [] + end + @doc """ Push an element onto the parsing stack. """ @@ -867,7 +923,7 @@ defmodule Statifier.Parser.SCXML.StateStack do end @doc """ - Handle text content for elements that support it (like ). + Handle text content for elements that support it (like and ). """ @spec handle_characters(String.t(), map()) :: {:ok, map()} | :not_handled def handle_characters(character_data, %{stack: [{element_name, element} | _rest]} = state) do @@ -885,6 +941,19 @@ defmodule Statifier.Parser.SCXML.StateStack do {:ok, state} end + "data" -> + # Add child content to data element + trimmed_content = String.trim(character_data) + + if trimmed_content != "" do + updated_data = %{element | child_content: trimmed_content} + updated_stack = replace_top_element(state.stack, {"data", updated_data}) + {:ok, %{state | stack: updated_stack}} + else + # Ignore whitespace-only content + {:ok, state} + end + _other_element -> :not_handled end diff --git a/test/passing_tests.json b/test/passing_tests.json index 26e75af..9d9c801 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-07", + "last_updated": "2025-09-08", "scion_tests": [ "test/scion_tests/actionSend/send1_test.exs", "test/scion_tests/actionSend/send2_test.exs", @@ -29,6 +29,7 @@ "test/scion_tests/cond_js/test0_test.exs", "test/scion_tests/cond_js/test1_test.exs", "test/scion_tests/cond_js/test2_test.exs", + "test/scion_tests/data/data_invalid_test.exs", "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", @@ -96,10 +97,14 @@ "test/scxml_tests/mandatory/SelectingTransitions/test413_test.exs", "test/scxml_tests/mandatory/SelectingTransitions/test419_test.exs", "test/scxml_tests/mandatory/SelectingTransitions/test503_test.exs", + "test/scxml_tests/mandatory/data/test277_test.exs", "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/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", diff --git a/test/statifier/actions/if_action_test.exs b/test/statifier/actions/if_action_test.exs index 85c7d65..155b92a 100644 --- a/test/statifier/actions/if_action_test.exs +++ b/test/statifier/actions/if_action_test.exs @@ -1,7 +1,7 @@ defmodule Statifier.Actions.IfActionTest do use ExUnit.Case, async: true - alias Statifier.{Actions.AssignAction, Actions.IfAction, Configuration, StateChart} + alias Statifier.{Actions.AssignAction, Actions.IfAction, Configuration, Evaluator, StateChart} describe "IfAction.new/2" do test "creates if action with single block" do @@ -103,5 +103,82 @@ defmodule Statifier.Actions.IfActionTest do # Should execute the elseif block and assign "second" to result assert result.datamodel["result"] == "second" end + + test "handles empty conditional blocks list" do + if_action = IfAction.new([]) + + state_chart = %StateChart{ + configuration: Configuration.new([]), + datamodel: %{"existing" => "value"} + } + + result = IfAction.execute(state_chart, if_action) + + # Should return unchanged state chart + assert result == state_chart + end + + test "handles invalid condition expressions" do + assign_action = AssignAction.new("result", "'executed'") + + blocks = [ + %{type: :if, cond: "invalid@#$syntax", actions: [assign_action]} + ] + + if_action = IfAction.new(blocks) + + state_chart = %StateChart{ + configuration: Configuration.new([]), + datamodel: %{} + } + + result = IfAction.execute(state_chart, if_action) + + # Should not execute actions due to invalid condition + refute Map.has_key?(result.datamodel, "result") + end + + test "handles nil condition in conditional block" do + assign_action = AssignAction.new("result", "'executed'") + + blocks = [ + %{type: :if, cond: nil, actions: [assign_action]} + ] + + if_action = IfAction.new(blocks) + + state_chart = %StateChart{ + configuration: Configuration.new([]), + datamodel: %{} + } + + result = IfAction.execute(state_chart, if_action) + + # Should not execute actions due to nil condition + refute Map.has_key?(result.datamodel, "result") + end + + test "handles pre-compiled conditions" do + assign_action = AssignAction.new("result", "'precompiled'") + + # Create a block with a pre-compiled condition + {:ok, compiled_condition} = Evaluator.compile_expression("true") + + blocks = [ + %{type: :if, cond: "true", compiled_cond: compiled_condition, actions: [assign_action]} + ] + + if_action = IfAction.new(blocks) + + state_chart = %StateChart{ + configuration: Configuration.new([]), + datamodel: %{} + } + + result = IfAction.execute(state_chart, if_action) + + # Should execute actions using pre-compiled condition + assert result.datamodel["result"] == "precompiled" + end end end diff --git a/test/statifier/datamodel_test.exs b/test/statifier/datamodel_test.exs index d5bfbcf..528c0b6 100644 --- a/test/statifier/datamodel_test.exs +++ b/test/statifier/datamodel_test.exs @@ -223,7 +223,7 @@ defmodule Statifier.DatamodelTest do } datamodel = %{"counter" => 5, "name" => "test"} - context = Datamodel.build_evaluation_context(datamodel, state_chart) + context = Datamodel.build_evaluation_context(%{state_chart | datamodel: datamodel}) # Should have datamodel variables assert context["counter"] == 5 @@ -289,14 +289,14 @@ defmodule Statifier.DatamodelTest do xml = create_scxml_with_datamodel(""" - + """) {:ok, state_chart} = initialize_from_xml(xml) assert state_chart.datamodel["valid"] == 42 - # Invalid expressions should fall back to the literal string - assert state_chart.datamodel["invalid"] == "this is not valid" + # Invalid expressions should create empty variable per SCXML spec + assert state_chart.datamodel["invalid"] == nil end test "handles data elements without valid id" do @@ -311,10 +311,10 @@ defmodule Statifier.DatamodelTest do # Mock data element without id should be skipped mock_invalid_data = %{expr: "test"} - result_model = Datamodel.initialize([mock_invalid_data], state_chart) + result_state_chart = Datamodel.initialize(state_chart, [mock_invalid_data]) # Should return empty datamodel since invalid data is skipped - assert result_model == %{} + assert result_state_chart.datamodel == %{} end test "handles empty expression strings" do @@ -372,7 +372,7 @@ defmodule Statifier.DatamodelTest do } datamodel = %{"counter" => 5} - context = Datamodel.build_evaluation_context(datamodel, state_chart) + context = Datamodel.build_evaluation_context(%{state_chart | datamodel: datamodel}) # Should have empty _event structure assert context["_event"]["name"] == "" @@ -388,7 +388,7 @@ defmodule Statifier.DatamodelTest do } datamodel = %{"counter" => 5} - context = Datamodel.build_evaluation_context(datamodel, state_chart) + context = Datamodel.build_evaluation_context(%{state_chart | datamodel: datamodel}) # Should handle nil data gracefully assert context["_event"]["name"] == "test" @@ -404,7 +404,7 @@ defmodule Statifier.DatamodelTest do } datamodel = %{"counter" => 5} - context = Datamodel.build_evaluation_context(datamodel, state_chart) + context = Datamodel.build_evaluation_context(%{state_chart | datamodel: datamodel}) # Should include non-map data in _event but not merge it assert context["_event"]["name"] == "test" @@ -424,7 +424,7 @@ defmodule Statifier.DatamodelTest do } datamodel = %{} - context = Datamodel.build_evaluation_context(datamodel, state_chart) + context = Datamodel.build_evaluation_context(%{state_chart | datamodel: datamodel}) # Should handle nil document assert context["_name"] == "" @@ -440,7 +440,7 @@ defmodule Statifier.DatamodelTest do } datamodel = %{} - context = Datamodel.build_evaluation_context(datamodel, state_chart) + context = Datamodel.build_evaluation_context(%{state_chart | datamodel: datamodel}) # Should handle nil document name assert context["_name"] == "" @@ -461,8 +461,8 @@ defmodule Statifier.DatamodelTest do document: %Document{name: "test2"} } - context1 = Datamodel.build_evaluation_context(%{}, state_chart1) - context2 = Datamodel.build_evaluation_context(%{}, state_chart2) + context1 = Datamodel.build_evaluation_context(state_chart1) + context2 = Datamodel.build_evaluation_context(state_chart2) # Session IDs should be different assert context1["_sessionid"] != context2["_sessionid"] diff --git a/test/statifier/logging/elixir_logger_adapter_test.exs b/test/statifier/logging/elixir_logger_adapter_test.exs index 717603d..f39077c 100644 --- a/test/statifier/logging/elixir_logger_adapter_test.exs +++ b/test/statifier/logging/elixir_logger_adapter_test.exs @@ -104,6 +104,34 @@ defmodule Statifier.Logging.ElixirLoggerAdapterTest do # Clean up Process.delete(:test_pid) end + + test "handles trace level with custom logger module" do + # Test the uncovered :trace branch for custom loggers + current_pid = self() + + defmodule TraceLoggerModule do + @spec trace(String.t(), map()) :: :ok + def trace(message, metadata) do + case Process.get(:test_pid) do + nil -> :ok + pid -> send(pid, {:log, :trace, message, metadata}) + end + end + end + + Process.put(:test_pid, current_pid) + + adapter = %ElixirLoggerAdapter{logger_module: TraceLoggerModule} + state_chart = %StateChart{} + + result = Adapter.log(adapter, state_chart, :trace, "Trace message", %{}) + + # Verify trace was called on custom logger + assert_received {:log, :trace, "Trace message", %{}} + assert result == state_chart + + Process.delete(:test_pid) + end end describe "enabled?/2" do @@ -129,6 +157,37 @@ defmodule Statifier.Logging.ElixirLoggerAdapterTest do assert Adapter.enabled?(adapter, :info) == true assert Adapter.enabled?(adapter, :debug) == true end + + test "calls custom logger enabled? function when available" do + defmodule CustomLoggerWithEnabled do + @spec enabled?(atom()) :: boolean() + def enabled?(:debug), do: false + def enabled?(:info), do: true + def enabled?(_level), do: true + + @spec info(String.t(), map()) :: :ok + def info(_message, _metadata), do: :ok + end + + adapter = %ElixirLoggerAdapter{logger_module: CustomLoggerWithEnabled} + + # Should call the custom enabled? function + assert Adapter.enabled?(adapter, :debug) == false + assert Adapter.enabled?(adapter, :info) == true + assert Adapter.enabled?(adapter, :warn) == true + end + + test "maps trace and warn levels correctly for Elixir Logger" do + adapter = %ElixirLoggerAdapter{logger_module: Logger} + + # These should not crash and should handle level mapping + # trace should be mapped to debug, warn should be mapped to warning + result_trace = Adapter.enabled?(adapter, :trace) + result_warn = Adapter.enabled?(adapter, :warn) + + assert is_boolean(result_trace) + assert is_boolean(result_warn) + end end describe "struct defaults" do diff --git a/test/statifier/parser/scxml_test.exs b/test/statifier/parser/scxml_test.exs index 67dbd28..c81c7a1 100644 --- a/test/statifier/parser/scxml_test.exs +++ b/test/statifier/parser/scxml_test.exs @@ -76,11 +76,92 @@ defmodule Statifier.Parser.SCXMLTest do %Statifier.Data{ id: "counter", expr: "0", - src: nil + src: nil, + child_content: nil }, %Statifier.Data{ id: "name", - expr: nil + expr: nil, + child_content: nil + } + ] + }} = SCXML.parse(xml) + end + + test "parses SCXML data elements with child content" do + xml = """ + + + + {"theme": "dark", "lang": "en"} + + + [1, 2, 3, 4, 5] + + + {"should": "be ignored"} + + + + + """ + + assert {:ok, + %Document{ + datamodel: "elixir", + datamodel_elements: [ + %Statifier.Data{ + id: "config", + expr: nil, + src: nil, + child_content: ~s|{"theme": "dark", "lang": "en"}| + }, + %Statifier.Data{ + id: "items", + expr: nil, + src: nil, + child_content: "[1, 2, 3, 4, 5]" + }, + %Statifier.Data{ + id: "mixed", + expr: "'override'", + src: nil, + child_content: ~s|{"should": "be ignored"}| + } + ] + }} = SCXML.parse(xml) + end + + test "parses SCXML data elements ignoring whitespace-only child content" do + xml = """ + + + + + + + + "real content" + + + + + """ + + assert {:ok, + %Document{ + datamodel_elements: [ + %Statifier.Data{ + id: "empty1", + child_content: nil + }, + %Statifier.Data{ + id: "empty2", + child_content: nil + }, + %Statifier.Data{ + id: "hasContent", + child_content: ~s|"real content"| } ] }} = SCXML.parse(xml)