lib/exunit_stamp_formatter.ex

defmodule ExUnit.Formatter.StampFormatter do
  use GenServer

  @cell 5
  @dir_path "/tmp/failures"     # TODO hardcoded paths
  @order_path "/tmp/test-order" # TODO hardcoded paths

  defstruct [:width, count: 0, pos: %{}, failed: []]

  @impl true
  def init(_opts) do
    pos = load_order()
    {:ok, width} = :io.columns()
    {:ok, %__MODULE__{width: width, pos: pos}}
  end

  defp report_failure({idx, [{:error, %ExUnit.AssertionError{}=ass, stack}]}) do
    err_str = ExUnit.Formatter.format_assertion_error(ass)

    stacktrace = stack
    |> Enum.map(fn entry -> case entry do
                              {_, _, _, x} -> x
                              {_, _, x} -> x
                            end
                end)
    |> Enum.map(fn x -> line = Keyword.get(x, :line)
                        file = Keyword.get(x, :file)
                        "#{file}:#{line}"
                end)
    |> Enum.join("\n")

    content = err_str <> "\n\n" <> stacktrace

    path = "#{@dir_path}/#{idx}"
    :ok = File.write!(path, content, [:write])
  end
  defp report_failure(_), do: nil

  @impl true
  def handle_cast({:suite_started, _opts}, state) do
    IO.ANSI.clear
    |> IO.puts

    {:noreply, state}
  end

  def handle_cast({:suite_finished, _times_us}, %__MODULE__{}=state) do
    IO.ANSI.default_color()
    |> IO.puts

    :ok = File.mkdir_p!(@dir_path)
    {:ok, ls} = File.ls(@dir_path)
    ls
    |> Enum.map(& @dir_path <> "/" <> &1)
    |> Enum.each(& File.rm!/1)

    Enum.each(state.failed, &report_failure/1)
    IO.puts("#{state.count} tests fin.")

    save_order(state.pos)

    {:noreply, state}
  end

  def handle_cast({:module_started, _test_module}, state) do
    {:noreply, state}
  end

  def handle_cast({:module_finished, _test_module}, state) do
    {:noreply, state}
  end

  def handle_cast({:test_started, %ExUnit.Test{module: m, name: n}},
    %__MODULE__{}=state
  ) do
    k = {m, n}
    {id, state_} = if Map.has_key?(state.pos, k) do
      id = state.pos[k]
      {id, state}
    else
      id = state.count
      state_ = %{state | pos: Map.put(state.pos, k, id)}
      {id, state_}
    end
    {y, x} = divmod(id, state.width)

    IO.ANSI.cursor(y, x * @cell + 1) <> to_string(id)
    |> IO.puts

    {:noreply, %{state_ | count: state.count + 1}}
  end

  def handle_cast({:test_finished, %ExUnit.Test{module: m, name: n, state: s}},
    %__MODULE__{pos: map}=state
  ) do
    i = map[{m, n}]
    txt = i |> to_string |> String.pad_trailing(@cell)
    stamp = case s do
      nil -> IO.ANSI.green_background <> txt
      {:excluded, _} -> ""
      {:failed, _} -> IO.ANSI.red_background <> txt
      {:invalid, _} -> IO.ANSI.crossed_out <> txt
      {:skipped, _} -> IO.ANSI.yellow_background <> txt
    end

    {y, x} = divmod(i, state.width)
    IO.ANSI.cursor(y, x * @cell + 1)
    <> stamp
    <> IO.ANSI.default_color() <> IO.ANSI.default_background()
    |> IO.puts

    {:noreply, state |> record_failure(i, s)}
  end

  def handle_cast({:sigquit, _remaining}, state) do
    {:noreply, state}
  end

  # DEPRECATED
  def handle_cast({:case_started, _}, x), do: {:noreply, x}
  # DEPRECATED
  def handle_cast({:case_finished, _}, x), do: {:noreply, x}

  def handle_cast(_, state), do: {:noreply, state}

  defp record_failure(state, i, {:failed, fail}) do
    %{state | failed: [{i, fail} | state.failed]}
  end
  defp record_failure(state, _, _), do: state

  defp save_order(pos) do
    content = pos
    |> Map.to_list()
    |> Enum.sort_by(&elem(&1, 1))
    |> Enum.map(&elem(&1, 0))
    |> Enum.map(fn {m, n} -> "#{m} #{n}" end)
    |> Enum.join("\n")

    :ok = File.write!(@order_path, content, [:write])
  end

  defp load_order() do
    path = "/tmp/test-order"
    if File.exists?(path) do
      path
      |> File.read!()
      |> String.trim_trailing()
      |> String.split("\n")
      |> Enum.map(fn s -> s |> String.split(" ", parts: 2) |> Enum.map(&String.to_atom/1) end)
      |> Enum.map(fn [module, test] -> {module, test} end)
      |> Enum.with_index()
      |> Map.new
    else
      %{}
    end
  end

  defp divmod(a, b) do
    {floor(a / b), rem(a, b)}
  end
end