Qqwy/ruby-prop_check

View on GitHub
lib/prop_check/property.rb

Summary

Maintainability
A
3 hrs
Test Coverage
B
81%
require 'stringio'

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.new
      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