diff --git a/.formatter.exs b/.formatter.exs index 4fa10204..c76a9a93 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -10,9 +10,8 @@ form: :*, suppress_if: :*, depth: :*, - called_from: :*, - global_call: :*, - local_call: :*, + calling_fn: :*, + called_fn: :*, should_be_present: :*, type: :* ] diff --git a/lib/elixir_analyzer/exercise_test.ex b/lib/elixir_analyzer/exercise_test.ex index 26ab329c..ee6738fb 100644 --- a/lib/elixir_analyzer/exercise_test.ex +++ b/lib/elixir_analyzer/exercise_test.ex @@ -1,14 +1,17 @@ defmodule ElixirAnalyzer.ExerciseTest do @moduledoc false + alias ElixirAnalyzer.ExerciseTest.Feature.Compiler, as: FeatureCompiler + alias ElixirAnalyzer.ExerciseTest.AssertCall.Compiler, as: AssertCallCompiler + alias ElixirAnalyzer.Submission - alias ElixirAnalyzer.QuoteUtil alias ElixirAnalyzer.Constants @doc false defmacro __using__(_opts) do quote do use ElixirAnalyzer.ExerciseTest.Feature + use ElixirAnalyzer.ExerciseTest.AssertCall import unquote(__MODULE__) @before_compile unquote(__MODULE__) @@ -21,22 +24,28 @@ defmodule ElixirAnalyzer.ExerciseTest do defmacro __before_compile__(env) do feature_test_data = Macro.escape(Module.get_attribute(env.module, :feature_tests)) + assert_call_data = Module.get_attribute(env.module, :assert_call_tests) # ast placeholder for the submission code ast code_ast = quote do: code_ast # compile each feature to a test - feature_tests = Enum.map(feature_test_data, &compile_feature(&1, code_ast)) + feature_tests = Enum.map(feature_test_data, &FeatureCompiler.compile(&1, code_ast)) + + # compile each assert_call to a test + assert_call_tests = Enum.map(assert_call_data, &AssertCallCompiler.compile(&1, code_ast)) quote do @spec analyze(Submission.t(), String.t()) :: Submission.t() def analyze(submission = %Submission{}, code_as_string) do case Code.string_to_quoted(code_as_string) do {:ok, code_ast} -> - feature_results = unquote(feature_tests) - feature_results = filter_suppressed_results(feature_results) + feature_results = unquote(feature_tests) |> filter_suppressed_results() + assert_call_results = unquote(assert_call_tests) - append_test_comments(submission, feature_results) + submission + |> append_test_comments(feature_results) + |> append_test_comments(assert_call_results) {:error, e} -> append_analysis_failure(submission, e) @@ -61,8 +70,8 @@ defmodule ElixirAnalyzer.ExerciseTest do end) end - defp append_test_comments(submission = %Submission{}, feature_results) do - Enum.reduce(feature_results, submission, fn + defp append_test_comments(submission = %Submission{}, results) do + Enum.reduce(results, submission, fn {:skip, _description}, submission -> submission @@ -104,166 +113,4 @@ defmodule ElixirAnalyzer.ExerciseTest do end end end - - def compile_feature({feature_data, feature_forms}, code_ast) do - name = Keyword.fetch!(feature_data, :name) - comment = Keyword.fetch!(feature_data, :comment) - status = Keyword.get(feature_data, :status, :test) - type = Keyword.get(feature_data, :type, :informative) - find_type = Keyword.get(feature_data, :find, :all) - find_at_depth = Keyword.get(feature_data, :depth, nil) - suppress_if = Keyword.get(feature_data, :suppress_if, false) - - form_expr = - feature_forms - |> Enum.map(&compile_form(&1, find_at_depth, code_ast)) - |> Enum.reduce(:start, &combine_compiled_forms(find_type, &1, &2)) - |> handle_combined_compiled_forms(find_type) - - test_description = - Macro.escape(%{ - name: name, - comment: comment, - status: status, - type: type, - suppress_if: suppress_if - }) - - case status do - :test -> - quote do - if unquote(form_expr) do - {:pass, unquote(test_description)} - else - {:fail, unquote(test_description)} - end - end - - :skip -> - quote do - {:skip, unquote(test_description)} - end - end - end - - def compile_form(form, find_at_depth, code_ast) do - find_ast_string = Keyword.fetch!(form, :find_ast_string) - block_params = Keyword.fetch!(form, :block_params) - - find_ast = Code.string_to_quoted!(find_ast_string) - - # create the walk function, determined if the form to find - # is multiple first level entries in a code block - walk_fn = get_compile_form_prewalk_fn(block_params, find_ast, find_at_depth) - - quote do - (fn ast -> - {_, result} = QuoteUtil.prewalk(ast, false, unquote(walk_fn)) - - result - end).(unquote(code_ast)) - end - end - - defp get_compile_form_prewalk_fn(block_params, find_ast, find_at_depth) - when is_integer(block_params) do - quote do - fn - # If the node matches a block, then chunk the block contents - # to the size of the form block, then pattern match on each chunk - # return true if a match - {:__block__, _, params} = node, false, depth -> - finding_depth = unquote(find_at_depth) in [nil, depth] - - cond do - finding_depth and is_list(params) -> - found = - params - |> Enum.chunk_every(unquote(block_params), 1, :discard) - |> Enum.reduce(false, fn - chunk, false -> - match?(unquote(find_ast), chunk) - - _chunk, true -> - true - end) - - {node, found} - - true -> - {node, false} - end - - # If not a block, then we know it can't match, so pass - # along the accumulator - node, val, _depth -> - {node, val} - end - end - end - - defp get_compile_form_prewalk_fn(false, find_ast, find_at_depth) do - quote do - fn - node, false, depth -> - finding_depth = unquote(find_at_depth) in [nil, depth] - - cond do - finding_depth -> - {node, match?(unquote(find_ast), node)} - - true -> - {node, false} - end - - node, true, _depth -> - {node, true} - end - end - end - - def combine_compiled_forms(:any, form, :start) do - # start the disjunction with false - combine_compiled_forms(:any, form, quote(do: false)) - end - - def combine_compiled_forms(:any, form, expr) do - quote do: unquote(form) or unquote(expr) - end - - def combine_compiled_forms(:all, form, :start) do - # start the conjunction with true - combine_compiled_forms(:all, form, quote(do: true)) - end - - def combine_compiled_forms(:all, form, expr) do - quote do: unquote(form) and unquote(expr) - end - - def combine_compiled_forms(type, form, :start) when type in [:one, :none] do - combine_compiled_forms(type, form, quote(do: 0)) - end - - def combine_compiled_forms(type, form, expr) when type in [:one, :none] do - quote do - value = - (fn form_val -> - if form_val do - 1 - else - 0 - end - end).(unquote(form)) - - value + unquote(expr) - end - end - - def handle_combined_compiled_forms(combined_expr, find_type) do - case find_type do - type when type in [:all, :any] -> combined_expr - type when type in [:none] -> quote do: unquote(combined_expr) == 0 - type when type in [:one] -> quote do: unquote(combined_expr) == 1 - end - end end diff --git a/lib/elixir_analyzer/exercise_test/assert_call.ex b/lib/elixir_analyzer/exercise_test/assert_call.ex new file mode 100644 index 00000000..5b22b0d0 --- /dev/null +++ b/lib/elixir_analyzer/exercise_test/assert_call.ex @@ -0,0 +1,137 @@ +defmodule ElixirAnalyzer.ExerciseTest.AssertCall do + @moduledoc false + + @type function_signature() :: {list(atom()), atom()} | {nil, atom()} + + @doc false + defmacro __using__(_opts) do + quote do + import unquote(__MODULE__) + + @assert_call_tests [] + end + end + + @doc """ + Defines a macro which allows a test case to be specified which looks for a + function call inside of a specific function. + This macro then collates the block into a map structure resembling: + test_data = %{ + description: description, + calling_function_module: %FunctionSignature{} + called_function_module: %FunctionSignature{} + should_call: true + type: :actionable + comment: "message" + } + """ + defmacro assert_call(description, do: block) do + parse(description, block, true) + end + + defmacro assert_no_call(description, do: block) do + parse(description, block, false) + end + + defp parse(description, block, should_call) do + test_data = + block + |> walk_assert_call_block() + |> Map.put(:description, description) + |> Map.put(:should_call, should_call) + |> Map.put_new(:type, :informational) + |> Map.put_new(:calling_fn, nil) + + unless Map.has_key?(test_data, :comment) do + raise "Comment must be defined for each assert_call test" + end + + quote do + @assert_call_tests [ + unquote(Macro.escape(test_data)) | @assert_call_tests + ] + end + end + + defp walk_assert_call_block(block, test_data \\ %{}) do + {_, test_data} = Macro.prewalk(block, test_data, &do_walk_assert_call_block/2) + test_data + end + + defp do_walk_assert_call_block({:calling_fn, _, [signature]} = node, test_data) do + formatted_signature = do_calling_fn(signature) + {node, Map.put(test_data, :calling_fn, formatted_signature)} + end + + defp do_walk_assert_call_block({:called_fn, _, [signature]} = node, test_data) do + formatted_signature = do_called_fn(signature) + {node, Map.put(test_data, :called_fn, formatted_signature)} + end + + defp do_walk_assert_call_block({:comment, _, [comment]} = node, test_data) + when is_binary(comment) do + {node, Map.put(test_data, :comment, comment)} + end + + defp do_walk_assert_call_block({:type, _, [type]} = node, test_data) + when type in ~w[essential actionable informational celebratory]a do + {node, Map.put(test_data, :type, type)} + end + + defp do_walk_assert_call_block(node, test_data) do + {node, test_data} + end + + @spec do_calling_fn(Keyword.t()) :: function_signature() + defp do_calling_fn(function_signature) do + case validate_signature(function_signature) do + {nil, _} -> + raise ArgumentError, "calling function signature requires :module to be an atom" + + signature -> + signature + end + end + + @spec do_called_fn(Keyword.t()) :: function_signature() + defp do_called_fn(function_signature) do + validate_signature(function_signature) + end + + @spec validate_signature(Keyword.t()) :: function_signature() + defp validate_signature(function_signature) do + function_signature = + function_signature + |> validate_module() + |> validate_name() + + {function_signature[:module], function_signature[:name]} + end + + defp validate_module(signature) do + module = + case signature[:module] do + nil -> + nil + + {:__aliases__, _, module} -> + module + + _ -> + raise ArgumentError, + "calling function signature requires :module to be nil or a module atom" + end + + Keyword.put(signature, :module, module) + end + + defp validate_name(signature) do + module = + case signature[:name] do + name when is_atom(name) -> name + _ -> raise ArgumentError, "calling function signature requires :name to be an atom" + end + + Keyword.put(signature, :name, module) + end +end diff --git a/lib/elixir_analyzer/exercise_test/assert_call/compiler.ex b/lib/elixir_analyzer/exercise_test/assert_call/compiler.ex new file mode 100644 index 00000000..ea4e7901 --- /dev/null +++ b/lib/elixir_analyzer/exercise_test/assert_call/compiler.ex @@ -0,0 +1,188 @@ +defmodule ElixirAnalyzer.ExerciseTest.AssertCall.Compiler do + @moduledoc """ + Provides the logic of the analyzer function `assert_call` + + When transformed at compile-time by `use ElixirAnalyzer.ExerciseTest`, this will place an expression inside + of an if statement which then returns :pass or :fail as required by `ElixirAnalyzer.ExerciseTest.analyze/4`. + """ + + alias ElixirAnalyzer.ExerciseTest.AssertCall + + def compile(assert_call_data, code_ast) do + name = assert_call_data.description + called_fn = Macro.escape(assert_call_data.called_fn) + calling_fn = Macro.escape(assert_call_data.calling_fn) + comment = assert_call_data.comment + should_call = assert_call_data.should_call + type = assert_call_data.type + + test_description = + Macro.escape(%{ + name: name, + comment: comment, + type: type + }) + + assert_result = assert_expr(code_ast, should_call, called_fn, calling_fn) + + quote do + if unquote(assert_result) do + {:pass, unquote(test_description)} + else + {:fail, unquote(test_description)} + end + end + end + + defp assert_expr(code_ast, should_call, called_fn, calling_fn) do + quote do + (fn + ast, true -> + unquote(__MODULE__).assert(ast, unquote(called_fn), unquote(calling_fn)) + + ast, false -> + not unquote(__MODULE__).assert(ast, unquote(called_fn), unquote(calling_fn)) + end).(unquote(code_ast), unquote(should_call)) + end + end + + def assert(ast, called_fn, calling_fn) do + acc = %{ + in_function_def: nil, + found_called: false, + called_fn: called_fn, + calling_fn: calling_fn + } + + ast + |> Macro.traverse(acc, &annotate/2, &annotate_and_find/2) + |> handle_traverse_result() + end + + @doc """ + Handle the final result from the assert function + """ + @spec handle_traverse_result({any, map()}) :: boolean + def handle_traverse_result({_, %{found_called: found}}), do: found + + @doc """ + When pre-order traversing, annotate the accumulator that we are now inside of a function definition + if it matches the calling_fn function signature + """ + @spec annotate(Macro.t(), map()) :: {Macro.t(), map()} + def annotate(node, acc) do + function_def? = function_def?(node) + name = extract_function_name(node) + + case {function_def?, name} do + {true, name} -> + {node, %{acc | in_function_def: name}} + + _ -> + {node, acc} + end + end + + @doc """ + When post-order traversing, annotate the accumulator that we are now leaving a function definition + """ + @spec annotate_and_find(Macro.t(), map()) :: {Macro.t(), map()} + def annotate_and_find(node, acc) do + {node, acc} = find(node, acc) + + if function_def?(node) do + {node, %{acc | in_function_def: nil}} + else + {node, acc} + end + end + + @doc """ + While traversing the AST, compare a node to check if it is a function call matching the called_fn + """ + @spec find(Macro.t(), map()) :: {Macro.t(), map()} + def find(node, %{found_called: true} = acc), do: {node, acc} + + def find( + node, + %{ + called_fn: called_fn, + calling_fn: calling_fn, + in_function_def: name + } = acc + ) do + match_called_fn? = + matching_function_call?(node, called_fn) and not in_function?(name, called_fn) + + match_calling_fn? = in_function?(name, calling_fn) or is_nil(calling_fn) + + if match_called_fn? and match_calling_fn? do + {node, %{acc | found_called: true}} + else + {node, acc} + end + end + + @doc """ + compare a node to the function_signature, looking for a match for a called function + """ + @spec matching_function_call?(Macro.t(), nil | AssertCall.function_signature()) :: boolean() + def matching_function_call?(_node, nil), do: false + + def matching_function_call?( + {{:., _, [{:__aliases__, _, module_path}, name]}, _, _args}, + {module_path, name} + ) do + true + end + + def matching_function_call?( + {name, _, _args}, + {_, name} + ) do + true + end + + def matching_function_call?(_, _), do: false + + @doc """ + compare a node to the function_signature, looking for a match for a called function + """ + @spec matching_function_def?(Macro.t(), AssertCall.function_signature()) :: boolean() + def matching_function_def?(_node, nil), do: false + + def matching_function_def?( + {def_type, _, [{name, _, _args}, [do: {:__block__, _, [_ | _]}]]}, + {_module_path, name} + ) + when def_type in ~w[def defp]a do + true + end + + def matching_function_def?(_, _), do: false + + @doc """ + node is a function definition + """ + def function_def?({def_type, _, [{name, _, _}, [do: _]]}) + when is_atom(name) and def_type in ~w[def defp]a do + true + end + + def function_def?(_node), do: false + + @doc """ + get the name of a function from a function definition node + """ + def extract_function_name({def_type, _, [{name, _, _}, [do: _]]}) + when is_atom(name) and def_type in ~w[def defp]a, + do: name + + def extract_function_name(_), do: nil + + @doc """ + compare the name of the function to the function signature, if they match return true + """ + def in_function?(name, {_module_path, name}), do: true + def in_function?(_, _), do: false +end diff --git a/lib/elixir_analyzer/exercise_test/assert_call/syntax_error.ex b/lib/elixir_analyzer/exercise_test/assert_call/syntax_error.ex new file mode 100644 index 00000000..7c49c306 --- /dev/null +++ b/lib/elixir_analyzer/exercise_test/assert_call/syntax_error.ex @@ -0,0 +1,3 @@ +defmodule ElixirAnalyzer.ExerciseTest.AssertCall.SyntaxError do + defexception message: "syntax error within assert_call test" +end diff --git a/lib/elixir_analyzer/exercise_test/ensure_call/syntax_error.ex b/lib/elixir_analyzer/exercise_test/ensure_call/syntax_error.ex deleted file mode 100644 index 0f5f8b03..00000000 --- a/lib/elixir_analyzer/exercise_test/ensure_call/syntax_error.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule ElixirAnalyzer.ExerciseTest.EnsureCall.SyntaxError do - defexception message: "syntax error within ensure_call test" -end diff --git a/lib/elixir_analyzer/exercise_test/feature/compiler.ex b/lib/elixir_analyzer/exercise_test/feature/compiler.ex new file mode 100644 index 00000000..e0c23aba --- /dev/null +++ b/lib/elixir_analyzer/exercise_test/feature/compiler.ex @@ -0,0 +1,167 @@ +defmodule ElixirAnalyzer.ExerciseTest.Feature.Compiler do + @moduledoc false + + alias ElixirAnalyzer.QuoteUtil + + def compile({feature_data, feature_forms}, code_ast) do + name = Keyword.fetch!(feature_data, :name) + comment = Keyword.fetch!(feature_data, :comment) + status = Keyword.get(feature_data, :status, :test) + type = Keyword.get(feature_data, :type, :informative) + find_type = Keyword.get(feature_data, :find, :all) + find_at_depth = Keyword.get(feature_data, :depth, nil) + suppress_if = Keyword.get(feature_data, :suppress_if, false) + + form_expr = + feature_forms + |> Enum.map(&compile_form(&1, find_at_depth, code_ast)) + |> Enum.reduce(:start, &combine_compiled_forms(find_type, &1, &2)) + |> handle_combined_compiled_forms(find_type) + + test_description = + Macro.escape(%{ + name: name, + comment: comment, + status: status, + type: type, + suppress_if: suppress_if + }) + + case status do + :test -> + quote do + if unquote(form_expr) do + {:pass, unquote(test_description)} + else + {:fail, unquote(test_description)} + end + end + + :skip -> + quote do + {:skip, unquote(test_description)} + end + end + end + + def compile_form(form, find_at_depth, code_ast) do + find_ast_string = Keyword.fetch!(form, :find_ast_string) + block_params = Keyword.fetch!(form, :block_params) + + find_ast = Code.string_to_quoted!(find_ast_string) + + # create the walk function, determined if the form to find + # is multiple first level entries in a code block + walk_fn = get_compile_form_prewalk_fn(block_params, find_ast, find_at_depth) + + quote do + (fn ast -> + {_, result} = QuoteUtil.prewalk(ast, false, unquote(walk_fn)) + + result + end).(unquote(code_ast)) + end + end + + defp get_compile_form_prewalk_fn(block_params, find_ast, find_at_depth) + when is_integer(block_params) do + quote do + fn + # If the node matches a block, then chunk the block contents + # to the size of the form block, then pattern match on each chunk + # return true if a match + {:__block__, _, params} = node, false, depth -> + finding_depth = unquote(find_at_depth) in [nil, depth] + + cond do + finding_depth and is_list(params) -> + found = + params + |> Enum.chunk_every(unquote(block_params), 1, :discard) + |> Enum.reduce(false, fn + chunk, false -> + match?(unquote(find_ast), chunk) + + _chunk, true -> + true + end) + + {node, found} + + true -> + {node, false} + end + + # If not a block, then we know it can't match, so pass + # along the accumulator + node, val, _depth -> + {node, val} + end + end + end + + defp get_compile_form_prewalk_fn(false, find_ast, find_at_depth) do + quote do + fn + node, false, depth -> + finding_depth = unquote(find_at_depth) in [nil, depth] + + cond do + finding_depth -> + {node, match?(unquote(find_ast), node)} + + true -> + {node, false} + end + + node, true, _depth -> + {node, true} + end + end + end + + def combine_compiled_forms(:any, form, :start) do + # start the disjunction with false + combine_compiled_forms(:any, form, quote(do: false)) + end + + def combine_compiled_forms(:any, form, expr) do + quote do: unquote(form) or unquote(expr) + end + + def combine_compiled_forms(:all, form, :start) do + # start the conjunction with true + combine_compiled_forms(:all, form, quote(do: true)) + end + + def combine_compiled_forms(:all, form, expr) do + quote do: unquote(form) and unquote(expr) + end + + def combine_compiled_forms(type, form, :start) when type in [:one, :none] do + combine_compiled_forms(type, form, quote(do: 0)) + end + + def combine_compiled_forms(type, form, expr) when type in [:one, :none] do + quote do + value = + (fn form_val -> + if form_val do + 1 + else + 0 + end + end).(unquote(form)) + + value + unquote(expr) + end + end + + def handle_combined_compiled_forms(combined_expr, find_type) do + case find_type do + type when type in [:all, :any] -> combined_expr + type when type in [:none] -> quote do: unquote(combined_expr) == 0 + type when type in [:one] -> quote do: unquote(combined_expr) == 1 + end + end +end diff --git a/lib/function_signature.ex b/lib/function_signature.ex deleted file mode 100644 index bd56006e..00000000 --- a/lib/function_signature.ex +++ /dev/null @@ -1,53 +0,0 @@ -defmodule FunctionSignature do - @enforce_keys [:global, :name, :arity] - defstruct [:module_path, :global, :name, :arity] - - defmodule IncompleteFunctionSignatureError do - defexception message: "function signature incomplete" - end - - defimpl String.Chars do - def to_string(%{global: true} = signature) do - module = - signature.module_path - |> Enum.join(".") - - "&#{module}.#{signature.name}/#{signature.arity}" - end - - def to_string(%{global: false} = signature) do - "&#{signature.name}/#{signature.arity}" - end - end - - def convert_to_local(%__MODULE__{} = signature) do - %{signature | global: false, module_path: nil} - end - - def parse(ast) do - case do_parse(ast) do - {:global, module_path, name, arity} -> - %__MODULE__{global: true, module_path: module_path, name: name, arity: arity} - - {:local, _, name, arity} -> - %__MODULE__{global: false, name: name, arity: arity} - - _ -> - raise IncompleteFunctionSignatureError - end - end - - defp do_parse( - {:&, _, [{:/, _, [{{:., _, [{:__aliases__, _, module_path}, name]}, _, _}, arity]}]} - ) do - {:global, module_path, name, arity} - end - - defp do_parse({:&, _, [{:/, _, [{name, _, Elixir}, arity]}]}) do - {:local, nil, name, arity} - end - - defp do_parse(_) do - :error - end -end diff --git a/test/elixir_analyzer/exercise_test/assert_call_test.exs b/test/elixir_analyzer/exercise_test/assert_call_test.exs new file mode 100644 index 00000000..de343780 --- /dev/null +++ b/test/elixir_analyzer/exercise_test/assert_call_test.exs @@ -0,0 +1,115 @@ +defmodule ElixirAnalyzer.ExerciseTest.AssertCallTest do + use ElixirAnalyzer.ExerciseTestCase, + exercise_test_module: ElixirAnalyzer.Support.AnalyzerVerification.AssertCall + + test_exercise_analysis "perfect solution", + comments: [] do + defmodule AssertCallVerification do + def function() do + result = helper() + IO.puts(result) + + private_helper() |> IO.puts() + end + + def helper do + :helped + end + + defp private_helper do + :privately_helped + end + end + end + + test_exercise_analysis "missing local call from anywhere in solution", + comments: [ + "didn't find a local call to helper/0", + "didn't find a local call to helper/0 within function/0" + ] do + defmodule AssertCallVerification do + def function() do + private_helper() |> IO.puts() + end + + def helper do + :helped + end + + defp private_helper do + :privately_helped + end + end + end + + test_exercise_analysis "missing local call from specific function solution", + comments: [ + "didn't find a local call to helper/0 within function/0", + "didn't find a local call to private_helper/0 within function/0" + ] do + defmodule AssertCallVerification do + def function() do + other() + IO.puts("1") + end + + def other() do + result = helper() + private_helper() |> IO.puts() + end + + def helper do + :helped + end + + defp private_helper do + :privately_helped + end + end + end + + test_exercise_analysis "missing call to IO.puts/1 in solution", + comments: [ + "didn't find a call to IO.puts/1 anywhere in solution", + "didn't find a call to IO.puts/1 in function/0" + ] do + defmodule AssertCallVerification do + def function() do + result = helper() + private_helper() + end + + def helper do + :helped + end + + defp private_helper do + :privately_helped + end + end + end + + test_exercise_analysis "missing call to IO.puts/1 in function/0 solution", + comments: [ + "didn't find a call to IO.puts/1 in function/0" + ] do + defmodule AssertCallVerification do + def function() do + result = helper() + private_helper() |> other() + end + + def other(x) do + IO.puts(x) + end + + def helper do + :helped + end + + defp private_helper do + :privately_helped + end + end + end +end diff --git a/test/elixir_analyzer/exercise_test/assert_not_call_test.exs b/test/elixir_analyzer/exercise_test/assert_not_call_test.exs new file mode 100644 index 00000000..f80846c9 --- /dev/null +++ b/test/elixir_analyzer/exercise_test/assert_not_call_test.exs @@ -0,0 +1,99 @@ +defmodule ElixirAnalyzer.ExerciseTest.AssertNoCallTest do + use ElixirAnalyzer.ExerciseTestCase, + exercise_test_module: ElixirAnalyzer.Support.AnalyzerVerification.AssertNoCall + + test_exercise_analysis "perfect solution", + comments: [] do + defmodule AssertNoCallVerification do + def function() do + IO.puts("string") + private_helper() + end + + def helper do + :helped + end + + defp private_helper do + :privately_helped + end + end + end + + test_exercise_analysis "found a local call to helper function", + comments: ["found a local call to helper/0"] do + defmodule AssertNoCallVerification do + def function() do + helper() |> IO.puts() + private_helper() + end + + def helper do + :helped + end + + defp private_helper do + :privately_helped + end + end + end + + test_exercise_analysis "found a local call to from specific function", + comments: [ + "found a local call to helper/0", + "found a local call to private_helper/0 from helper/0" + ] do + defmodule AssertNoCallVerification do + def function() do + helper() |> IO.puts() + end + + def helper do + private_helper() + :helped + end + + defp private_helper do + :privately_helped + end + end + end + + test_exercise_analysis "found a call to other module function anywhere", + comments: [ + "found a call to Enum.map in solution" + ] do + defmodule AssertNoCallVerification do + def function() do + Enum.map([], fn x -> x + 1 end) + end + + def helper do + :helped + end + + defp private_helper do + :privately_helped + end + end + end + + test_exercise_analysis "found a call to other module function in specific function", + comments: [ + "found a call to Atom.to_string/1 in helper/0 function in solution" + ] do + defmodule AssertNoCallVerification do + def function() do + "something" + end + + def helper do + :helped |> Atom.to_string() + end + + defp private_helper do + :privately_helped + end + end + end +end diff --git a/test/support/analyzer_verification/assert_call.ex b/test/support/analyzer_verification/assert_call.ex new file mode 100644 index 00000000..eba53738 --- /dev/null +++ b/test/support/analyzer_verification/assert_call.ex @@ -0,0 +1,47 @@ +defmodule ElixirAnalyzer.Support.AnalyzerVerification.AssertCall do + @dialyzer generated: true + @moduledoc """ + This is an exercise analyzer extension module to test the assert_call macro + """ + + use ElixirAnalyzer.ExerciseTest + + assert_call "finds call to local helper function" do + type :informational + called_fn name: :helper + comment "didn't find a local call to helper/0" + end + + assert_call "finds call to private local helper function" do + type :informational + called_fn name: :private_helper + comment "didn't find a local call to private_helper/0" + end + + assert_call "finds call to local helper function within function" do + type :informational + calling_fn module: AssertCallVerification, name: :function + called_fn name: :helper + comment "didn't find a local call to helper/0 within function/0" + end + + assert_call "finds call to private local helper function within function" do + type :informational + calling_fn module: AssertCallVerification, name: :function + called_fn name: :private_helper + comment "didn't find a local call to private_helper/0 within function/0" + end + + assert_call "finds call to IO.puts anywhere" do + type :informational + called_fn module: IO, name: :puts + comment "didn't find a call to IO.puts/1 anywhere in solution" + end + + assert_call "finds call to IO.puts in function/0" do + type :informational + called_fn module: IO, name: :puts + calling_fn module: AssertCallVerification, name: :function + comment "didn't find a call to IO.puts/1 in function/0" + end +end diff --git a/test/support/analyzer_verification/assert_no_call.ex b/test/support/analyzer_verification/assert_no_call.ex new file mode 100644 index 00000000..96e7f037 --- /dev/null +++ b/test/support/analyzer_verification/assert_no_call.ex @@ -0,0 +1,34 @@ +defmodule ElixirAnalyzer.Support.AnalyzerVerification.AssertNoCall do + @dialyzer generated: true + @moduledoc """ + This is an exercise analyzer extension module to test the assert_call macro + """ + + use ElixirAnalyzer.ExerciseTest + + assert_no_call "does not call local function" do + type :informational + called_fn name: :helper + comment "found a local call to helper/0" + end + + assert_no_call "does not call local function from specific function" do + type :informational + calling_fn module: AssertNoCallVerification, name: :helper + called_fn name: :private_helper + comment "found a local call to private_helper/0 from helper/0" + end + + assert_no_call "does not call other Enum.map function" do + type :informational + called_fn module: Enum, name: :map + comment "found a call to Enum.map in solution" + end + + assert_no_call "does not call other Atom.to_string in specific function" do + type :informational + calling_fn module: AssertNoCallVerification, name: :helper + called_fn module: Atom, name: :to_string + comment "found a call to Atom.to_string/1 in helper/0 function in solution" + end +end