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
122
123
124
125
126
127
require "digest"

require "minitest"

require_relative "thesis/directory_db"
require_relative "thesis/error"
require_relative "thesis/possibility"
require_relative "thesis/status"
require_relative "thesis/test_case"
require_relative "thesis/testing_state"
require_relative "thesis/version"

module Minitest
  module Thesis
    class Test < Minitest::Test

      # Runs a test. Usage is:
      #
      # run_test do |test_case|
      #   n = test_case.choice(1000)
      # end
      #
      # The block takes a `TestCase` argument, and should raise an exception to
      # indicate a test failure. It will either run silently or print drawn
      # values and then fail with an exception if minithesis finds some test case
      # that fails.
      #
      # Arguments:
      # * max_examples: the maximum number of valid test cases to run for.
      #   Note that under some circumstances the test may run fewer test
      #   cases than this.
      # * random: An instance of random.Random that will be used for all
      #   nondeterministic choices.
      # * database: A Hash-like object in which results will be cached and resumed
      #   from, ensuring that if a test is run twice it fails in the same way.
      # * quiet: Will not print anything on failure if True.
      def run_test(
        name,
        max_examples: 100,
        random: Random.new,
        database: DirectoryDb.new(".minitest-thesis-cache"),
        quiet: false,
        &test
      )
        mark_failures_interesting = ->(test_case) do
          test.(test_case)
        rescue Exception
          raise unless test_case.status.nil?

          test_case.mark_status(Status::INTERESTING)
        end

        state = TestingState.new(random:, test_function: mark_failures_interesting, max_examples:)

        prev_failure = database[name]

        if prev_failure
          choices = prev_failure.unpack("Q>*")
          state.test_function(TestCase.for_choices(choices))
        end

        if state.result.nil?
          state.run
        end

        if state.valid_test_cases.zero?
          raise Unsatisfiable
        end

        if state.result.nil?
          database.delete(name)
        else
          database[name] = state.result.pack("Q>*")
        end

        unless state.result.nil?
          test.(TestCase.for_choices(state.result, print_results: !quiet))
        end
      end

      # Any integer in the range [m, n] is possible
      def integers(m, n) = Possibility.new("integers(#{m}, #{n})") {|tc| m + tc.choice(n - m) }

      # Any lists whose elements are possible values from `elements` are possible.
      def lists(elements, min_size: 0, max_size: Float::INFINITY)
        Possibility.new("lists(#{elements.name})") {|test_case|
          result = []
          loop do
            if result.length < min_size
              test_case.forced_choice(1)
            elsif result.length + 1 >= max_size
              test_case.forced_choice(0)
              break
            elsif test_case.weighted(0.9).zero?
              break
            end
            result << test_case.any(elements)
          end
          result
        }
      end

      # Only `value` is possible.
      def just(value) = Possibility.new("just(#{value})") { value }

      # No possible values. i.e. Any call to `any` will reject the test case.
      def nothing = Possibility.new("nothing") {|tc| tc.reject }

      # Possible values can be any value possible for one of `possibilities`.
      def mix_of(*possibilities)
        return nothing if possibilities.empty?

        Possibility.new("mix_of(#{possibilities.map(&:name).join(", ")})") {|tc|
          tc.any(possibilities[tc.choice(possibilities.length - 1)])
        }
      end

      # Any tuple t of of length len(possibilities) such that t[i] is possible
      # for possibilities[i] is possible.
      def tuples(*possibilities)
        Possibility.new( "tuples(#{possibilities.map(&:name).join(", ")})") {|tc|
          possibilities.map {|p| tc.any(p) }
        }
      end
    end
  end
end