From 304dbaf8240815b20ffa3c0b0d79c1b5337cfd53 Mon Sep 17 00:00:00 2001 From: "T. Hopia" <25904171+tuomohopia@users.noreply.github.com> Date: Sat, 18 Nov 2023 22:33:33 +0200 Subject: [PATCH] Context & Colors (#9) --- lib/mix/mutate.ex | 9 ++--- lib/mutix/report.ex | 69 +++++++++++++++++++++++++++++++------- test/mutix/report_test.exs | 49 +++++++++++++++++++++------ 3 files changed, 101 insertions(+), 26 deletions(-) diff --git a/lib/mix/mutate.ex b/lib/mix/mutate.ex index f1592a4..9505e2c 100644 --- a/lib/mix/mutate.ex +++ b/lib/mix/mutate.ex @@ -131,9 +131,10 @@ defmodule Mix.Tasks.Mutate do # Internal - defp do_run(source_file, test_files, mutation) do + defp do_run(source_path, test_files, mutation) do # Get source file's AST - ast = source_file |> File.read!() |> Code.string_to_quoted!() + source_content = File.read!(source_path) + ast = Code.string_to_quoted!(source_content) {:ok, test_modules, _compilation_metas} = Kernel.ParallelCompiler.require(test_files, []) ExUnit.Server.modules_loaded(false) @@ -173,12 +174,12 @@ defmodule Mix.Tasks.Mutate do """ - No ( #{from} ) operators found in #{source_file}. + No ( #{from} ) operators found in #{source_path}. Thus, no mutants injected. """ else - Mutix.mutation_report(test_results, source_file, mutation) + Mutix.mutation_report(test_results, {source_path, source_content}, mutation) end IO.puts(mutation_report) diff --git a/lib/mutix/report.ex b/lib/mutix/report.ex index 45f0240..2c534ae 100644 --- a/lib/mutix/report.ex +++ b/lib/mutix/report.ex @@ -11,29 +11,37 @@ defmodule Mutix.Report do @typep mutation_test_result :: {result :: exunit_test_result(), meta :: Keyword.t(), io_output :: String.t()} - @spec mutation(list(mutation_test_result()), String.t(), {atom(), atom()}) :: String.t() - def mutation(test_results, source_file_path, operator_mutation) do + @spec mutation(list(mutation_test_result()), {String.t(), String.t()}, {atom(), atom()}) :: + String.t() + def mutation(test_results, {source_file_path, source_content}, operator_mutation) do {from, to} = operator_mutation score = mutation_score(test_results) percentage = (score.mutant_count - score.survived_count) / score.mutant_count * 100 + percentage = Float.round(percentage, 1) + + percentage = + if percentage > 50, + do: color(percentage, [:green, :bright]), + else: color(percentage, [:red, :bright]) + survived = score.survived survived_report = if Enum.count(survived) > 0, - do: survived_report(survived, source_file_path, operator_mutation), + do: survived_report(survived, {source_file_path, source_content}, operator_mutation), else: nil """ - Results: + #{color("Results:", [:cyan, :bright])} - #{score.mutant_count} mutants were generated by mutating ( #{from} ) into ( #{to} ). + #{color(score.mutant_count, [:blue, :bright])} mutants were generated by mutating ( #{color(from, :green)} ) into ( #{color(to, :red)} ). - #{score.test_count} tests were run for each mutant. + #{color(score.test_count, [:blue, :bright])} tests were run for each mutant. - #{score.killed_count} / #{score.mutant_count} mutants killed by the test suite. + #{color(score.killed_count, :green)} / #{color(score.mutant_count, [:blue, :bright])} mutants killed by the test suite. - Mutation score: #{Float.round(percentage, 1)} % + Mutation score: #{percentage} % #{survived_report} """ end @@ -59,24 +67,61 @@ defmodule Mutix.Report do } end - defp survived_report(survived, source_file_path, {from, to}) do + defp survived_report(survived, {source_file_path, source_content}, {from, to}) do + lines = String.split(source_content, "\n") + surviving = for {_, meta, _} <- survived do line = Keyword.fetch!(meta, :line) index = Keyword.fetch!(meta, :index_on_line) + context = + [ + Enum.at(lines, line - 2), + Enum.at(lines, line - 1), + Enum.at(lines, line) + ] + |> trim_context() + |> Enum.map(&String.pad_leading(&1, String.length(&1) + 8)) + # TODO: color only the operator here, not the entire line + |> List.update_at(1, fn line -> color(line, :red) end) + |> Enum.join("\n") + if index == 0 do - "#{source_file_path} - line #{line} where ( #{from} ) was mutated into ( #{to} )" + """ + #{color(source_file_path, :magenta)}:#{color(line, :magenta)} where ( #{color(from, :green)} ) was mutated into ( #{color(to, :red)} ): + + #{context} + """ else - "#{source_file_path} - line #{line} where the #{index + 1}. ( #{from} ) from left was mutated into ( #{to} )" + """ + #{source_file_path} - line #{line} where the #{index + 1}. ( #{color(from, :green)} ) from left was mutated into ( #{color(to, :red)} ) + + #{context} + """ end end """ - Surviving mutants (no test failed with these injections): + #{color("Surviving mutants", [:magenta, :bright])} - no test failed with these injections: #{Enum.join(surviving, "\n ")} """ end + + defp color(value, ansi), do: IO.ANSI.format([ansi | to_string(value)]) + + defp trim_context(lines) do + trimmed_lines = Enum.map(lines, &String.trim/1) + + min_whitespace = + lines + |> Enum.zip_with(trimmed_lines, fn line, trimmed -> + String.length(line) - String.length(trimmed) + end) + |> Enum.min() + + Enum.map(lines, fn line -> String.slice(line, min_whitespace..-1) end) + end end diff --git a/test/mutix/report_test.exs b/test/mutix/report_test.exs index 6e04c3b..ffce37a 100644 --- a/test/mutix/report_test.exs +++ b/test/mutix/report_test.exs @@ -8,22 +8,32 @@ defmodule Mutix.ReportTest do @single_operator_result """ Results: - 1 mutants were generated by mutating ( > ) into ( < ). + 1 mutants were generated by mutating ( > ) into ( < ). - 5 tests were run for each mutant. + 5 tests were run for each mutant. - 0 / 1 mutants killed by the test suite. + 0 / 1 mutants killed by the test suite. - Mutation score: 0.0 % + Mutation score: 0.0 % - Surviving mutants (no test failed with these injections): + Surviving mutants - no test failed with these injections: - test/support/single_operator_source.ex - line 3 where ( > ) was mutated into ( < ) + test/support/single_operator_source.ex:3 where ( > ) was mutated into ( < ): + + def larger_than_1(a) do + a > 1 + end """ + ExUnit.after_suite(fn _result -> Application.put_env(:elixir, :ansi_enabled, true) end) + describe "mutation/3" do + setup do + Application.put_env(:elixir, :ansi_enabled, false) + end + test "produces a mutation report for the output of a list of mutation test suites" do test_results = [ {%{total: 5, failures: 0, excluded: 0, skipped: 0}, [index_on_line: 0, line: 3], @@ -33,11 +43,24 @@ defmodule Mutix.ReportTest do source_file_path = "test/support/single_operator_source.ex" operator_mutation = {:>, :<} - result = Report.mutation(test_results, source_file_path, operator_mutation) - assert result == String.replace_trailing(@single_operator_result, "\n\n\n", "\n\n") + result = + Report.mutation( + test_results, + {source_file_path, File.read!(source_file_path)}, + operator_mutation + ) + + result = result |> String.split("\n") |> Enum.map(&String.trim/1) + + single_operator_result = + @single_operator_result |> String.split("\n") |> Enum.map(&String.trim/1) + + assert result == single_operator_result end - test "reports mutation score correctly" do + test "reports mutation score correctly with context" do + Application.put_env(:elixir, :ansi_enabled, false) + test_results = [ {%{total: 5, failures: 0, excluded: 0, skipped: 0}, [index_on_line: 0, line: 3], ""}, {%{total: 5, failures: 1, excluded: 0, skipped: 0}, [index_on_line: 0, line: 5], ""}, @@ -47,7 +70,13 @@ defmodule Mutix.ReportTest do source_file_path = "test/support/single_operator_source.ex" operator_mutation = {:>, :<} - result = Report.mutation(test_results, source_file_path, operator_mutation) + result = + Report.mutation( + test_results, + {source_file_path, File.read!(source_file_path)}, + operator_mutation + ) + assert result =~ "5 tests were run for each mutant." assert result =~ "2 / 3 mutants killed by the test suite." assert result =~ "Mutation score: 66.7 %"