diff --git a/.credo.exs b/.credo.exs index c3033f4..63133c0 100644 --- a/.credo.exs +++ b/.credo.exs @@ -72,6 +72,7 @@ {Credo.Check.Consistency.ExceptionNames, []}, {Credo.Check.Consistency.LineEndings, []}, {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, {Credo.Check.Consistency.SpaceAroundOperators, []}, {Credo.Check.Consistency.SpaceInParentheses, []}, {Credo.Check.Consistency.TabsOrSpaces, []}, @@ -102,7 +103,7 @@ {Credo.Check.Readability.ModuleDoc, []}, {Credo.Check.Readability.ModuleNames, []}, {Credo.Check.Readability.ParenthesesInCondition, []}, - {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + # {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, {Credo.Check.Readability.PredicateFunctionNames, []}, {Credo.Check.Readability.PreferImplicitTry, []}, diff --git a/.githooks/pre-push b/.githooks/pre-push index 458f4a9..8a488b9 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -10,8 +10,20 @@ echo "๐Ÿ” Running pre-push validation pipeline..." # Run formatting check echo "๐Ÿ“ Checking code formatting..." if ! mix format --check-formatted; then - echo "โŒ Code formatting check failed. Run 'mix format' to fix." - exit 1 + echo "โŒ Code formatting issues found. Running 'mix format' to fix..." + mix format + + # Check if files were actually changed by formatting + if ! git diff --quiet; then + echo "๐Ÿ“ Code has been automatically formatted." + echo "๐Ÿ”„ Please commit the formatting changes and push again:" + echo " git add ." + echo " git commit -m 'Auto-format code with mix format'" + echo " git push" + exit 1 + else + echo "โœ… No files needed formatting changes." + fi fi # Run regression tests (critical - these should never break) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0eaf17..239dfbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,7 +208,7 @@ jobs: regression-tests: name: Regression Test Suite runs-on: ubuntu-latest - needs: test + needs: compile steps: - name: Checkout code diff --git a/README.md b/README.md index afef4a7..96c6218 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,39 @@ An Elixir implementation of SCXML (State Chart XML) state charts with a focus on - Expression evaluation and datamodel support - Enhanced validation for complex SCXML constructs +## Future Extensions + +### **Feature-Based Test Validation System** +An enhancement to improve test accuracy by validating that tests actually exercise intended SCXML functionality: + +**Goal**: Prevent false positive tests where unsupported features are silently ignored, leading to "passing" tests that don't actually validate the intended behavior. + +**Proposed Enhancement**: +```elixir +# Example test with feature requirements +defmodule SCIONTest.SendIdlocation.Test0Test do + use SC.Case + @tag :scion + @required_features [:datamodel, :send_elements, :onentry_actions, :conditional_transitions] + + test "test0" do + # Test implementation that requires these features + end +end +``` + +**Implementation Phases**: +1. **Feature Detection Phase** - Analyze SCXML documents to identify used features +2. **Feature Validation Phase** - Fail tests when required features are unsupported +3. **Test Annotation Phase** - Add `@required_features` tags to existing tests +4. **Incremental Implementation** - Enable feature flags as capabilities are added + +**Benefits**: +- Eliminates false positive test results +- Provides clear roadmap of which features tests depend on +- Enables progressive test suite expansion as features are implemented +- Improves test reliability and developer confidence + ## Installation Add `sc` to your list of dependencies in `mix.exs`: diff --git a/lib/mix/tasks/test.baseline.ex b/lib/mix/tasks/test.baseline.ex index 9af1bbf..2fd3d15 100644 --- a/lib/mix/tasks/test.baseline.ex +++ b/lib/mix/tasks/test.baseline.ex @@ -4,24 +4,44 @@ defmodule Mix.Tasks.Test.Baseline do @moduledoc """ Update the baseline of passing tests. - This task helps maintain test/passing_tests.json by showing which tests - are currently passing and can be added to the regression suite. + This task helps maintain test/passing_tests.json by analyzing current test status + and providing guidance on adding new passing tests to the regression suite. ## Usage + # Analyze current test status and get guidance mix test.baseline - This will run all tests and show a summary of which tests pass. + # Add specific test files to the baseline (verifies they pass first) + mix test.baseline add test/scion_tests/basic/basic3_test.exs + mix test.baseline add test/scion_tests/path/test1.exs test/scion_tests/path/test2.exs + + The analysis mode will show discrepancies between current passing tests and + the baseline, providing suggestions for manual review and update. """ # credo:disable-for-this-file Credo.Check.Refactor.IoPuts + # credo:disable-for-this-file Credo.Check.Refactor.Nesting use Mix.Task @impl Mix.Task - def run(_args) do + def run(args) do + case args do + ["add" | test_files] when test_files != [] -> + add_tests_to_baseline(test_files) + + _args -> + run_baseline_analysis() + end + end + + defp run_baseline_analysis do IO.puts("๐Ÿ” Running all tests to check current baseline...") + # Load current baseline + {:ok, current_baseline} = load_passing_tests() + # Run internal tests (should all pass) {_output, internal_exit} = System.cmd("mix", ["test", "--exclude", "scion", "--exclude", "scxml_w3"], @@ -30,35 +50,45 @@ defmodule Mix.Tasks.Test.Baseline do IO.puts("\n๐Ÿ“Š Internal Tests: #{if internal_exit == 0, do: "โœ… PASSING", else: "โŒ FAILING"}") - # Run SCION tests + # Run SCION tests and extract passing ones + IO.puts("๐Ÿ” Analyzing SCION tests...") + {scion_output, _scion_exit} = System.cmd("mix", ["test", "--include", "scion", "--only", "scion"], stderr_to_stdout: true) scion_summary = extract_test_summary(scion_output) + passing_scion_tests = extract_passing_test_files_from_output(scion_output, "scion") IO.puts("๐Ÿ“Š SCION Tests: #{scion_summary}") - # Run W3C tests + # Run W3C tests and extract passing ones + IO.puts("๐Ÿ” Analyzing W3C tests...") + {w3c_output, _w3c_exit} = System.cmd("mix", ["test", "--include", "scxml_w3", "--only", "scxml_w3"], stderr_to_stdout: true ) w3c_summary = extract_test_summary(w3c_output) + passing_w3c_tests = extract_passing_test_files_from_output(w3c_output, "scxml_w3") IO.puts("๐Ÿ“Š W3C Tests: #{w3c_summary}") - IO.puts(""" + # Find newly passing tests + current_scion = MapSet.new(current_baseline["scion_tests"] || []) + current_w3c = MapSet.new(current_baseline["w3c_tests"] || []) - ๐Ÿ“ Next Steps: - 1. Review the test results above - 2. Add passing SCION tests to test/passing_tests.json under "scion_tests" - 3. Add passing W3C tests to test/passing_tests.json under "w3c_tests" - 4. Run 'mix test.regression' to verify the baseline works + new_scion_tests = MapSet.difference(MapSet.new(passing_scion_tests), current_scion) + new_w3c_tests = MapSet.difference(MapSet.new(passing_w3c_tests), current_w3c) - Currently in regression suite (24 tests): - - All internal tests (test/sc/**/*_test.exs + test/mix/**/*_test.exs + test/sc_test.exs) - - 8 SCION tests (basic + hierarchy + 2 parallel tests) - - Run 'mix test.regression' to see current status - """) + # Show detailed analysis + show_test_analysis( + passing_scion_tests, + passing_w3c_tests, + new_scion_tests, + new_w3c_tests, + current_baseline, + scion_summary, + w3c_summary + ) end @doc """ @@ -109,4 +139,354 @@ defmodule Mix.Tasks.Test.Baseline do end end end + + @doc """ + Loads the current passing tests configuration from test/passing_tests.json. + """ + @spec load_passing_tests() :: {:ok, map()} | {:error, String.t()} + def load_passing_tests do + case File.read("test/passing_tests.json") do + {:ok, content} -> + case Jason.decode(content) do + {:ok, data} -> {:ok, data} + {:error, _json_error} -> {:error, "Invalid JSON in test/passing_tests.json"} + end + + {:error, _file_error} -> + {:error, "Could not read test/passing_tests.json"} + end + end + + @doc """ + Finds test files that are currently passing by testing each file individually. + + This approach is slower but more reliable than parsing trace output. + """ + @spec extract_passing_test_files_from_output(String.t(), String.t()) :: [String.t()] + def extract_passing_test_files_from_output(_output, test_type) do + test_dir = + case test_type do + "scion" -> "test/scion_tests" + "scxml_w3" -> "test/scxml_w3_tests" + _other -> "test/#{test_type}_tests" + end + + IO.puts(" ๐Ÿ” Testing individual files for #{test_type}...") + + # Get all test files + all_test_files = get_all_test_files(test_dir) + + # Filter to only passing files + passing_files = + all_test_files + |> Enum.filter(fn test_file -> + test_args = get_test_args_for_file(test_file) + + {_output, exit_code} = + System.cmd("mix", test_args ++ [test_file], stderr_to_stdout: true, into: "") + + if exit_code == 0 do + IO.write(".") + true + else + IO.write("โœ—") + false + end + end) + + IO.puts(" done!") + passing_files + end + + defp get_all_test_files(test_dir) do + case File.ls(test_dir) do + {:ok, subdirs} -> + subdirs + |> Enum.filter(&File.dir?(Path.join(test_dir, &1))) + |> Enum.flat_map(fn subdir -> + subdir_path = Path.join(test_dir, subdir) + + case File.ls(subdir_path) do + {:ok, files} -> + files + |> Enum.filter(&String.ends_with?(&1, "_test.exs")) + |> Enum.map(&Path.join(subdir_path, &1)) + + {:error, _ls_error} -> + [] + end + end) + |> Enum.sort() + + {:error, _ls_error} -> + [] + end + end + + defp add_tests_to_baseline(test_files) do + IO.puts("๐Ÿ“ Adding tests to baseline: #{Enum.join(test_files, ", ")}") + + with {:ok, current_baseline} <- load_passing_tests(), + {scion_tests, w3c_tests} <- categorize_test_files(test_files), + :ok <- validate_has_valid_tests(scion_tests, w3c_tests) do + verify_and_add_tests(scion_tests, w3c_tests, current_baseline) + else + {:error, reason} -> + IO.puts("โŒ Failed to load current baseline: #{reason}") + + :no_valid_tests -> + IO.puts("โŒ No valid test files provided. Provide SCION or W3C test file paths.") + end + end + + defp validate_has_valid_tests(scion_tests, w3c_tests) do + if length(scion_tests) > 0 or length(w3c_tests) > 0 do + :ok + else + :no_valid_tests + end + end + + defp verify_and_add_tests(scion_tests, w3c_tests, current_baseline) do + all_tests = scion_tests ++ w3c_tests + IO.puts("๐Ÿงช Verifying tests pass before adding to baseline...") + + failed_tests = find_failed_tests(all_tests) + + case failed_tests do + [] -> + add_all_passing_tests(scion_tests, w3c_tests, current_baseline) + + _failed -> + handle_mixed_results(scion_tests, w3c_tests, failed_tests, current_baseline) + end + end + + defp find_failed_tests(all_tests) do + all_tests + |> Enum.filter(fn test_file -> + test_args = get_test_args_for_file(test_file) + {_output, exit_code} = System.cmd("mix", test_args ++ [test_file], stderr_to_stdout: true) + exit_code != 0 + end) + end + + defp add_all_passing_tests(scion_tests, w3c_tests, current_baseline) do + IO.puts("โœ… All tests pass! Adding to baseline...") + do_update_baseline(MapSet.new(scion_tests), MapSet.new(w3c_tests), current_baseline) + end + + defp handle_mixed_results(scion_tests, w3c_tests, failed_tests, current_baseline) do + IO.puts("โŒ The following tests are failing and won't be added:") + Enum.each(failed_tests, &IO.puts(" - #{&1}")) + + passing_scion = scion_tests -- failed_tests + passing_w3c = w3c_tests -- failed_tests + + add_only_passing_tests(passing_scion, passing_w3c, current_baseline) + end + + defp add_only_passing_tests(passing_scion, passing_w3c, current_baseline) do + if length(passing_scion) > 0 or length(passing_w3c) > 0 do + IO.puts("\nโœ… Adding only the passing tests:") + Enum.each(passing_scion ++ passing_w3c, &IO.puts(" + #{&1}")) + + do_update_baseline( + MapSet.new(passing_scion), + MapSet.new(passing_w3c), + current_baseline + ) + end + end + + defp categorize_test_files(test_files) do + Enum.reduce(test_files, {[], []}, fn test_file, {scion_acc, w3c_acc} -> + cond do + String.contains?(test_file, "scion_tests/") -> + {[test_file | scion_acc], w3c_acc} + + String.contains?(test_file, "scxml_w3_tests/") -> + {scion_acc, [test_file | w3c_acc]} + + true -> + IO.puts("โš ๏ธ Skipping unrecognized test file: #{test_file}") + {scion_acc, w3c_acc} + end + end) + end + + defp get_test_args_for_file(test_file) do + cond do + String.contains?(test_file, "scion_tests/") -> ["test", "--include", "scion"] + String.contains?(test_file, "scxml_w3_tests/") -> ["test", "--include", "scxml_w3"] + # Internal tests + true -> ["test"] + end + end + + defp do_update_baseline(new_scion_tests, new_w3c_tests, current_baseline) do + IO.puts("๐Ÿ’พ Updating test/passing_tests.json...") + + updated_scion = + ((current_baseline["scion_tests"] || []) ++ Enum.to_list(new_scion_tests)) + |> Enum.uniq() + |> Enum.sort() + + updated_w3c = + ((current_baseline["w3c_tests"] || []) ++ Enum.to_list(new_w3c_tests)) + |> Enum.uniq() + |> Enum.sort() + + updated_baseline = + current_baseline + |> Map.put("scion_tests", updated_scion) + |> Map.put("w3c_tests", updated_w3c) + |> Map.put("last_updated", Date.to_string(Date.utc_today())) + + case Jason.encode(updated_baseline, pretty: true) do + {:ok, json} -> + case File.write("test/passing_tests.json", json) do + :ok -> + IO.puts("โœ… Successfully updated baseline!") + IO.puts("๐Ÿ“Š New totals:") + IO.puts(" - SCION: #{length(updated_scion)} tests") + IO.puts(" - W3C: #{length(updated_w3c)} tests") + IO.puts("๐Ÿ”„ Run 'mix test.regression' to verify the updated baseline.") + + {:error, reason} -> + IO.puts("โŒ Failed to write test/passing_tests.json: #{reason}") + end + + {:error, reason} -> + IO.puts("โŒ Failed to encode JSON: #{reason}") + end + end + + defp show_test_analysis( + passing_scion_tests, + passing_w3c_tests, + new_scion_tests, + new_w3c_tests, + current_baseline, + scion_summary, + w3c_summary + ) do + print_analysis_header() + show_scion_analysis(passing_scion_tests, new_scion_tests, current_baseline, scion_summary) + show_w3c_analysis(passing_w3c_tests, new_w3c_tests, current_baseline, w3c_summary) + show_new_tests_summary(new_scion_tests, new_w3c_tests, current_baseline) + end + + defp print_analysis_header do + IO.puts("\n" <> String.duplicate("=", 60)) + IO.puts("๐Ÿ“Š DETAILED TEST ANALYSIS") + IO.puts(String.duplicate("=", 60)) + end + + defp show_scion_analysis(passing_tests, new_tests, baseline, summary) do + IO.puts("\n๐Ÿ”ต SCION Tests:") + IO.puts(" Summary: #{summary}") + IO.puts(" Total passing files detected: #{length(passing_tests)}") + IO.puts(" Currently in baseline: #{length(baseline["scion_tests"] || [])}") + IO.puts(" New passing files: #{MapSet.size(new_tests)}") + + show_test_files_list(passing_tests, new_tests, "SCION") + end + + defp show_w3c_analysis(passing_tests, new_tests, baseline, summary) do + IO.puts("\n๐Ÿ”ต W3C Tests:") + IO.puts(" Summary: #{summary}") + IO.puts(" Total passing files detected: #{length(passing_tests)}") + IO.puts(" Currently in baseline: #{length(baseline["w3c_tests"] || [])}") + IO.puts(" New passing files: #{MapSet.size(new_tests)}") + + show_test_files_list(passing_tests, new_tests, "W3C") + end + + defp show_test_files_list(passing_tests, new_tests, test_type) do + if Enum.empty?(passing_tests), + do: :ok, + else: display_test_files(passing_tests, new_tests, test_type) + end + + defp display_test_files(passing_tests, new_tests, test_type) do + IO.puts("\n โœ… All passing #{test_type} test files:") + Enum.each(passing_tests, &print_test_file(&1, new_tests)) + end + + defp print_test_file(test, new_tests) do + marker = if MapSet.member?(new_tests, test), do: "๐Ÿ†•", else: " " + IO.puts(" #{marker} #{test}") + end + + defp show_new_tests_summary(new_scion_tests, new_w3c_tests, current_baseline) do + if has_new_tests?(new_scion_tests, new_w3c_tests) do + print_new_tests_header() + show_new_scion_tests(new_scion_tests) + show_new_w3c_tests(new_w3c_tests) + show_new_tests_prompt(new_scion_tests, new_w3c_tests, current_baseline) + else + show_up_to_date_message() + end + end + + defp has_new_tests?(new_scion_tests, new_w3c_tests) do + MapSet.size(new_scion_tests) > 0 or MapSet.size(new_w3c_tests) > 0 + end + + defp print_new_tests_header do + IO.puts("\n" <> String.duplicate("-", 60)) + IO.puts("๐Ÿ†• NEW PASSING TESTS (not in baseline)") + IO.puts(String.duplicate("-", 60)) + end + + defp show_new_scion_tests(new_scion_tests) do + if MapSet.size(new_scion_tests) > 0 do + IO.puts("\n๐Ÿ“ˆ New SCION tests (#{MapSet.size(new_scion_tests)}):") + + new_scion_tests + |> Enum.sort() + |> Enum.each(&IO.puts(" + #{&1}")) + end + end + + defp show_new_w3c_tests(new_w3c_tests) do + if MapSet.size(new_w3c_tests) > 0 do + IO.puts("\n๐Ÿ“ˆ New W3C tests (#{MapSet.size(new_w3c_tests)}):") + + new_w3c_tests + |> Enum.sort() + |> Enum.each(&IO.puts(" + #{&1}")) + end + end + + defp show_up_to_date_message do + IO.puts("\nโœ… No new passing tests found. Baseline is up to date!") + IO.puts("๐Ÿ”„ Run 'mix test.regression' to verify the current baseline.") + end + + defp show_new_tests_prompt(new_scion_tests, new_w3c_tests, current_baseline) do + IO.puts("\nโ“ Would you like to automatically add these new tests to the baseline? (y/n)") + + case IO.gets("") do + :eof -> + show_manual_instructions() + + response when is_binary(response) -> + case String.trim(response) |> String.downcase() do + answer when answer in ["y", "yes"] -> + do_update_baseline(new_scion_tests, new_w3c_tests, current_baseline) + + _other_answer -> + show_manual_instructions() + end + end + end + + defp show_manual_instructions do + IO.puts("\n๐Ÿ“ To manually add tests, you can:") + IO.puts(" 1. Edit test/passing_tests.json directly, or") + IO.puts(" 2. Use: mix test.baseline add [ ...]") + IO.puts("๐Ÿ’ก Run 'mix test.baseline' again after making changes to verify.") + end end diff --git a/lib/mix/tasks/test.regression.ex b/lib/mix/tasks/test.regression.ex index 81a7f7a..d0688df 100644 --- a/lib/mix/tasks/test.regression.ex +++ b/lib/mix/tasks/test.regression.ex @@ -18,6 +18,7 @@ defmodule Mix.Tasks.Test.Regression do """ # credo:disable-for-this-file Credo.Check.Refactor.IoPuts + # credo:disable-for-this-file Credo.Check.Refactor.Nesting use Mix.Task @@ -54,8 +55,9 @@ defmodule Mix.Tasks.Test.Regression do {_output, 0} -> IO.puts("โœ… All #{length(all_test_files)} regression tests passed!") - {_output, exit_code} -> + {output, exit_code} -> IO.puts("โŒ Some regression tests failed (exit code: #{exit_code})") + show_failing_tests(output, all_test_files) System.halt(1) end @@ -119,6 +121,145 @@ defmodule Mix.Tasks.Test.Regression do System.cmd("mix", args, stderr_to_stdout: true) end + defp show_failing_tests(output, expected_test_files) do + IO.puts("\n๐Ÿ“‹ REGRESSION TEST FAILURE ANALYSIS") + IO.puts(String.duplicate("=", 50)) + + # Extract failure information from ExUnit output + failing_tests = extract_failing_tests_from_output(output) + + if length(failing_tests) > 0 do + IO.puts("\nโŒ Failed Tests:") + + Enum.each(failing_tests, fn {test_name, file_path, reason} -> + IO.puts(" โ€ข #{test_name}") + IO.puts(" File: #{file_path}") + + if reason do + # Show first line of reason to keep it concise + first_line = reason |> String.split("\n") |> List.first() |> String.trim() + IO.puts(" Reason: #{first_line}") + end + + IO.puts("") + end) + + IO.puts( + "๐Ÿ“Š Summary: #{length(failing_tests)} test(s) failed out of #{length(expected_test_files)} regression tests" + ) + else + # If we can't parse specific failures, try to identify failing files + failing_files = identify_failing_files(output, expected_test_files) + + if length(failing_files) > 0 do + IO.puts("\nโŒ Files with failures:") + Enum.each(failing_files, &IO.puts(" โ€ข #{&1}")) + IO.puts("\n๐Ÿ“Š Summary: #{length(failing_files)} file(s) had failures") + else + IO.puts("\nโš ๏ธ Could not identify specific failing tests from output.") + IO.puts("Run 'mix test.regression --verbose' for detailed output.") + end + end + + IO.puts("\n๐Ÿ’ก To debug:") + IO.puts(" 1. Run individual failing tests: mix test ") + IO.puts(" 2. Check if tests need to be removed from baseline: mix test.baseline") + IO.puts(" 3. Run with verbose output: mix test.regression --verbose") + end + + defp extract_failing_tests_from_output(output) do + # Parse ExUnit output to extract failing test information + # Look for patterns like: + # " 1) test test_name (ModuleName)" + # " path/to/test_file.exs:line_number" + + failure_pattern = ~r/^\s+\d+\)\s+test\s+(.+?)\s+\((.+?)\)\s*$/m + + failures = Regex.scan(failure_pattern, output) + + failures + |> Enum.map(fn [_full_match, test_name, module_name] -> + # Try to extract file path from subsequent lines + file_path = extract_file_path_for_test(output, test_name, module_name) + reason = extract_failure_reason(output, test_name) + + {test_name, file_path, reason} + end) + end + + defp extract_file_path_for_test(output, test_name, _module_name) do + # Look for file path in the lines following the test failure + lines = String.split(output, "\n") + + # Find the line with our test + test_line_index = + Enum.find_index(lines, fn line -> + String.contains?(line, test_name) and String.contains?(line, "test ") + end) + + if test_line_index do + # Look in the next few lines for a file path + lines + |> Enum.drop(test_line_index + 1) + |> Enum.take(3) + |> Enum.find_value(fn line -> + if Regex.match?(~r/^\s+(.+\.exs):\d+/, line) do + line |> String.trim() |> String.split(":") |> List.first() + end + end) + end + end + + defp extract_failure_reason(output, test_name) do + lines = String.split(output, "\n") + + # Find the line with our test + test_line_index = + Enum.find_index(lines, fn line -> + String.contains?(line, test_name) and String.contains?(line, "test ") + end) + + if test_line_index do + # Look for reason in subsequent lines, stopping at next test or end + lines + |> Enum.drop(test_line_index + 1) + |> Enum.take_while(fn line -> + not Regex.match?(~r/^\s+\d+\)\s+test\s+/, line) and String.trim(line) != "" + end) + |> Enum.drop_while(fn line -> + # Skip file path and stacktrace lines + Regex.match?(~r/^\s+(.+\.exs):\d+/, line) or + String.contains?(line, "stacktrace:") or + String.trim(line) == "" + end) + # Take first couple lines of actual error + |> Enum.take(2) + |> Enum.join(" ") + |> String.trim() + |> case do + "" -> nil + reason -> reason + end + end + end + + defp identify_failing_files(output, expected_test_files) do + # Extract file paths mentioned in the output that are in our expected list + file_pattern = ~r/([a-zA-Z0-9_\/\.]+_test\.exs)/ + + mentioned_files = + Regex.scan(file_pattern, output) + |> Enum.map(fn [_full, file] -> file end) + |> Enum.uniq() + + # Filter to only files that are in our regression test list + expected_test_files + |> Enum.filter(fn file -> + filename = Path.basename(file) + Enum.any?(mentioned_files, &String.ends_with?(&1, filename)) + end) + end + @doc """ Expands a list of test patterns, supporting glob wildcards. diff --git a/lib/sc/document/validator.ex b/lib/sc/document/validator.ex index fd3986d..d26ce1b 100644 --- a/lib/sc/document/validator.ex +++ b/lib/sc/document/validator.ex @@ -56,10 +56,8 @@ defmodule SC.Document.Validator do final_document = case validated_result.errors do [] -> - # Only optimize valid documents - document - |> determine_state_types() - |> SC.Document.build_lookup_maps() + # Only optimize valid documents (state types already determined at parse time) + SC.Document.build_lookup_maps(document) _errors -> # Don't waste time optimizing invalid documents @@ -284,39 +282,4 @@ defmodule SC.Document.Validator do add_warning(result, "Document initial state '#{initial_id}' is not a top-level state") end end - - # State type determination (moved from parser) - - # Determine state types for all states in the document based on structure - @spec determine_state_types(SC.Document.t()) :: SC.Document.t() - defp determine_state_types(%SC.Document{} = document) do - updated_states = Enum.map(document.states, &update_state_types/1) - %{document | states: updated_states} - end - - # Update state types based on structure after parsing is complete - @spec update_state_types(SC.State.t()) :: SC.State.t() - defp update_state_types(%SC.State{type: :parallel} = state) do - # Parallel states keep their type, just update children - updated_children = Enum.map(state.states, &update_state_types/1) - %{state | states: updated_children} - end - - defp update_state_types(%SC.State{} = state) do - # Update children first (bottom-up) - updated_children = Enum.map(state.states, &update_state_types/1) - - # Determine this state's type based on structure (atomic vs compound) - state_type = determine_state_type(updated_children, state.initial) - - %{state | type: state_type, states: updated_children} - end - - defp determine_state_type(child_states, initial_value) do - cond do - Enum.empty?(child_states) -> :atomic - initial_value != nil or not Enum.empty?(child_states) -> :compound - true -> :atomic - end - end end diff --git a/lib/sc/feature_detector.ex b/lib/sc/feature_detector.ex new file mode 100644 index 0000000..cc7e017 --- /dev/null +++ b/lib/sc/feature_detector.ex @@ -0,0 +1,283 @@ +defmodule SC.FeatureDetector do + @moduledoc """ + Detects SCXML features used in documents to enable proper test validation. + + This module analyzes SCXML documents (either raw XML strings or parsed SC.Document + structures) to identify which SCXML features are being used. This enables the test + framework to fail appropriately when tests depend on unsupported features. + """ + + alias SC.{Document, State, Transition} + + @doc """ + Detects features used in an SCXML document. + + Takes either a raw XML string or a parsed SC.Document and returns a MapSet + of feature atoms representing the SCXML features detected in the document. + + ## Examples + + iex> xml = "" + iex> SC.FeatureDetector.detect_features(xml) + #MapSet<[:basic_states, :event_transitions]> + iex> {:ok, document} = SC.Parser.SCXML.parse(xml) + iex> SC.FeatureDetector.detect_features(document) + #MapSet<[:basic_states, :event_transitions]> + """ + @spec detect_features(String.t() | Document.t()) :: MapSet.t(atom()) + def detect_features(xml) when is_binary(xml) do + detect_features_from_xml(xml) + end + + def detect_features(%Document{} = document) do + detect_features_from_document(document) + end + + @doc """ + Returns a registry of all known SCXML features with their support status. + + Features are categorized as: + - `:supported` - Fully implemented and working + - `:unsupported` - Not yet implemented + - `:partial` - Partially implemented (may work in simple cases) + """ + @spec feature_registry() :: %{atom() => :supported | :unsupported | :partial} + def feature_registry do + %{ + # Basic features (supported) + basic_states: :supported, + event_transitions: :supported, + compound_states: :supported, + parallel_states: :supported, + final_states: :supported, + initial_attributes: :supported, + + # Conditional features (unsupported) + conditional_transitions: :unsupported, + + # Data model features (unsupported) + datamodel: :unsupported, + data_elements: :unsupported, + script_elements: :unsupported, + assign_elements: :unsupported, + + # Executable content (unsupported) + onentry_actions: :unsupported, + onexit_actions: :unsupported, + send_elements: :unsupported, + log_elements: :unsupported, + raise_elements: :unsupported, + + # Advanced transitions (unsupported) + targetless_transitions: :unsupported, + internal_transitions: :unsupported, + + # History (unsupported) + history_states: :unsupported, + + # Advanced attributes (unsupported) + send_idlocation: :unsupported, + event_expressions: :unsupported, + target_expressions: :unsupported + } + end + + @doc """ + Checks if all detected features are supported. + + Returns `{:ok, features}` if all features are supported, + or `{:error, unsupported_features}` if any unsupported features are detected. + """ + @spec validate_features(MapSet.t(atom())) :: + {:ok, MapSet.t(atom())} | {:error, MapSet.t(atom())} + def validate_features(detected_features) do + registry = feature_registry() + + unsupported = + detected_features + |> Enum.filter(fn feature -> + case Map.get(registry, feature, :unsupported) do + :supported -> false + _unsupported -> true + end + end) + |> MapSet.new() + + if MapSet.size(unsupported) == 0 do + {:ok, detected_features} + else + {:error, unsupported} + end + end + + # Private functions for XML-based detection + defp detect_features_from_xml(xml) do + features = MapSet.new() + + features + |> detect_xml_elements(xml) + |> detect_xml_attributes(xml) + end + + defp detect_xml_elements(features, xml) do + features + |> add_if_present(xml, ~r/)/, :basic_states) + |> add_if_present(xml, ~r/)/, :parallel_states) + |> add_if_present(xml, ~r/)/, :final_states) + |> add_if_present(xml, ~r/)/, :history_states) + |> add_if_present(xml, ~r/)/, :event_transitions) + |> add_if_present(xml, ~r/)/, :datamodel) + |> add_if_present(xml, ~r/)/, :data_elements) + |> add_if_present(xml, ~r/)/, :script_elements) + |> add_if_present(xml, ~r/)/, :assign_elements) + |> add_if_present(xml, ~r/)/, :onentry_actions) + |> add_if_present(xml, ~r/)/, :onexit_actions) + |> add_if_present(xml, ~r/)/, :send_elements) + |> add_if_present(xml, ~r/)/, :log_elements) + |> add_if_present(xml, ~r/)/, :raise_elements) + end + + defp detect_xml_attributes(features, xml) do + features + |> add_if_present(xml, ~r/cond\s*=/, :conditional_transitions) + |> add_if_present(xml, ~r/idlocation\s*=/, :send_idlocation) + |> add_if_present(xml, ~r/type\s*=\s*["']internal["']/, :internal_transitions) + |> detect_compound_states(xml) + |> detect_targetless_transitions(xml) + end + + defp detect_compound_states(features, xml) do + # Check if any state has an initial attribute or nested states + cond do + Regex.match?(~r/]+initial\s*=/, xml) -> + MapSet.put(features, :compound_states) + + Regex.match?(~r/]*>.* + MapSet.put(features, :compound_states) + + true -> + features + end + end + + defp detect_targetless_transitions(features, xml) do + # Look for transitions without target attribute + if Regex.match?(~r/]*target\s*=)[^>]*>/, xml) do + MapSet.put(features, :targetless_transitions) + else + features + end + end + + defp add_if_present(features, xml, pattern, feature) do + if Regex.match?(pattern, xml) do + MapSet.put(features, feature) + else + features + end + end + + # Private functions for Document-based detection + defp detect_features_from_document(%Document{} = document) do + features = MapSet.new() + + features + |> detect_document_elements(document) + |> detect_state_features(document.states) + |> detect_transition_features(document) + end + + defp detect_document_elements(features, %Document{datamodel_elements: datamodel_elements}) do + if length(datamodel_elements) > 0 do + features + |> MapSet.put(:datamodel) + |> MapSet.put(:data_elements) + else + features + end + end + + defp detect_state_features(features, states) do + Enum.reduce(states, features, fn state, acc -> + acc + |> detect_single_state_features(state) + # Recursively check nested states + |> detect_state_features(state.states) + end) + end + + defp detect_single_state_features(features, %State{} = state) do + features + |> add_state_type_feature(state.type) + |> add_if_has_initial(state) + |> detect_transition_features_for_state(state) + end + + defp add_state_type_feature(features, type) do + case type do + :atomic -> MapSet.put(features, :basic_states) + :compound -> MapSet.put(features, :compound_states) + :parallel -> MapSet.put(features, :parallel_states) + :final -> MapSet.put(features, :final_states) + :history -> MapSet.put(features, :history_states) + _other_type -> features + end + end + + defp add_if_has_initial(features, %State{initial: initial}) when not is_nil(initial) do + MapSet.put(features, :compound_states) + end + + defp add_if_has_initial(features, _state), do: features + + defp detect_transition_features_for_state(features, %State{transitions: transitions}) do + Enum.reduce(transitions, features, fn transition, acc -> + detect_single_transition_features(acc, transition) + end) + end + + defp detect_transition_features(features, %Document{} = document) do + # Collect all transitions from all states + all_transitions = collect_all_transitions(document.states) + + Enum.reduce(all_transitions, features, fn transition, acc -> + detect_single_transition_features(acc, transition) + end) + end + + defp collect_all_transitions(states) do + Enum.flat_map(states, fn state -> + state.transitions ++ collect_all_transitions(state.states) + end) + end + + defp detect_single_transition_features(features, %Transition{} = transition) do + features + |> add_if_has_event(transition) + |> add_if_has_cond(transition) + |> add_if_targetless(transition) + |> add_if_internal(transition) + end + + defp add_if_has_event(features, %Transition{event: event}) when not is_nil(event) do + MapSet.put(features, :event_transitions) + end + + defp add_if_has_event(features, _transition), do: features + + defp add_if_has_cond(features, %Transition{cond: cond}) when not is_nil(cond) do + MapSet.put(features, :conditional_transitions) + end + + defp add_if_has_cond(features, _transition), do: features + + defp add_if_targetless(features, %Transition{target: target}) when is_nil(target) do + MapSet.put(features, :targetless_transitions) + end + + defp add_if_targetless(features, _transition), do: features + + # Note: SC.Transition doesn't currently have a type field + # This is a placeholder for when internal transitions are implemented + defp add_if_internal(features, _transition), do: features +end diff --git a/lib/sc/interpreter.ex b/lib/sc/interpreter.ex index 89d536b..0c08572 100644 --- a/lib/sc/interpreter.ex +++ b/lib/sc/interpreter.ex @@ -116,6 +116,11 @@ defmodule SC.Interpreter do [state.id] end + defp enter_state(%SC.State{type: :final} = state, _document) do + # Final state is treated like an atomic state - return its ID + [state.id] + end + defp enter_state( %SC.State{type: :compound, states: child_states, initial: initial_id}, document diff --git a/lib/sc/parser/scxml/element_builder.ex b/lib/sc/parser/scxml/element_builder.ex index 442b2b3..e2cf7a9 100644 --- a/lib/sc/parser/scxml/element_builder.ex +++ b/lib/sc/parser/scxml/element_builder.ex @@ -103,6 +103,34 @@ defmodule SC.Parser.SCXML.ElementBuilder do } end + @doc """ + Build an SC.State from final XML attributes and location info. + """ + @spec build_final_state(list(), map(), String.t(), map()) :: SC.State.t() + def build_final_state(attributes, location, xml_string, element_counts) do + attrs_map = attributes_to_map(attributes) + document_order = LocationTracker.document_order(element_counts) + + # Calculate attribute-specific locations + id_location = LocationTracker.attribute_location(xml_string, "id", location) + + %SC.State{ + id: get_attr_value(attrs_map, "id"), + # Final states don't have initial attributes + initial: nil, + # Set type directly during parsing + type: :final, + states: [], + transitions: [], + document_order: document_order, + # Location information + source_location: location, + id_location: id_location, + # Final states don't have initial + initial_location: nil + } + end + @doc """ Build an SC.Transition from XML attributes and location info. """ diff --git a/lib/sc/parser/scxml/handler.ex b/lib/sc/parser/scxml/handler.ex index e39ccc6..9b99401 100644 --- a/lib/sc/parser/scxml/handler.ex +++ b/lib/sc/parser/scxml/handler.ex @@ -56,6 +56,9 @@ defmodule SC.Parser.SCXML.Handler do "parallel" -> handle_parallel_start(attributes, location, state) + "final" -> + handle_final_start(attributes, location, state) + "transition" -> handle_transition_start(attributes, location, state) @@ -77,11 +80,7 @@ defmodule SC.Parser.SCXML.Handler do "scxml" -> {:ok, state} - "state" -> - StateStack.handle_state_end(state) - - "parallel" -> - # Handle parallel same as state + state_type when state_type in ["state", "parallel", "final"] -> StateStack.handle_state_end(state) "transition" -> @@ -170,6 +169,23 @@ defmodule SC.Parser.SCXML.Handler do {:ok, StateStack.push_element(state, "datamodel", nil)} end + defp handle_final_start(attributes, location, state) do + final_element = + ElementBuilder.build_final_state( + attributes, + location, + state.xml_string, + state.element_counts + ) + + updated_state = %{ + state + | current_element: {:final, final_element} + } + + {:ok, StateStack.push_element(updated_state, "final", final_element)} + end + defp handle_data_start(attributes, location, state) do data_element = ElementBuilder.build_data_element( diff --git a/lib/sc/parser/scxml/state_stack.ex b/lib/sc/parser/scxml/state_stack.ex index 53aac03..f12ab14 100644 --- a/lib/sc/parser/scxml/state_stack.ex +++ b/lib/sc/parser/scxml/state_stack.ex @@ -40,7 +40,9 @@ defmodule SC.Parser.SCXML.StateStack do } updated_parent = %{parent_state | states: parent_state.states ++ [state_with_hierarchy]} - {:ok, %{state | stack: [{"state", updated_parent} | rest]}} + # Update parent state type based on its children + updated_parent_with_type = update_state_type(updated_parent) + {:ok, %{state | stack: [{"state", updated_parent_with_type} | rest]}} [{"parallel", parent_state} | rest] -> # State is nested in a parallel state - calculate depth from stack level @@ -53,7 +55,24 @@ defmodule SC.Parser.SCXML.StateStack do } updated_parent = %{parent_state | states: parent_state.states ++ [state_with_hierarchy]} - {:ok, %{state | stack: [{"parallel", updated_parent} | rest]}} + # Update parent state type based on its children (parallel states keep their type) + updated_parent_with_type = update_state_type(updated_parent) + {:ok, %{state | stack: [{"parallel", updated_parent_with_type} | rest]}} + + [{"final", parent_state} | rest] -> + # State is nested in a final state - calculate depth from stack level + current_depth = calculate_stack_depth(rest) + 1 + + state_with_hierarchy = %{ + state_element + | parent: parent_state.id, + depth: current_depth + } + + updated_parent = %{parent_state | states: parent_state.states ++ [state_with_hierarchy]} + # Update parent state type based on its children (final states keep their type) + updated_parent_with_type = update_state_type(updated_parent) + {:ok, %{state | stack: [{"final", updated_parent_with_type} | rest]}} _other_parent -> {:ok, %{state | stack: parent_stack}} @@ -91,6 +110,17 @@ defmodule SC.Parser.SCXML.StateStack do {:ok, %{state | stack: [{"parallel", updated_parent} | rest]}} + [{"final", parent_state} | rest] -> + # Set the source state ID for final states too + transition_with_source = %{transition | source: parent_state.id} + + updated_parent = %{ + parent_state + | transitions: parent_state.transitions ++ [transition_with_source] + } + + {:ok, %{state | stack: [{"final", updated_parent} | rest]}} + _other_parent -> {:ok, %{state | stack: parent_stack}} end @@ -152,4 +182,28 @@ defmodule SC.Parser.SCXML.StateStack do defp calculate_stack_depth(stack) do Enum.count(stack, fn {element_type, _element} -> element_type == "state" end) end + + # Update state type based on current structure and children + # This allows us to determine compound vs atomic at parse time + defp update_state_type(%SC.State{type: :parallel} = state) do + # Parallel states keep their type regardless of children + state + end + + defp update_state_type(%SC.State{type: :final} = state) do + # Final states keep their type regardless of children + state + end + + defp update_state_type(%SC.State{states: child_states} = state) do + # For regular states, determine type based on children + new_type = + if Enum.empty?(child_states) do + :atomic + else + :compound + end + + %{state | type: new_type} + end end diff --git a/test/passing_tests.json b/test/passing_tests.json index 8bd0b0c..7d87b4a 100644 --- a/test/passing_tests.json +++ b/test/passing_tests.json @@ -1,21 +1,38 @@ { "description": "Registry of tests that should always pass - used for regression testing", + "internal_tests": [ + "test/sc_test.exs", + "test/sc/**/*_test.exs", + "test/mix/**/*_test.exs" + ], "last_updated": "2025-08-18", "scion_tests": [ "test/scion_tests/basic/basic0_test.exs", - "test/scion_tests/basic/basic1_test.exs", + "test/scion_tests/basic/basic1_test.exs", "test/scion_tests/basic/basic2_test.exs", + "test/scion_tests/default_initial_state/initial1_test.exs", + "test/scion_tests/default_initial_state/initial2_test.exs", + "test/scion_tests/documentOrder/documentOrder0_test.exs", "test/scion_tests/hierarchy/hier0_test.exs", "test/scion_tests/hierarchy/hier1_test.exs", "test/scion_tests/hierarchy/hier2_test.exs", + "test/scion_tests/hierarchy_documentOrder/test0_test.exs", + "test/scion_tests/hierarchy_documentOrder/test1_test.exs", + "test/scion_tests/more_parallel/test1_test.exs", + "test/scion_tests/more_parallel/test4_test.exs", "test/scion_tests/parallel/test0_test.exs", - "test/scion_tests/parallel/test1_test.exs" + "test/scion_tests/parallel/test1_test.exs", + "test/scion_tests/parallel_interrupt/test13_test.exs", + "test/scion_tests/parallel_interrupt/test18_test.exs", + "test/scion_tests/parallel_interrupt/test19_test.exs", + "test/scion_tests/parallel_interrupt/test22_test.exs", + "test/scion_tests/parallel_interrupt/test23_test.exs", + "test/scion_tests/parallel_interrupt/test24_test.exs", + "test/scion_tests/parallel_interrupt/test27_test.exs", + "test/scion_tests/parallel_interrupt/test28_test.exs", + "test/scion_tests/parallel_interrupt/test29_test.exs", + "test/scion_tests/parallel_interrupt/test6_test.exs", + "test/scion_tests/parallel_interrupt/test9_test.exs" ], - "w3c_tests": [ - ], - "internal_tests": [ - "test/sc_test.exs", - "test/sc/**/*_test.exs", - "test/mix/**/*_test.exs" - ] -} \ No newline at end of file + "w3c_tests": [] +} diff --git a/test/sc/document/validator_edge_cases_test.exs b/test/sc/document/validator_edge_cases_test.exs new file mode 100644 index 0000000..fa3ca3e --- /dev/null +++ b/test/sc/document/validator_edge_cases_test.exs @@ -0,0 +1,191 @@ +defmodule SC.Document.ValidatorEdgeCasesTest do + use ExUnit.Case + + alias SC.Document.Validator + alias SC.{Document, State, Transition} + + describe "error handling edge cases" do + test "handles duplicate state IDs" do + state1 = %State{id: "duplicate", states: []} + state2 = %State{id: "duplicate", states: []} + document = %Document{states: [state1, state2]} + + {:error, errors, _warnings} = Validator.validate(document) + + assert length(errors) == 1 + assert hd(errors) =~ "Duplicate state ID 'duplicate'" + end + + test "handles multiple duplicate state IDs" do + state1 = %State{id: "dup1", states: []} + state2 = %State{id: "dup1", states: []} + state3 = %State{id: "dup2", states: []} + state4 = %State{id: "dup2", states: []} + document = %Document{states: [state1, state2, state3, state4]} + + {:error, errors, _warnings} = Validator.validate(document) + + assert length(errors) == 2 + assert Enum.any?(errors, &(&1 =~ "Duplicate state ID 'dup1'")) + assert Enum.any?(errors, &(&1 =~ "Duplicate state ID 'dup2'")) + end + + test "handles nil state ID" do + state1 = %State{id: nil, states: []} + state2 = %State{id: "valid", states: []} + document = %Document{states: [state1, state2]} + + {:error, errors, _warnings} = Validator.validate(document) + + assert length(errors) == 1 + assert hd(errors) =~ "State found with empty or nil ID" + end + + test "handles empty string state ID" do + state1 = %State{id: "", states: []} + state2 = %State{id: "valid", states: []} + document = %Document{states: [state1, state2]} + + {:error, errors, _warnings} = Validator.validate(document) + + assert length(errors) == 1 + assert hd(errors) =~ "State found with empty or nil ID" + end + + test "handles missing transition target" do + transition = %Transition{event: "go", target: "nonexistent"} + state = %State{id: "test", transitions: [transition], states: []} + document = %Document{states: [state]} + + {:error, errors, _warnings} = Validator.validate(document) + + assert length(errors) == 1 + assert hd(errors) =~ "Transition target 'nonexistent' does not exist" + end + + test "handles invalid initial state reference in compound state" do + child1 = %State{id: "child1", states: []} + child2 = %State{id: "child2", states: []} + parent = %State{id: "parent", initial: "nonexistent", states: [child1, child2]} + document = %Document{states: [parent]} + + {:error, errors, _warnings} = Validator.validate(document) + + assert length(errors) == 1 + + assert hd(errors) =~ + "State 'parent' specifies initial='nonexistent' but 'nonexistent' is not a direct child" + end + + test "handles unreachable state from first state when no initial specified" do + state1 = %State{id: "first", transitions: [], states: []} + state2 = %State{id: "unreachable", transitions: [], states: []} + document = %Document{initial: nil, states: [state1, state2]} + + {:ok, _document, warnings} = Validator.validate(document) + + assert length(warnings) == 1 + assert hd(warnings) =~ "State 'unreachable' is unreachable from initial state" + end + + test "handles document initial state that is not top-level" do + child = %State{id: "nested_initial", states: []} + parent = %State{id: "parent", states: [child]} + document = %Document{initial: "nested_initial", states: [parent]} + + {:ok, _document, warnings} = Validator.validate(document) + + # Will have multiple warnings: the initial state warning plus unreachability warnings + assert length(warnings) >= 1 + + assert Enum.any?( + warnings, + &(&1 =~ "Document initial state 'nested_initial' is not a top-level state") + ) + end + + test "handles missing parent reference during reachability (edge case)" do + # This tests the nil case in collect_ancestors that's currently uncovered + state = %State{id: "orphan", parent: "nonexistent", states: []} + document = %Document{states: [state]} + + # Should not crash even with invalid parent reference + {:ok, _document, _warnings} = Validator.validate(document) + end + + test "validates empty document successfully" do + document = %Document{states: []} + + {:ok, _document, _warnings} = Validator.validate(document) + end + + test "optimizes valid document with lookup maps" do + state1 = %State{id: "s1", states: []} + state2 = %State{id: "s2", states: []} + document = %Document{states: [state1, state2]} + + {:ok, optimized_document, _warnings} = Validator.validate(document) + + # Lookup maps should be built for valid documents + assert map_size(optimized_document.state_lookup) == 2 + assert Map.has_key?(optimized_document.state_lookup, "s1") + assert Map.has_key?(optimized_document.state_lookup, "s2") + end + + test "does not optimize invalid document" do + # Document with errors should not be optimized + state1 = %State{id: "dup", states: []} + state2 = %State{id: "dup", states: []} + document = %Document{states: [state1, state2]} + + {:error, _errors, _warnings} = Validator.validate(document) + + # Original document should be unchanged (no lookup maps built) + assert map_size(document.state_lookup) == 0 + end + end + + describe "complex reachability scenarios" do + test "correctly finds reachable states through child states" do + # Test the child state reachability marking + grandchild = %State{id: "grandchild", states: []} + child = %State{id: "child", states: [grandchild]} + parent = %State{id: "parent", states: [child]} + unreachable = %State{id: "unreachable", states: []} + document = %Document{initial: "parent", states: [parent, unreachable]} + + {:ok, _document, warnings} = Validator.validate(document) + + # Only the unreachable state should generate a warning + assert length(warnings) == 1 + assert hd(warnings) =~ "State 'unreachable' is unreachable from initial state" + end + + test "follows transitions to find reachable states" do + transition = %Transition{event: "go", target: "s2"} + state1 = %State{id: "s1", transitions: [transition], states: []} + state2 = %State{id: "s2", transitions: [], states: []} + unreachable = %State{id: "unreachable", states: []} + document = %Document{initial: "s1", states: [state1, state2, unreachable]} + + {:ok, _document, warnings} = Validator.validate(document) + + # Only the unreachable state should generate a warning + assert length(warnings) == 1 + assert hd(warnings) =~ "State 'unreachable' is unreachable from initial state" + end + + test "handles circular references in reachability without infinite loops" do + transition1 = %Transition{event: "go", target: "s2"} + transition2 = %Transition{event: "back", target: "s1"} + state1 = %State{id: "s1", transitions: [transition1], states: []} + state2 = %State{id: "s2", transitions: [transition2], states: []} + document = %Document{initial: "s1", states: [state1, state2]} + + {:ok, _document, warnings} = Validator.validate(document) + + # No warnings - both states are reachable + assert Enum.empty?(warnings) + end + end +end diff --git a/test/sc/document/validator_final_state_test.exs b/test/sc/document/validator_final_state_test.exs new file mode 100644 index 0000000..00ea389 --- /dev/null +++ b/test/sc/document/validator_final_state_test.exs @@ -0,0 +1,109 @@ +defmodule SC.Document.ValidatorFinalStateTest do + use ExUnit.Case + + alias SC.{Document, Parser.SCXML} + + test "preserves final state type during validation" do + xml = """ + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + {:ok, validated_document, _warnings} = Document.Validator.validate(document) + + # Find the final state in the validated document + final_state = Enum.find(validated_document.states, &(&1.id == "final_state")) + assert final_state != nil + assert final_state.type == :final + end + + test "preserves final state type with children during validation" do + xml = """ + + + + + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + {:ok, validated_document, _warnings} = Document.Validator.validate(document) + + # Find the compound state + compound_state = Enum.find(validated_document.states, &(&1.id == "compound")) + assert compound_state != nil + assert compound_state.type == :compound + + # Find the nested final state + child_final = Enum.find(compound_state.states, &(&1.id == "child_final")) + assert child_final != nil + assert child_final.type == :final + end + + test "validates final state in parallel configuration" do + xml = """ + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + {:ok, validated_document, _warnings} = Document.Validator.validate(document) + + # Find the parallel state + parallel_state = Enum.find(validated_document.states, &(&1.id == "parallel_state")) + assert parallel_state != nil + assert parallel_state.type == :parallel + + # Find the final state within parallel + branch1_final = Enum.find(parallel_state.states, &(&1.id == "branch1_final")) + assert branch1_final != nil + assert branch1_final.type == :final + end + + test "validates document with multiple final states" do + xml = """ + + + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + {:ok, validated_document, _warnings} = Document.Validator.validate(document) + + # Check both final states are preserved + final1 = Enum.find(validated_document.states, &(&1.id == "final1")) + assert final1 != nil + assert final1.type == :final + + final2 = Enum.find(validated_document.states, &(&1.id == "final2")) + assert final2 != nil + assert final2.type == :final + + # Check lookup maps are built correctly + assert validated_document.state_lookup["final1"] == final1 + assert validated_document.state_lookup["final2"] == final2 + end +end diff --git a/test/sc/document/validator_state_types_test.exs b/test/sc/document/validator_state_types_test.exs index 8fe55ec..f89a83b 100644 --- a/test/sc/document/validator_state_types_test.exs +++ b/test/sc/document/validator_state_types_test.exs @@ -3,8 +3,8 @@ defmodule SC.Document.ValidatorStateTypesTest do alias SC.{Document, Parser.SCXML} - describe "state type determination in validator" do - test "determines atomic state type" do + describe "state type determination at parse time" do + test "atomic state type determined at parse time" do xml = """ @@ -13,13 +13,13 @@ defmodule SC.Document.ValidatorStateTypesTest do """ {:ok, document} = SCXML.parse(xml) - {:ok, optimized_document, _warnings} = Document.Validator.validate(document) - [state] = optimized_document.states + # State type should be determined at parse time, no validation needed + [state] = document.states assert state.type == :atomic end - test "determines compound state type" do + test "compound state type determined at parse time" do xml = """ @@ -30,16 +30,16 @@ defmodule SC.Document.ValidatorStateTypesTest do """ {:ok, document} = SCXML.parse(xml) - {:ok, optimized_document, _warnings} = Document.Validator.validate(document) - [parent_state] = optimized_document.states + # State types should be determined at parse time, no validation needed + [parent_state] = document.states assert parent_state.type == :compound [child_state] = parent_state.states assert child_state.type == :atomic end - test "state types are only determined for valid documents" do + test "validator only builds lookup maps for valid documents" do xml = """ @@ -50,11 +50,10 @@ defmodule SC.Document.ValidatorStateTypesTest do {:ok, document} = SCXML.parse(xml) {:error, _errors, _warnings} = Document.Validator.validate(document) - # Invalid documents should not have state types determined or lookup maps built + # State types are determined at parse time regardless of validity [state] = document.states - # Still the default from parsing assert state.type == :atomic - # No lookup maps built + # But lookup maps should not be built for invalid documents assert document.state_lookup == %{} end @@ -71,9 +70,9 @@ defmodule SC.Document.ValidatorStateTypesTest do """ {:ok, document} = SCXML.parse(xml) - {:ok, optimized_document, _warnings} = Document.Validator.validate(document) - [level1] = optimized_document.states + # State types should be determined at parse time, no validation needed + [level1] = document.states assert level1.type == :compound [level2] = level1.states diff --git a/test/sc/feature_detector_test.exs b/test/sc/feature_detector_test.exs new file mode 100644 index 0000000..422f5e2 --- /dev/null +++ b/test/sc/feature_detector_test.exs @@ -0,0 +1,422 @@ +defmodule SC.FeatureDetectorTest do + use ExUnit.Case + + alias SC.{Document, FeatureDetector, Parser, State} + + describe "feature detection from XML" do + test "detects basic states and transitions" do + xml = """ + + + + + + + """ + + features = FeatureDetector.detect_features(xml) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :event_transitions) + refute MapSet.member?(features, :datamodel) + refute MapSet.member?(features, :conditional_transitions) + end + + test "detects compound states" do + xml = """ + + + + + + + + + """ + + features = FeatureDetector.detect_features(xml) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :compound_states) + assert MapSet.member?(features, :event_transitions) + end + + test "detects parallel states" do + xml = """ + + + + + + + """ + + features = FeatureDetector.detect_features(xml) + + assert MapSet.member?(features, :parallel_states) + assert MapSet.member?(features, :basic_states) + end + + test "detects final states" do + xml = """ + + + + + + + """ + + features = FeatureDetector.detect_features(xml) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :final_states) + assert MapSet.member?(features, :event_transitions) + end + + test "detects datamodel features" do + xml = """ + + + + + + + """ + + features = FeatureDetector.detect_features(xml) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :datamodel) + assert MapSet.member?(features, :data_elements) + end + + test "detects conditional transitions" do + xml = """ + + + + + + + """ + + features = FeatureDetector.detect_features(xml) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :event_transitions) + assert MapSet.member?(features, :conditional_transitions) + end + + test "detects executable content" do + xml = """ + + + + + + + + + + + + """ + + features = FeatureDetector.detect_features(xml) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :onentry_actions) + assert MapSet.member?(features, :onexit_actions) + assert MapSet.member?(features, :log_elements) + assert MapSet.member?(features, :send_elements) + assert MapSet.member?(features, :assign_elements) + end + + test "detects send idlocation feature (from SCION test)" do + xml = """ + + + + + + + + + + + """ + + features = FeatureDetector.detect_features(xml) + + assert MapSet.member?(features, :datamodel) + assert MapSet.member?(features, :data_elements) + assert MapSet.member?(features, :onentry_actions) + assert MapSet.member?(features, :send_elements) + assert MapSet.member?(features, :send_idlocation) + end + + test "detects targetless transitions" do + xml = """ + + + + + + + + """ + + features = FeatureDetector.detect_features(xml) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :targetless_transitions) + assert MapSet.member?(features, :log_elements) + end + end + + describe "feature validation" do + test "validates supported features successfully" do + supported_features = MapSet.new([:basic_states, :event_transitions, :compound_states]) + + assert {:ok, ^supported_features} = FeatureDetector.validate_features(supported_features) + end + + test "fails validation for unsupported features" do + mixed_features = MapSet.new([:basic_states, :datamodel, :conditional_transitions]) + + assert {:error, unsupported} = FeatureDetector.validate_features(mixed_features) + assert MapSet.member?(unsupported, :datamodel) + assert MapSet.member?(unsupported, :conditional_transitions) + refute MapSet.member?(unsupported, :basic_states) + end + + test "fails validation for unknown features" do + unknown_features = MapSet.new([:basic_states, :unknown_feature]) + + assert {:error, unsupported} = FeatureDetector.validate_features(unknown_features) + assert MapSet.member?(unsupported, :unknown_feature) + end + end + + describe "feature registry" do + test "categorizes features correctly" do + registry = FeatureDetector.feature_registry() + + # Supported features + assert registry[:basic_states] == :supported + assert registry[:event_transitions] == :supported + assert registry[:compound_states] == :supported + assert registry[:parallel_states] == :supported + assert registry[:final_states] == :supported + + # Unsupported features + assert registry[:datamodel] == :unsupported + assert registry[:conditional_transitions] == :unsupported + assert registry[:onentry_actions] == :unsupported + assert registry[:send_elements] == :unsupported + assert registry[:send_idlocation] == :unsupported + end + + test "registry contains expected number of features" do + registry = FeatureDetector.feature_registry() + + # Should have a reasonable number of features defined + assert map_size(registry) >= 20 + + # Should have both supported and unsupported features + supported_count = registry |> Enum.count(fn {_k, v} -> v == :supported end) + unsupported_count = registry |> Enum.count(fn {_k, v} -> v == :unsupported end) + + assert supported_count > 0 + assert unsupported_count > 0 + end + end + + describe "integration with parsed documents" do + test "detects features from parsed SC.Document" do + xml = """ + + + + + + + + + """ + + {:ok, document} = Parser.SCXML.parse(xml) + features = FeatureDetector.detect_features(document) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :compound_states) + assert MapSet.member?(features, :event_transitions) + end + + test "detects final states from parsed document" do + xml = """ + + + + + + + """ + + {:ok, document} = Parser.SCXML.parse(xml) + features = FeatureDetector.detect_features(document) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :final_states) + assert MapSet.member?(features, :event_transitions) + end + + test "detects parallel states from parsed document" do + xml = """ + + + + + + + """ + + {:ok, document} = Parser.SCXML.parse(xml) + features = FeatureDetector.detect_features(document) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :parallel_states) + end + + test "detects history states from XML directly" do + xml = """ + + + + + + """ + + features = FeatureDetector.detect_features(xml) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :history_states) + end + + test "detects datamodel elements from parsed document" do + xml = """ + + + + + + + """ + + {:ok, document} = Parser.SCXML.parse(xml) + features = FeatureDetector.detect_features(document) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :datamodel) + assert MapSet.member?(features, :data_elements) + end + + test "handles document with no datamodel elements" do + xml = """ + + + + """ + + {:ok, document} = Parser.SCXML.parse(xml) + features = FeatureDetector.detect_features(document) + + assert MapSet.member?(features, :basic_states) + refute MapSet.member?(features, :datamodel) + refute MapSet.member?(features, :data_elements) + end + + test "detects conditional transitions from parsed document" do + xml = """ + + + + + + + """ + + {:ok, document} = Parser.SCXML.parse(xml) + features = FeatureDetector.detect_features(document) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :event_transitions) + assert MapSet.member?(features, :conditional_transitions) + end + + test "detects targetless transitions from parsed document" do + xml = """ + + + + + + """ + + {:ok, document} = Parser.SCXML.parse(xml) + features = FeatureDetector.detect_features(document) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :event_transitions) + assert MapSet.member?(features, :targetless_transitions) + end + end + + describe "edge cases" do + test "detects compound states via nested state XML pattern (multiline match)" do + xml = """ + + + + """ + + features = FeatureDetector.detect_features(xml) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :compound_states) + end + + test "handles unknown state type gracefully" do + # Create a state with an unknown type (this would be an edge case in practice) + state = %State{id: "test", type: :unknown_type} + document = %Document{states: [state]} + + features = FeatureDetector.detect_features(document) + + # Should not crash and should not add any specific state type features + refute MapSet.member?(features, :basic_states) + refute MapSet.member?(features, :compound_states) + refute MapSet.member?(features, :parallel_states) + refute MapSet.member?(features, :final_states) + end + + test "handles raise elements detection" do + xml = """ + + + + + + + + """ + + features = FeatureDetector.detect_features(xml) + + assert MapSet.member?(features, :basic_states) + assert MapSet.member?(features, :onentry_actions) + assert MapSet.member?(features, :raise_elements) + end + end +end diff --git a/test/sc/interpreter_final_state_test.exs b/test/sc/interpreter_final_state_test.exs new file mode 100644 index 0000000..744273e --- /dev/null +++ b/test/sc/interpreter_final_state_test.exs @@ -0,0 +1,124 @@ +defmodule SC.InterpreterFinalStateTest do + use ExUnit.Case + + alias SC.{Event, Interpreter, Parser.SCXML} + + test "interprets final state as atomic state" do + xml = """ + + + + + """ + + {:ok, document} = SCXML.parse(xml) + {:ok, state_chart} = Interpreter.initialize(document) + + # Final state should be active as initial state + active_states = Interpreter.active_states(state_chart) + assert MapSet.member?(active_states, "final_state") + end + + test "transitions to final state" do + xml = """ + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + {:ok, state_chart} = Interpreter.initialize(document) + + # Initially in s1 + active_states = Interpreter.active_states(state_chart) + assert MapSet.member?(active_states, "s1") + refute MapSet.member?(active_states, "final_state") + + # Send done event to transition to final state + event = Event.new("done") + {:ok, new_state_chart} = Interpreter.send_event(state_chart, event) + + # Should now be in final state + active_states = Interpreter.active_states(new_state_chart) + refute MapSet.member?(active_states, "s1") + assert MapSet.member?(active_states, "final_state") + end + + test "transitions from final state" do + xml = """ + + + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + {:ok, state_chart} = Interpreter.initialize(document) + + # Transition to final state + event = Event.new("done") + {:ok, state_chart} = Interpreter.send_event(state_chart, event) + + # Verify in final state + active_states = Interpreter.active_states(state_chart) + assert MapSet.member?(active_states, "final_state") + + # Transition back from final state + restart_event = Event.new("restart") + {:ok, final_state_chart} = Interpreter.send_event(state_chart, restart_event) + + # Should be back in s1 + active_states = Interpreter.active_states(final_state_chart) + assert MapSet.member?(active_states, "s1") + refute MapSet.member?(active_states, "final_state") + end + + test "final state in compound state hierarchy" do + xml = """ + + + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + {:ok, state_chart} = Interpreter.initialize(document) + + # Initially in child1 + active_states = Interpreter.active_states(state_chart) + assert MapSet.member?(active_states, "child1") + + # Check ancestors include compound state + active_ancestors = Interpreter.active_ancestors(state_chart) + assert MapSet.member?(active_ancestors, "compound") + assert MapSet.member?(active_ancestors, "child1") + + # Transition to final state + event = Event.new("finish") + {:ok, new_state_chart} = Interpreter.send_event(state_chart, event) + + # Should be in child_final + active_states = Interpreter.active_states(new_state_chart) + assert MapSet.member?(active_states, "child_final") + refute MapSet.member?(active_states, "child1") + + # Check ancestors still include compound state + active_ancestors = Interpreter.active_ancestors(new_state_chart) + assert MapSet.member?(active_ancestors, "compound") + assert MapSet.member?(active_ancestors, "child_final") + end +end diff --git a/test/sc/parser/scxml/final_state_test.exs b/test/sc/parser/scxml/final_state_test.exs new file mode 100644 index 0000000..bf2e544 --- /dev/null +++ b/test/sc/parser/scxml/final_state_test.exs @@ -0,0 +1,202 @@ +defmodule SC.Parser.SCXML.FinalStateTest do + use ExUnit.Case + + alias SC.Parser.SCXML + + test "parses final state correctly" do + xml = """ + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + + # Find the final state + final_state = Enum.find(document.states, &(&1.id == "final_state")) + + assert final_state != nil + assert final_state.type == :final + assert final_state.id == "final_state" + assert final_state.initial == nil + assert final_state.states == [] + assert final_state.transitions == [] + end + + test "parses final state with transitions" do + xml = """ + + + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + + # Find the final state + final_state = Enum.find(document.states, &(&1.id == "final_state")) + + assert final_state != nil + assert final_state.type == :final + assert final_state.id == "final_state" + assert length(final_state.transitions) == 1 + + transition = hd(final_state.transitions) + assert transition.target == "s1" + assert transition.event == "restart" + end + + test "parses nested final state in compound state" do + xml = """ + + + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + + # Find the compound state (should be correctly typed at parse time) + compound_state = Enum.find(document.states, &(&1.id == "compound")) + assert compound_state != nil + assert compound_state.type == :compound + + # Find the nested final state + child_final = Enum.find(compound_state.states, &(&1.id == "child_final")) + assert child_final != nil + assert child_final.type == :final + assert child_final.parent == "compound" + assert child_final.depth == 1 + end + + test "parses nested final state in parallel state" do + xml = """ + + + + + + + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + + # Find the parallel state (should be correctly typed at parse time) + parallel_state = Enum.find(document.states, &(&1.id == "parallel_state")) + assert parallel_state != nil + assert parallel_state.type == :parallel + + # Find the nested final states + branch1_final = Enum.find(parallel_state.states, &(&1.id == "branch1_final")) + assert branch1_final != nil + assert branch1_final.type == :final + assert branch1_final.parent == "parallel_state" + assert branch1_final.depth == 1 + + branch2_final = Enum.find(parallel_state.states, &(&1.id == "branch2_final")) + assert branch2_final != nil + assert branch2_final.type == :final + assert branch2_final.parent == "parallel_state" + assert branch2_final.depth == 1 + end + + test "parses final state with nested states (should be empty)" do + xml = """ + + + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + + final_state = Enum.find(document.states, &(&1.id == "final_state")) + assert final_state != nil + assert final_state.type == :final + assert final_state.states == [] + assert final_state.initial == nil + assert final_state.initial_location == nil + end + + test "validates final state location information" do + xml = """ + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + + final_state = Enum.find(document.states, &(&1.id == "final_state")) + assert final_state != nil + + # Check location information is populated + assert final_state.source_location != nil + assert final_state.id_location != nil + assert is_integer(final_state.document_order) + assert final_state.document_order > 0 + end + + test "parses final state with multiple transitions" do + xml = """ + + + + + + + + + + + + """ + + {:ok, document} = SCXML.parse(xml) + + final_state = Enum.find(document.states, &(&1.id == "final_state")) + assert final_state != nil + assert final_state.type == :final + assert length(final_state.transitions) == 2 + + # Check both transitions have correct source + Enum.each(final_state.transitions, fn transition -> + assert transition.source == "final_state" + end) + + # Check specific transition targets + targets = Enum.map(final_state.transitions, & &1.target) + assert "s1" in targets + assert "s2" in targets + end +end diff --git a/test/sc/parser/scxml/state_stack_test.exs b/test/sc/parser/scxml/state_stack_test.exs new file mode 100644 index 0000000..b0cbc7c --- /dev/null +++ b/test/sc/parser/scxml/state_stack_test.exs @@ -0,0 +1,340 @@ +defmodule SC.Parser.SCXML.StateStackTest do + use ExUnit.Case + + alias SC.Parser.SCXML.StateStack + alias SC.{Document, State} + + describe "handle_state_end/1" do + test "handles state at document root" do + state_element = %State{id: "test_state", type: :atomic} + document = %Document{states: []} + + parsing_state = %{ + stack: [{"state", state_element}, {"scxml", document}], + result: document + } + + {:ok, result} = StateStack.handle_state_end(parsing_state) + + # Should have updated the document with the state + [{"scxml", updated_document} | _rest] = result.stack + assert length(updated_document.states) == 1 + + added_state = hd(updated_document.states) + assert added_state.id == "test_state" + assert added_state.parent == nil + assert added_state.depth == 0 + end + + test "handles nested state in parent state" do + child_state = %State{id: "child_state", type: :atomic} + parent_state = %State{id: "parent_state", states: [], type: :atomic} + + parsing_state = %{ + stack: [ + {"state", child_state}, + {"state", parent_state}, + {"scxml", %Document{}} + ], + result: %Document{} + } + + {:ok, result} = StateStack.handle_state_end(parsing_state) + + # Should have updated the parent state with the child + [{"state", updated_parent} | _rest] = result.stack + assert length(updated_parent.states) == 1 + + added_child = hd(updated_parent.states) + assert added_child.id == "child_state" + assert added_child.parent == "parent_state" + assert added_child.depth == 1 + + # Parent should now be compound type since it has children + assert updated_parent.type == :compound + end + + test "handles state in parallel parent" do + child_state = %State{id: "child_state", type: :atomic} + parent_parallel = %State{id: "parallel_state", states: [], type: :parallel} + + parsing_state = %{ + stack: [ + {"state", child_state}, + {"parallel", parent_parallel}, + {"scxml", %Document{}} + ], + result: %Document{} + } + + {:ok, result} = StateStack.handle_state_end(parsing_state) + + # Should have updated the parallel state with the child + [{"parallel", updated_parent} | _rest] = result.stack + assert length(updated_parent.states) == 1 + + added_child = hd(updated_parent.states) + assert added_child.id == "child_state" + assert added_child.parent == "parallel_state" + assert added_child.depth == 1 + end + + test "handles deeply nested state" do + child_state = %State{id: "deep_child", type: :atomic} + mid_parent = %State{id: "mid_parent", states: [], type: :atomic} + top_parent = %State{id: "top_parent", states: [], type: :atomic} + + parsing_state = %{ + stack: [ + {"state", child_state}, + {"state", mid_parent}, + {"state", top_parent}, + {"scxml", %Document{}} + ], + result: %Document{} + } + + {:ok, result} = StateStack.handle_state_end(parsing_state) + + [{"state", updated_mid} | _rest] = result.stack + added_child = hd(updated_mid.states) + # Three levels deep: top -> mid -> child + assert added_child.depth == 2 + end + end + + describe "handle_state_end/1 with parallel elements" do + test "handles parallel at document root" do + parallel_element = %State{id: "test_parallel", type: :parallel} + document = %Document{states: []} + + parsing_state = %{ + stack: [{"parallel", parallel_element}, {"scxml", document}], + result: document + } + + {:ok, result} = StateStack.handle_state_end(parsing_state) + + # Should have updated the document with the parallel state + [{"scxml", updated_document} | _rest] = result.stack + assert length(updated_document.states) == 1 + + added_state = hd(updated_document.states) + assert added_state.id == "test_parallel" + assert added_state.parent == nil + assert added_state.depth == 0 + end + + test "handles parallel nested in state" do + parallel_element = %State{id: "nested_parallel", type: :parallel} + parent_state = %State{id: "parent_state", states: [], type: :atomic} + + parsing_state = %{ + stack: [ + {"parallel", parallel_element}, + {"state", parent_state}, + {"scxml", %Document{}} + ], + result: %Document{} + } + + {:ok, result} = StateStack.handle_state_end(parsing_state) + + [{"state", updated_parent} | _rest] = result.stack + assert length(updated_parent.states) == 1 + + added_parallel = hd(updated_parent.states) + assert added_parallel.id == "nested_parallel" + assert added_parallel.parent == "parent_state" + assert added_parallel.depth == 1 + + # Parent should now be compound type since it has children + assert updated_parent.type == :compound + end + end + + describe "handle_state_end/1 with final elements" do + test "handles final state at document root" do + final_element = %State{id: "final_state", type: :final} + document = %Document{states: []} + + parsing_state = %{ + stack: [{"final", final_element}, {"scxml", document}], + result: document + } + + {:ok, result} = StateStack.handle_state_end(parsing_state) + + [{"scxml", updated_document} | _rest] = result.stack + assert length(updated_document.states) == 1 + + added_state = hd(updated_document.states) + assert added_state.id == "final_state" + assert added_state.type == :final + assert added_state.parent == nil + assert added_state.depth == 0 + end + + test "handles final state nested in compound state" do + final_element = %State{id: "nested_final", type: :final} + parent_state = %State{id: "parent_state", states: [], type: :atomic} + + parsing_state = %{ + stack: [ + {"final", final_element}, + {"state", parent_state}, + {"scxml", %Document{}} + ], + result: %Document{} + } + + {:ok, result} = StateStack.handle_state_end(parsing_state) + + [{"state", updated_parent} | _rest] = result.stack + assert length(updated_parent.states) == 1 + + added_final = hd(updated_parent.states) + assert added_final.id == "nested_final" + assert added_final.type == :final + assert added_final.parent == "parent_state" + assert added_final.depth == 1 + end + end + + describe "handle_transition_end/1" do + test "adds transition to state" do + transition = %SC.Transition{event: "test_event", target: "target_state"} + parent_state = %State{id: "source_state", transitions: []} + + parsing_state = %{ + stack: [ + {"transition", transition}, + {"state", parent_state}, + {"scxml", %Document{}} + ], + result: %Document{} + } + + {:ok, result} = StateStack.handle_transition_end(parsing_state) + + [{"state", updated_state} | _rest] = result.stack + assert length(updated_state.transitions) == 1 + + added_transition = hd(updated_state.transitions) + assert added_transition.event == "test_event" + assert added_transition.target == "target_state" + assert added_transition.source == "source_state" + end + + test "adds transition to parallel state" do + transition = %SC.Transition{event: "parallel_event", target: "target_state"} + parallel_state = %State{id: "parallel_source", transitions: [], type: :parallel} + + parsing_state = %{ + stack: [ + {"transition", transition}, + {"parallel", parallel_state}, + {"scxml", %Document{}} + ], + result: %Document{} + } + + {:ok, result} = StateStack.handle_transition_end(parsing_state) + + [{"parallel", updated_parallel} | _rest] = result.stack + assert length(updated_parallel.transitions) == 1 + + added_transition = hd(updated_parallel.transitions) + assert added_transition.source == "parallel_source" + end + + test "adds transition to final state" do + transition = %SC.Transition{event: "final_event", target: "target_state"} + final_state = %State{id: "final_source", transitions: [], type: :final} + + parsing_state = %{ + stack: [ + {"transition", transition}, + {"final", final_state}, + {"scxml", %Document{}} + ], + result: %Document{} + } + + {:ok, result} = StateStack.handle_transition_end(parsing_state) + + [{"final", updated_final} | _rest] = result.stack + assert length(updated_final.transitions) == 1 + + added_transition = hd(updated_final.transitions) + assert added_transition.source == "final_source" + end + end + + describe "handle_datamodel_end/1" do + test "pops datamodel from stack" do + datamodel_placeholder = nil + + parsing_state = %{ + stack: [ + {"datamodel", datamodel_placeholder}, + {"scxml", %Document{}} + ], + result: %Document{} + } + + {:ok, result} = StateStack.handle_datamodel_end(parsing_state) + + # Should just pop the datamodel from stack + assert length(result.stack) == 1 + [{"scxml", _document}] = result.stack + end + end + + describe "handle_data_end/1" do + test "adds data element to document datamodel" do + data_element = %SC.DataElement{id: "test_data", expr: "value"} + + existing_document = %Document{ + datamodel_elements: [%SC.DataElement{id: "existing", expr: "old"}] + } + + parsing_state = %{ + stack: [ + {"data", data_element}, + # Placeholder for datamodel + {"datamodel", nil}, + {"scxml", existing_document} + ], + result: existing_document + } + + {:ok, result} = StateStack.handle_data_end(parsing_state) + + [{"datamodel", nil}, {"scxml", updated_document}] = result.stack + assert length(updated_document.datamodel_elements) == 2 + + # New data should be added to the end + assert List.last(updated_document.datamodel_elements).id == "test_data" + assert hd(updated_document.datamodel_elements).id == "existing" + end + + test "handles data element with non-datamodel parent" do + data_element = %SC.DataElement{id: "orphan_data", expr: "value"} + + parsing_state = %{ + stack: [ + {"data", data_element}, + {"state", %State{id: "parent_state", states: []}} + ], + result: %Document{} + } + + {:ok, result} = StateStack.handle_data_end(parsing_state) + + # Should just pop the data element from stack + assert length(result.stack) == 1 + [{"state", _state}] = result.stack + end + end +end diff --git a/test/scion_tests/assign-current-small-step/test0_test.exs b/test/scion_tests/assign_current_small_step/test0_test.exs similarity index 100% rename from test/scion_tests/assign-current-small-step/test0_test.exs rename to test/scion_tests/assign_current_small_step/test0_test.exs diff --git a/test/scion_tests/assign-current-small-step/test1_test.exs b/test/scion_tests/assign_current_small_step/test1_test.exs similarity index 100% rename from test/scion_tests/assign-current-small-step/test1_test.exs rename to test/scion_tests/assign_current_small_step/test1_test.exs diff --git a/test/scion_tests/assign-current-small-step/test2_test.exs b/test/scion_tests/assign_current_small_step/test2_test.exs similarity index 100% rename from test/scion_tests/assign-current-small-step/test2_test.exs rename to test/scion_tests/assign_current_small_step/test2_test.exs diff --git a/test/scion_tests/assign-current-small-step/test3_test.exs b/test/scion_tests/assign_current_small_step/test3_test.exs similarity index 100% rename from test/scion_tests/assign-current-small-step/test3_test.exs rename to test/scion_tests/assign_current_small_step/test3_test.exs diff --git a/test/scion_tests/assign-current-small-step/test4_test.exs b/test/scion_tests/assign_current_small_step/test4_test.exs similarity index 100% rename from test/scion_tests/assign-current-small-step/test4_test.exs rename to test/scion_tests/assign_current_small_step/test4_test.exs diff --git a/test/scion_tests/atom3-basic-tests/m0_test.exs b/test/scion_tests/atom3_basic_tests/m0_test.exs similarity index 100% rename from test/scion_tests/atom3-basic-tests/m0_test.exs rename to test/scion_tests/atom3_basic_tests/m0_test.exs diff --git a/test/scion_tests/atom3-basic-tests/m1_test.exs b/test/scion_tests/atom3_basic_tests/m1_test.exs similarity index 100% rename from test/scion_tests/atom3-basic-tests/m1_test.exs rename to test/scion_tests/atom3_basic_tests/m1_test.exs diff --git a/test/scion_tests/atom3-basic-tests/m2_test.exs b/test/scion_tests/atom3_basic_tests/m2_test.exs similarity index 100% rename from test/scion_tests/atom3-basic-tests/m2_test.exs rename to test/scion_tests/atom3_basic_tests/m2_test.exs diff --git a/test/scion_tests/atom3-basic-tests/m3_test.exs b/test/scion_tests/atom3_basic_tests/m3_test.exs similarity index 100% rename from test/scion_tests/atom3-basic-tests/m3_test.exs rename to test/scion_tests/atom3_basic_tests/m3_test.exs diff --git a/test/scion_tests/cond-js/TestConditionalTransition_test.exs b/test/scion_tests/cond_js/TestConditionalTransition_test.exs similarity index 100% rename from test/scion_tests/cond-js/TestConditionalTransition_test.exs rename to test/scion_tests/cond_js/TestConditionalTransition_test.exs diff --git a/test/scion_tests/cond-js/test0_test.exs b/test/scion_tests/cond_js/test0_test.exs similarity index 100% rename from test/scion_tests/cond-js/test0_test.exs rename to test/scion_tests/cond_js/test0_test.exs diff --git a/test/scion_tests/cond-js/test1_test.exs b/test/scion_tests/cond_js/test1_test.exs similarity index 100% rename from test/scion_tests/cond-js/test1_test.exs rename to test/scion_tests/cond_js/test1_test.exs diff --git a/test/scion_tests/cond-js/test2_test.exs b/test/scion_tests/cond_js/test2_test.exs similarity index 100% rename from test/scion_tests/cond-js/test2_test.exs rename to test/scion_tests/cond_js/test2_test.exs diff --git a/test/scion_tests/default-initial-state/initial1_test.exs b/test/scion_tests/default_initial_state/initial1_test.exs similarity index 100% rename from test/scion_tests/default-initial-state/initial1_test.exs rename to test/scion_tests/default_initial_state/initial1_test.exs diff --git a/test/scion_tests/default-initial-state/initial2_test.exs b/test/scion_tests/default_initial_state/initial2_test.exs similarity index 100% rename from test/scion_tests/default-initial-state/initial2_test.exs rename to test/scion_tests/default_initial_state/initial2_test.exs diff --git a/test/scion_tests/hierarchy+documentOrder/test0_test.exs b/test/scion_tests/hierarchy_documentOrder/test0_test.exs similarity index 94% rename from test/scion_tests/hierarchy+documentOrder/test0_test.exs rename to test/scion_tests/hierarchy_documentOrder/test0_test.exs index 27f28c1..243e02d 100644 --- a/test/scion_tests/hierarchy+documentOrder/test0_test.exs +++ b/test/scion_tests/hierarchy_documentOrder/test0_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Hierarchy+documentOrder.Test0" do +defmodule SCIONTest.HierarchyDocumentOrder.Test0Test do use SC.Case @tag :scion @tag spec: "hierarchy+document_order" diff --git a/test/scion_tests/hierarchy+documentOrder/test1_test.exs b/test/scion_tests/hierarchy_documentOrder/test1_test.exs similarity index 94% rename from test/scion_tests/hierarchy+documentOrder/test1_test.exs rename to test/scion_tests/hierarchy_documentOrder/test1_test.exs index 971f571..bf7d5d6 100644 --- a/test/scion_tests/hierarchy+documentOrder/test1_test.exs +++ b/test/scion_tests/hierarchy_documentOrder/test1_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Hierarchy+documentOrder.Test1" do +defmodule SCIONTest.HierarchyDocumentOrder.Test1Test do use SC.Case @tag :scion @tag spec: "hierarchy+document_order" diff --git a/test/scion_tests/if-else/test0_test.exs b/test/scion_tests/if_else/test0_test.exs similarity index 100% rename from test/scion_tests/if-else/test0_test.exs rename to test/scion_tests/if_else/test0_test.exs diff --git a/test/scion_tests/internal-transitions/test0.scxml.bak b/test/scion_tests/internal_transitions/test0.scxml.bak similarity index 100% rename from test/scion_tests/internal-transitions/test0.scxml.bak rename to test/scion_tests/internal_transitions/test0.scxml.bak diff --git a/test/scion_tests/internal-transitions/test0_test.exs b/test/scion_tests/internal_transitions/test0_test.exs similarity index 100% rename from test/scion_tests/internal-transitions/test0_test.exs rename to test/scion_tests/internal_transitions/test0_test.exs diff --git a/test/scion_tests/internal-transitions/test1_test.exs b/test/scion_tests/internal_transitions/test1_test.exs similarity index 100% rename from test/scion_tests/internal-transitions/test1_test.exs rename to test/scion_tests/internal_transitions/test1_test.exs diff --git a/test/scion_tests/misc/deep-initial_test.exs b/test/scion_tests/misc/deep_initial_test.exs similarity index 90% rename from test/scion_tests/misc/deep-initial_test.exs rename to test/scion_tests/misc/deep_initial_test.exs index f7c6430..df6376a 100644 --- a/test/scion_tests/misc/deep-initial_test.exs +++ b/test/scion_tests/misc/deep_initial_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Misc.Deep-initial" do +defmodule SCIONTest.Misc.DeepInitialTest do use SC.Case @tag :scion @tag spec: "misc" diff --git a/test/scion_tests/more-parallel/test0_test.exs b/test/scion_tests/more_parallel/test0_test.exs similarity index 100% rename from test/scion_tests/more-parallel/test0_test.exs rename to test/scion_tests/more_parallel/test0_test.exs diff --git a/test/scion_tests/more-parallel/test10_test.exs b/test/scion_tests/more_parallel/test10_test.exs similarity index 100% rename from test/scion_tests/more-parallel/test10_test.exs rename to test/scion_tests/more_parallel/test10_test.exs diff --git a/test/scion_tests/more-parallel/test10b_test.exs b/test/scion_tests/more_parallel/test10b_test.exs similarity index 100% rename from test/scion_tests/more-parallel/test10b_test.exs rename to test/scion_tests/more_parallel/test10b_test.exs diff --git a/test/scion_tests/more-parallel/test1_test.exs b/test/scion_tests/more_parallel/test1_test.exs similarity index 100% rename from test/scion_tests/more-parallel/test1_test.exs rename to test/scion_tests/more_parallel/test1_test.exs diff --git a/test/scion_tests/more-parallel/test2_test.exs b/test/scion_tests/more_parallel/test2_test.exs similarity index 100% rename from test/scion_tests/more-parallel/test2_test.exs rename to test/scion_tests/more_parallel/test2_test.exs diff --git a/test/scion_tests/more-parallel/test2b_test.exs b/test/scion_tests/more_parallel/test2b_test.exs similarity index 100% rename from test/scion_tests/more-parallel/test2b_test.exs rename to test/scion_tests/more_parallel/test2b_test.exs diff --git a/test/scion_tests/more-parallel/test3_test.exs b/test/scion_tests/more_parallel/test3_test.exs similarity index 100% rename from test/scion_tests/more-parallel/test3_test.exs rename to test/scion_tests/more_parallel/test3_test.exs diff --git a/test/scion_tests/more-parallel/test3b_test.exs b/test/scion_tests/more_parallel/test3b_test.exs similarity index 100% rename from test/scion_tests/more-parallel/test3b_test.exs rename to test/scion_tests/more_parallel/test3b_test.exs diff --git a/test/scion_tests/more-parallel/test4_test.exs b/test/scion_tests/more_parallel/test4_test.exs similarity index 100% rename from test/scion_tests/more-parallel/test4_test.exs rename to test/scion_tests/more_parallel/test4_test.exs diff --git a/test/scion_tests/more-parallel/test5_test.exs b/test/scion_tests/more_parallel/test5_test.exs similarity index 100% rename from test/scion_tests/more-parallel/test5_test.exs rename to test/scion_tests/more_parallel/test5_test.exs diff --git a/test/scion_tests/more-parallel/test6_test.exs b/test/scion_tests/more_parallel/test6_test.exs similarity index 100% rename from test/scion_tests/more-parallel/test6_test.exs rename to test/scion_tests/more_parallel/test6_test.exs diff --git a/test/scion_tests/more-parallel/test6b_test.exs b/test/scion_tests/more_parallel/test6b_test.exs similarity index 100% rename from test/scion_tests/more-parallel/test6b_test.exs rename to test/scion_tests/more_parallel/test6b_test.exs diff --git a/test/scion_tests/more-parallel/test7_test.exs b/test/scion_tests/more_parallel/test7_test.exs similarity index 100% rename from test/scion_tests/more-parallel/test7_test.exs rename to test/scion_tests/more_parallel/test7_test.exs diff --git a/test/scion_tests/more-parallel/test8_test.exs b/test/scion_tests/more_parallel/test8_test.exs similarity index 100% rename from test/scion_tests/more-parallel/test8_test.exs rename to test/scion_tests/more_parallel/test8_test.exs diff --git a/test/scion_tests/more-parallel/test9_test.exs b/test/scion_tests/more_parallel/test9_test.exs similarity index 100% rename from test/scion_tests/more-parallel/test9_test.exs rename to test/scion_tests/more_parallel/test9_test.exs diff --git a/test/scion_tests/multiple-events-per-transition/test1_test.exs b/test/scion_tests/multiple_events_per_transition/test1_test.exs similarity index 100% rename from test/scion_tests/multiple-events-per-transition/test1_test.exs rename to test/scion_tests/multiple_events_per_transition/test1_test.exs diff --git a/test/scion_tests/parallel+interrupt/test0_test.exs b/test/scion_tests/parallel_interrupt/test0_test.exs similarity index 95% rename from test/scion_tests/parallel+interrupt/test0_test.exs rename to test/scion_tests/parallel_interrupt/test0_test.exs index 08f912f..7ecb89b 100644 --- a/test/scion_tests/parallel+interrupt/test0_test.exs +++ b/test/scion_tests/parallel_interrupt/test0_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test0" do +defmodule SCIONTest.ParallelInterrupt.Test0Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test10_test.exs b/test/scion_tests/parallel_interrupt/test10_test.exs similarity index 95% rename from test/scion_tests/parallel+interrupt/test10_test.exs rename to test/scion_tests/parallel_interrupt/test10_test.exs index 2c92071..8901dea 100644 --- a/test/scion_tests/parallel+interrupt/test10_test.exs +++ b/test/scion_tests/parallel_interrupt/test10_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test10" do +defmodule SCIONTest.ParallelInterrupt.Test10Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test11_test.exs b/test/scion_tests/parallel_interrupt/test11_test.exs similarity index 93% rename from test/scion_tests/parallel+interrupt/test11_test.exs rename to test/scion_tests/parallel_interrupt/test11_test.exs index 049f1ee..6813823 100644 --- a/test/scion_tests/parallel+interrupt/test11_test.exs +++ b/test/scion_tests/parallel_interrupt/test11_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test11" do +defmodule SCIONTest.ParallelInterrupt.Test11Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test12_test.exs b/test/scion_tests/parallel_interrupt/test12_test.exs similarity index 95% rename from test/scion_tests/parallel+interrupt/test12_test.exs rename to test/scion_tests/parallel_interrupt/test12_test.exs index e3fe7ce..d430c2b 100644 --- a/test/scion_tests/parallel+interrupt/test12_test.exs +++ b/test/scion_tests/parallel_interrupt/test12_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test12" do +defmodule SCIONTest.ParallelInterrupt.Test12Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test13_test.exs b/test/scion_tests/parallel_interrupt/test13_test.exs similarity index 95% rename from test/scion_tests/parallel+interrupt/test13_test.exs rename to test/scion_tests/parallel_interrupt/test13_test.exs index 1dc2ea8..d6cb1d8 100644 --- a/test/scion_tests/parallel+interrupt/test13_test.exs +++ b/test/scion_tests/parallel_interrupt/test13_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test13" do +defmodule SCIONTest.ParallelInterrupt.Test13Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test14_test.exs b/test/scion_tests/parallel_interrupt/test14_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test14_test.exs rename to test/scion_tests/parallel_interrupt/test14_test.exs index 8385dcf..1b267ed 100644 --- a/test/scion_tests/parallel+interrupt/test14_test.exs +++ b/test/scion_tests/parallel_interrupt/test14_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test14" do +defmodule SCIONTest.ParallelInterrupt.Test14Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test15_test.exs b/test/scion_tests/parallel_interrupt/test15_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test15_test.exs rename to test/scion_tests/parallel_interrupt/test15_test.exs index fc9dd55..b2e7442 100644 --- a/test/scion_tests/parallel+interrupt/test15_test.exs +++ b/test/scion_tests/parallel_interrupt/test15_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test15" do +defmodule SCIONTest.ParallelInterrupt.Test15Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test16_test.exs b/test/scion_tests/parallel_interrupt/test16_test.exs similarity index 95% rename from test/scion_tests/parallel+interrupt/test16_test.exs rename to test/scion_tests/parallel_interrupt/test16_test.exs index 68d7582..92df2be 100644 --- a/test/scion_tests/parallel+interrupt/test16_test.exs +++ b/test/scion_tests/parallel_interrupt/test16_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test16" do +defmodule SCIONTest.ParallelInterrupt.Test16Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test17_test.exs b/test/scion_tests/parallel_interrupt/test17_test.exs similarity index 95% rename from test/scion_tests/parallel+interrupt/test17_test.exs rename to test/scion_tests/parallel_interrupt/test17_test.exs index ae7a522..85d4814 100644 --- a/test/scion_tests/parallel+interrupt/test17_test.exs +++ b/test/scion_tests/parallel_interrupt/test17_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test17" do +defmodule SCIONTest.ParallelInterrupt.Test17Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test18_test.exs b/test/scion_tests/parallel_interrupt/test18_test.exs similarity index 95% rename from test/scion_tests/parallel+interrupt/test18_test.exs rename to test/scion_tests/parallel_interrupt/test18_test.exs index 40729e1..5b405e2 100644 --- a/test/scion_tests/parallel+interrupt/test18_test.exs +++ b/test/scion_tests/parallel_interrupt/test18_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test18" do +defmodule SCIONTest.ParallelInterrupt.Test18Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test19_test.exs b/test/scion_tests/parallel_interrupt/test19_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test19_test.exs rename to test/scion_tests/parallel_interrupt/test19_test.exs index 57630c1..f564a73 100644 --- a/test/scion_tests/parallel+interrupt/test19_test.exs +++ b/test/scion_tests/parallel_interrupt/test19_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test19" do +defmodule SCIONTest.ParallelInterrupt.Test19Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test1_test.exs b/test/scion_tests/parallel_interrupt/test1_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test1_test.exs rename to test/scion_tests/parallel_interrupt/test1_test.exs index cbadb95..3050ce5 100644 --- a/test/scion_tests/parallel+interrupt/test1_test.exs +++ b/test/scion_tests/parallel_interrupt/test1_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test1" do +defmodule SCIONTest.ParallelInterrupt.Test1Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test20_test.exs b/test/scion_tests/parallel_interrupt/test20_test.exs similarity index 95% rename from test/scion_tests/parallel+interrupt/test20_test.exs rename to test/scion_tests/parallel_interrupt/test20_test.exs index 8b73889..c8ede69 100644 --- a/test/scion_tests/parallel+interrupt/test20_test.exs +++ b/test/scion_tests/parallel_interrupt/test20_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test20" do +defmodule SCIONTest.ParallelInterrupt.Test20Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test21_test.exs b/test/scion_tests/parallel_interrupt/test21_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test21_test.exs rename to test/scion_tests/parallel_interrupt/test21_test.exs index 8b8b1bb..7aed95c 100644 --- a/test/scion_tests/parallel+interrupt/test21_test.exs +++ b/test/scion_tests/parallel_interrupt/test21_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test21" do +defmodule SCIONTest.ParallelInterrupt.Test21Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test21b_test.exs b/test/scion_tests/parallel_interrupt/test21b_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test21b_test.exs rename to test/scion_tests/parallel_interrupt/test21b_test.exs index 1eef0fa..306f04e 100644 --- a/test/scion_tests/parallel+interrupt/test21b_test.exs +++ b/test/scion_tests/parallel_interrupt/test21b_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test21b" do +defmodule SCIONTest.ParallelInterrupt.Test21bTest do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test21c_test.exs b/test/scion_tests/parallel_interrupt/test21c_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test21c_test.exs rename to test/scion_tests/parallel_interrupt/test21c_test.exs index 56eb60c..67866a5 100644 --- a/test/scion_tests/parallel+interrupt/test21c_test.exs +++ b/test/scion_tests/parallel_interrupt/test21c_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test21c" do +defmodule SCIONTest.ParallelInterrupt.Test21cTest do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test22_test.exs b/test/scion_tests/parallel_interrupt/test22_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test22_test.exs rename to test/scion_tests/parallel_interrupt/test22_test.exs index 14c28b1..4042393 100644 --- a/test/scion_tests/parallel+interrupt/test22_test.exs +++ b/test/scion_tests/parallel_interrupt/test22_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test22" do +defmodule SCIONTest.ParallelInterrupt.Test22Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test23_test.exs b/test/scion_tests/parallel_interrupt/test23_test.exs similarity index 95% rename from test/scion_tests/parallel+interrupt/test23_test.exs rename to test/scion_tests/parallel_interrupt/test23_test.exs index e408b18..50316b1 100644 --- a/test/scion_tests/parallel+interrupt/test23_test.exs +++ b/test/scion_tests/parallel_interrupt/test23_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test23" do +defmodule SCIONTest.ParallelInterrupt.Test23Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test24_test.exs b/test/scion_tests/parallel_interrupt/test24_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test24_test.exs rename to test/scion_tests/parallel_interrupt/test24_test.exs index f8dd6ba..39ab498 100644 --- a/test/scion_tests/parallel+interrupt/test24_test.exs +++ b/test/scion_tests/parallel_interrupt/test24_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test24" do +defmodule SCIONTest.ParallelInterrupt.Test24Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test25_test.exs b/test/scion_tests/parallel_interrupt/test25_test.exs similarity index 95% rename from test/scion_tests/parallel+interrupt/test25_test.exs rename to test/scion_tests/parallel_interrupt/test25_test.exs index 4dc171f..a2a750a 100644 --- a/test/scion_tests/parallel+interrupt/test25_test.exs +++ b/test/scion_tests/parallel_interrupt/test25_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test25" do +defmodule SCIONTest.ParallelInterrupt.Test25Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test27_test.exs b/test/scion_tests/parallel_interrupt/test27_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test27_test.exs rename to test/scion_tests/parallel_interrupt/test27_test.exs index ad7a644..7cd698e 100644 --- a/test/scion_tests/parallel+interrupt/test27_test.exs +++ b/test/scion_tests/parallel_interrupt/test27_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test27" do +defmodule SCIONTest.ParallelInterrupt.Test27Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test28_test.exs b/test/scion_tests/parallel_interrupt/test28_test.exs similarity index 95% rename from test/scion_tests/parallel+interrupt/test28_test.exs rename to test/scion_tests/parallel_interrupt/test28_test.exs index 22a0963..5335be2 100644 --- a/test/scion_tests/parallel+interrupt/test28_test.exs +++ b/test/scion_tests/parallel_interrupt/test28_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test28" do +defmodule SCIONTest.ParallelInterrupt.Test28Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test29_test.exs b/test/scion_tests/parallel_interrupt/test29_test.exs similarity index 95% rename from test/scion_tests/parallel+interrupt/test29_test.exs rename to test/scion_tests/parallel_interrupt/test29_test.exs index 505dd1b..33c1af8 100644 --- a/test/scion_tests/parallel+interrupt/test29_test.exs +++ b/test/scion_tests/parallel_interrupt/test29_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test29" do +defmodule SCIONTest.ParallelInterrupt.Test29Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test2_test.exs b/test/scion_tests/parallel_interrupt/test2_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test2_test.exs rename to test/scion_tests/parallel_interrupt/test2_test.exs index fd7e9be..f6744f1 100644 --- a/test/scion_tests/parallel+interrupt/test2_test.exs +++ b/test/scion_tests/parallel_interrupt/test2_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test2" do +defmodule SCIONTest.ParallelInterrupt.Test2Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test30_test.exs b/test/scion_tests/parallel_interrupt/test30_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test30_test.exs rename to test/scion_tests/parallel_interrupt/test30_test.exs index e09bfbe..25416be 100644 --- a/test/scion_tests/parallel+interrupt/test30_test.exs +++ b/test/scion_tests/parallel_interrupt/test30_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test30" do +defmodule SCIONTest.ParallelInterrupt.Test30Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test31_test.exs b/test/scion_tests/parallel_interrupt/test31_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test31_test.exs rename to test/scion_tests/parallel_interrupt/test31_test.exs index 0ba7b41..4598cec 100644 --- a/test/scion_tests/parallel+interrupt/test31_test.exs +++ b/test/scion_tests/parallel_interrupt/test31_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test31" do +defmodule SCIONTest.ParallelInterrupt.Test31Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test3_test.exs b/test/scion_tests/parallel_interrupt/test3_test.exs similarity index 94% rename from test/scion_tests/parallel+interrupt/test3_test.exs rename to test/scion_tests/parallel_interrupt/test3_test.exs index c1fb657..3241e1a 100644 --- a/test/scion_tests/parallel+interrupt/test3_test.exs +++ b/test/scion_tests/parallel_interrupt/test3_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test3" do +defmodule SCIONTest.ParallelInterrupt.Test3Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test4_test.exs b/test/scion_tests/parallel_interrupt/test4_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test4_test.exs rename to test/scion_tests/parallel_interrupt/test4_test.exs index 8e30df0..9d77ac1 100644 --- a/test/scion_tests/parallel+interrupt/test4_test.exs +++ b/test/scion_tests/parallel_interrupt/test4_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test4" do +defmodule SCIONTest.ParallelInterrupt.Test4Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test5_test.exs b/test/scion_tests/parallel_interrupt/test5_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test5_test.exs rename to test/scion_tests/parallel_interrupt/test5_test.exs index de9a4fb..fb61a9f 100644 --- a/test/scion_tests/parallel+interrupt/test5_test.exs +++ b/test/scion_tests/parallel_interrupt/test5_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test5" do +defmodule SCIONTest.ParallelInterrupt.Test5Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test6_test.exs b/test/scion_tests/parallel_interrupt/test6_test.exs similarity index 96% rename from test/scion_tests/parallel+interrupt/test6_test.exs rename to test/scion_tests/parallel_interrupt/test6_test.exs index 522afd8..ab95473 100644 --- a/test/scion_tests/parallel+interrupt/test6_test.exs +++ b/test/scion_tests/parallel_interrupt/test6_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test6" do +defmodule SCIONTest.ParallelInterrupt.Test6Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test7_test.exs b/test/scion_tests/parallel_interrupt/test7_test.exs similarity index 97% rename from test/scion_tests/parallel+interrupt/test7_test.exs rename to test/scion_tests/parallel_interrupt/test7_test.exs index 4a0f5db..63a1646 100644 --- a/test/scion_tests/parallel+interrupt/test7_test.exs +++ b/test/scion_tests/parallel_interrupt/test7_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test7" do +defmodule SCIONTest.ParallelInterrupt.Test7Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test7b_test.exs b/test/scion_tests/parallel_interrupt/test7b_test.exs similarity index 97% rename from test/scion_tests/parallel+interrupt/test7b_test.exs rename to test/scion_tests/parallel_interrupt/test7b_test.exs index 9a93f95..42d0469 100644 --- a/test/scion_tests/parallel+interrupt/test7b_test.exs +++ b/test/scion_tests/parallel_interrupt/test7b_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test7b" do +defmodule SCIONTest.ParallelInterrupt.Test7bTest do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test8_test.exs b/test/scion_tests/parallel_interrupt/test8_test.exs similarity index 95% rename from test/scion_tests/parallel+interrupt/test8_test.exs rename to test/scion_tests/parallel_interrupt/test8_test.exs index 05f69e4..00eaf59 100644 --- a/test/scion_tests/parallel+interrupt/test8_test.exs +++ b/test/scion_tests/parallel_interrupt/test8_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test8" do +defmodule SCIONTest.ParallelInterrupt.Test8Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/parallel+interrupt/test9_test.exs b/test/scion_tests/parallel_interrupt/test9_test.exs similarity index 95% rename from test/scion_tests/parallel+interrupt/test9_test.exs rename to test/scion_tests/parallel_interrupt/test9_test.exs index 2c23a35..ffd4510 100644 --- a/test/scion_tests/parallel+interrupt/test9_test.exs +++ b/test/scion_tests/parallel_interrupt/test9_test.exs @@ -1,4 +1,4 @@ -defmodule :"Elixir.Test.StateChart.Scion.Parallel+interrupt.Test9" do +defmodule SCIONTest.ParallelInterrupt.Test9Test do use SC.Case @tag :scion @tag spec: "parallel+interrupt" diff --git a/test/scion_tests/script-src/script-0-0.js b/test/scion_tests/script_src/script_0_0.js similarity index 100% rename from test/scion_tests/script-src/script-0-0.js rename to test/scion_tests/script_src/script_0_0.js diff --git a/test/scion_tests/script-src/script-1-0.js b/test/scion_tests/script_src/script_1_0.js similarity index 100% rename from test/scion_tests/script-src/script-1-0.js rename to test/scion_tests/script_src/script_1_0.js diff --git a/test/scion_tests/script-src/script-1-1.js b/test/scion_tests/script_src/script_1_1.js similarity index 100% rename from test/scion_tests/script-src/script-1-1.js rename to test/scion_tests/script_src/script_1_1.js diff --git a/test/scion_tests/script-src/script-2-0.js b/test/scion_tests/script_src/script_2_0.js similarity index 100% rename from test/scion_tests/script-src/script-2-0.js rename to test/scion_tests/script_src/script_2_0.js diff --git a/test/scion_tests/script-src/script-2-1.js b/test/scion_tests/script_src/script_2_1.js similarity index 100% rename from test/scion_tests/script-src/script-2-1.js rename to test/scion_tests/script_src/script_2_1.js diff --git a/test/scion_tests/script-src/script-2-2.js b/test/scion_tests/script_src/script_2_2.js similarity index 100% rename from test/scion_tests/script-src/script-2-2.js rename to test/scion_tests/script_src/script_2_2.js diff --git a/test/scion_tests/script-src/script-2-3.js b/test/scion_tests/script_src/script_2_3.js similarity index 100% rename from test/scion_tests/script-src/script-2-3.js rename to test/scion_tests/script_src/script_2_3.js diff --git a/test/scion_tests/script-src/script-3-0.js b/test/scion_tests/script_src/script_3_0.js similarity index 100% rename from test/scion_tests/script-src/script-3-0.js rename to test/scion_tests/script_src/script_3_0.js diff --git a/test/scion_tests/script-src/test0_test.exs b/test/scion_tests/script_src/test0_test.exs similarity index 100% rename from test/scion_tests/script-src/test0_test.exs rename to test/scion_tests/script_src/test0_test.exs diff --git a/test/scion_tests/script-src/test1_test.exs b/test/scion_tests/script_src/test1_test.exs similarity index 100% rename from test/scion_tests/script-src/test1_test.exs rename to test/scion_tests/script_src/test1_test.exs diff --git a/test/scion_tests/script-src/test2_test.exs b/test/scion_tests/script_src/test2_test.exs similarity index 100% rename from test/scion_tests/script-src/test2_test.exs rename to test/scion_tests/script_src/test2_test.exs diff --git a/test/scion_tests/script-src/test3_test.exs b/test/scion_tests/script_src/test3_test.exs similarity index 100% rename from test/scion_tests/script-src/test3_test.exs rename to test/scion_tests/script_src/test3_test.exs diff --git a/test/scion_tests/scxml-prefix-event-name-matching/star0_test.exs b/test/scion_tests/scxml_prefix_event_name_matching/star0_test.exs similarity index 100% rename from test/scion_tests/scxml-prefix-event-name-matching/star0_test.exs rename to test/scion_tests/scxml_prefix_event_name_matching/star0_test.exs diff --git a/test/scion_tests/scxml-prefix-event-name-matching/test0_test.exs b/test/scion_tests/scxml_prefix_event_name_matching/test0_test.exs similarity index 100% rename from test/scion_tests/scxml-prefix-event-name-matching/test0_test.exs rename to test/scion_tests/scxml_prefix_event_name_matching/test0_test.exs diff --git a/test/scion_tests/scxml-prefix-event-name-matching/test1_test.exs b/test/scion_tests/scxml_prefix_event_name_matching/test1_test.exs similarity index 100% rename from test/scion_tests/scxml-prefix-event-name-matching/test1_test.exs rename to test/scion_tests/scxml_prefix_event_name_matching/test1_test.exs diff --git a/test/scion_tests/send-data/send1_test.exs b/test/scion_tests/send_data/send1_test.exs similarity index 100% rename from test/scion_tests/send-data/send1_test.exs rename to test/scion_tests/send_data/send1_test.exs diff --git a/test/scion_tests/send-idlocation/test0_test.exs b/test/scion_tests/send_idlocation/test0_test.exs similarity index 100% rename from test/scion_tests/send-idlocation/test0_test.exs rename to test/scion_tests/send_idlocation/test0_test.exs diff --git a/test/scion_tests/send-internal/test0_test.exs b/test/scion_tests/send_internal/test0_test.exs similarity index 100% rename from test/scion_tests/send-internal/test0_test.exs rename to test/scion_tests/send_internal/test0_test.exs diff --git a/test/scion_tests/targetless-transition/test0_test.exs b/test/scion_tests/targetless_transition/test0_test.exs similarity index 100% rename from test/scion_tests/targetless-transition/test0_test.exs rename to test/scion_tests/targetless_transition/test0_test.exs diff --git a/test/scion_tests/targetless-transition/test1_test.exs b/test/scion_tests/targetless_transition/test1_test.exs similarity index 100% rename from test/scion_tests/targetless-transition/test1_test.exs rename to test/scion_tests/targetless_transition/test1_test.exs diff --git a/test/scion_tests/targetless-transition/test2_test.exs b/test/scion_tests/targetless_transition/test2_test.exs similarity index 100% rename from test/scion_tests/targetless-transition/test2_test.exs rename to test/scion_tests/targetless_transition/test2_test.exs diff --git a/test/scion_tests/targetless-transition/test3_test.exs b/test/scion_tests/targetless_transition/test3_test.exs similarity index 100% rename from test/scion_tests/targetless-transition/test3_test.exs rename to test/scion_tests/targetless_transition/test3_test.exs diff --git a/test/support/sc_case.ex b/test/support/sc_case.ex index d0605ca..d6a70af 100644 --- a/test/support/sc_case.ex +++ b/test/support/sc_case.ex @@ -4,11 +4,14 @@ defmodule SC.Case do Provides utilities for testing state machine behavior against both SCION and W3C test suites using the SC.Interpreter. + + Now includes feature detection to fail tests that depend on unsupported + SCXML features, preventing false positive test results. """ use ExUnit.CaseTemplate, async: true - alias SC.{Event, Interpreter, Parser.SCXML} + alias SC.{Event, FeatureDetector, Interpreter, Parser.SCXML} using do quote do @@ -23,10 +26,39 @@ defmodule SC.Case do - description: Test description (for debugging) - expected_initial_config: List of expected initial active state IDs - events: List of {event_map, expected_states} tuples + + Now includes feature detection - will fail with descriptive error if the test + depends on unsupported SCXML features, preventing false positive results. """ @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 + def test_scxml(xml, description, expected_initial_config, events) do + # Detect features used in the SCXML document + detected_features = FeatureDetector.detect_features(xml) + + # Validate that all detected features are supported + 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) + + {:error, unsupported_features} -> + # Test uses unsupported features - fail with descriptive message + unsupported_list = unsupported_features |> Enum.sort() |> Enum.join(", ") + + ExUnit.Assertions.flunk(""" + Test depends on unsupported SCXML features: #{unsupported_list} + + This test cannot pass until these features are implemented in the SC library. + Detected features: #{detected_features |> Enum.sort() |> Enum.join(", ")} + + To see which features are supported, check SC.FeatureDetector.feature_registry/0 + Test description: #{description} + """) + end + end + + defp run_scxml_test(xml, _description, expected_initial_config, events) do # Parse and initialize the state chart {:ok, document} = SCXML.parse(xml) {:ok, state_chart} = Interpreter.initialize(document)