test/support/test_mpv_server.ex

defmodule ExshomeTest.TestMpvServer do
  @moduledoc """
  Test MPV server. You can use it to emulate an MPV server.
  """
  use GenServer

  alias ExUnit.Callbacks
  import ExUnit.Assertions
  alias ExshomeTest.Fixtures
  import ExshomeTest.TestHelpers, only: [assert_receive_dependency: 1]
  alias ExshomePlayer.Services.{MpvServer, MpvSocket}

  defmodule State do
    @moduledoc """
    A structure to represent internal state of test MPV server.
    """
    defstruct [
      :server,
      :connection,
      :response_fn,
      observed_properties: MapSet.new(),
      received_messages: [],
      playlist: []
    ]

    @type t() :: %__MODULE__{
            connection: :gen_tcp.socket() | nil,
            received_messages: [term()],
            server: :gen_tcp.socket() | nil,
            observed_properties: MapSet.t(),
            response_fn: ExshomeTest.TestMpvServer.response_fn() | nil,
            playlist: [String.t()]
          }
  end

  defmodule Arguments do
    @moduledoc """
    Arguments to start a test MPV server.
    """
    @enforce_keys [:init_fn]
    defstruct [:init_fn]

    @type t() :: %__MODULE__{init_fn: (() -> any())}
  end

  @type response_fn() :: (request_id :: String.t(), data :: map() -> map())

  @spec generate_random_tracks(Range.t()) :: :ok
  def generate_random_tracks(amount \\ 1..10) do
    amount = Enum.random(amount)

    for _ <- 1..amount do
      file_name = "track_#{Fixtures.unique_integer()}.mp3"

      :ok =
        MpvServer.music_folder()
        |> Path.join(file_name)
        |> File.touch!()
    end

    :ok
  end

  @spec received_messages() :: [map()]
  def received_messages do
    received_messages(test_server())
  end

  @spec received_messages(pid()) :: [map()]
  def received_messages(server) do
    GenServer.call(server, :messages)
  end

  @spec last_received_message() :: map()
  def last_received_message do
    [message | _] = received_messages()
    message
  end

  @spec test_server() :: pid()
  defp test_server do
    Process.get(TestMpvServer) || raise "Test server not found"
  end

  @spec set_test_server(pid()) :: term()
  defp set_test_server(server) do
    Process.put(TestMpvServer, server)
  end

  @spec set_events([%{}]) :: term()
  defp set_events(events) do
    Process.put(:events, events)
  end

  def respond_with_errors do
    set_response_fn(fn request_id, _ ->
      %{request_id: request_id, error: "some error #{Fixtures.unique_integer()}"}
    end)
  end

  @spec set_response_fn(function :: response_fn()) :: term()
  def set_response_fn(function) do
    set_response_fn(test_server(), function)
  end

  @spec set_response_fn(server :: pid(), response_fn :: response_fn()) :: term()
  def set_response_fn(server, response_fn) do
    GenServer.call(server, {:set_response_fn, response_fn})
  end

  @spec server_fixture() :: term()
  def server_fixture do
    my_pid = self()

    server =
      Callbacks.start_supervised!({
        __MODULE__,
        %Arguments{
          init_fn: fn -> ExshomeTest.TestRegistry.allow(my_pid, self()) end
        }
      })

    set_events([])
    set_test_server(server)
    server
  end

  @spec stop_server() :: :ok
  def stop_server do
    :ok = Callbacks.stop_supervised!(ExshomeTest.TestMpvServer)
  end

  @spec start_link(Arguments.t()) :: GenServer.on_start()
  def start_link(%Arguments{} = init_arguments) do
    GenServer.start_link(__MODULE__, init_arguments)
  end

  @spec send_event(map()) :: map()
  def send_event(event) do
    send_event(test_server(), event)
  end

  @spec send_event(server :: pid(), event :: %{}) :: term()
  def send_event(server, event) do
    GenServer.call(server, {:event, event})
  end

  @spec playlist() :: list(String.t())
  def playlist do
    GenServer.call(test_server(), :get_playlist)
  end

  def wait_until_socket_disconnects do
    assert_receive_dependency({MpvSocket, :disconnected})
  end

  def wait_until_socket_connects do
    assert_receive_dependency({MpvSocket, :connected})
  end

  @impl GenServer
  def init(%Arguments{init_fn: init_fn}) do
    init_fn.()
    socket_path = MpvServer.socket_path()
    File.rm(socket_path)

    {:ok, server} =
      :gen_tcp.listen(0, [
        {:ip, {:local, socket_path}},
        :binary,
        {:packet, :line},
        reuseaddr: true
      ])

    state = %State{server: server}
    {:ok, state, {:continue, :accept_connection}}
  end

  @impl GenServer
  def handle_continue(:accept_connection, %State{} = state) do
    {:ok, connection} = :gen_tcp.accept(state.server)
    new_state = %State{state | connection: connection}
    {:noreply, new_state}
  end

  @impl GenServer
  def handle_info({:tcp, port, message}, %State{} = state) when port == state.connection do
    decoded = Jason.decode!(message)
    new_state = Map.update!(state, :received_messages, &[decoded | &1])

    request_id = decoded["request_id"]

    if new_state.response_fn do
      response = state.response_fn.(request_id, decoded)
      send_data(state, response)
      {:noreply, new_state}
    else
      default_response_handler(request_id, decoded, new_state)
    end
  end

  @impl GenServer
  def handle_call(:messages, _from, %State{} = state) do
    {:reply, state.received_messages, state}
  end

  @impl GenServer
  def handle_call(:get_playlist, _from, %State{} = state) do
    {:reply, state.playlist, state}
  end

  @impl GenServer
  def handle_call({:event, event}, _from, %State{} = state) do
    send_client_event(state, event)
    {:reply, :ok, state}
  end

  @impl GenServer
  def handle_call({:set_response_fn, response_fn}, _from, %State{} = state) do
    new_state = %State{state | response_fn: response_fn}
    {:reply, :ok, new_state}
  end

  @spec default_response_handler(
          request_id :: String.t(),
          request_data :: map(),
          state :: State.t()
        ) :: {:noreply, State.t()}
  def default_response_handler(
        request_id,
        %{"command" => [command_name | args]},
        %State{} = state
      ) do
    new_state = handle_command(command_name, args, state)
    send_data(new_state, %{request_id: request_id, error: "success"})
    {:noreply, new_state}
  end

  def default_response_handler(request_id, _request_data, %State{} = state) do
    send_data(state, %{test: 123, request_id: request_id, error: "success"})
    {:noreply, state}
  end

  @spec handle_command(command_name :: String.t(), args :: list(), state :: State.t()) ::
          State.t()
  defp handle_command("observe_property", [_subscription_id, property_name], %State{} = state) do
    %State{
      state
      | observed_properties: MapSet.put(state.observed_properties, property_name)
    }
    |> update_property(property_name, nil)
  end

  defp handle_command("loadfile", [path], %State{} = state) do
    %State{
      state
      | playlist: [path | state.playlist]
    }
    |> update_property("path", path)
  end

  defp handle_command("playlist-clear", [], %State{} = state) do
    %State{state | playlist: []}
  end

  defp handle_command("set_property", [property_name, value], %State{} = state) do
    update_property(state, property_name, value)
  end

  defp handle_command("seek", [value, "absolute"], %State{} = state) do
    update_property(state, "time-pos", value)
  end

  defp handle_command("stop", [], %State{} = state) do
    for property <- state.observed_properties, reduce: %State{state | playlist: []} do
      state -> update_property(state, property, nil)
    end
  end

  @spec update_property(state :: State.t(), property_name :: String.t(), value :: term()) ::
          State.t()
  defp update_property(%State{} = state, property_name, value) do
    if MapSet.member?(state.observed_properties, property_name) do
      send_client_event(state, %{event: "property-change", name: property_name, data: value})
    end

    state
  end

  defp send_client_event(%State{} = state, event) do
    send_data(state, event)
  end

  defp send_data(%State{} = state, data) do
    json_data = Jason.encode!(data)
    :gen_tcp.send(state.connection, "#{json_data}\n")
  end
end