1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
require_relative "error"
require_relative "status"

module Minitest::Thesis
  # Represents a single generated test case, which consists of an underlying
  # set of choices that produce possibilities.
  class TestCase

    # Returns a test case that makes this series of choices.
    def self.for_choices(choices, print_results: false)
      self.new(prefix: choices, random: nil, max_size: choices.length, print_results:)
    end

    attr_accessor :status
    attr_reader :choices, :targeting_score

    def initialize(prefix:, random:, max_size: Float::INFINITY, print_results: false)
      @prefix, @random, @max_size, @print_results = prefix, random, max_size, print_results
      @choices = []
      @status = nil
      @depth = 0
      @targeting_score = nil
    end

    # Returns a number in the range [0, n]
    def choice(n)
      result = make_choice(n) { @random.rand(n+1) }

      puts "choice(#{n}): #{result.inspect}" if should_print?

      result
    end

    # Return True with probability `p`.
    def weighted(p)
      result = if    p <= 0 then forced_choice(0)
               elsif p >= 1 then forced_choice(1)
               else              make_choice(1) { (@random.rand <= p) ? 1 : 0 }
               end

      puts "weighted(#{p}): #{result.inspect}" if should_print?

      result
    end

    # Inserts a fake choice into the choice sequence, as if some call to
    # choice() had returned `n`. You almost never need this, but sometimes it
    # can be a useful hint to the shrinker.
    def forced_choice(n)
      raise RangeError.new("Invalid choice #{n}") if n.bit_length > 64 || n.negative?
      raise Frozen unless @status.nil?

      mark_status(Status::OVERRUN) if @choices.length >= @max_size

      choices << n
      n
    end

    # Mark this test case as invalid.
    def reject = mark_status(Status::INVALID)

    # If this precondition is not met, abort the test and mark this test case as invalid.
    def assume(precondition)
      return if precondition
      reject
    end

    # Set a score to maximize. Multiple calls to this function will override previous ones.
    #
    # The name and idea come from Löscher, Andreas, and Konstantinos Sagonas.
    # "Targeted property-based testing." ISSTA. 2017, but the implementation
    # is based on that found in Hypothesis, which is not that similar to
    # anything described in the paper.
    def target(score) = @targeting_score = score

    # Return a possible value from `possibility`.
    def any(possibility)
      begin
        @depth += 1
        result = possibility.produce.(self)
      ensure
        @depth -= 1
      end

      puts "any(#{possibility}): #{result.inspect}" if should_print?

      result
    end

    # Set the status and raise StopTest.
    def mark_status(status)
      raise Frozen unless self.status.nil?

      @status = status
      raise StopTest
    end

    private

    def should_print? = @print_results && @depth.zero?

    # Make a choice in [0, n], by calling rnd_method if randomness is needed.
    def make_choice(n, &rnd_method)
      raise RangeError.new("Invalid choice #{n}") if n.bit_length > 64 || n.negative?
      raise Frozen unless @status.nil?

      mark_status(Status::OVERRUN) if @choices.length >= @max_size

      result = if @choices.length < @prefix.length
                 @prefix[@choices.length]
               else
                 rnd_method.()
               end
      @choices << result

      mark_status(Status::INVALID) if result > n

      result
    end
  end
end