lib/test_utils/stream_data_vtc.ex

if Code.ensure_loaded?(StreamData) do
  defmodule Vtc.TestUtils.StreamDataVtc do
    @moduledoc """
    `StreamData` generators for use in tests that involve custom Ecto types. For use in
    property tests.
    """

    alias Vtc.Framerate
    alias Vtc.Framestamp
    alias Vtc.Rates
    alias Vtc.Utils.DropFrame

    require ExUnit.Assertions

    @doc """
    Yields rational values as %Ratio{} structs.

    ## Options

    - `numerator`: A static value to use for the numerator of all generated rationals.
      Default: nil.

    - `denominator`: A static value to use for the denominator of all generated rationals.
      Default: nil.

    - `positive?`: If true, only positive values (greater than 0) are generated.
      Default: `false`.

    ## Examples

    ```elixir
    property "prop test" do
      check all(fraction <- StreamDataVtc.rational()) do
        ...
      end
    end
    ```
    """
    @spec rational(numerator: integer(), denominator: pos_integer(), positive?: boolean()) :: StreamData.t(Ratio.t())
    def rational(opts \\ []) do
      numerator = Keyword.get(opts, :numerator)
      denominator = Keyword.get(opts, :denominator)
      positive? = Keyword.get(opts, :positive?, false)

      numerator_gen = if positive?, do: StreamData.positive_integer(), else: StreamData.integer()
      numerator_gen = if is_integer(numerator), do: StreamData.constant(numerator), else: numerator_gen

      denominator_gen =
        if is_integer(denominator), do: StreamData.constant(denominator), else: StreamData.positive_integer()

      {numerator_gen, denominator_gen}
      |> StreamData.tuple()
      |> StreamData.map(fn {numerator, denominator} ->
        Ratio.new(numerator, denominator)
      end)
    end

    @typedoc """
    Describes the opts that can be passed to `framerate/1`.
    """
    @type framerate_opts() :: [type: :whole | :fractional | :drop | :non_drop]

    @doc """
    Yields Vtc.Framerates, always yields true-frame or NTSC; never a mixture of the two.

    ## Options

    - `type`: The ntsc value all framerates should be generated with. Can be any of the
      following:

        - `:whole`: All yielded framerates will be non-ntsc, whole-frame rates. Ex: 24/1
          fps.
        - `:fractional`: All yielded framerates will be a random non-drop rate.
        - `:non_drop`: All yielded framerates will be NTSC, non-drop values.
        - `:drop`: All yielded framerates will be NTSC, drop-frame values.

        A list of the above options may be passed and each value yielded from this
        generator will pick randomly from them.

        Defaults to `[:whole, :fractional, :non_drop, :drop]`

    ## Examples

    ```elixir
    property "prop test" do
      check all(framerate <- StreamDataVtc.framerate()) do
        ...
      end
    end
    ```
    """
    @spec framerate(framerate_opts()) :: StreamData.t(Framerate.t())
    def framerate(opts \\ []) do
      ntsc_list =
        case Keyword.get(opts, :type, [:whole, :fractional, :non_drop, :drop]) do
          ntsc_list when is_list(ntsc_list) -> ntsc_list
          ntsc -> [ntsc]
        end

      ntsc_gen =
        ntsc_list
        |> Enum.map(&StreamData.constant(&1))
        |> StreamData.one_of()

      numerator_gen = StreamData.positive_integer()
      denominator_gen = StreamData.positive_integer()
      drop_mult_gen = StreamData.integer(1..3)

      {numerator_gen, denominator_gen, drop_mult_gen, ntsc_gen}
      |> StreamData.tuple()
      |> StreamData.map(fn
        {numerator, _, _, :whole} ->
          numerator |> Ratio.new(1) |> Framerate.new!(ntsc: nil)

        {numerator, denominator, _, :fractional} ->
          numerator |> Ratio.new(denominator) |> Framerate.new!(ntsc: nil)

        {_, _, _, :drop} ->
          Rates.f29_97_df()

        {numerator, _, _, :non_drop} ->
          (numerator * 1000) |> Ratio.new(1001) |> Framerate.new!(ntsc: :non_drop)
      end)
    end

    @type framestamp_opts() :: [non_negative?: boolean(), rate: Framerate.t(), rate_opts: framerate_opts()]

    @doc """
    Yields Vtc.Framestamp values.

    ## Options

    - `non_negative?`: Will only return values greater than or equal to `00:00:00:00`.

    - `rate`: A framerate to use for this test. If one is not provided, a random one will
      be used.

    - `rate_opts`: Opts that should be passed to `framerate/1` when generating the
      framerate. Has no effect if `rate` is set.

    ## Examples

    ```elixir
    property "returns input of negate/1" do
      check all(positive <- StreamDataVtc.framestamp(non_negative?: true)) do
        negative = Framestamp.minus(positive)
        assert Framestamp.abs(positive) == Framestamp.abs(negative)
      end
    end
    ```
    """
    @spec framestamp(framestamp_opts()) ::
            StreamData.t(Framestamp.t())
    def framestamp(opts \\ []) do
      non_negative? = Keyword.get(opts, :non_negative?, false)
      rate = Keyword.get(opts, :rate)
      rate_opts = Keyword.get(opts, :rate_opts, [])

      frames_gen = StreamData.integer(0..20_736_000)

      frames_gen =
        if non_negative?, do: StreamData.filter(frames_gen, &(&1 >= 0)), else: StreamData.integer(-20_736_000..20_736_000)

      framerate_gen =
        case rate do
          %Framerate{} -> StreamData.constant(rate)
          nil -> framerate(rate_opts)
        end

      {frames_gen, framerate_gen}
      |> StreamData.tuple()
      |> StreamData.map(fn {frames, framerate} ->
        frames
        |> clip_drop_frames(framerate)
        |> Framestamp.with_frames!(framerate)
      end)
    end

    # Clips drop frames to the maximum legal value for SMPTE timecode.
    @spec clip_drop_frames(integer(), Framerate.t()) :: integer()
    defp clip_drop_frames(frames, %{ntsc: :drop} = rate) do
      max_frames = DropFrame.max_frames(rate)

      if frames >= 0 do
        min(frames, max_frames)
      else
        max(frames, -max_frames)
      end
    end

    defp clip_drop_frames(frames, _), do: frames

    @doc """
      Yields Vtc.Framestamp.Range values.

    ## Options

    - `rate_opts`: The options to pass to the `framerate/1` generator for this range.

    - `stamp_opts`: The options to pass to the `framestamp/1` generators for this range.

    - `filter_empty?`: If `true`, filters 0-length ranges from the output.
      Default: `false`.

    ## Examples

    ```elixir
    property "returns input of negate/1" do
      check all(positive <- StreamDataVtc.framestamp_range()) do
        ...
      end
    end
    ```
    """
    @spec framestamp_range(
            rate_opts: framerate_opts(),
            stamp_opts: framestamp_opts(),
            filter_empty?: boolean()
          ) :: StreamData.t(Framestamp.Range.t())
    def framestamp_range(opts \\ []) do
      rate_opts = Keyword.get(opts, :rate_opts, [])
      stamp_opts = Keyword.get(opts, :stamp_opts, [])
      filter_empty? = Keyword.get(opts, :filter_empty?, false)

      value_stream =
        StreamData.bind(framerate(rate_opts), fn rate ->
          stamp_opts = Keyword.put_new(stamp_opts, :rate, rate)

          {framestamp(stamp_opts), framestamp(stamp_opts)}
          |> StreamData.tuple()
          |> StreamData.map(fn {stamp_in, stamp_out} ->
            stamp_in = Enum.min([stamp_in, stamp_out], Framestamp)
            stamp_out = Enum.max([stamp_in, stamp_out], Framestamp)

            Framestamp.Range.new!(stamp_in, stamp_out)
          end)
        end)

      if filter_empty? do
        StreamData.filter(value_stream, fn range ->
          duration = Framestamp.Range.duration(range)
          not Ratio.eq?(duration.seconds, 0)
        end)
      else
        value_stream
      end
    end

    @doc """
    Runs a test, but does not fail if the operation causes a drop-frame overflow
    exception to occur.
    """
    @spec run_test_rescue_drop_overflow((() -> term())) :: term()
    def run_test_rescue_drop_overflow(test_runner) do
      test_runner.()
    rescue
      error in Framestamp.ParseError ->
        ExUnit.Assertions.assert(error.reason == :drop_frame_maximum_exceeded)
    end
  end
end