diff --git a/lib/stream_data.ex b/lib/stream_data.ex index a413a7e..40a7478 100644 --- a/lib/stream_data.ex +++ b/lib/stream_data.ex @@ -2223,6 +2223,47 @@ defmodule StreamData do scale(data, fn size -> trunc(:math.pow(size, exponent)) end) end + @doc """ + Generates either `nil` or the value generated by the given `data`. + + The frequency distribution can be specified using the `ratio` option. + + ## Options + + * `:ratio` - (float in between `0.0` and `1.0`, not included) specifies the + frequency with which `nil` should be selected. Higher means that `nil` is + more likely to be selected. Defaults to `0.5`. + + ## Examples + + Enum.take(StreamData.nullable(StreamData.integer()), 10) + #=> [1, -1, nil, nil, 5, 4, -3, nil, nil, 1] + + Enum.take(StreamData.nullable(StreamData.integer(), ratio: 0.25), 10) + #=> [1, nil, -3, nil, 3, -3, 0, 3, nil, nil] + + Enum.take(StreamData.nullable(StreamData.integer(), ratio: 0.25), 10) + #=> [0, -2, 1, nil, -2, 0, -4, 4, 6, nil] + + Enum.take(StreamData.nullable(StreamData.boolean(), ratio: 0.25), 10) + #=> [false, false, false, nil, true, true, true, false, false, true] + + """ + @spec nullable(t(a), keyword()) :: t(nil | a) when a: term() + def nullable(generator, options \\ []) when is_list(options) do + ratio = Keyword.get(options, :ratio, 0.5) + + {numerator, denominator} = + if 0 <= ratio and ratio <= 1.0 do + Float.ratio(ratio) + else + raise ArgumentError, + "expected :ratio to be greater than or equal to 0.0 and less than or equal to 1.0, got: #{inspect(ratio)}" + end + + StreamData.frequency([{numerator, nil}, {denominator - numerator, generator}]) + end + @doc """ Checks the behaviour of a given function on values generated by `data`. diff --git a/test/stream_data_test.exs b/test/stream_data_test.exs index 1622733..325086b 100644 --- a/test/stream_data_test.exs +++ b/test/stream_data_test.exs @@ -745,6 +745,44 @@ defmodule StreamDataTest do end end + describe "nullable/2" do + test "with invalid options" do + data = constant(:term) + + message = + "expected :ratio to be greater than or equal to 0.0 and less than or equal to 1.0, got: 1.1" + + assert_raise ArgumentError, message, fn -> nullable(data, ratio: 1.1) end + + message = + "expected :ratio to be greater than or equal to 0.0 and less than or equal to 1.0, got: -0.1" + + assert_raise ArgumentError, message, fn -> nullable(data, ratio: -0.1) end + end + + property "returns nil or the value returned by data" do + check all maybe_nil <- nullable(constant(:term)) do + assert is_nil(maybe_nil) or maybe_nil == :term + end + end + + test "with very large chance of nils" do + values = Enum.take(nullable(:small_chance, ratio: 0.99), 1000) + + assert :small_chance in values + assert nil in values + assert Enum.count(values, &(&1 == :small_chance)) < Enum.count(values, &is_nil(&1)) + end + + test "with very small chance of nils" do + values = Enum.take(nullable(:big_chance, ratio: 0.01), 1000) + + assert :big_chance in values + assert nil in values + assert Enum.count(values, &is_nil(&1)) < Enum.count(values, &(&1 == :big_chance)) + end + end + test "check_all/3 with :os.timestamp" do options = [initial_seed: :os.timestamp()]