Packages

Tools for tighter (more static) programming in Elixir with a particular focus on distinguishing types of binary data and pushing offensive programming capabilities to their limits.

Current section

Files

Jump to
uptight lib uptight assertions formatter.ex
Raw

lib/uptight/assertions/formatter.ex

defmodule Uptight.Assertions.Formatter do
@moduledoc """
Helper functions for formatting and the formatting protocols.
Formatters are `GenServer`s specified during Uptight configuration
that receive a series of events as casts.
The following events are possible:
* `{:suite_started, opts}` -
the suite has started with the specified options to the runner.
* `{:suite_finished, times_us}` -
the suite has finished. Returns several measurements in microseconds
for running the suite. See `t:times_us` for more information.
* `{:module_started, test_module}` -
a test module has started. See `Uptight.TestModule` for details.
* `{:module_finished, test_module}` -
a test module has finished. See `Uptight.TestModule` for details.
* `{:test_started, test}` -
a test has started. See `Uptight.Test` for details.
* `{:test_finished, test}` -
a test has finished. See `Uptight.Test` for details.
* `{:sigquit, [test | test_module]}` -
the VM is going to shutdown. It receives the test cases (or test
module in case of `setup_all`) still running.
The formatter will also receive the following events but they are deprecated
and should be ignored:
* `{:case_started, test_module}` -
a test module has started. See `Uptight.TestModule` for details.
* `{:case_finished, test_module}` -
a test module has finished. See `Uptight.TestModule` for details.
The full Uptight configuration is passed as the argument to `c:GenServer.init/1` callback when the
formatters are started. If you need to do runtime configuration of a
formatter, you can add any configuration needed by using `Uptight.configure/1`
or `Uptight.start/1`, and this will then be included in the options passed to
the `c:GenServer.init/1` callback.
"""
@type id :: term
# @type test :: Uptight.Test.t()
@typedoc """
The times spent on several parts of the test suite.
The following properties can be computed:
sync = run - (async || 0)
total = run + (load || 0)
`async` is nil when there are no async tests.
`load` is nil when the test suite is running and loading
tests concurrently.
"""
@type times_us :: %{
run: pos_integer,
async: pos_integer | nil,
load: pos_integer | nil
}
import Exception, only: [format_stacktrace_entry: 1, format_file_line: 3]
alias Uptight.Assertions.Diff
alias Inspect.Algebra
@counter_padding " "
@mailbox_label_padding @counter_padding <> " "
@formatter_exceptions [Uptight.AssertionError, FunctionClauseError]
@no_value Uptight.AssertionError.no_value()
@doc """
Formats time taken running the test suite.
## Examples
iex> format_times(%{run: 10000, async: nil, load: nil})
"Finished in 0.01 seconds (0.00s async, 0.01s sync)"
iex> format_times(%{run: 10000, async: nil, load: 20000})
"Finished in 0.03 seconds (0.02s on load, 0.00s async, 0.01s sync)"
iex> format_times(%{run: 10000, async: nil, load: 200_000})
"Finished in 0.2 seconds (0.2s on load, 0.00s async, 0.01s sync)"
iex> format_times(%{run: 100_000, async: 50000, load: 200_000})
"Finished in 0.3 seconds (0.2s on load, 0.05s async, 0.05s sync)"
"""
@spec format_times(times_us) :: String.t()
def format_times(times) do
run_us = normalize_us(times.run)
load_us = normalize_us(times.load)
async_us = normalize_us(times.async)
sync_us = run_us - async_us
total_us = run_us + load_us
maybe_load =
if times.load do
"#{format_us(load_us)}s on load, "
else
""
end
"Finished in #{format_us(total_us)} seconds " <>
"(#{maybe_load}#{format_us(async_us)}s async, #{format_us(sync_us)}s sync)"
end
defp normalize_us(nil), do: 0
defp normalize_us(us), do: div(us, 10_000)
defp format_us(us) do
if us < 10 do
"0.0#{us}"
else
us = div(us, 10)
"#{div(us, 10)}.#{rem(us, 10)}"
end
end
# Deprecate me on Elixir v1.16
@doc false
def format_time(run, load) do
format_times(%{run: run, load: load, async: nil})
end
@doc """
Formats filters used to constrain cases to be run.
## Examples
iex> format_filters([run: true, slow: false], :include)
"Including tags: [run: true, slow: false]"
"""
@spec format_filters(keyword, atom) :: String.t()
def format_filters(filters, type) do
case type do
:exclude -> "Excluding tags: #{inspect(filters)}"
:include -> "Including tags: #{inspect(filters)}"
end
end
@doc false
def format_assertion_error(%Uptight.AssertionError{} = struct) do
format_exception(%{}, struct, [], :infinity, fn _, msg -> msg end, "") |> elem(0)
end
defp format_exception(test, %Uptight.AssertionError{} = struct, stack, width, formatter, pad) do
label_padding_size = if has_value?(struct.right), do: 7, else: 6
padding_size = label_padding_size + byte_size(@counter_padding)
code_multiline =
if struct.doctest != @no_value,
do: &pad_multiline(&1, padding_size),
else: &code_multiline(&1, padding_size)
formatted =
[
note: if_value(struct.message, &format_message(&1, formatter)),
doctest: if_value(struct.doctest, &pad_multiline(&1, 2 + byte_size(@counter_padding))),
code: if_value(struct.expr, code_multiline),
code: unless_value(struct.expr, fn -> get_code(test, stack) || @no_value end),
arguments: if_value(struct.args, &format_args(&1, width))
]
|> Kernel.++(format_context(struct, formatter, padding_size, width))
|> format_meta(formatter, pad, label_padding_size)
|> IO.iodata_to_binary()
{formatted, stack}
end
defp format_exception(test, %FunctionClauseError{} = struct, stack, _width, formatter, _pad) do
{blamed, stack} = Exception.blame(:error, struct, stack)
banner = Exception.format_banner(:error, struct)
blamed = FunctionClauseError.blame(blamed, &inspect/1, &blame_match(&1, formatter))
message = error_info(banner, formatter) <> "\n" <> pad(String.trim_leading(blamed, "\n"))
{message <> format_code(test, stack, formatter), stack}
end
@doc false
@deprecated "Use Uptight.Assertions.Formatter.format_test_all_failure/5 instead"
def format_test_case_failure(test_case, failures, counter, width, formatter) do
format_test_all_failure(test_case, failures, counter, width, formatter)
end
@doc """
Receives a test module and formats its failure.
"""
def format_test_all_failure(test_module, failures, counter, width, formatter) do
name = test_module.name
test_module_info(with_counter(counter, "#{inspect(name)}: "), formatter) <>
Enum.map_join(Enum.with_index(failures), "", fn {{kind, reason, stack}, index} ->
{text, stack} = format_kind_reason(test_module, kind, reason, stack, width, formatter)
failure_header(failures, index) <> text <> format_stacktrace(stack, name, nil, formatter)
end)
end
defp format_kind_reason(test, :error, %mod{} = struct, stack, width, formatter)
when mod in @formatter_exceptions do
format_exception(test, struct, stack, width, formatter, @counter_padding)
end
defp format_kind_reason(test, kind, reason, stack, width, formatter) do
case linked_or_trapped_exit(kind, reason) do
{header, wrapped_reason, wrapped_stack} ->
struct = Exception.normalize(:error, wrapped_reason, wrapped_stack)
{formatted_reason, wrapped_stack} =
format_exception(test, struct, wrapped_stack, width, formatter, @counter_padding)
formatted_stack = format_stacktrace(wrapped_stack, test.module, test.name, formatter)
{error_info(header, formatter) <> pad(formatted_reason <> formatted_stack), stack}
:error ->
{reason, stack} = Exception.blame(kind, reason, stack)
message = error_info(Exception.format_banner(kind, reason), formatter)
{message <> format_code(test, stack, formatter), stack}
end
end
defp linked_or_trapped_exit({:EXIT, pid}, {reason, [_ | _] = stack})
when reason.__struct__ in @formatter_exceptions
when reason == :function_clause do
{"** (EXIT from #{inspect(pid)}) an exception was raised:\n", reason, stack}
end
defp linked_or_trapped_exit(:exit, {{reason, [_ | _] = stack}, {mod, fun, args}})
when is_atom(mod) and is_atom(fun) and is_list(args) and
reason.__struct__ in @formatter_exceptions
when is_atom(mod) and is_atom(fun) and is_list(args) and reason == :function_clause do
{
"** (exit) exited in: #{Exception.format_mfa(mod, fun, args)}\n ** (EXIT) an exception was raised:",
reason,
stack
}
end
defp linked_or_trapped_exit(_kind, _reason), do: :error
defp format_code(test, stack, formatter) do
if snippet = get_code(test, stack) do
" " <> formatter.(:extra_info, "code: ") <> snippet <> "\n"
else
""
end
end
defp get_code(%{module: module, name: name}, stack) do
info =
Enum.find_value(stack, fn
{^module, ^name, _, info} -> info
_ -> nil
end)
file = info[:file]
line = info[:line]
if line > 0 && file && File.exists?(file) do
file |> File.stream!() |> Enum.at(line - 1) |> String.trim()
end
rescue
_ -> nil
end
defp get_code(%{}, _) do
nil
end
defp blame_match(%{match?: true, node: node}, _formatter),
do: Macro.to_string(node)
defp blame_match(%{match?: false, node: node}, formatter),
do: formatter.(:blame_diff, Macro.to_string(node))
defp format_meta(fields, formatter, padding, padding_size) do
for {label, value} <- fields, has_value?(value) do
[padding, format_label(label, formatter, padding_size), value, "\n"]
end
end
defp if_value(value, fun) do
if has_value?(value) do
fun.(value)
else
value
end
end
defp unless_value(value, fun) do
if has_value?(value) do
@no_value
else
fun.()
end
end
defp has_value?(value) do
value != @no_value
end
defp format_label(:note, _formatter, _padding_size), do: ""
defp format_label(label, formatter, padding_size) do
formatter.(:extra_info, String.pad_trailing("#{label}:", padding_size))
end
defp format_message(value, formatter) do
value = String.replace(value, "\n", "\n" <> @counter_padding)
formatter.(:error_info, value)
end
defp format_args(args, width) do
entries =
for {arg, i} <- Enum.with_index(args, 1) do
"""
# #{i}
#{inspect_multiline(arg, 9, width)}
"""
end
["\n" | entries]
end
@assertions [
:assert,
:assert_raise,
:assert_receive,
:assert_received,
:refute,
:refute_receive,
:refute_received
]
defp code_multiline({fun, _, [expr]}, padding_size) when fun in @assertions do
pad_multiline(Atom.to_string(fun) <> " " <> Macro.to_string(expr), padding_size)
end
defp code_multiline(expr, padding_size) do
pad_multiline(Macro.to_string(expr), padding_size)
end
defp inspect_multiline(expr, padding_size, width) do
width = if width == :infinity, do: width, else: width - padding_size
expr
|> Algebra.to_doc(%Inspect.Opts{width: width})
|> Algebra.group()
|> Algebra.nest(padding_size)
|> Algebra.format(width)
end
defp format_context(%{context: {:mailbox, _pins, []}}, _, _, _) do
[]
end
defp format_context(
%{left: left, context: {:mailbox, pins, mailbox}},
formatter,
padding_size,
width
) do
formatted_mailbox =
for message <- mailbox do
{pattern, value, _warnings} =
format_sides(
left,
message,
{:match, pins},
formatter,
padding_size + 5,
width
)
[
"\n",
@mailbox_label_padding,
format_label(:pattern, formatter, 9),
pattern,
"\n",
@mailbox_label_padding,
format_label(:value, formatter, 9),
value
]
end
[mailbox: Enum.join(formatted_mailbox, "\n")]
end
defp format_context(
%{left: left, right: right, context: context},
formatter,
padding_size,
width
) do
{left, right, extras} = format_sides(left, right, context, formatter, padding_size, width)
[left: left, right: right] ++ extras
end
defp format_sides(left, right, context, formatter, padding_size, width) do
inspect = &inspect_multiline(&1, padding_size, width)
content_width = if width == :infinity, do: width, else: width - padding_size
case format_diff(left, right, context, formatter) do
{result, env} ->
left =
result.left
|> Diff.to_algebra(&colorize_diff_delete(&1, formatter))
|> Algebra.nest(padding_size)
|> Algebra.format(content_width)
right =
result.right
|> Diff.to_algebra(&colorize_diff_insert(&1, formatter))
|> Algebra.nest(padding_size)
|> Algebra.format(content_width)
{left, right, Enum.map(env.hints, &{:hint, format_hint(&1)})}
nil when is_atom(context) ->
{if_value(left, inspect), if_value(right, inspect), []}
nil ->
{if_value(left, &code_multiline(&1, padding_size)), if_value(right, inspect), []}
end
end
defp format_hint(:equivalent_but_different_strings) do
"you are comparing strings that have the same visual representation but are made of different Unicode codepoints"
end
defp format_diff(left, right, context, formatter) do
if has_value?(left) and has_value?(right) and formatter.(:diff_enabled?, false) do
find_diff(left, right, context)
end
end
defp colorize_diff_delete(doc, formatter) do
format = colorize_format(doc, :diff_delete, :diff_delete_whitespace)
formatter.(format, doc)
end
defp colorize_diff_insert(doc, formatter) do
format = colorize_format(doc, :diff_insert, :diff_insert_whitespace)
formatter.(format, doc)
end
defp colorize_format(content, normal, whitespace) when is_binary(content) do
if String.trim_leading(content) == "", do: whitespace, else: normal
end
defp colorize_format(_doc, normal, _whitespace) do
normal
end
defp find_diff(left, right, context) do
task = Task.async(Diff, :compute, [left, right, context])
case Task.yield(task, 1500) || Task.shutdown(task, :brutal_kill) do
{:ok, diff} -> diff
nil -> nil
end
end
defp format_stacktrace([], _case, _test, _color) do
""
end
defp format_stacktrace(stacktrace, test_case, test, color) do
extra_info("stacktrace:", color) <>
Enum.map_join(stacktrace, fn entry ->
stacktrace_info(format_stacktrace_entry(entry, test_case, test), color)
end)
end
defp format_stacktrace_entry({test_case, test, _, location}, test_case, test) do
format_file_line(location[:file], location[:line], " (test)")
end
defp format_stacktrace_entry(entry, _test_case, _test) do
format_stacktrace_entry(entry)
end
defp failure_header([_], _), do: ""
defp failure_header(_, i), do: "\n#{@counter_padding}Failure ##{i + 1}\n"
defp with_counter(counter, msg) when counter < 10 do
" #{counter}) #{msg}"
end
defp with_counter(counter, msg) when counter < 100 do
" #{counter}) #{msg}"
end
defp with_counter(counter, msg) do
"#{counter}) #{msg}"
end
defp test_module_info(msg, nil),
do: msg <> "failure on setup_all callback, all tests have been invalidated\n"
defp test_module_info(msg, formatter),
do: test_module_info(formatter.(:test_module_info, msg), nil)
defp pad(msg) do
" " <> pad_multiline(msg, 5) <> "\n"
end
defp pad_multiline(expr, padding_size) when is_binary(expr) do
padding = String.duplicate(" ", padding_size)
String.replace(expr, "\n", "\n" <> padding)
end
defp error_info(msg, nil), do: pad(msg)
defp error_info(msg, formatter), do: pad(formatter.(:error_info, msg))
defp extra_info(msg, nil), do: pad(msg)
defp extra_info(msg, formatter), do: pad(formatter.(:extra_info, msg))
defp stacktrace_info("", _formatter), do: ""
defp stacktrace_info(msg, nil), do: " " <> msg <> "\n"
defp stacktrace_info(msg, formatter),
do: stacktrace_info(formatter.(:stacktrace_info, msg), nil)
end