lib/prop_check/generators.rb in prop_check-0.14.1 vs lib/prop_check/generators.rb in prop_check-0.15.0

- old
+ new

@@ -1,8 +1,8 @@ -# coding: utf-8 # frozen_string_literal: true +require 'date' require 'prop_check/generator' require 'prop_check/lazy_tree' module PropCheck ## # Contains common generators. @@ -134,37 +134,148 @@ # # Will only generate 'reals', # that is: no infinity, no NaN, # no numbers testing the limits of floating-point arithmetic. # - # Shrinks to numbers closer to zero. + # Shrinks towards zero. + # The shrinking strategy also moves towards 'simpler' floats (like `1.0`) from 'complicated' floats (like `3.76543`). # # >> Generators.real_float().sample(10, size: 10, rng: Random.new(42)) # => [-2.2, -0.2727272727272727, 4.0, 1.25, -3.7272727272727275, -8.833333333333334, -8.090909090909092, 1.1428571428571428, 0.0, 8.0] def real_float tuple(integer, integer, integer).map do |a, b, c| fraction(a, b, c) end end - @@special_floats = [Float::NAN, Float::INFINITY, -Float::INFINITY, Float::MAX, Float::MIN, 0.0.next_float, 0.0.prev_float] ## + # Generates any real floating-point numbers, + # but will never generate zero. + # c.f. #real_float + # + # >> Generators.real_nonzero_float().sample(10, size: 10, rng: Random.new(43)) + # => [-7.25, 7.125, -7.636363636363637, -3.0, -8.444444444444445, -6.857142857142857, 2.4545454545454546, 3.0, -7.454545454545455, -6.25] + def real_nonzero_float + real_float.where { |val| val != 0.0 } + end + + ## + # Generates real floating-point numbers which are never negative. + # Shrinks towards 0 + # c.f. #real_float + # + # >> Generators.real_nonnegative_float().sample(10, size: 10, rng: Random.new(43)) + # => [7.25, 7.125, 7.636363636363637, 3.0, 8.444444444444445, 0.0, 6.857142857142857, 2.4545454545454546, 3.0, 7.454545454545455] + def real_nonnegative_float + real_float.map(&:abs) + end + + ## + # Generates real floating-point numbers which are never positive. + # Shrinks towards 0 + # c.f. #real_float + # + # >> Generators.real_nonpositive_float().sample(10, size: 10, rng: Random.new(44)) + # => [-9.125, -2.3636363636363638, -8.833333333333334, -1.75, -8.4, -2.4, -3.5714285714285716, -1.0, -6.111111111111111, -4.0] + def real_nonpositive_float + real_nonnegative_float.map(&:-@) + end + + ## + # Generates real floating-point numbers which are always positive + # Shrinks towards Float::MIN + # + # Does not consider denormals. + # c.f. #real_float + # + # >> Generators.real_positive_float().sample(10, size: 10, rng: Random.new(42)) + # => [2.2, 0.2727272727272727, 4.0, 1.25, 3.7272727272727275, 8.833333333333334, 8.090909090909092, 1.1428571428571428, 2.2250738585072014e-308, 8.0] + def real_positive_float + real_nonnegative_float.map { |val| val + Float::MIN } + end + + ## + # Generates real floating-point numbers which are always negative + # Shrinks towards -Float::MIN + # + # Does not consider denormals. + # c.f. #real_float + # + # >> Generators.real_negative_float().sample(10, size: 10, rng: Random.new(42)) + # => [-2.2, -0.2727272727272727, -4.0, -1.25, -3.7272727272727275, -8.833333333333334, -8.090909090909092, -1.1428571428571428, -2.2250738585072014e-308, -8.0] + def real_negative_float + real_positive_float.map(&:-@) + end + + @@special_floats = [Float::NAN, + Float::INFINITY, + -Float::INFINITY, + Float::MAX, + -Float::MAX, + Float::MIN, + -Float::MIN, + Float::EPSILON, + -Float::EPSILON, + 0.0.next_float, + 0.0.prev_float] + ## # Generates floating-point numbers # Will generate NaN, Infinity, -Infinity, # as well as Float::EPSILON, Float::MAX, Float::MIN, # 0.0.next_float, 0.0.prev_float, # to test the handling of floating-point edge cases. - # Approx. 1/100 generated numbers is a special one. + # Approx. 1/50 generated numbers is a special one. # # Shrinks to smaller, real floats. # >> Generators.float().sample(10, size: 10, rng: Random.new(42)) - # => [4.0, 9.555555555555555, 0.0, -Float::INFINITY, 5.5, -5.818181818181818, 1.1428571428571428, 0.0, 8.0, 7.857142857142858] + # >> Generators.float().sample(10, size: 10, rng: Random.new(4)) + # => [-8.0, 2.0, 2.7142857142857144, -4.0, -10.2, -6.666666666666667, -Float::INFINITY, -10.2, 2.1818181818181817, -6.2] def float - frequency(99 => real_float, 1 => one_of(*@@special_floats.map(&method(:constant)))) + frequency(49 => real_float, 1 => one_of(*@@special_floats.map(&method(:constant)))) end ## + # Generates any nonzerno floating-point number. + # Will generate special floats (except NaN) from time to time. + # c.f. #float + def nonzero_float + float.where { |val| val != 0.0 && val } + end + + ## + # Generates nonnegative floating point numbers + # Will generate special floats (except NaN) from time to time. + # c.f. #float + def nonnegative_float + float.map(&:abs).where { |val| val != Float::NAN } + end + + ## + # Generates nonpositive floating point numbers + # Will generate special floats (except NaN) from time to time. + # c.f. #float + def nonpositive_float + nonnegative_float.map(&:-@) + end + + ## + # Generates positive floating point numbers + # Will generate special floats (except NaN) from time to time. + # c.f. #float + def positive_float + nonnegative_float.where { |val| val != 0.0 && val } + end + + ## + # Generates positive floating point numbers + # Will generate special floats (except NaN) from time to time. + # c.f. #float + def negative_float + positive_float.map(&:-@).where { |val| val != 0.0 } + end + + ## # Picks one of the given generators in `choices` at random uniformly every time. # # Shrinks to values earlier in the list of `choices`. # # >> Generators.one_of(Generators.constant(true), Generators.constant(false)).sample(5, size: 10, rng: Random.new(42)) @@ -267,11 +378,11 @@ uniq = proc { |x| x } if uniq == true if max.nil? nonnegative_integer.bind { |count| make_array(element_generator, min, count, uniq) } else - make_array(element_generator, min, max, uniq) + choose(min..max).bind { |count| make_array(element_generator, min, count, uniq) } end end private def make_array(element_generator, min, count, uniq) amount = min if count < min @@ -296,36 +407,62 @@ private def make_array_uniq(element_generator, min, amount, uniq_fun) Generator.new do |**kwargs| arr = [] uniques = Set.new count = 0 - (0..).lazy.map do - elem = element_generator.clone.generate(**kwargs) - if uniques.add?(uniq_fun.call(elem.root)) - arr.push(elem) - count = 0 - else - count += 1 - end - if count > kwargs[:max_consecutive_attempts] - if arr.size >= min - # Give up and return shorter array in this case - amount = min + if amount == 0 + LazyTree.new([]) + else + 0.step.lazy.map do + elem = element_generator.clone.generate(**kwargs) + if uniques.add?(uniq_fun.call(elem.root)) + arr.push(elem) + count = 0 else - raise Errors::GeneratorExhaustedError, "Too many consecutive elements filtered by 'uniq:'." + count += 1 end + + if count > kwargs[:max_consecutive_attempts] + if arr.size >= min + # Give up and return shorter array in this case + amount = min + else + raise Errors::GeneratorExhaustedError, "Too many consecutive elements filtered by 'uniq:'." + end + end end - end - .take_while { arr.size < amount } - .force + .take_while { arr.size < amount } + .force - LazyTree.zip(arr).map { |array| array.uniq(&uniq_fun) } + LazyTree.zip(arr).map { |array| array.uniq(&uniq_fun) } + end end end ## + # Generates a set of elements, where each of the elements + # is generated by `element_generator`. + # + # Shrinks to smaller sets (with shrunken elements). + # Accepted keyword arguments: + # + # `empty:` When false, behaves the same as `min: 1` + # `min:` Ensures at least this many elements are generated. (default: 0) + # `max:` Ensures at most this many elements are generated. When nil, an arbitrary count is used instead. (default: nil) + # + # In the set, elements are always unique. + # If it is not possible to generate another unique value after the configured `max_consecutive_attempts` + # a `PropCheck::Errors::GeneratorExhaustedError` will be raised. + # + # >> Generators.set(Generators.positive_integer).sample(5, size: 4, rng: Random.new(42)) + # => [Set[2, 4], Set[], Set[3, 4], Set[], Set[4]] + def set(element_generator, min: 0, max: nil, empty: true) + array(element_generator, min: min, max: max, empty: empty, uniq: true).map(&:to_set) + end + + ## # Generates a hash of key->values, # where each of the keys is made using the `key_generator` # and each of the values using the `value_generator`. # # Shrinks to hashes with less key/value pairs. @@ -557,12 +694,12 @@ ## # Generates common terms that are not `nil` or `false`. # # Shrinks towards simpler terms, like `true`, an empty array, a single character or an integer. # - # >> Generators.truthy.sample(5, size: 10, rng: Random.new(42)) - # => [[4, 0, -3, 10, -4, 8, 0, 0, 10], -3, [5.5, -5.818181818181818, 1.1428571428571428, 0.0, 8.0, 7.857142857142858, -0.6666666666666665, 5.25], [], ["\u{9E553}\u{DD56E}\u{A5BBB}\u{8BDAB}\u{3E9FC}\u{C4307}\u{DAFAE}\u{1A022}\u{938CD}\u{70631}", "\u{C4C01}\u{32D85}\u{425DC}"]] + # >> Generators.truthy.sample(5, size: 2, rng: Random.new(42)) + # => [[2], {:gz=>0, :""=>0}, [1.0, 0.5], 0.6666666666666667, {"𦐺\u{9FDDB}"=>1, ""=>1}] def truthy one_of(constant(true), constant([]), char, integer, @@ -572,21 +709,131 @@ array(float), array(char), array(string), hash(simple_symbol, integer), hash(string, integer), - hash(string, string) - ) + hash(string, string)) end ## # Generates whatever `other_generator` generates # but sometimes instead `nil`.` # # >> Generators.nillable(Generators.integer).sample(20, size: 10, rng: Random.new(42)) # => [9, 10, 8, 0, 10, -3, -8, 10, 1, -9, -10, nil, 1, 6, nil, 1, 9, -8, 8, 10] def nillable(other_generator) frequency(9 => other_generator, 1 => constant(nil)) + end + + ## + # Generates `Date` objects. + # DateTimes start around the given `epoch:` and deviate more when `size` increases. + # when no epoch is set, `PropCheck::Property::Configuration.default_epoch` is used, which defaults to `DateTime.now.to_date`. + # + # >> Generators.date(epoch: Date.new(2022, 01, 01)).sample(2, rng: Random.new(42)) + # => [Date.new(2021, 12, 28), Date.new(2022, 01, 10)] + def date(epoch: nil) + date_from_offset(integer, epoch: epoch) + end + + ## + # variant of #date that only generates dates in the future (relative to `:epoch`). + # + # >> Generators.future_date(epoch: Date.new(2022, 01, 01)).sample(2, rng: Random.new(42)) + # => [Date.new(2022, 01, 06), Date.new(2022, 01, 11)] + def future_date(epoch: Date.today) + date_from_offset(positive_integer, epoch: epoch) + end + + ## + # variant of #date that only generates dates in the past (relative to `:epoch`). + # + # >> Generators.past_date(epoch: Date.new(2022, 01, 01)).sample(2, rng: Random.new(42)) + # => [Date.new(2021, 12, 27), Date.new(2021, 12, 22)] + def past_date(epoch: Date.today) + date_from_offset(negative_integer, epoch: epoch) + end + + private def date_from_offset(offset_gen, epoch:) + if epoch + offset_gen.map { |offset| Date.jd(epoch.jd + offset) } + else + offset_gen.with_config.map do |offset, config| + puts config.inspect + epoch = config.default_epoch.to_date + Date.jd(epoch.jd + offset) + end + end + end + + ## + # Generates `DateTime` objects. + # DateTimes start around the given `epoch:` and deviate more when `size` increases. + # when no epoch is set, `PropCheck::Property::Configuration.default_epoch` is used, which defaults to `DateTime.now`. + # + # >> PropCheck::Generators.datetime.sample(2, rng: Random.new(42), config: PropCheck::Property::Configuration.new) + # => [DateTime.parse("2022-11-17 07:11:59.999983907 +0000"), DateTime.parse("2022-11-19 05:27:16.363618076 +0000")] + def datetime(epoch: nil) + datetime_from_offset(real_float, epoch: epoch) + end + + ## + # alias for `#datetime`, for backwards compatibility. + # Prefer using `datetime`! + def date_time(epoch: nil) + datetime(epoch: epoch) + end + + ## + # Variant of `#datetime` that only generates datetimes in the future (relative to `:epoch`). + # + # >> PropCheck::Generators.future_datetime.sample(2, rng: Random.new(42), config: PropCheck::Property::Configuration.new).map(&:inspect) + # => ["#<DateTime: 2022-11-21T16:48:00+00:00 ((2459905j,60480s,16093n),+0s,2299161j)>", "#<DateTime: 2022-11-19T18:32:43+00:00 ((2459903j,66763s,636381924n),+0s,2299161j)>"] + def future_datetime(epoch: nil) + datetime_from_offset(real_positive_float, epoch: epoch) + end + + ## + # Variant of `#datetime` that only generates datetimes in the past (relative to `:epoch`). + # + # >> PropCheck::Generators.past_datetime.sample(2, rng: Random.new(42), config: PropCheck::Property::Configuration.new) + # => [DateTime.parse("2022-11-17 07:11:59.999983907 +0000"), DateTime.parse("2022-11-19 05:27:16.363618076 +0000")] + def past_datetime(epoch: nil) + datetime_from_offset(real_negative_float, epoch: epoch) + end + + ## + # Generates `Time` objects. + # Times start around the given `epoch:` and deviate more when `size` increases. + # when no epoch is set, `PropCheck::Property::Configuration.default_epoch` is used, which defaults to `DateTime.now`. + # + # >> PropCheck::Generators.time.sample(2, rng: Random.new(42), config: PropCheck::Property::Configuration.new) + # => [DateTime.parse("2022-11-17 07:11:59.999983907 +0000").to_time, DateTime.parse("2022-11-19 05:27:16.363618076 +0000").to_time] + def time(epoch: nil) + datetime(epoch: epoch).map(&:to_time) + end + + ## + # Variant of `#time` that only generates datetimes in the future (relative to `:epoch`). + def future_time(epoch: nil) + future_datetime(epoch: epoch).map(&:to_time) + end + + ## + # Variant of `#time` that only generates datetimes in the past (relative to `:epoch`). + def past_time(epoch: nil) + past_datetime(epoch: epoch).map(&:to_time) + end + + private def datetime_from_offset(offset_gen, epoch:) + if epoch + offset_gen.map { |offset| DateTime.jd(epoch.ajd + offset) } + else + offset_gen.with_config.map do |offset, config| + epoch = config.default_epoch.to_date + DateTime.jd(epoch.ajd + offset) + end + end end ## # Generates an instance of `klass` # using `args` and/or `kwargs`