lib/github/testing/mock.ex

defmodule GitHub.Testing.Mock do
  @moduledoc """
  Internal representation of a mocked API call
  """

  @typedoc """
  Specification of arguments

  Each set of arguments can be given as a list of elements to match or a total arity. When given
  as a list, each element can be any Erlang term or the special value `:_` to match any value.
  Specifications given as a list will have higher precedence depending on the number of arguments
  that match exactly.
  """
  @type args :: [any] | non_neg_integer

  @typedoc """
  Limit to the number of times a mock can be used

  The special value `:infinity` can be used to place no limit.
  """
  @type limit :: pos_integer | :infinity

  @typedoc """
  Return value from a mocked API call

  Return values are tagged tuples with optional additional information. For example:

      {:ok, %GitHub.Repository{}}
      {:ok, %GitHub.Repository{}, code: 200}
      {:error, %GitHub.Error{}}
      {:error, %GitHub.Error{}, code: 404}

  For more on the possible values, see `GitHub.Testing`.
  """
  @type return ::
          {:ok, any}
          | {:ok, any, keyword}
          | {:error, any}
          | {:error, any, keyword}

  @typedoc """
  Return value or generator of a mocked API call

  This may be a constant (value) or a zero-arity function returning a constant (generator).
  """
  @type return_fun ::
          return
          | (-> return)
          | (... -> return)

  @typedoc """
  Mocked API call
  """
  @type t :: %__MODULE__{
          args: args,
          cache: boolean,
          function: atom,
          implicit: boolean,
          limit: limit,
          module: module,
          return: return_fun
        }

  defstruct [:args, :cache, :function, :implicit, :limit, :module, :return]

  #
  # Matching
  #

  @doc false
  @spec choose([t], [any]) :: t | nil
  def choose(mocks, args)
  def choose(nil, _args), do: nil

  def choose(mocks, args) do
    mocks
    |> filter_by_arity(args)
    |> filter_by_argument(args)
    |> Enum.sort_by(ranking_fn(args))
    |> List.first()
  end

  @spec filter_by_arity([t], [any]) :: [t]
  defp filter_by_arity(mocks, args) do
    Enum.filter(mocks, fn
      %{args: arity} when is_integer(arity) -> arity == length(args)
      %{args: mock_args} when is_list(mock_args) -> length(mock_args) == length(args)
    end)
  end

  @spec filter_by_argument([t], [any]) :: [t]
  defp filter_by_argument(mocks, args) do
    Enum.filter(mocks, fn
      %{args: arity} when is_integer(arity) ->
        true

      %{args: mock_args} ->
        Enum.zip(mock_args, args)
        |> Enum.all?(fn
          {:_, _} -> true
          {same_value, same_value} -> true
          _else -> false
        end)
    end)
  end

  @spec ranking_fn([any]) :: ([t] -> integer)
  defp ranking_fn(args) do
    args_length = length(args)

    fn
      %{args: ^args, implicit: false} -> 1
      %{args: ^args, implicit: true} -> 2
      %{args: ^args_length, implicit: false} -> 100
      %{args: ^args_length, implicit: true} -> 200
      %{args: mock_args, implicit: false} -> 100 - count_matching_args(mock_args, args)
      %{args: mock_args, implicit: true} -> 200 - count_matching_args(mock_args, args)
      _else -> 999
    end
  end

  @spec count_matching_args([any], [any], non_neg_integer) :: non_neg_integer
  defp count_matching_args(mock_args, actual_args, counter \\ 0)

  defp count_matching_args([same | rest_mock], [same | rest_real], counter) do
    count_matching_args(rest_mock, rest_real, counter + 1)
  end

  defp count_matching_args([_a | rest_mock], [_b | rest_real], counter) do
    count_matching_args(rest_mock, rest_real, counter)
  end

  defp count_matching_args([], [], counter), do: counter
end