lib/finitomata/test/ex_unit.ex

defmodule Finitomata.ExUnit do
  @moduledoc """
  Helpers and assertions to make `Finitomata` implementation easily testable.
  """
  alias Finitomata.TestTransitionError

  @doc false
  def estructura_path({{:., _, [{hd, _, _}, tl]}, _, []}) do
    [tl | estructura_path(hd)]
  end

  def estructura_path({leaf, _, args}) when args in [nil, []], do: estructura_path(leaf)
  def estructura_path(leaf), do: [leaf]

  setup_schema = [
    fsm: [
      required: true,
      type: :non_empty_keyword_list,
      doc: "The _FSM_ declaration to be used in tests.",
      keys: [
        id: [
          required: false,
          type: :any,
          default: nil,
          doc: "The ID of the `Finitomata` tree."
        ],
        implementation: [
          required: true,
          type: {:custom, Finitomata, :behaviour, [Finitomata]},
          doc: "The implementatoin of `Finitomata` (the module with `use Finitomata`.)"
        ],
        name: [
          required: false,
          type: :string,
          doc: "The name of the `Finitomata` instance."
        ],
        payload: [
          required: true,
          type: :any,
          doc: "The initial payload for the _FSM_ to start with."
        ],
        options: [
          required: false,
          type: :keyword_list,
          default: [],
          doc: "Additional options to use in _FSM_ initialization.",
          keys: [
            transition_count: [
              required: false,
              type: :non_neg_integer,
              doc: "The expected by `Mox` number of transitions to handle."
            ]
          ]
        ]
      ]
    ],
    context: [
      required: false,
      type: :keyword_list,
      doc: "The additional context to be passed to actual `ExUnit.Callbacks.setup/2` call."
    ]
  ]

  @setup_schema NimbleOptions.new!(setup_schema)

  @doc """
  Setups `Finitomata` for testing in the case and/or in `ExUnit.Case.describe/2` block.

  It would effectively init the _FSM_ with an underlying call to `init_finitomata/5`,
    and put `finitomata` key into `context`, assigning `:test_pid` subkey to the `pid`
    of the running test process, and mixing `:context` content into test context.

  Although one might pass the name, it’s more convenient to avoid doing it, in this case
    the name would be assigned from the test name, which guarantees uniqueness of
    _FSM_s running in concurrent environment.

  It should return the keyword which would be validated with `NimbleOptions` schema

  #{NimbleOptions.docs(@setup_schema)}

  _Example:_

  ```elixir
  describe "MyFSM tests" do
    setup_finitomata do
      parent = self()

      [
        fsm: [implementation: MyFSM, payload: %{}],
        context: [parent: parent]
      ]
    end

    …
  ```
  """
  defmacro setup_finitomata(do: block) do
    quote generated: true, location: :keep do
      fsm_setup = NimbleOptions.validate!(unquote(block), unquote(Macro.escape(@setup_schema)))

      @fini Keyword.fetch!(fsm_setup, :fsm)
      @fini_context Keyword.get(fsm_setup, :context, [])
      @fini_implementation @fini[:implementation]

      setup ctx do
        fini =
          @fini
          |> update_in([:name], fn
            nil -> ctx.test
            other -> other
          end)
          |> Map.new()

        init_finitomata(fini.id, @fini_implementation, fini.name, fini.payload, fini.options)
        Keyword.put(@fini_context, :finitomata, %{test_pid: self(), fsm: Map.new(fini)})
      end
    end
  end

  @doc """
  This macro initiates the _FSM_ implementation specified by arguments passed.

  **NB** it’s not recommended to use low-level helpers, normally one should
    define an _FSM_ in `setup_finitomata/1` block, which would initiate
    the _FSM_ amongs other things.

  _Arguments:_

  - `id` — a `Finitomata` instance, carrying multiple _FSM_s
  - `impl` — the module implementing _FSM_ (having `use Finitomata` clause)
  - `name` — the name of the _FSM_
  - `payload` — the initial payload for this _FSM_
  - `options` — the options to control the test, such as
    - `transition_count` — the number of expectations to declare (defaults to number of states)

  Once called, this macro will start `Finitomata.Suprevisor` with the `id` given,
    define a mox for `impl` unless already efined,
    `Mox.allow/3` the _FSM_ to call testing process,
    and expectations as a listener to `after_transition/3` callback,
    sending a message of a shape `{:on_transition, id, state, payload}` to test process.

  Then it’ll start _FSM_ and ensure it has entered `Finitomata.Transition.entry/2` state.
  """
  defmacro init_finitomata(id \\ nil, impl, name, payload, options \\ []) do
    require_ast = quote generated: true, location: :keep, do: require(unquote(impl))

    init_ast =
      quote generated: true,
            location: :keep,
            bind_quoted: [id: id, impl: impl, name: name, payload: payload, options: options] do
        mock = Module.concat(impl, "Mox")
        fsm_name = {:via, Registry, {Finitomata.Supervisor.registry_name(id), name}}
        transition_count = Keyword.get(options, :transition_count, Enum.count(impl.states()))

        parent = self()

        unless Code.ensure_loaded?(mock),
          do: Mox.defmock(mock, for: Finitomata.Listener)

        start_supervised({Finitomata.Supervisor, id: id})

        mock
        |> allow(parent, fn -> GenServer.whereis(fsm_name) end)
        |> expect(:after_transition, transition_count, fn id, state, payload ->
          parent |> send({:on_transition, id, state, payload}) |> then(fn _ -> :ok end)
        end)

        Finitomata.start_fsm(id, impl, name, payload)

        entry_state = impl.entry()
        assert_receive {:on_transition, ^fsm_name, ^entry_state, ^payload}, 1_000
      end

    [require_ast, init_ast]
  end

  @doc """
  Convenience macro to assert a transition initiated by `event_payload`
    argument on the _FSM_ defined by the test context previously setup
    with a call to `setup_finitomata/1`.

  Last regular argument in a call to `assert_transition/3` would be an
    `event_payload` in a form of `{event, payload}`, or just `event`
    for no payload.

  `to_state` argument would be matched to the resulting state of the transition,
    and `block` accepts validation of the `payload` after transition in a form of

  ```elixir
  test "some", ctx do
    assert_transition ctx, {:increase, 1} do
      :counted ->
        assert_payload do
          user_data.counter ~> 2
          internals.pid ~> ^parent
        end
        # or: assert_payload %{user_data: %{counter: 2}, internals: %{pid: ^parent}}

        assert_receive {:increased, 2}
    end
  end
  ```
  """
  defmacro assert_transition(ctx, event_payload, do: block) do
    quote do
      fsm =
        case unquote(ctx) do
          %{finitomata: %{fsm: fsm}} ->
            fsm

          other ->
            raise TestTransitionError,
              message:
                "in order to use `assert_transition/3` one should declare _FSM_ in `setup_finitomata/1` callback"
        end

      assert_transition(fsm.id, fsm.implementation, fsm.name, unquote(event_payload),
        do: unquote(block)
      )
    end
  end

  @doc """
  Convenience macro to assert a transition initiated by `event_payload`
    argument on the _FSM_ defined by first three arguments.

  **NB** it’s not recommended to use low-level helpers, normally one should
    define an _FSM_ in `setup_finitomata/1` block and use `assert_transition/3`
    or even better `test_path/3`.

  Last regular argument in a call to `assert_transition/3` would be an
    `event_payload` in a form of `{event, payload}`, or just `event`
    for no payload.

  `to_state` argument would be matched to the resulting state of the transition,
    and `block` accepts validation of the `payload` after transition in a form of

  ```elixir
  parent = self()

  assert_transition id, impl, name, {:increase, 1} do
    :counted ->
      assert_payload do
        user_data.counter ~> 2
        internals.pid ~> ^parent
      end
      # or: assert_payload %{user_data: %{counter: 2}, internals: %{pid: ^parent}}
      assert_receive {:increased, 2}
  end
  ```
  """
  defmacro assert_transition(id \\ nil, impl, name, event_payload, do: block),
    do: do_assert_transition(id, impl, name, event_payload, do: block)

  defp do_assert_transition(id, impl, name, event_payload, do: block) do
    states_with_assertions =
      block
      |> unblock()
      |> Enum.map(fn {:->, meta, [[state], conditions]} ->
        line = Keyword.get(meta, :line, 1)

        assertions =
          conditions
          |> unblock()
          |> Enum.flat_map(fn
            :ok ->
              []

            {:assert_payload, _meta, [[do: matches]]} ->
              do_handle_matches(matches)

            {:assert_payload, _meta, [assertion]} ->
              [quote(do: assert(unquote(assertion) = payload))]

            {:refute_receive, _, _} = ast ->
              [ast]

            {:assert_receive, _, _} = ast ->
              [ast]

            other ->
              content = other |> Macro.to_string() |> String.split("\n") |> hd() |> String.trim()

              raise TestTransitionError,
                message:
                  "clauses in a call to `assert_transition/5` must be either `:ok`, or `payload.inner.struct ~> match`, given:\n" <>
                    Exception.format_snippet(%{content: content <> " …", offset: 0}, line)
          end)

        {state, assertions}
      end)

    if Enum.empty?(states_with_assertions) do
      raise TestTransitionError,
        message:
          "handler in `assert_transition/5` for event #{inspect(event_payload)} must have at least one clause"
    end

    states = Keyword.keys(states_with_assertions)

    guard_ast =
      quote generated: true,
            location: :keep,
            bind_quoted: [impl: impl, event_name: event_name(event_payload), states: states] do
        [state | continuation] = states

        transitions =
          :fsm
          |> impl.__config__()
          |> Enum.filter(&match?(%Finitomata.Transition{to: ^state, event: ^event_name}, &1))

        case states -- impl.__config__(:states) do
          [] -> :ok
          some -> raise TestTransitionError, transition: transitions, unknown_states: some
        end

        expected_continuation =
          :transitions
          |> Finitomata.Transition.continuation(
            state,
            Keyword.values(impl.__config__(:hard))
          )
          |> Enum.map(& &1.to)

        [continuation, expected_continuation]
        |> Enum.map(&MapSet.new/1)
        |> Enum.reduce(&MapSet.equal?/2)
        |> unless do
          raise TestTransitionError,
            transition: transitions,
            missing_states: expected_continuation -- continuation,
            unknown_states: continuation -- expected_continuation
        end
      end

    init_ast =
      quote generated: true, location: :keep do
        fsm_name =
          {:via, Registry, {Finitomata.Supervisor.registry_name(unquote(id)), unquote(name)}}
      end

    assertion_ast =
      states_with_assertions
      |> Enum.with_index()
      |> Enum.map(fn {{to_state, ast}, idx} ->
        transition_ast =
          if idx == 0 do
            quote do: Finitomata.transition(unquote(id), unquote(name), unquote(event_payload))
          end

        action_ast =
          quote generated: true, location: :keep do
            to_state = unquote(to_state)
            assert_receive {:on_transition, ^fsm_name, ^to_state, payload}, 1_000
            unquote(ast)
          end

        quote generated: true, location: :keep do
          unquote(transition_ast)
          unquote(action_ast)
        end
      end)

    quote generated: true, location: :keep do
      unquote(init_ast)
      unquote(guard_ast)
      unquote(assertion_ast)
    end
  end

  @doc """
  Convenience macro to test the whole _Finitomata_ path,
    from starting to ending state.

  Must be used with a `setup_finitomata/1` callback.

  _Example:_

  ```elixir
    test_path "The only path", %{finitomata: %{test_pid: parent}} do
      {:start, self()} ->
        assert_state :started do
          assert_payload do
            internals.counter ~> 1
            pid ~> ^parent
          end

          assert_receive {:on_start, ^parent}
        end

      :do ->
        assert_state :done do
          assert_receive :on_do
        end

        assert_state :* do
          assert_receive :on_end
        end
    end
  ```
  """
  defmacro test_path(test_name, ctx \\ quote(do: _), do: block) do
    quote generated: true, location: :keep do
      test unquote(test_name), unquote(ctx) = ctx do
        fsm =
          case ctx do
            %{finitomata: %{fsm: fsm}} ->
              fsm

            other ->
              raise TestTransitionError,
                message:
                  "in order to use `test_path/3` one should declare _FSM_ in `setup_finitomata/1` callback"
          end

        test_path_transitions(
          fsm.id,
          fsm.implementation,
          fsm.name,
          do: unquote(block)
        )
      end
    end
  end

  @doc false
  defmacro test_path(test_name, id \\ nil, impl, name, initial_payload, context \\ [], do: block),
    do: do_test_path(test_name, id, impl, name, initial_payload, context, do: unblock(block))

  defp do_test_path(test_name, id, impl, name, initial_payload, context, do: block) do
    expanded_context = [
      quote do
        init_finitomata(
          unquote(id),
          unquote(impl),
          unquote(name),
          unquote(initial_payload)
        )
      end
      | Enum.map(context, fn {var, val} ->
          {:=, [], [Macro.var(var, nil), val]}
        end)
    ]

    quote generated: true, location: :keep do
      test unquote(test_name), ctx do
        unquote(expanded_context)
        test_path_transitions(unquote(id), unquote(impl), unquote(name), do: unquote(block))
      end
    end
  end

  @doc false
  defmacro test_path_transitions(id, impl, name, do: block) do
    block
    |> unblock()
    |> Enum.map(fn {:->, _meta, [[event_payload], state_assertions]} ->
      state_assertions_ast =
        state_assertions
        |> unblock()
        |> Enum.map(fn {:assert_state, meta, [state, [do: block]]} ->
          {:->, meta, [[state], {:__block__, meta, unblock(block)}]}
        end)

      {event_payload,
       do_assert_transition(id, impl, name, event_payload, do: state_assertions_ast)}
    end)
  end

  defp event_name({event, _payload}) when is_atom(event), do: event
  defp event_name(event) when is_atom(event), do: event

  defp unblock({:__block__, _, block}), do: unblock(block)
  defp unblock(block), do: List.wrap(block)

  defp do_handle_matches([]), do: []

  defp do_handle_matches([{:->, meta, [[{_, _, _} = var], match_ast]} | more]),
    do: do_handle_matches([{:~>, meta, [var, match_ast]} | more])

  defp do_handle_matches([{:~>, _meta, [{_, _, _} = var, match_ast]} | more]) do
    path = var |> estructura_path() |> Enum.reverse()

    match =
      quote do
        assert unquote(match_ast) = get_in(payload, unquote(path))
      end

    [match | do_handle_matches(more)]
  end

  defp do_handle_matches(any), do: any |> unblock() |> do_handle_matches()
end