require 'stringio' require "awesome_print" require 'prop_check/property/configuration' require 'prop_check/hooks' module PropCheck ## # Run properties 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, &block) property = new(*bindings) return property.check(&block) if block_given? property 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 attr_reader :bindings, :condition def initialize(*bindings, **kwbindings) raise ArgumentError, 'No bindings specified!' if bindings.empty? && kwbindings.empty? @bindings = bindings @kwbindings = kwbindings @condition = proc { true } @config = self.class.configuration @hooks = PropCheck::Hooks.new 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) @config = @config.merge(config) return self.check(&block) if block_given? self 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) original_condition = @condition.dup @condition = proc do |*args| original_condition.call(*args) && condition.call(*args) end self end def before(&hook) @hooks.add_before(&hook) self end def after(&hook) @hooks.add_after(&hook) self end def around(&hook) @hooks.add_around(&hook) self end ## # Checks the property (after settings have been altered using the other instance methods in this class.) def check(&block) gens = if @kwbindings != {} kwbinding_generator = PropCheck::Generators.fixed_hash(**@kwbindings) @bindings + [kwbinding_generator] else @bindings end binding_generator = PropCheck::Generators.tuple(*gens) # binding_generator = PropCheck::Generators.fixed_hash(**@kwbindings) n_runs = 0 n_successful = 0 # Loop stops at first exception attempts_enum(binding_generator).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 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) block.call(*generator_result.root) # 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 { binding_generator.generate(size, rng) } .reject { |val| val.root.any? { |elem| elem == :"_PropCheck.filter_me" }} .select { |val| @condition.call(*val.root) } .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 = pre_output(output, n_successful, generator_results.root, problem) shrunken_result, shrunken_exception, n_shrink_steps = shrink(generator_results, output, &block) output = post_output(output, n_shrink_steps, shrunken_result, shrunken_exception) [output, shrunken_result, shrunken_exception, n_shrink_steps] end private def pre_output(output, n_successful, generated_root, problem) output.puts "" output.puts "(after #{n_successful} successful property test runs)" output.puts "Failed on: " output.puts "`#{print_roots(generated_root)}`" output.puts "" output.puts "Exception message:\n---\n#{problem}" output.puts "---" output.puts "" output end private def post_output(output, n_shrink_steps, shrunken_result, shrunken_exception) if n_shrink_steps == 0 output.puts '(shrinking impossible)' else output.puts '' output.puts "Shrunken input (after #{n_shrink_steps} shrink steps):" output.puts "`#{print_roots(shrunken_result)}`" output.puts "" output.puts "Shrunken exception:\n---\n#{shrunken_exception}" output.puts "---" output.puts "" end output end private def print_roots(lazy_tree_val) if lazy_tree_val.is_a?(Array) && lazy_tree_val.length == 1 && lazy_tree_val[0].is_a?(Hash) lazy_tree_val[0].ai else lazy_tree_val.ai end end private def shrink(bindings_tree, io, &block) io.puts 'Shrinking...' if @config.verbose problem_child = bindings_tree siblings = problem_child.children.lazy parent_siblings = nil problem_exception = nil shrink_steps = 0 @hooks.wrap_enum(0..@config.max_shrink_steps).lazy.each do begin sibling = siblings.next rescue StopIteration break if parent_siblings.nil? siblings = parent_siblings.lazy parent_siblings = nil next end shrink_steps += 1 io.print '.' if @config.verbose begin block.call(*sibling.root) rescue Exception => e problem_child = sibling parent_siblings = siblings siblings = problem_child.children.lazy problem_exception = e end end io.puts "(Note: Exceeded #{@config.max_shrink_steps} shrinking steps, the maximum.)" if shrink_steps >= @config.max_shrink_steps [problem_child.root, problem_exception, shrink_steps] end end end