require 'stringio' require 'amazing_print' require 'prop_check/property/configuration' require 'prop_check/property/output_formatter' require 'prop_check/property/shrinker' require 'prop_check/hooks' module PropCheck ## # Create and run property-checks. # # For simple usage, see `.forall`. # # For advanced usage, call `PropCheck::Property.new(...)` and then configure it to your liking # using e.g. `#with_config`, `#before`, `#after`, `#around` etc. # Each of these methods will return a new `Property`, so earlier properties are not mutated. # This allows you to re-use configuration and hooks between multiple tests. class Property ## # Main entry-point to create (and possibly immediately run) a property-test. # # This method accepts a list of generators and a block. # The block will then be executed many times, passing the values generated by the generators # as respective arguments: # # ``` # include PropCheck::Generators # PropCheck.forall(integer(), float()) { |x, y| ... } # ``` # # It is also possible (and recommended when having more than a few generators) to use a keyword-list # of generators instead: # # ``` # include PropCheck::Generators # PropCheck.forall(x: integer(), y: float()) { |x:, y:| ... } # ``` # # # If you do not pass a block right away, # a Property object is returned, which you can call the other instance methods # of this class on before finally passing a block to it using `#check`. # (so `forall(Generators.integer) do |val| ... end` and forall(Generators.integer).check do |val| ... end` are the same) def self.forall(*bindings, **kwbindings, &block) new(*bindings, **kwbindings) .check(&block) end ## # Returns the default configuration of the library as it is configured right now # for introspection. # # For the configuration of a single property, check its `configuration` instance method. # See PropCheck::Property::Configuration for more info on available settings. def self.configuration @configuration ||= Configuration.new end ## # Yields the library's configuration object for you to alter. # See PropCheck::Property::Configuration for more info on available settings. def self.configure yield(configuration) end def initialize(*bindings, **kwbindings) @config = self.class.configuration @hooks = PropCheck::Hooks.new @gen = gen_from_bindings(bindings, kwbindings) unless bindings.empty? && kwbindings.empty? freeze end # [:condition, :config, :hooks, :gen].each do |symbol| # define_method(symbol) do # self.instance_variable_get("@#{symbol}") # end # protected define_method("#{symbol}=") do |value| # duplicate = self.dup # duplicate.instance_variable_set("@#{symbol}", value) # duplicate # end ## # Returns the configuration of this property # for introspection. # # See PropCheck::Property::Configuration for more info on available settings. def configuration @config end ## # Allows you to override the configuration of this property # by giving a hash with new settings. # # If no other changes need to occur before you want to check the property, # you can immediately pass a block to this method. # (so `forall(a: Generators.integer).with_config(verbose: true) do ... end` is the same as `forall(a: Generators.integer).with_config(verbose: true).check do ... end`) def with_config(**config, &block) duplicate = dup duplicate.instance_variable_set(:@config, @config.merge(config)) duplicate.freeze duplicate.check(&block) end ## # Resizes all generators in this property with the given function. # # Shorthand for manually wrapping `PropCheck::Property::Configuration.resize_function` with the new function. def resize(&block) raise '#resize called without a block' unless block_given? orig_fun = @config.resize_function with_config(resize_function: block) end ## # Resizes all generators in this property. The new size is `2.pow(orig_size)` # # c.f. #resize def growing_exponentially(&block) orig_fun = @config.resize_function fun = proc { |size| 2.pow(orig_fun.call(size)) } with_config(resize_function: fun, &block) end ## # Resizes all generators in this property. The new size is `orig_size * orig_size` # # c.f. #resize def growing_quadratically(&block) orig_fun = @config.resize_function fun = proc { |size| orig_fun.call(size).pow(2) } with_config(resize_function: fun, &block) end ## # Resizes all generators in this property. The new size is `2 * orig_size` # # c.f. #resize def growing_fast(&block) orig_fun = @config.resize_function fun = proc { |size| orig_fun.call(size) * 2 } with_config(resize_function: fun, &block) end ## # Resizes all generators in this property. The new size is `0.5 * orig_size` # # c.f. #resize def growing_slowly(&block) orig_fun = @config.resize_function fun = proc { |size| orig_fun.call(size) * 0.5 } with_config(resize_function: fun, &block) end ## # Resizes all generators in this property. The new size is `Math.log2(orig_size)` # # c.f. #resize def growing_logarithmically(&block) orig_fun = @config.resize_function fun = proc { |size| Math.log2(orig_fun.call(size)) } with_config(resize_function: fun, &block) end def with_bindings(*bindings, **kwbindings) raise ArgumentError, 'No bindings specified!' if bindings.empty? && kwbindings.empty? duplicate = dup duplicate.instance_variable_set(:@gen, gen_from_bindings(bindings, kwbindings)) duplicate.freeze duplicate end ## # filters the generator using the given `condition`. # The final property checking block will only be run if the condition is truthy. # # If wanted, multiple `where`-conditions can be specified on a property. # Be aware that if you filter away too much generated inputs, # you might encounter a GeneratorExhaustedError. # Only filter if you have few inputs to reject. Otherwise, improve your generators. def where(&condition) unless @gen raise ArgumentError, 'No generator bindings specified! #where should be called after `#forall` or `#with_bindings`.' end duplicate = dup duplicate.instance_variable_set(:@gen, @gen.where(&condition)) duplicate.freeze duplicate end ## # Calls `hook` before each time a check is run with new data. # # This is useful to add setup logic # When called multiple times, earlier-added hooks will be called _before_ `hook` is called. def before(&hook) duplicate = dup duplicate.instance_variable_set(:@hooks, @hooks.add_before(&hook)) duplicate.freeze duplicate end ## # Calls `hook` after each time a check is run with new data. # # This is useful to add teardown logic # When called multiple times, earlier-added hooks will be called _after_ `hook` is called. def after(&hook) duplicate = dup duplicate.instance_variable_set(:@hooks, @hooks.add_after(&hook)) duplicate.freeze duplicate end ## # Calls `hook` around each time a check is run with new data. # # `hook` should `yield` to the passed block. # # When called multiple times, earlier-added hooks will be wrapped _around_ `hook`. # # Around hooks will be called after all `#before` hooks # and before all `#after` hooks. # # Note that if the block passed to `hook` raises an exception, # it is possible for the code after `yield` not to be called. # So make sure that cleanup logic is wrapped with the `ensure` keyword. def around(&hook) duplicate = dup duplicate.instance_variable_set(:@hooks, @hooks.add_around(&hook)) duplicate.freeze duplicate end ## # Checks the property (after settings have been altered using the other instance methods in this class.) def check(&block) return self unless block_given? n_runs = 0 n_successful = 0 # Loop stops at first exception attempts_enum(@gen).each do |generator_result| n_runs += 1 check_attempt(generator_result, n_successful, &block) n_successful += 1 end ensure_not_exhausted!(n_runs) end private def gen_from_bindings(bindings, kwbindings) if bindings == [] && kwbindings != {} PropCheck::Generators.fixed_hash(**kwbindings) elsif bindings != [] && kwbindings == {} if bindings.size == 1 bindings.first else PropCheck::Generators.tuple(*bindings) end else raise ArgumentError, 'Attempted to use both normal and keyword bindings at the same time. This is not supported because of the separation of positional and keyword arguments (the old behaviour is deprecated in Ruby 2.7 and will be removed in 3.0) c.f. https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/ ' end end private def ensure_not_exhausted!(n_runs) return if n_runs >= @config.n_runs raise_generator_exhausted! end private def raise_generator_exhausted! raise Errors::GeneratorExhaustedError, ''" Could not perform `n_runs = #{@config.n_runs}` runs, (exhausted #{@config.max_generate_attempts} tries) because too few generator results were adhering to the `where` condition. Try refining your generators instead. "'' end private def check_attempt(generator_result, n_successful, &block) PropCheck::Helper.call_splatted(generator_result.root, &block) # immediately stop (without shrinnking) for when the app is asked # to close by outside intervention rescue SignalException, SystemExit raise # We want to capture _all_ exceptions (even low-level ones) here, # so we can shrink to find their cause. # don't worry: they all get reraised rescue Exception => e output, shrunken_result, shrunken_exception, n_shrink_steps = show_problem_output(e, generator_result, n_successful, &block) output_string = output.is_a?(StringIO) ? output.string : e.message e.define_singleton_method :prop_check_info do { original_input: generator_result.root, original_exception_message: e.message, shrunken_input: shrunken_result, shrunken_exception: shrunken_exception, n_successful: n_successful, n_shrink_steps: n_shrink_steps } end raise e, output_string, e.backtrace end private def attempts_enum(binding_generator) @hooks .wrap_enum(raw_attempts_enum(binding_generator)) .lazy .take(@config.n_runs) end private def raw_attempts_enum(binding_generator) rng = Random::DEFAULT size = 1 (0...@config.max_generate_attempts) .lazy .map do generator_size = @config.resize_function.call(size).to_i binding_generator.generate( size: generator_size, rng: rng, max_consecutive_attempts: @config.max_consecutive_attempts, config: @config ) end .map do |result| size += 1 result end end private def show_problem_output(problem, generator_results, n_successful, &block) output = @config.verbose ? STDOUT : StringIO.new output = PropCheck::Property::OutputFormatter.pre_output(output, n_successful, generator_results.root, problem) shrunken_result, shrunken_exception, n_shrink_steps = shrink(generator_results, output, &block) output = PropCheck::Property::OutputFormatter.post_output(output, n_shrink_steps, shrunken_result, shrunken_exception) [output, shrunken_result, shrunken_exception, n_shrink_steps] end private def shrink(bindings_tree, io, &block) PropCheck::Property::Shrinker.call(bindings_tree, io, @hooks, @config, &block) end end end