defmodule PropCheck.StateM.Reporter do @moduledoc false alias PropCheck.StateM @type mod_alias :: module() | {module(), as :: module()} @type option :: {:return_values, boolean} | {:last_state, boolean} | {:pre_cmd_state, boolean} | {:post_cmd_state, boolean} | {:cmd_args, boolean} | {:inspect_opts, keyword} | {:alias, mod_alias | [mod_alias]} @type options :: [option] @spec print_report({StateM.history, StateM.dynamic_state, StateM.result}, StateM.command_list, options) :: :ok def print_report({history, state, result}, commands, opts \\ []), do: pretty_report(result, history, state, commands, opts) defp pretty_report(_result, seq_history, par_history, cmds, opts) when is_tuple(cmds) do title = "Concurrency Failure, we don't show the state :-/" history = [{:sequential, seq_history}, {:parallel, par_history}] print_pretty_report(title, :parallel, history, :no_state, cmds, opts) end defp pretty_report(:ok, history, state, commands, opts), do: print_pretty_report( "All commands were successful and all postconditions were true.", :all, history, state, commands, opts ) defp pretty_report(:initialization_error, history, state, commands, opts), do: print_pretty_report("Error while evaluating initial state.", :none, history, state, commands, opts) defp pretty_report({:precondition, false}, history, state, commands, opts) do failing_cmd_idx = {:fail_at, length(history)} print_pretty_report("Precondition failed.", failing_cmd_idx, history, state, commands, opts) end defp pretty_report({:precondition, exp = {:exception, _, _, _}}, history, state, commands, opts) do failing_cmd_idx = {:fail_at, length(history)} title = "Precondition crashed:\n" <> inspectx(exp, opts) print_pretty_report(title, failing_cmd_idx, history, state, commands, opts) end defp pretty_report({:postcondition, false}, history, state, commands, opts) do failing_cmd_idx = {:fail_at, length(history) - 1} print_pretty_report("Postcondition failed.", failing_cmd_idx, history, state, commands, opts) end defp pretty_report({:postcondition, exp = {:exception, _, _, _}}, history, state, commands, opts) do failing_cmd_idx = {:fail_at, length(history) - 1} title = "Postcondition crashed:\n#{inspectx(exp, opts)}" print_pretty_report(title, failing_cmd_idx, history, state, commands, opts) end defp pretty_report(exp = {:exception, _, _, _}, history, state, commands, opts) do failing_cmd_idx = {:fail_at, length(history)} history = history ++ [{state, exp}] print_pretty_report("Command crashed.", failing_cmd_idx, history, state, commands, opts) end @header String.duplicate("=", 80) defp print_pretty_report(title, cmds_to_print, history, state, [{:init, _} | commands], opts), do: print_pretty_report(title, cmds_to_print, history, state, commands, opts) defp print_pretty_report(title, cmds_to_print, history, state, commands, opts) do main = main_section(cmds_to_print, history, state, commands, opts) IO.puts """ #{@header} #{title} #{main} """ end defp main_section(cmds_to_print, history, state, commands, opts) defp main_section(:none, _history, _state, _commands, _opts), do: "" defp main_section(:all, history, state, commands, opts) do history_commands = zip_cmds_history(commands, history) last_state = last_state_section(state, opts) cmds = history_commands |> print_command_lines(false, opts) |> Enum.join("\n") """ Commands: #{cmds} #{last_state} """ end defp main_section({:fail_at, cmd_idx}, history, state, commands, opts) do history_commands = zip_cmds_history(commands, history) last_state = last_state_section(state, opts) # commands before the failing one priori_cmds = history_commands |> Enum.take(cmd_idx) |> print_command_lines(false, opts) # failing command failing_cmd = history_commands |> Enum.at(cmd_idx) |> print_command_line(true, opts) cmds = Enum.join priori_cmds ++ [failing_cmd], "\n" """ Commands: #{cmds} #{last_state} """ end defp main_section(:parallel, history, _state, cmds, opts) do # require Logger # Logger.info "Main Section (parallel): history = #{inspect history, pretty: true}" # Logger.info "Main Section (parallel): state = #{inspect state, pretty: true}" # Logger.info "Main Section (parallel): cmds = #{inspect cmds, pretty: true}" {seq_cmds, [p1_cmds, p2_cmds]} = cmds seq_history = Keyword.fetch!(history, :sequential) [p1_history, p2_history] = case Keyword.fetch(history, :parallel) do {:ok, [h1, h2]} -> remove_cmd = fn {_cmd, return} -> {nil, return} end [Enum.map(h1, remove_cmd), Enum.map(h2, remove_cmd)] :error -> [[], []] end seq_commands = zip_sequential_cmds_history(seq_cmds, seq_history) |> print_command_lines(false, opts) |> Enum.join("") par_opts = Keyword.merge([post_cmd_state: false], opts) p1_states = zip_parallel_cmds_history(p1_cmds, p1_history) |> print_command_lines(false, par_opts)|> Enum.join("") p2_states = zip_parallel_cmds_history(p2_cmds, p2_history) |> print_command_lines(false, par_opts)|> Enum.join("") # par_commands_1 = "#{inspect p1_history, pretty: true}" # par_commands_2 = "#{inspect p2_history, pretty: true}" """ Sequential commands: #{seq_commands} Process 1: #{p1_states} Process 2: #{p2_states} """ end defp zip_parallel_cmds_history(cmds, history) do Enum.zip(cmds, history) |> Enum.map(fn {cmd, {_cmd_hist, ret_value}} -> {cmd, {ret_value, nil, nil}} end) end defp zip_sequential_cmds_history(cmds, history) do Enum.zip(cmds, history) |> Enum.map(fn {cmd, {post_state, ret_value}} -> {cmd, {ret_value, nil, {:just, post_state}}} end) end defp last_state_section(state, opts) do case Keyword.get(opts, :last_state, true) do true -> """ Last state: #{inspectx state, opts} """ false -> "" end end defp zip_cmds_history(commands, history) do zipper = fn cmd, acc -> case acc do {[], zipped} -> {[], [{cmd, nil} | zipped]} {[{pre_state, return_val}], zipped} -> post_state = nil {[], [{cmd, {return_val, pre_state, post_state}} | zipped]} {[{pre_state, return_val}, h = {post_state, _} | history], zipped} -> {[h|history], [{cmd, {return_val, pre_state, {:just, post_state}}} | zipped]} end end Enum.reduce(commands, {history, []}, zipper) |> elem(1) |> Enum.reverse end defp print_command_lines(hist_cmds, failing?, opts) do Enum.map(hist_cmds, &print_command_line(&1, failing?, opts)) end defp print_command_line({cmd, history}, failing?, opts) do has_history? = match?({_return_val, _pre_state, _post_state}, history) print_return_val? = has_history? and Keyword.get(opts, :return_values, true) print_pre_state? = has_history? and Keyword.get(opts, :pre_cmd_state, false) print_post_state? = has_history? and Keyword.get(opts, :post_cmd_state, true) [ if(print_pre_state?, do: print_pre_state(history, opts), else: ""), print_command(cmd, failing?, opts), if(print_return_val?, do: print_return_value(history, opts), else: ""), if(print_post_state?, do: print_post_state(history, opts), else: ""), ] |> to_string end @doc false def pretty_print_counter_example_cmd({:init, _}), do: "" def pretty_print_counter_example_cmd(cmd) do pretty_cmd_name(cmd, [syntax_colors: []]) <> "\n" end def pretty_print_counter_example_parallel({seq, [par1, par2]}) do (IO.ANSI.format([:reset, "Sequential Start: \n"]) |> to_string()) <> (print_parallel_commands(seq, false, [syntax_colors: []]) |> Enum.join("")) <> "\n" <> "Parallel Process 1: \n" <> (print_parallel_commands(par1, false, [syntax_colors: []]) |> Enum.join("")) <> "\n" <> "Parallel Process 2: \n" <> (print_parallel_commands(par2, false, [syntax_colors: []]) |> Enum.join("")) <> "\n" end defp print_parallel_commands(cmds, failing?, opts) do history = [] # there is no history in parallel test cases cmds |> zip_cmds_history(history) |> print_command_lines(failing?, opts) end @cmd_indent_level 3 @comment_indent_level 10 defp print_command(cmd, failing?, opts) defp print_command(cmd, false, opts), do: indent(pretty_cmd_name(cmd, opts), @cmd_indent_level, false) <> "\n" defp print_command(cmd, true, opts) do cmt = [:red, "#! ", :reset] |> IO.ANSI.format() |> to_string cmd_str = [:red, pretty_cmd_name(cmd, opts), :reset] |> IO.ANSI.format() |> to_string indent(cmd_str, @cmd_indent_level, cmt) <> "\n" end defp print_return_value({return_val, _, _}, opts) do comment("->", return_val, opts) end defp print_pre_state({_, pre_state, _}, opts) do comment("Pre state:", pre_state, opts) end defp print_post_state({_, _, nil}, _opts), do: "" defp print_post_state({_, _, {:just, post_state}}, opts) do # "\n" <> comment("Post state:", post_state, opts) comment("Post state:", post_state, opts) end defp comment(title, val, opts) do str = inspectx(val, opts) indent("#{title} #{str}", @comment_indent_level, true) <> "\n" end defp indent(str, level, commented?) defp indent(str, level, false) do String.duplicate(" ", level) |> do_indent(str) end defp indent(str, level, true) when level > 2 do (String.duplicate(" ", level - 2) <> "# ") |> do_indent(str) end defp indent(str, _level, true), do: do_indent("# ", str) defp indent(str, level, cmt) when is_binary(cmt) do length = String.length(cmt) if level > length do (String.duplicate(" ", level - length) <> cmt) |> do_indent(str) else do_indent(cmt, str) end end defp do_indent(indent, str) do str |> String.split("\n") |> Enum.map(& "#{indent}#{&1}") |> Enum.join("\n") end def pretty_cmds_name(cmds, opts) do Enum.map(cmds, &pretty_cmd_name(&1, opts)) end def pretty_cmd_name([tuple], opts), do: pretty_cmd_name(tuple, opts) def pretty_cmd_name({:init, data}, _opts), do: "init call -> #{inspect data, pretty: true}" def pretty_cmd_name({:set, {:var, n}, {:call, mod, fun, args}}, opts) do aliases = Keyword.get(opts, :alias, [mod]) mod = alias_module(mod, aliases) args = args |> Enum.with_index() |> Enum.map(fn {{:var, m}, _} -> symb_var(m) {arg, i} -> case Keyword.get(opts, :cmd_args, true) do true -> inspectx(arg, opts) false -> "arg#{n}_#{i+1}" end end) |> Enum.join(", ") "#{symb_var(n)} = #{inspect mod}.#{fun}(#{args})" end defp symb_var(x) when is_integer(x), do: "var#{x}" defp symb_var(x) when is_atom(x), do: "var_#{x}" defp alias_module(module, module) when is_atom(module), do: alias_module(module, [module]) defp alias_module(module, {module, alias}) when is_atom(module) and is_atom(alias), do: alias_module(module, [{module, alias}]) defp alias_module(module, aliases) when is_list(aliases) do mod_split = Module.split(module) aliased = for a <- aliases do {alias_split, alias_as} = case a do {m, a} when is_atom(m) and is_atom(a) -> {Module.split(m), Module.split(a)} m when is_atom(m) -> m = Module.split(m) {m, [List.last(m)]} end case :lists.prefix(alias_split, mod_split) do false -> module true -> mod_tail = Enum.drop(mod_split, length(alias_split)) (alias_as ++ mod_tail) |> Module.concat end end [module | aliased] |> Enum.sort_by(& &1 |> to_string |> byte_size, &<=/2) |> hd end def inspectx({:exception, :error, exception, stacktrace}, _opts) do Exception.format :error, exception, stacktrace end def inspectx(x, opts) do opts = Keyword.get(opts, :inspect_opts, []) default_opts = [ pretty: true, limit: :infinity, syntax_colors: syntax_colors() ] inspect x, Keyword.merge(default_opts, opts) end # added some default colors, since GitHub Actions does not provide a colorized terminal # such that some tests fail for the Reporter. defp syntax_colors do default_colors = [ atom: :cyan, string: :green, list: :default_color, boolean: :magenta, nil: :magenta, tuple: :default_color, binary: :default_color, map: :default_color ] case IEx.Config.color(:syntax_colors) do [] -> default_colors kw when is_list(kw) -> kw _anything_else -> default_colors end end end