diff --git a/.github/workflows/elixir_test_external.yml b/.github/workflows/elixir_test_external.yml index b4229ee2..a6926ef9 100644 --- a/.github/workflows/elixir_test_external.yml +++ b/.github/workflows/elixir_test_external.yml @@ -10,11 +10,25 @@ jobs: image: hexpm/elixir:1.12.1-erlang-24.0.1-ubuntu-focal-20210325 steps: - - uses: actions/checkout@v1 + - name: Install git + run: | + apt-get update + apt-get install -y git + + - name: Checkout repository and submodules + uses: actions/checkout@v2 + with: + submodules: recursive + + - name: Update submodules + run: | + git submodule update --recursive --remote + - name: Install Dependencies run: | mix local.rebar --force mix local.hex --force mix deps.get + - name: Run Tests run: mix test --only external diff --git a/elixir b/elixir index 4e0853da..284316cd 160000 --- a/elixir +++ b/elixir @@ -1 +1 @@ -Subproject commit 4e0853da74ecec55d07240acb78d53189f1c133b +Subproject commit 284316cd4ef0ba9222c878f10c1c119ae6b18437 diff --git a/lib/elixir_analyzer.ex b/lib/elixir_analyzer.ex index 401caee5..2428b77d 100644 --- a/lib/elixir_analyzer.ex +++ b/lib/elixir_analyzer.ex @@ -9,6 +9,7 @@ defmodule ElixirAnalyzer do alias ElixirAnalyzer.Constants alias ElixirAnalyzer.Submission alias ElixirAnalyzer.Comment + alias ElixirAnalyzer.Source import ElixirAnalyzer.Summary, only: [summary: 2] @@ -85,8 +86,6 @@ defmodule ElixirAnalyzer do defaults = [ {:exercise, exercise}, {:path, input_path}, - {:file, nil}, - {:module, nil}, {:output_path, output_path}, {:output_file, @output_file}, {:exercise_config, default_exercise_config()}, @@ -100,22 +99,28 @@ defmodule ElixirAnalyzer do # Do init work # -read config, create the initial Submission struct defp init(params) do - submission = %Submission{ + source = %Source{ path: params.path, - code_path: nil, - code_file: nil, + slug: params.exercise + } + + submission = %Submission{ + source: source, analysis_module: nil } try do Logger.debug("Getting the exercise config") exercise_config = params.exercise_config[params.exercise] - {code_path, code_file, exemplar_path, analysis_module} = do_init(params, exercise_config) + + {code_path, exercise_type, exemploid_path, analysis_module} = + do_init(params, exercise_config) Logger.debug("Initialization successful", path: params.path, code_path: code_path, - exemplar_path: exemplar_path, + exercise_type: exercise_type, + exemploid_path: exemploid_path, analysis_module: analysis_module ) @@ -128,11 +133,16 @@ defmodule ElixirAnalyzer do Logger.info("Exercise test suite '#{m}' found and loaded.") end + source = %{ + source + | code_path: code_path, + exercise_type: exercise_type, + exemploid_path: exemploid_path + } + %{ submission - | code_path: code_path, - code_file: code_file, - exemplar_path: exemplar_path, + | source: source, analysis_module: analysis_module } rescue @@ -159,69 +169,52 @@ defmodule ElixirAnalyzer do end end - # When file is nil, pull code params from config file - defp do_init(%{file: nil} = params, exercise_config) do + defp do_init(params, exercise_config) do meta_config = Path.join(params.path, @meta_config) |> File.read!() |> Jason.decode!() relative_code_path = meta_config["files"]["solution"] |> hd() - full_code_path = Path.join(params.path, relative_code_path) + code_path = Path.join(params.path, relative_code_path) - code_path = Path.dirname(full_code_path) - code_file = Path.basename(full_code_path) - - exemplar_path = - case meta_config["files"]["exemplar"] do - [path | _] -> Path.join(params.path, path) - _ -> nil + {exercise_type, exemploid_path} = + case meta_config["files"] do + %{"exemplar" => [path | _]} -> {:concept, Path.join(params.path, path)} + %{"example" => [path | _]} -> {:practice, Path.join(params.path, path)} end - {code_path, code_file, exemplar_path, + {code_path, exercise_type, exemploid_path, exercise_config[:analyzer_module] || ElixirAnalyzer.TestSuite.Default} end - # Else, use passed in params to analyze - defp do_init(params, _exercise_config) do - { - params.path, - params.file, - String.to_existing_atom("ElixirAnalyzer.ExerciseTest.#{params.module}") - } - end - # Check # - check if the file exists # - read in the code - # - check if there is an exemplar - # - read in the exemplar - # - parse the exemplar into an AST + # - check if there is an exemploid + # - read in the exemploid + # - parse the exemploid into an AST defp check(%Submission{halted: true} = submission, _params) do Logger.warning("Check not performed, halted previously") submission end - defp check(%Submission{} = submission, _params) do - with path_to_code <- Path.join(submission.code_path, submission.code_file), - :ok <- Logger.info("Attempting to read code file", code_file_path: path_to_code), - {:code_read, {:ok, code_str}} <- {:code_read, File.read(path_to_code)}, - :ok <- Logger.info("Code file read successfully"), - submission <- %{submission | code: code_str}, - :ok <- Logger.info("Check if exemplar exists", exemplar_path: submission.exemplar_path), - {:exemplar_exists, submission, exemplar_path} when not is_nil(exemplar_path) <- - {:exemplar_exists, submission, submission.exemplar_path}, - :ok <- - Logger.info("Exemplar file exists, attempting to read", exemplar_path: exemplar_path), - {:exemplar_read, submission, {:ok, exemplar_code}} <- - {:exemplar_read, submission, File.read(exemplar_path)}, - :ok <- Logger.info("Exemplar file read successfully, attempting to parse"), - {:exemplar_ast, submission, {:ok, exemplar_code}} <- - {:exemplar_ast, submission, Code.string_to_quoted(exemplar_code)}, - :ok <- Logger.info("Exemplar file parsed successfully"), - submission <- %{submission | exemplar_code: exemplar_code} do - submission + defp check(%Submission{source: source} = submission, _params) do + Logger.info("Attempting to read code file", code_file_path: source.code_path) + + with {:code_read, {:ok, code_string}} <- {:code_read, File.read(source.code_path)}, + source <- %{source | code_string: code_string}, + Logger.info("Code file read successfully"), + Logger.info("Attempting to read exemploid", exemploid_path: source.exemploid_path), + {:exemploid_read, _, {:ok, exemploid_string}} <- + {:exemploid_read, source, File.read(source.exemploid_path)}, + Logger.info("Exemploid file read successfully, attempting to parse"), + {:exemploid_ast, _, {:ok, exemploid_ast}} <- + {:exemploid_ast, source, Code.string_to_quoted(exemploid_string)} do + Logger.info("Exemploid file parsed successfully") + source = %{source | exemploid_string: exemploid_string, exemploid_ast: exemploid_ast} + %{submission | source: source} else {:code_read, {:error, reason}} -> Logger.warning("TestSuite halted: Code file not found. Reason: #{reason}", - path: submission.path, - file_name: submission.code_file + path: source.path, + code_path: source.code_path ) submission @@ -229,29 +222,25 @@ defmodule ElixirAnalyzer do |> Submission.append_comment(%Comment{ comment: Constants.general_file_not_found(), params: %{ - "file_name" => submission.code_file, - "path" => submission.path + "file_name" => Path.basename(source.code_path), + "path" => source.path }, type: :essential }) - {:exemplar_exists, submission, nil} -> - Logger.info("There is no exemplar file for this exercise") - submission - - {:exemplar_read, submission, {:error, reason}} -> - Logger.warning("Exemplar file not found. Reason: #{reason}", - exemplar_path: submission.exemplar_path + {:exemploid_read, source, {:error, reason}} -> + Logger.warning("Exemploid file not found. Reason: #{reason}", + exemploid_path: source.exemploid_path ) - submission + %{submission | source: source} - {:exemplar_ast, submission, {:error, reason}} -> - Logger.warning("Exemplar file could not be parsed. Reason: #{inspect(reason)}", - exemplar_code: submission.exemplar_code + {:exemploid_ast, source, {:error, reason}} -> + Logger.warning("Exemploid file could not be parsed. Reason: #{inspect(reason)}", + exemploid_path: source.exemploid_path ) - submission + %{submission | source: source} end end @@ -267,7 +256,7 @@ defmodule ElixirAnalyzer do submission = submission - |> submission.analysis_module.analyze(submission.code, submission.exemplar_code) + |> submission.analysis_module.analyze(submission.source) |> Submission.set_analyzed(true) Logger.info("Analyzing code complete") diff --git a/lib/elixir_analyzer/constants.ex b/lib/elixir_analyzer/constants.ex index a0b93646..fddd5725 100644 --- a/lib/elixir_analyzer/constants.ex +++ b/lib/elixir_analyzer/constants.ex @@ -35,6 +35,7 @@ defmodule ElixirAnalyzer.Constants do solution_no_integer_literal: "elixir.solution.no_integer_literal", solution_boilerplate_comment: "elixir.solution.boilerplate_comment", solution_todo_comment: "elixir.solution.todo_comment", + solution_private_helper_functions: "elixir.solution.private_helper_functions", # Concept exercises diff --git a/lib/elixir_analyzer/exercise_test.ex b/lib/elixir_analyzer/exercise_test.ex index 8b3035d6..2da8e5e0 100644 --- a/lib/elixir_analyzer/exercise_test.ex +++ b/lib/elixir_analyzer/exercise_test.ex @@ -9,6 +9,7 @@ defmodule ElixirAnalyzer.ExerciseTest do alias ElixirAnalyzer.Submission alias ElixirAnalyzer.Constants alias ElixirAnalyzer.Comment + alias ElixirAnalyzer.Source @doc false defmacro __using__(opts) do @@ -21,7 +22,7 @@ defmodule ElixirAnalyzer.ExerciseTest do import unquote(__MODULE__) @before_compile unquote(__MODULE__) - @dialyzer no_match: {:do_analyze, 4} + @dialyzer no_match: {:do_analyze, 2} end end @@ -38,7 +39,7 @@ defmodule ElixirAnalyzer.ExerciseTest do # placeholders for submission code code_ast = quote do: code_ast - code_as_string = quote do: code_as_string + code_string = quote do: code_string # compile each feature to a test feature_tests = Enum.map(feature_test_data, &FeatureCompiler.compile(&1, code_ast)) @@ -48,28 +49,34 @@ defmodule ElixirAnalyzer.ExerciseTest do # compile each check_source to a test check_source_tests = - Enum.map(check_source_data, &CheckSourceCompiler.compile(&1, code_as_string)) + Enum.map(check_source_data, &CheckSourceCompiler.compile(&1, code_string)) quote do - @spec analyze(Submission.t(), String.t(), nil | Macro.t()) :: Submission.t() - def analyze(%Submission{} = submission, code_as_string, exemplar_ast) do - case Code.string_to_quoted(code_as_string) do + @spec analyze(Submission.t(), Source.t()) :: Submission.t() + def analyze(%Submission{} = submission, %Source{code_string: code_string} = source) do + case Code.string_to_quoted(code_string) do {:ok, code_ast} -> - do_analyze(submission, code_ast, code_as_string, exemplar_ast) + source = %{source | code_ast: code_ast} + do_analyze(submission, source) {:error, e} -> append_analysis_failure(submission, e) end end - defp do_analyze(%Submission{} = submission, code_ast, code_as_string, exemplar_ast) - when is_binary(code_as_string) do + defp do_analyze( + %Submission{} = submission, + %Source{ + code_string: code_string, + code_ast: code_ast + } = source + ) do results = Enum.concat([ unquote(feature_tests), unquote(assert_call_tests), unquote(check_source_tests), - CommonChecks.run(code_ast, code_as_string, exemplar_ast) + CommonChecks.run(source) ]) |> filter_suppressed_results() diff --git a/lib/elixir_analyzer/exercise_test/common_checks.ex b/lib/elixir_analyzer/exercise_test/common_checks.ex index 68473ae9..f0ed43ac 100644 --- a/lib/elixir_analyzer/exercise_test/common_checks.ex +++ b/lib/elixir_analyzer/exercise_test/common_checks.ex @@ -12,10 +12,12 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks do BooleanFunctions, ExemplarComparison, Indentation, + PrivateHelperFunctions, Comments } alias ElixirAnalyzer.Comment + alias ElixirAnalyzer.Source # CommonChecks that use feature or assert_call should be called here defmacro __using__(_opts) do @@ -27,18 +29,25 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks do end end - @spec run(Macro.t(), String.t(), nil | Macro.t()) :: [{:pass | :fail | :skip, %Comment{}}] - def run(code_ast, code_as_string, exemplar_ast) when is_binary(code_as_string) do + @spec run(Source.t()) :: [{:pass | :fail | :skip, %Comment{}}] + def run(%Source{ + code_path: code_path, + code_ast: code_ast, + code_string: code_string, + exercise_type: type, + exemploid_ast: exemploid_ast + }) do [ FunctionNames.run(code_ast), VariableNames.run(code_ast), ModuleAttributeNames.run(code_ast), ModulePascalCase.run(code_ast), - CompilerWarnings.run(code_ast), + CompilerWarnings.run(code_path, code_ast), BooleanFunctions.run(code_ast), - ExemplarComparison.run(code_ast, exemplar_ast), - Indentation.run(code_ast, code_as_string), - Comments.run(code_ast, code_as_string) + ExemplarComparison.run(code_ast, type, exemploid_ast), + Indentation.run(code_ast, code_string), + PrivateHelperFunctions.run(code_ast, exemploid_ast), + Comments.run(code_ast, code_string) ] |> List.flatten() end diff --git a/lib/elixir_analyzer/exercise_test/common_checks/compiler_warnings.ex b/lib/elixir_analyzer/exercise_test/common_checks/compiler_warnings.ex index feb2e32b..1923b8fe 100644 --- a/lib/elixir_analyzer/exercise_test/common_checks/compiler_warnings.ex +++ b/lib/elixir_analyzer/exercise_test/common_checks/compiler_warnings.ex @@ -5,14 +5,14 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks.CompilerWarnings do alias ElixirAnalyzer.Constants alias ElixirAnalyzer.Comment - def run(code_ast) do + def run(code_path, code_ast) do import ExUnit.CaptureIO Application.put_env(:elixir, :ansi_enabled, false) warnings = capture_io(:stderr, fn -> try do - Code.compile_quoted(code_ast) + Code.compile_quoted(code_ast, Path.basename(code_path)) |> Enum.each(fn {module, _binary} -> :code.delete(module) :code.purge(module) diff --git a/lib/elixir_analyzer/exercise_test/common_checks/exemplar_comparison.ex b/lib/elixir_analyzer/exercise_test/common_checks/exemplar_comparison.ex index f94a6451..2d5cd585 100644 --- a/lib/elixir_analyzer/exercise_test/common_checks/exemplar_comparison.ex +++ b/lib/elixir_analyzer/exercise_test/common_checks/exemplar_comparison.ex @@ -7,10 +7,9 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks.ExemplarComparison do alias ElixirAnalyzer.Constants alias ElixirAnalyzer.Comment - @spec run(Macro.t(), nil | Macro.t()) :: [{:pass | :fail | :skip, %Comment{}}] - def run(_ast, nil), do: [] + @spec run(Macro.t(), atom, Macro.t()) :: [{:pass | :fail | :skip, %Comment{}}] - def run(code_ast, exemplar_ast) do + def run(code_ast, :concept, exemplar_ast) do if Macro.to_string(code_ast) == Macro.to_string(exemplar_ast) do [ {:pass, @@ -24,4 +23,6 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks.ExemplarComparison do [] end end + + def run(_, _, _), do: [] end diff --git a/lib/elixir_analyzer/exercise_test/common_checks/private_helper_functions.ex b/lib/elixir_analyzer/exercise_test/common_checks/private_helper_functions.ex new file mode 100644 index 00000000..c6a4061b --- /dev/null +++ b/lib/elixir_analyzer/exercise_test/common_checks/private_helper_functions.ex @@ -0,0 +1,95 @@ +defmodule ElixirAnalyzer.ExerciseTest.CommonChecks.PrivateHelperFunctions do + @moduledoc """ + Compare the module functions to the exemploid and check if helper functions should be private. + """ + + alias ElixirAnalyzer.Constants + alias ElixirAnalyzer.Comment + + @public_ops [:def, :defmacro, :defguard] + + @spec run(Macro.t(), nil | Macro.t()) :: [{:pass | :fail | :skip, %Comment{}}] + def run(_ast, nil), do: [] + + def run(code_ast, exemploid_ast) do + acc = %{module: [], definitions: []} + + {_, %{definitions: code_definitions}} = + Macro.traverse(code_ast, acc, &annotate/2, &find_definition/2) + + {_, %{definitions: exemploid_definitions}} = + Macro.traverse(exemploid_ast, acc, &annotate/2, &find_definition/2) + + case Enum.reverse(find_public_helpers(code_definitions, exemploid_definitions)) do + [] -> + [] + + [{wrong_definition, correct_definition} | _] -> + [ + {:fail, + %Comment{ + type: :informative, + name: Constants.solution_private_helper_functions(), + comment: Constants.solution_private_helper_functions(), + params: %{ + expected: correct_definition, + actual: wrong_definition + } + }} + ] + end + end + + defp annotate(node, %{module: modules} = acc) do + case module_name(node) do + {:ok, module} -> {node, %{acc | module: [module | modules]}} + :not_defmodule -> {node, acc} + end + end + + defp find_definition(node, %{module: modules, definitions: definitions} = acc) do + acc = + case module_name(node) do + {:ok, _} -> %{acc | module: tl(modules)} + :not_defmodule -> acc + end + + case public_definition(node, modules) do + {:ok, definition} -> {node, %{acc | definitions: [definition | definitions]}} + :not_public_definition -> {node, acc} + end + end + + def module_name({:defmodule, _, [{:__aliases__, _, module}, _]}), do: {:ok, module} + def module_name(_node), do: :not_defmodule + + defp public_definition({op, _meta, [{:when, _, [{name, _, args} | _]} | _]}, module) + when op in @public_ops do + {:ok, {module, op, name, if(is_atom(args), do: 0, else: length(args))}} + end + + defp public_definition({op, _meta, [{name, _, args} | _]}, module) when op in @public_ops do + {:ok, {module, op, name, if(is_atom(args), do: 0, else: length(args))}} + end + + defp public_definition(_node, _module), do: :not_public_definition + + defp find_public_helpers(code_definitions, exemploid_definitions) do + exemploid_modules = + exemploid_definitions |> Enum.map(fn {module, _, _, _} -> module end) |> Enum.uniq() + + (Enum.uniq(code_definitions) -- exemploid_definitions) + |> Enum.filter(fn {module, _, _, _} -> module in exemploid_modules end) + |> Enum.map(&print_definition/1) + end + + defp print_definition({_module, op, name, arity}) do + args = make_args(arity) + {"#{op} #{name}(#{args})", "#{op}p #{name}(#{args})"} + end + + defp make_args(arity) do + for(_ <- 1..arity//1, do: "_") + |> Enum.join(", ") + end +end diff --git a/lib/elixir_analyzer/source.ex b/lib/elixir_analyzer/source.ex new file mode 100644 index 00000000..a5267b16 --- /dev/null +++ b/lib/elixir_analyzer/source.ex @@ -0,0 +1,28 @@ +defmodule ElixirAnalyzer.Source do + @moduledoc """ + Represents all the data received: solution code, exemploid, slug and paths + """ + defstruct [ + :slug, + :path, + :code_path, + :code_string, + :code_ast, + :exercise_type, + :exemploid_path, + :exemploid_string, + :exemploid_ast + ] + + @type t() :: %__MODULE__{ + slug: String.t(), + path: String.t(), + code_path: String.t(), + code_string: String.t(), + code_ast: Macro.t(), + exercise_type: :concept | :practice, + exemploid_path: String.t(), + exemploid_string: String.t(), + exemploid_ast: Macro.t() + } +end diff --git a/lib/elixir_analyzer/submission.ex b/lib/elixir_analyzer/submission.ex index 211cf7aa..8bddf9fe 100644 --- a/lib/elixir_analyzer/submission.ex +++ b/lib/elixir_analyzer/submission.ex @@ -20,18 +20,14 @@ defmodule ElixirAnalyzer.Submission do """ alias ElixirAnalyzer.Comment + alias ElixirAnalyzer.Source - @enforce_keys [:code_file, :code_path, :path, :analysis_module] + @enforce_keys [:source, :analysis_module] defstruct halted: false, halt_reason: nil, analyzed: false, comments: [], - path: nil, - code_path: nil, - code_file: nil, - exemplar_path: nil, - exemplar_code: nil, - code: nil, + source: %Source{}, analysis_module: nil @type t() :: %__MODULE__{ @@ -39,12 +35,7 @@ defmodule ElixirAnalyzer.Submission do halt_reason: String.t() | nil, analyzed: boolean, comments: list([Comment.t()]), - path: String.t(), - code_path: String.t(), - code_file: String.t(), - exemplar_path: String.t() | nil, - exemplar_code: Macro.t() | nil, - code: String.t(), + source: Source.t(), analysis_module: atom() } diff --git a/test/elixir_analyzer/exercise_test/common_checks/private_helper_functions_test.exs b/test/elixir_analyzer/exercise_test/common_checks/private_helper_functions_test.exs new file mode 100644 index 00000000..76c90561 --- /dev/null +++ b/test/elixir_analyzer/exercise_test/common_checks/private_helper_functions_test.exs @@ -0,0 +1,489 @@ +defmodule ElixirAnalyzer.ExerciseTest.CommonChecks.PrivateHelperFunctionsTest do + use ExUnit.Case + + alias ElixirAnalyzer.ExerciseTest.CommonChecks.PrivateHelperFunctions + alias ElixirAnalyzer.Comment + alias ElixirAnalyzer.Constants + + @pacman_exemplar "elixir/exercises/concept/pacman-rules/.meta/exemplar.ex" + |> File.read!() + |> Code.string_to_quoted() + + @sqrt_example "elixir/exercises/practice/square-root/.meta/example.ex" + |> File.read!() + |> Code.string_to_quoted() + + @comment %Comment{ + type: :informative, + comment: Constants.solution_private_helper_functions(), + name: Constants.solution_private_helper_functions() + } + + describe "concept exercise with pacman-rules" do + test "solution with private helpers" do + code = + quote do + defmodule Rules do + defguardp is_bool(bool) when bool == true or bool == false + + defmacrop super_and(left, right) do + quote do: unquote(left) and unquote(right) + end + + defp do_or(left, right), do: left or right + + def eat_ghost?(power_pellet_active, touching_ghost) when is_bool(touching_ghost) do + super_and(power_pellet_active, touching_ghost) + end + + def score?(touching_power_pellet, touching_dot) do + do_or(touching_power_pellet, touching_dot) + end + + def lose?(power_pellet_active, touching_ghost) do + not power_pellet_active and touching_ghost + end + + def win?(has_eaten_all_dots, power_pellet_active, touching_ghost) do + has_eaten_all_dots and not lose?(power_pellet_active, touching_ghost) + end + end + end + + assert PrivateHelperFunctions.run(code, @pacman_exemplar) == [] + end + + test "solution with public guard" do + code = + quote do + defmodule Rules do + defguard is_bool(bool) when bool == true or bool == false + + defmacrop super_and(left, right) do + quote do: unquote(left) and unquote(right) + end + + defp do_or(left, right), do: left or right + + def eat_ghost?(power_pellet_active, touching_ghost) when is_bool(touching_ghost) do + super_and(power_pellet_active, touching_ghost) + end + + def score?(touching_power_pellet, touching_dot) do + do_or(touching_power_pellet, touching_dot) + end + + def lose?(power_pellet_active, touching_ghost) do + not power_pellet_active and touching_ghost + end + + def win?(has_eaten_all_dots, power_pellet_active, touching_ghost) do + has_eaten_all_dots and not lose?(power_pellet_active, touching_ghost) + end + end + end + + comment = %{ + @comment + | params: %{actual: "defguard is_bool(_)", expected: "defguardp is_bool(_)"} + } + + assert PrivateHelperFunctions.run(code, @pacman_exemplar) == [{:fail, comment}] + end + + test "solution with public macro" do + code = + quote do + defmodule Rules do + defguardp is_bool(bool) when bool == true or bool == false + + defmacro super_and(left, right) do + quote do: unquote(left) and unquote(right) + end + + defp do_or(left, right), do: left or right + + def eat_ghost?(power_pellet_active, touching_ghost) when is_bool(touching_ghost) do + super_and(power_pellet_active, touching_ghost) + end + + def score?(touching_power_pellet, touching_dot) do + do_or(touching_power_pellet, touching_dot) + end + + def lose?(power_pellet_active, touching_ghost) do + not power_pellet_active and touching_ghost + end + + def win?(has_eaten_all_dots, power_pellet_active, touching_ghost) do + has_eaten_all_dots and not lose?(power_pellet_active, touching_ghost) + end + end + end + + comment = %{ + @comment + | params: %{actual: "defmacro super_and(_, _)", expected: "defmacrop super_and(_, _)"} + } + + assert PrivateHelperFunctions.run(code, @pacman_exemplar) == [{:fail, comment}] + end + + test "solution with public def" do + code = + quote do + defmodule Rules do + defguardp is_bool(bool) when bool == true or bool == false + + defmacrop super_and(left, right) do + quote do: unquote(left) and unquote(right) + end + + def do_or(left, right), do: left or right + + def eat_ghost?(power_pellet_active, touching_ghost) when is_bool(touching_ghost) do + super_and(power_pellet_active, touching_ghost) + end + + def score?(touching_power_pellet, touching_dot) do + do_or(touching_power_pellet, touching_dot) + end + + def lose?(power_pellet_active, touching_ghost) do + not power_pellet_active and touching_ghost + end + + def win?(has_eaten_all_dots, power_pellet_active, touching_ghost) do + has_eaten_all_dots and not lose?(power_pellet_active, touching_ghost) + end + end + end + + comment = %{ + @comment + | params: %{actual: "def do_or(_, _)", expected: "defp do_or(_, _)"} + } + + assert PrivateHelperFunctions.run(code, @pacman_exemplar) == [{:fail, comment}] + end + + test "only one comment will be shown" do + code = + quote do + defmodule Rules do + defguard is_bool(bool) when bool == true or bool == false + + defmacro super_and(left, right) do + quote do: unquote(left) and unquote(right) + end + + def do_or(left, right), do: left or right + + def eat_ghost?(power_pellet_active, touching_ghost) when is_bool(touching_ghost) do + super_and(power_pellet_active, touching_ghost) + end + + def score?(touching_power_pellet, touching_dot) do + do_or(touching_power_pellet, touching_dot) + end + + def lose?(power_pellet_active, touching_ghost) do + not power_pellet_active and touching_ghost + end + + def win?(has_eaten_all_dots, power_pellet_active, touching_ghost) do + has_eaten_all_dots and not lose?(power_pellet_active, touching_ghost) + end + end + end + + comment = %{ + @comment + | params: %{actual: "defguard is_bool(_)", expected: "defguardp is_bool(_)"} + } + + assert PrivateHelperFunctions.run(code, @pacman_exemplar) == [{:fail, comment}] + end + + test "submodules are not included" do + code = + quote do + defmodule Rules do + defmodule BooleanLogic do + def do_or(left, right), do: left or right + end + + def score?(touching_power_pellet, touching_dot) do + BooleanLogic.do_or(touching_power_pellet, touching_dot) + end + end + end + + assert PrivateHelperFunctions.run(code, @pacman_exemplar) == [] + end + + test "modules other than the main one are not included" do + code = + quote do + defmodule BooleanLogic do + def do_or(left, right), do: left or right + end + + defmodule Rules do + defmodule EmptyModule do + end + + def score?(touching_power_pellet, touching_dot) do + BooleanLogic.do_or(touching_power_pellet, touching_dot) + end + end + end + + assert PrivateHelperFunctions.run(code, @pacman_exemplar) == [] + end + end + + describe "practice exercise with square-root" do + test "solution with private helpers" do + code = + quote do + defmodule SquareRoot do + defguardp is_positive(n) when n >= 0 + + defmacrop unless_equal_to(guess, goal, do: expr) do + quote do + if unquote(guess) == unquote(goal) do + unquote(goal) + else + unquote(expr) + end + end + end + + def calculate(1), do: 1 + + def calculate(radicand) do + guess = div(radicand, 2) + do_calculate(radicand, guess) + end + + defp do_calculate(radicand, guess) when is_positive(guess) do + new_guess = div(guess + div(radicand, guess), 2) + + unless_equal_to new_guess, guess do + do_calculate(radicand, new_guess) + end + end + end + end + + assert PrivateHelperFunctions.run(code, @sqrt_example) == [] + end + + test "solution with public guard" do + code = + quote do + defmodule SquareRoot do + defguard is_positive(n) when n >= 0 + + defmacrop unless_equal_to(guess, goal, do: expr) do + quote do + if unquote(guess) == unquote(goal) do + unquote(goal) + else + unquote(expr) + end + end + end + + def calculate(1), do: 1 + + def calculate(radicand) do + guess = div(radicand, 2) + do_calculate(radicand, guess) + end + + defp do_calculate(radicand, guess) when is_positive(guess) do + new_guess = div(guess + div(radicand, guess), 2) + + unless_equal_to new_guess, guess do + do_calculate(radicand, new_guess) + end + end + end + end + + comment = %{ + @comment + | params: %{actual: "defguard is_positive(_)", expected: "defguardp is_positive(_)"} + } + + assert PrivateHelperFunctions.run(code, @sqrt_example) == [{:fail, comment}] + end + + test "solution with public macro" do + code = + quote do + defmodule SquareRoot do + defguardp is_positive(n) when n >= 0 + + defmacro unless_equal_to(guess, goal, do: expr) do + quote do + if unquote(guess) == unquote(goal) do + unquote(goal) + else + unquote(expr) + end + end + end + + def calculate(1), do: 1 + + def calculate(radicand) do + guess = div(radicand, 2) + do_calculate(radicand, guess) + end + + defp do_calculate(radicand, guess) when is_positive(guess) do + new_guess = div(guess + div(radicand, guess), 2) + + unless_equal_to new_guess, guess do + do_calculate(radicand, new_guess) + end + end + end + end + + comment = %{ + @comment + | params: %{ + actual: "defmacro unless_equal_to(_, _, _)", + expected: "defmacrop unless_equal_to(_, _, _)" + } + } + + assert PrivateHelperFunctions.run(code, @sqrt_example) == [{:fail, comment}] + end + + test "solution with public def" do + code = + quote do + defmodule SquareRoot do + defguardp is_positive(n) when n >= 0 + + defmacrop unless_equal_to(guess, goal, do: expr) do + quote do + if unquote(guess) == unquote(goal) do + unquote(goal) + else + unquote(expr) + end + end + end + + def calculate(1), do: 1 + + def calculate(radicand) do + guess = div(radicand, 2) + do_calculate(radicand, guess) + end + + def do_calculate(radicand, guess) when is_positive(guess) do + new_guess = div(guess + div(radicand, guess), 2) + + unless_equal_to new_guess, guess do + do_calculate(radicand, new_guess) + end + end + end + end + + comment = %{ + @comment + | params: %{actual: "def do_calculate(_, _)", expected: "defp do_calculate(_, _)"} + } + + assert PrivateHelperFunctions.run(code, @sqrt_example) == [{:fail, comment}] + end + + test "solution with different arity" do + code = + quote do + defmodule SquareRoot do + defmacrop unless_equal_to(guess, goal, do: expr) do + quote do + if unquote(guess) == unquote(goal) do + unquote(goal) + else + unquote(expr) + end + end + end + + def calculate(1), do: 1 + + def calculate(radicand) do + guess = div(radicand, 2) + do_calculate(radicand, guess) + end + + def calculate(radicand, guess) do + new_guess = div(guess + div(radicand, guess), 2) + + unless_equal_to new_guess, guess do + calculate(radicand, new_guess) + end + end + end + end + + comment = %{ + @comment + | params: %{actual: "def calculate(_, _)", expected: "defp calculate(_, _)"} + } + + assert PrivateHelperFunctions.run(code, @sqrt_example) == [{:fail, comment}] + end + + test "solution with different arity using when" do + code = + quote do + defmodule SquareRoot do + defguardp is_positive(n) when n >= 0 + + defmacrop unless_equal_to(guess, goal, do: expr) do + quote do + if unquote(guess) == unquote(goal) do + unquote(goal) + else + unquote(expr) + end + end + end + + def calculate(1), do: 1 + + def calculate(radicand) do + guess = div(radicand, 2) + do_calculate(radicand, guess) + end + + def calculate(radicand, guess) when is_positive(guess) do + new_guess = div(guess + div(radicand, guess), 2) + + unless_equal_to new_guess, guess do + calculate(radicand, new_guess) + end + end + end + end + + comment = %{ + @comment + | params: %{actual: "def calculate(_, _)", expected: "defp calculate(_, _)"} + } + + assert PrivateHelperFunctions.run(code, @sqrt_example) == [{:fail, comment}] + end + end +end diff --git a/test/elixir_analyzer/test_suite/freelancer_rates_test.exs b/test/elixir_analyzer/test_suite/freelancer_rates_test.exs index 76c92053..43b4842b 100644 --- a/test/elixir_analyzer/test_suite/freelancer_rates_test.exs +++ b/test/elixir_analyzer/test_suite/freelancer_rates_test.exs @@ -126,7 +126,7 @@ defmodule ElixirAnalyzer.ExerciseTest.FreelancerRatesTest do before_discount - before_discount * (discount / 100.0) end - def some_other_function() do + defp some_other_function() do apply_discount(100, 3) end end diff --git a/test/elixir_analyzer/test_suite/list_ops_test.exs b/test/elixir_analyzer/test_suite/list_ops_test.exs index 5d76db16..421cb1f8 100644 --- a/test/elixir_analyzer/test_suite/list_ops_test.exs +++ b/test/elixir_analyzer/test_suite/list_ops_test.exs @@ -5,11 +5,11 @@ defmodule ElixirAnalyzer.ExerciseTest.ListOpsTest do test_exercise_analysis "example solution", comments: [] do defmodule ListOps do - @spec length(list) :: non_neg_integer - def length(l), do: do_length(l, 0) + @spec count(list) :: non_neg_integer + def count(l), do: do_count(l, 0) - defp do_length([], acc), do: acc - defp do_length([_ | t], acc), do: do_length(t, acc + 1) + defp do_count([], acc), do: acc + defp do_count([_ | t], acc), do: do_count(t, acc + 1) @spec reverse(list) :: list def reverse(l), do: do_reverse(l, []) @@ -80,19 +80,19 @@ defmodule ElixirAnalyzer.ExerciseTest.ListOpsTest do end, defmodule ListOps do import Stream - def filter(l, a, f), do: Stream.filter(l, a, f) |> Enum.to_list() + def filter(l, f), do: filter(l, f) |> Enum.to_list() end, defmodule ListOps do def append(l1, l2), do: l1 ++ l2 end, defmodule ListOps do - def map(l) do + def map(l, f) do try do hd(l) rescue _ -> [] else - h -> [h | map(tl(l))] + h -> [f.(h) | map(tl(l), f)] end end end, @@ -114,7 +114,7 @@ defmodule ElixirAnalyzer.ExerciseTest.ListOpsTest do end end, defmodule ListOps do - def length(l), do: Kernel.length(l) + def count(l), do: Kernel.length(l) end ] end diff --git a/test/elixir_analyzer/test_suite/name_badge_test.exs b/test/elixir_analyzer/test_suite/name_badge_test.exs index d12f3293..2e71217c 100644 --- a/test/elixir_analyzer/test_suite/name_badge_test.exs +++ b/test/elixir_analyzer/test_suite/name_badge_test.exs @@ -39,7 +39,7 @@ defmodule ElixirAnalyzer.ExerciseTest.NameBadgeTest do comments: [] do [ defmodule NameBadge do - def totally_not_if(a, b, c), do: if(a, do: b, else: c) + defp totally_not_if(a, b, c), do: if(a, do: b, else: c) def print(id, name, department) do department = totally_not_if(department, department, "owner") @@ -114,8 +114,8 @@ defmodule ElixirAnalyzer.ExerciseTest.NameBadgeTest do end end, defmodule NameBadge do - def totally_not_if(true, a, _b), do: a - def totally_not_if(false, _a, b), do: b + defp totally_not_if(true, a, _b), do: a + defp totally_not_if(false, _a, b), do: b def print(id, name, department) do department = totally_not_if(department == nil, "owner", department) @@ -124,8 +124,8 @@ defmodule ElixirAnalyzer.ExerciseTest.NameBadgeTest do end end, defmodule NameBadge do - def if(true, a, _b), do: a - def if(false, _a, b), do: b + defp if(true, a, _b), do: a + defp if(false, _a, b), do: b def print(id, name, department) do department = if(department == nil, "owner", department) diff --git a/test/elixir_analyzer/test_suite/square_root_test.exs b/test/elixir_analyzer/test_suite/square_root_test.exs index 38fd6bb7..806af24b 100644 --- a/test/elixir_analyzer/test_suite/square_root_test.exs +++ b/test/elixir_analyzer/test_suite/square_root_test.exs @@ -12,7 +12,7 @@ defmodule ElixirAnalyzer.ExerciseTest.SquareRootTest do calculate(radicand, guess) end - def calculate(radicand, guess) do + defp calculate(radicand, guess) do new_guess = div(guess + div(radicand, guess), 2) if new_guess == guess do diff --git a/test/elixir_analyzer_test.exs b/test/elixir_analyzer_test.exs index 493929b2..547494ee 100644 --- a/test/elixir_analyzer_test.exs +++ b/test/elixir_analyzer_test.exs @@ -24,10 +24,11 @@ defmodule ElixirAnalyzerTest do test "referred solution with comments" do exercise = "two-fer" path = "./test_data/two_fer/imperfect_solution/" + analyzed_exercise = ElixirAnalyzer.analyze_exercise(exercise, path, path, @options) expected_output = - "{\"comments\":[{\"comment\":\"elixir.two-fer.use_of_function_header\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.use_specification\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.raise_fn_clause_error\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.variable_name_snake_case\",\"params\":{\"actual\":\"_nameInPascalCase\",\"expected\":\"_name_in_pascal_case\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_attribute_name_snake_case\",\"params\":{\"actual\":\"someUnusedModuleAttribute\",\"expected\":\"some_unused_module_attribute\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_pascal_case\",\"params\":{\"actual\":\"My_empty_module\",\"expected\":\"MyEmptyModule\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.compiler_warnings\",\"params\":{\"warnings\":\"warning: module attribute @someUnusedModuleAttribute was set but never used\\n nofile:2\\n\\n\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.use_module_doc\",\"type\":\"informative\"},{\"comment\":\"elixir.solution.indentation\",\"type\":\"informative\"}],\"summary\":\"Check the comments for some code suggestions. 📣\"}" + "{\"comments\":[{\"comment\":\"elixir.two-fer.use_of_function_header\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.use_specification\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.raise_fn_clause_error\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.variable_name_snake_case\",\"params\":{\"actual\":\"_nameInPascalCase\",\"expected\":\"_name_in_pascal_case\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_attribute_name_snake_case\",\"params\":{\"actual\":\"someUnusedModuleAttribute\",\"expected\":\"some_unused_module_attribute\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_pascal_case\",\"params\":{\"actual\":\"My_empty_module\",\"expected\":\"MyEmptyModule\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.compiler_warnings\",\"params\":{\"warnings\":\"warning: module attribute @someUnusedModuleAttribute was set but never used\\n two_fer.ex:2\\n\\n\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.use_module_doc\",\"type\":\"informative\"},{\"comment\":\"elixir.solution.indentation\",\"type\":\"informative\"},{\"comment\":\"elixir.solution.private_helper_functions\",\"params\":{\"actual\":\"def public_helper(_)\",\"expected\":\"defp public_helper(_)\"},\"type\":\"informative\"}],\"summary\":\"Check the comments for some code suggestions. 📣\"}" assert Submission.to_json(analyzed_exercise) == expected_output end @@ -36,41 +37,51 @@ defmodule ElixirAnalyzerTest do exercise = "two-fer" path = "./test_data/two_fer/error_solution/" - capture_log(fn -> - analyzed_exercise = ElixirAnalyzer.analyze_exercise(exercise, path, path, @options) + assert capture_log(fn -> + analyzed_exercise = ElixirAnalyzer.analyze_exercise(exercise, path, path, @options) - expected_output = """ - {\"comments\":[{\"comment\":\"elixir.general.parsing_error\",\"params\":{\"error\":\"missing terminator: end (for \\\"do\\\" starting at line 1)\",\"line\":14},\"type\":\"essential\"}],\"summary\":\"Check the comments for things to fix. 🛠\"} - """ + expected_output = """ + {\"comments\":[{\"comment\":\"elixir.general.parsing_error\",\"params\":{\"error\":\"missing terminator: end (for \\\"do\\\" starting at line 1)\",\"line\":14},\"type\":\"essential\"}],\"summary\":\"Check the comments for things to fix. 🛠\"} + """ - assert Submission.to_json(analyzed_exercise) == String.trim(expected_output) - end) + assert Submission.to_json(analyzed_exercise) == String.trim(expected_output) + end) =~ "Exemploid file could not be parsed." end test "missing file solution" do exercise = "two-fer" path = "./test_data/two_fer/missing_file_solution/" - analyzed_exercise = ElixirAnalyzer.analyze_exercise(exercise, path, path, @options) - expected_output = """ - {\"comments\":[{\"comment\":\"elixir.general.file_not_found\",\"params\":{\"file_name\":\"two_fer.ex\",\"path\":\"./test_data/two_fer/missing_file_solution/\"},\"type\":\"essential\"}],\"summary\":\"Check the comments for things to fix. 🛠\"} - """ + assert capture_log(fn -> + analyzed_exercise = ElixirAnalyzer.analyze_exercise(exercise, path, path, @options) - assert Submission.to_json(analyzed_exercise) == String.trim(expected_output) + expected_output = """ + {\"comments\":[{\"comment\":\"elixir.general.file_not_found\",\"params\":{\"file_name\":\"two_fer.ex\",\"path\":\"./test_data/two_fer/missing_file_solution/\"},\"type\":\"essential\"}],\"summary\":\"Check the comments for things to fix. 🛠\"} + """ + + assert Submission.to_json(analyzed_exercise) == String.trim(expected_output) + end) =~ "Code file not found. Reason: enoent" + end + + test "missing example file solution" do + exercise = "two-fer" + path = "./test_data/two_fer/missing_example_solution/" + + assert capture_log(fn -> + ElixirAnalyzer.analyze_exercise(exercise, path, path, @options) + end) =~ "Exemploid file not found. Reason: enoent" end test "solution for an exercise with no analyzer module uses the default module" do exercise = "not-a-real-exercise" path = "./test_data/two_fer/imperfect_solution/" - capture_log(fn -> - analyzed_exercise = ElixirAnalyzer.analyze_exercise(exercise, path, path, @options) + analyzed_exercise = ElixirAnalyzer.analyze_exercise(exercise, path, path, @options) - expected_output = - "{\"comments\":[{\"comment\":\"elixir.solution.raise_fn_clause_error\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.variable_name_snake_case\",\"params\":{\"actual\":\"_nameInPascalCase\",\"expected\":\"_name_in_pascal_case\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_attribute_name_snake_case\",\"params\":{\"actual\":\"someUnusedModuleAttribute\",\"expected\":\"some_unused_module_attribute\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_pascal_case\",\"params\":{\"actual\":\"My_empty_module\",\"expected\":\"MyEmptyModule\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.compiler_warnings\",\"params\":{\"warnings\":\"warning: module attribute @someUnusedModuleAttribute was set but never used\\n nofile:2\\n\\n\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.indentation\",\"type\":\"informative\"}],\"summary\":\"Check the comments for some code suggestions. 📣\"}" + expected_output = + "{\"comments\":[{\"comment\":\"elixir.solution.raise_fn_clause_error\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.variable_name_snake_case\",\"params\":{\"actual\":\"_nameInPascalCase\",\"expected\":\"_name_in_pascal_case\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_attribute_name_snake_case\",\"params\":{\"actual\":\"someUnusedModuleAttribute\",\"expected\":\"some_unused_module_attribute\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_pascal_case\",\"params\":{\"actual\":\"My_empty_module\",\"expected\":\"MyEmptyModule\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.compiler_warnings\",\"params\":{\"warnings\":\"warning: module attribute @someUnusedModuleAttribute was set but never used\\n two_fer.ex:2\\n\\n\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.indentation\",\"type\":\"informative\"},{\"comment\":\"elixir.solution.private_helper_functions\",\"params\":{\"actual\":\"def public_helper(_)\",\"expected\":\"defp public_helper(_)\"},\"type\":\"informative\"}],\"summary\":\"Check the comments for some code suggestions. 📣\"}" - assert Submission.to_json(analyzed_exercise) == String.trim(expected_output) - end) + assert Submission.to_json(analyzed_exercise) == String.trim(expected_output) end end @@ -94,7 +105,7 @@ defmodule ElixirAnalyzerTest do analyzed_exercise = ElixirAnalyzer.analyze_exercise(exercise, path, path, @options) expected_output = - "{\"comments\":[{\"comment\":\"elixir.lasagna.function_reuse\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.todo_comment\",\"type\":\"informative\"}],\"summary\":\"Check the comments for some code suggestions. 📣\"}" + "{\"comments\":[{\"comment\":\"elixir.lasagna.function_reuse\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.private_helper_functions\",\"params\":{\"actual\":\"def public_helper(_)\",\"expected\":\"defp public_helper(_)\"},\"type\":\"informative\"},{\"comment\":\"elixir.solution.todo_comment\",\"type\":\"informative\"}],\"summary\":\"Check the comments for some code suggestions. 📣\"}" assert Submission.to_json(analyzed_exercise) == expected_output end @@ -110,7 +121,7 @@ defmodule ElixirAnalyzerTest do "{\"comments\":[],\"summary\":\"Submission analyzed. No automated suggestions found.\"}" assert Submission.to_json(analyzed_exercise) == String.trim(expected_output) - end) =~ "Exemplar file not found. Reason: enoent" + end) =~ "Exemploid file not found. Reason: enoent" end test "solution with parsing error for incomplete exemplar" do @@ -124,7 +135,7 @@ defmodule ElixirAnalyzerTest do "{\"comments\":[],\"summary\":\"Submission analyzed. No automated suggestions found.\"}" assert Submission.to_json(analyzed_exercise) == String.trim(expected_output) - end) =~ "Exemplar file could not be parsed." + end) =~ "Exemploid file could not be parsed." end end diff --git a/test/support/exercise_test_case.ex b/test/support/exercise_test_case.ex index 92dac74c..2b47976a 100644 --- a/test/support/exercise_test_case.ex +++ b/test/support/exercise_test_case.ex @@ -10,17 +10,16 @@ defmodule ElixirAnalyzer.ExerciseTestCase do """ use ExUnit.CaseTemplate + alias ElixirAnalyzer.Source @dialyzer no_match: {:assert_comments, 3} @exercise_config Application.compile_env(:elixir_analyzer, :exercise_config) - @concept_exercice_path "elixir/exercises/concept" - @meta_config ".meta/config.json" using opts do quote do @exercise_test_module unquote(opts)[:exercise_test_module] @unsorted_comments unquote(opts)[:unsorted_comments] - @exemplar_code ElixirAnalyzer.ExerciseTestCase.find_exemplar_code(@exercise_test_module) + @source ElixirAnalyzer.ExerciseTestCase.find_source(@exercise_test_module) require ElixirAnalyzer.ExerciseTestCase import ElixirAnalyzer.ExerciseTestCase alias ElixirAnalyzer.Constants @@ -91,14 +90,14 @@ defmodule ElixirAnalyzer.ExerciseTestCase do quote line: line do test "#{unquote(test_name)}" do + source = %{@source | code_string: unquote(code)} + empty_submission = %ElixirAnalyzer.Submission{ - code_file: "", - code_path: "", - path: "", + source: source, analysis_module: "" } - result = @exercise_test_module.analyze(empty_submission, unquote(code), @exemplar_code) + result = @exercise_test_module.analyze(empty_submission, source) comments = result.comments @@ -156,23 +155,71 @@ defmodule ElixirAnalyzer.ExerciseTestCase do :noop end - # Return the exemplar AST for concept exercises, or nil for pracices exercises and other tests - def find_exemplar_code(test_module) do - with {slug, _test_module} <- - Enum.find(@exercise_config, &match?({_, %{analyzer_module: ^test_module}}, &1)), - {:ok, config_file} <- - Path.join([@concept_exercice_path, slug, @meta_config]) |> File.read() do - get_exemplar_ast!(config_file, slug) - else - _ -> nil + # Return as much of the source data as can be found + + @concept_exercise_path "elixir/exercises/concept" + @practice_exercise_path "elixir/exercises/practice" + @meta_config ".meta/config.json" + def find_source(test_module) do + %Source{} + |> find_source_slug(test_module) + |> find_source_type + |> find_source_exemploid_path + |> find_source_exemploid + end + + defp find_source_slug(source, test_module) do + match_slug = Enum.find(@exercise_config, &match?({_, %{analyzer_module: ^test_module}}, &1)) + + case match_slug do + {slug, _test_module} -> %{source | slug: slug} + _ -> source end end - defp get_exemplar_ast!(config_file_path, slug) do - %{"files" => %{"exemplar" => [path]}} = Jason.decode!(config_file_path) + defp find_source_type(%Source{slug: slug} = source) do + concept_ex = File.ls!(@concept_exercise_path) + practice_ex = File.ls!(@practice_exercise_path) - Path.join([@concept_exercice_path, slug, path]) - |> File.read!() - |> Code.string_to_quoted!() + cond do + slug in concept_ex -> %{source | exercise_type: :concept} + slug in practice_ex -> %{source | exercise_type: :practice} + true -> source + end end + + defp find_source_exemploid_path(%Source{slug: slug, exercise_type: :concept} = source) do + %{"files" => %{"exemplar" => [exemploid_path | _]}} = + [@concept_exercise_path, slug, @meta_config] + |> Path.join() + |> File.read!() + |> Jason.decode!() + + exemploid_path = Path.join([@concept_exercise_path, slug, exemploid_path]) + %{source | exemploid_path: exemploid_path} + end + + defp find_source_exemploid_path(%Source{slug: slug, exercise_type: :practice} = source) do + %{"files" => %{"example" => [exemploid_path | _]}} = + [@practice_exercise_path, slug, @meta_config] + |> Path.join() + |> File.read!() + |> Jason.decode!() + + exemploid_path = Path.join([@practice_exercise_path, slug, exemploid_path]) + + %{source | exemploid_path: exemploid_path} + end + + defp find_source_exemploid_path(source), do: source + + defp find_source_exemploid(%Source{exemploid_path: exemploid_path} = source) + when is_binary(exemploid_path) do + exemploid_string = File.read!(exemploid_path) + exemploid_ast = Code.string_to_quoted!(exemploid_string) + + %{source | exemploid_string: exemploid_string, exemploid_ast: exemploid_ast} + end + + defp find_source_exemploid(source), do: source end diff --git a/test_data/lasagna/failing_solution/lib/lasagna.ex b/test_data/lasagna/failing_solution/lib/lasagna.ex index 4361b85f..9594af95 100644 --- a/test_data/lasagna/failing_solution/lib/lasagna.ex +++ b/test_data/lasagna/failing_solution/lib/lasagna.ex @@ -8,4 +8,6 @@ defmodule Lasagna do # TODO: define the 'total_time_in_minutes/2' function # TODO: define the 'alarm/0' function + + def public_helper(_pasta), do: :delicious end diff --git a/test_data/two_fer/error_solution/.meta/example.ex b/test_data/two_fer/error_solution/.meta/example.ex new file mode 100644 index 00000000..9f0d8c5a --- /dev/null +++ b/test_data/two_fer/error_solution/.meta/example.ex @@ -0,0 +1,5 @@ +defmodule TwoFer do + @doc """ + Two-fer or 2-fer is short for two for one. One for you and one for me. + """ + # Corrupted file that won't compile diff --git a/test_data/two_fer/imperfect_solution/.meta/example.ex b/test_data/two_fer/imperfect_solution/.meta/example.ex new file mode 100644 index 00000000..0485a716 --- /dev/null +++ b/test_data/two_fer/imperfect_solution/.meta/example.ex @@ -0,0 +1,7 @@ +defmodule TwoFer do + @doc """ + Two-fer or 2-fer is short for two for one. One for you and one for me. + """ + @spec two_fer(String.t()) :: String.t() + def two_fer(name \\ "you") when is_binary(name), do: "One for #{name}, one for me." +end diff --git a/test_data/two_fer/imperfect_solution/lib/two_fer.ex b/test_data/two_fer/imperfect_solution/lib/two_fer.ex index 84e63dce..a2cf60b5 100644 --- a/test_data/two_fer/imperfect_solution/lib/two_fer.ex +++ b/test_data/two_fer/imperfect_solution/lib/two_fer.ex @@ -14,4 +14,6 @@ defmodule TwoFer do end def two_fer(_nameInPascalCase), do: raise(FunctionClauseError) + +def public_helper(_pasta), do: :delicious end diff --git a/test_data/two_fer/missing_example_solution/.meta/config.json b/test_data/two_fer/missing_example_solution/.meta/config.json new file mode 100644 index 00000000..ed319ba5 --- /dev/null +++ b/test_data/two_fer/missing_example_solution/.meta/config.json @@ -0,0 +1,10 @@ +{ + "blurb": "Create a sentence of the form \"One for X, one for me.\"", + "authors": [], + "files": { + "solution": ["lib/two_fer.ex"], + "test": ["test/two_fer_test.exs"], + "example": [".meta/example.ex"] + }, + "source_url": "https://github.com/exercism/problem-specifications/issues/757" +} diff --git a/test_data/two_fer/missing_example_solution/lib/two_fer.ex b/test_data/two_fer/missing_example_solution/lib/two_fer.ex new file mode 100644 index 00000000..e6b6cbc6 --- /dev/null +++ b/test_data/two_fer/missing_example_solution/lib/two_fer.ex @@ -0,0 +1,13 @@ +defmodule TwoFer do + @moduledoc false + + @doc """ + Two-fer or 2-fer is short for two for one. One for you and one for me. + + Using a tab like this or like this \t in a @doc is allowed. + """ + @spec two_fer(String.t()) :: String.t() + def two_fer(name \\ "you") when is_binary(name) do + "One for #{name}, one for me." + end +end diff --git a/test_data/two_fer/missing_example_solution/test/two_fer_test.exs b/test_data/two_fer/missing_example_solution/test/two_fer_test.exs new file mode 100644 index 00000000..56b455fa --- /dev/null +++ b/test_data/two_fer/missing_example_solution/test/two_fer_test.exs @@ -0,0 +1,33 @@ +defmodule TwoFerTest do + use ExUnit.Case + + test "no name given" do + assert TwoFer.two_fer() == "One for you, one for me." + end + + @tag :pending + test "a name given" do + assert TwoFer.two_fer("Gilberto Barrosi") == "One for Gilberto Barros, one for me." + end + + @tag :pending + test "when the parameter is a number" do + assert_raise FunctionClauseError, fn -> + TwoFer.two_fer(10) + end + end + + @tag :pending + test "when the parameter is an atom" do + assert_raise FunctionClauseError, fn -> + TwoFer.two_fer(:bob) + end + end + + @tag :pending + test "when the parameter is a charlist" do + assert_raise FunctionClauseError, fn -> + refute TwoFer.two_fer('Jon Snow') + end + end +end diff --git a/test_data/two_fer/missing_file_solution/.meta/example.ex b/test_data/two_fer/missing_file_solution/.meta/example.ex new file mode 100644 index 00000000..0485a716 --- /dev/null +++ b/test_data/two_fer/missing_file_solution/.meta/example.ex @@ -0,0 +1,7 @@ +defmodule TwoFer do + @doc """ + Two-fer or 2-fer is short for two for one. One for you and one for me. + """ + @spec two_fer(String.t()) :: String.t() + def two_fer(name \\ "you") when is_binary(name), do: "One for #{name}, one for me." +end diff --git a/test_data/two_fer/perfect_solution/.meta/example.ex b/test_data/two_fer/perfect_solution/.meta/example.ex new file mode 100644 index 00000000..0485a716 --- /dev/null +++ b/test_data/two_fer/perfect_solution/.meta/example.ex @@ -0,0 +1,7 @@ +defmodule TwoFer do + @doc """ + Two-fer or 2-fer is short for two for one. One for you and one for me. + """ + @spec two_fer(String.t()) :: String.t() + def two_fer(name \\ "you") when is_binary(name), do: "One for #{name}, one for me." +end diff --git a/test_data/two_fer/perfect_solution/lib/two_fer.ex b/test_data/two_fer/perfect_solution/lib/two_fer.ex index 465ec697..e6b6cbc6 100644 --- a/test_data/two_fer/perfect_solution/lib/two_fer.ex +++ b/test_data/two_fer/perfect_solution/lib/two_fer.ex @@ -11,11 +11,3 @@ defmodule TwoFer do "One for #{name}, one for me." end end - - - - - - - -