minitest-proptest 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2f1cde4cf0a022319a3336a06e5509275ea420b5b36d39835f938c6ef830fae
4
- data.tar.gz: 50a1ce6ade048840831ad943840c914cda998da1e6f7b98c07d02b7ee859d2db
3
+ metadata.gz: 61aa3a3421a7b1996cf35540600199e11d18bbf5959d96866f994be97f372bcf
4
+ data.tar.gz: f89adae15e67f595c56f274d034b18e3e325fe097865ef60f42a7d49f7509214
5
5
  SHA512:
6
- metadata.gz: 6af2348e841f27a7401a64cc777dde8aad6a1487289cea278ecb6ec2771602143bce354dad192c55d2c57c4aa828ed6a34842d048a1dcb41d45d09fcc5e9e96a
7
- data.tar.gz: 8919b2d754966201a1f489c0b6594128e36fcf3301774cbabb0f67848b363d2702143829301042e0c8344dc0950c62995f2bf10147d94e4bbedc1e5f853cf063
6
+ metadata.gz: 6c114584acb4f0d59d93958c65e665a5acde52dce3083f1a1343e8cc85962ec11f4e8e582821bf86002cb2e02cfe45d255fe653450944fdc573112168a734520
7
+ data.tar.gz: 4f23345243cb106aa1fca9e4d9a4c87c6c20e22407e2564990de246cb81c95f96a2e8138a3991121ab24a2b2bfa9bf5066a7cdc3b639f0880d6e1170bd214652
@@ -19,8 +19,8 @@ module Minitest
19
19
  end
20
20
 
21
21
  def self.with_append(bound_min, bound_max, &f)
22
- define_singleton_method(:bound_max) { bound_max }
23
22
  define_singleton_method(:bound_min) { bound_min }
23
+ define_singleton_method(:bound_max) { bound_max }
24
24
  define_method(:append) do |other|
25
25
  @value = f.call(value, other.value)
26
26
  self
@@ -119,28 +119,60 @@ module Minitest
119
119
 
120
120
  until y == 0
121
121
  candidates << (x - y)
122
- candidates << y
123
- # Prevent negative integral from preventing termination
122
+ candidates << y if y.abs < x.abs
124
123
  y = (y / 2.0).to_i
125
124
  end
126
125
 
127
126
  candidates
128
- .flat_map { |i| [i - 1, i, i + 1] }
129
- .reject { |i| i.abs >= x.abs }
127
+ end
128
+
129
+ score_float = ->(f) do
130
+ if f.nan? || f.infinite?
131
+ 0
132
+ else
133
+ f.abs.ceil
134
+ end
130
135
  end
131
136
 
132
137
  float_shrink = ->(x) do
138
+ return [] if x.nan? || x.infinite? || x.zero?
139
+
133
140
  candidates = [Float::NAN, Float::INFINITY]
134
141
  y = x
135
142
 
136
- until y == 0 || y
143
+ until y.zero? || y.to_f.infinite? || y.to_f.nan?
137
144
  candidates << (x - y)
138
145
  y = (y / 2.0).to_i
139
146
  end
140
147
 
141
- candidates
142
- .flat_map { |i| [i - 1, i, i + 1] }
143
- .reject { |i| i.abs >= x.abs }
148
+ score = score_float.call(x)
149
+ candidates.reduce([]) do |cs, c|
150
+ cs + (score_float.call(c) < score ? [c.to_f] : [])
151
+ end
152
+ end
153
+
154
+ score_complex = ->(c) do
155
+ r = if c.real.to_f.nan? || c.real.to_f.infinite?
156
+ 0
157
+ else
158
+ c.real.abs.ceil
159
+ end
160
+ i = if c.imaginary.to_f.nan? || c.imaginary.to_f.infinite?
161
+ 0
162
+ else
163
+ c.imaginary.abs.ceil
164
+ end
165
+ r + i
166
+ end
167
+
168
+ complex_shrink = ->(x) do
169
+ rs = float_shrink.call(x.real)
170
+ is = float_shrink.call(x.imaginary)
171
+
172
+ score = score_complex.call(x)
173
+ rs.flat_map { |real| is.map { |imag| Complex(real, imag) } }
174
+ .reject { |c| score_complex.call(c) >= score }
175
+ .uniq
144
176
  end
145
177
 
146
178
  # List shrink adapted from QuickCheck
@@ -196,6 +228,34 @@ module Minitest
196
228
  end
197
229
  end
198
230
 
231
+ range_shrink = ->(f, r) do
232
+ xs = f.call(r.first)
233
+ ys = f.call(r.last)
234
+
235
+ xs.flat_map { |x| ys.map { |y| x <= y ? (x..y) : (y..x) } }
236
+ end
237
+
238
+ score_rational = ->(r) do
239
+ (r.numerator * r.denominator).abs
240
+ end
241
+
242
+ rational_shrink = ->(r) do
243
+ ns = integral_shrink.call(r.numerator)
244
+ ds = integral_shrink.call(r.denominator)
245
+
246
+ score = score_rational.call(r)
247
+ ns.flat_map do |n|
248
+ ds.reduce([]) do |rs, d|
249
+ if d.zero?
250
+ rs
251
+ else
252
+ rational = Rational(n, d)
253
+ rs + (score_rational.call(rational) < score ? [rational] : [])
254
+ end
255
+ end
256
+ end
257
+ end
258
+
199
259
  hash_shrink = ->(_fk, _fv, h) do
200
260
  candidates = []
201
261
  n = h.length
@@ -303,45 +363,36 @@ module Minitest
303
363
  .unpack1('f')
304
364
  end.with_shrink_function do |f|
305
365
  float_shrink.call(f)
306
- end.with_score_function do |f|
307
- if f.nan? || f.infinite?
308
- 0
309
- else
310
- f.abs.ceil
311
- end
312
- end
366
+ end.with_score_function(&score_float)
313
367
 
314
- generator_for(Float64) do
315
- bits = sized(0xffffffffffffffff)
368
+ float64build = ->(bits) do
316
369
  (0..7)
317
370
  .map { |y| ((bits & (0xff << (8 * y))) >> (8 * y)).chr }
318
371
  .join
319
372
  .unpack1('d')
373
+ end
374
+
375
+ generator_for(Float64) do
376
+ bits = sized(0xffffffffffffffff)
377
+ float64build.call(bits)
320
378
  end.with_shrink_function do |f|
321
379
  float_shrink.call(f)
322
- end.with_score_function do |f|
323
- if f.nan? || f.infinite?
324
- 0
325
- else
326
- f.abs.ceil
327
- end
328
- end
380
+ end.with_score_function(&score_float)
329
381
 
330
382
  generator_for(Float) do
331
383
  bits = sized(0xffffffffffffffff)
332
- (0..7)
333
- .map { |y| ((bits & (0xff << (8 * y))) >> (8 * y)).chr }
334
- .join
335
- .unpack1('d')
384
+ float64build.call(bits)
336
385
  end.with_shrink_function do |f|
337
386
  float_shrink.call(f)
338
- end.with_score_function do |f|
339
- if f.nan? || f.infinite?
340
- 0
341
- else
342
- f.abs.ceil
343
- end
344
- end
387
+ end.with_score_function(&score_float)
388
+
389
+ generator_for(Complex) do
390
+ real = sized(0xffffffffffffffff)
391
+ imag = sized(0xffffffffffffffff)
392
+ Complex(float64build.call(real), float64build.call(imag))
393
+ end.with_shrink_function do |c|
394
+ complex_shrink.call(c)
395
+ end.with_score_function(&score_complex)
345
396
 
346
397
  generator_for(ASCIIChar) do
347
398
  sized(0x7f).chr
@@ -392,6 +443,29 @@ module Minitest
392
443
  xm.merge(ym)
393
444
  end.with_empty { {} }
394
445
 
446
+ generator_for(Range) do |x|
447
+ (x..x)
448
+ end.with_shrink_function(&range_shrink).with_score_function do |f, r|
449
+ r.to_a.reduce(1) do |c, x|
450
+ y = f.call(x).abs
451
+ c * (y > 0 ? y + 1 : 1)
452
+ end.to_i * r.to_a.length
453
+ end.with_append(2, 2) do |ra, rb|
454
+ xs = [ra.first, ra.last, rb.first, rb.last].sort
455
+ (xs.first..xs.last)
456
+ end
457
+
458
+ generator_for(Rational) do
459
+ n = sized(MAX_SIZE)
460
+ d = sized(MAX_SIZE - 1) + 1
461
+ if (n & SIGN_BIT).zero?
462
+ Rational(n, d)
463
+ else
464
+ Rational(-(((n & (MAX_SIZE ^ SIGN_BIT)) - 1) ^ (MAX_SIZE ^ SIGN_BIT)), d)
465
+ end
466
+ end.with_shrink_function(&rational_shrink)
467
+ .with_score_function(&score_rational)
468
+
395
469
  generator_for(Bool) do
396
470
  sized(0x1).odd?
397
471
  end.with_score_function do |_|
@@ -4,11 +4,20 @@ module Minitest
4
4
  module Proptest
5
5
  # Property evaluation - status, scoring, shrinking
6
6
  class Property
7
- attr_reader :result, :status, :trivial
7
+ require 'minitest/assertions'
8
+ include Minitest::Assertions
9
+
10
+ attr_reader :calls, :result, :status, :trivial
11
+
12
+ attr_accessor :assertions
8
13
 
9
14
  def initialize(
10
15
  # The function which proves the property
11
16
  test_proc,
17
+ # The file in which our property lives
18
+ filename,
19
+ # The method containing our property
20
+ methodname,
12
21
  # Any class which provides `rand` accepting both an Integer and a Range
13
22
  # is acceptable. The default value is Ruby's standard Mersenne Twister
14
23
  # implementation.
@@ -28,6 +37,8 @@ module Minitest
28
37
  previous_failure: []
29
38
  )
30
39
  @test_proc = test_proc
40
+ @filename = filename
41
+ @methodname = methodname
31
42
  @random = random.call
32
43
  @generator = ::Minitest::Proptest::Gen.new(@random)
33
44
  @max_success = max_success
@@ -36,13 +47,16 @@ module Minitest
36
47
  @max_shrinks = max_shrinks
37
48
  @status = Status.unknown
38
49
  @trivial = false
50
+ @valid_test_case = true
39
51
  @result = nil
40
52
  @exception = nil
41
53
  @calls = 0
54
+ @assertions = 0
42
55
  @valid_test_cases = 0
43
56
  @generated = []
44
57
  @arbitrary = nil
45
58
  @previous_failure = previous_failure.to_a
59
+ @local_variables = {}
46
60
  end
47
61
 
48
62
  def run!
@@ -51,17 +65,6 @@ module Minitest
51
65
  shrink!
52
66
  end
53
67
 
54
- def arbitrary(*classes)
55
- if @arbitrary
56
- @arbitrary.call(*classes)
57
- else
58
- a = @generator.for(*classes)
59
- @generated << a
60
- @status = Status.overrun unless @generated.length <= @max_size
61
- a.value
62
- end
63
- end
64
-
65
68
  def explain
66
69
  prop = if @status.valid?
67
70
  'The property was proved to satsfaction across ' \
@@ -77,10 +80,26 @@ module Minitest
77
80
  elsif @status.unknown?
78
81
  'The property has not yet been tested.'
79
82
  elsif @status.interesting?
80
- 'The property has found the following counterexample after ' \
81
- "#{@valid_test_cases} valid " \
82
- "example#{@valid_test_cases == 1 ? '' : 's'}:\n" \
83
- "#{@generated.map(&:value).inspect}"
83
+ info = 'A counterexample to a property has been found after ' \
84
+ "#{@valid_test_cases} valid " \
85
+ "example#{@valid_test_cases == 1 ? '' : 's'}.\n"
86
+ var_info = if @local_variables.empty?
87
+ 'Variables local to the property were unable ' \
88
+ 'to be determined. This is usually a bug.'
89
+ else
90
+ "The values at the time of the failure were:\n"
91
+ end
92
+ vars = @local_variables
93
+ .map { |k, v| "\t#{k}: #{v.inspect}" }
94
+ .join("\n")
95
+
96
+ info + var_info + vars
97
+ elsif @status.exhausted?
98
+ "The property was unable to generate #{@max_success} test " \
99
+ 'cases before generating ' \
100
+ "#{@max_success * @max_discard_ratio} rejected test " \
101
+ "cases. This might be a problem with the property's " \
102
+ '`where` blocks.'
84
103
  end
85
104
  trivial = if @trivial
86
105
  "\nThe test does not appear to use any generated values " \
@@ -95,18 +114,47 @@ module Minitest
95
114
 
96
115
  private
97
116
 
117
+ def arbitrary(*classes)
118
+ if @arbitrary
119
+ @arbitrary.call(*classes)
120
+ else
121
+ a = @generator.for(*classes)
122
+ @generated << a
123
+ @status = Status.overrun unless @generated.length <= @max_size
124
+ a.value
125
+ end
126
+ end
127
+
128
+ def where(&b)
129
+ @valid_test_case &= b.call
130
+ end
131
+
98
132
  def iterate!
99
- while continue_iterate? && @result.nil? && @valid_test_cases <= @max_success / 2
133
+ while continue_iterate? && @result.nil? && @valid_test_cases <= @max_success
134
+ @valid_test_case = true
100
135
  @generated = []
101
136
  @generator = ::Minitest::Proptest::Gen.new(@random)
102
137
  @calls += 1
103
- if instance_eval(&@test_proc)
138
+
139
+ success = begin
140
+ instance_eval(&@test_proc)
141
+ rescue Minitest::Assertion
142
+ if @valid_test_case
143
+ @result = @generated
144
+ @status = Status.interesting
145
+ end
146
+ rescue => e
147
+ raise e if @valid_test_case
148
+ end
149
+ if @valid_test_case && success
104
150
  @status = Status.valid if @status.unknown?
105
151
  @valid_test_cases += 1
106
- else
152
+ elsif @valid_test_case
107
153
  @result = @generated
108
154
  @status = Status.interesting
109
155
  end
156
+
157
+ @status = Status.exhausted if @calls >= @max_success * (@max_discard_ratio + 1)
110
158
  @trivial = true if @generated.empty?
111
159
  end
112
160
  rescue => e
@@ -133,9 +181,20 @@ module Minitest
133
181
  end
134
182
 
135
183
  @generator = ::Minitest::Proptest::Gen.new(@random)
136
- if instance_eval(&@test_proc)
184
+ success = begin
185
+ instance_eval(&@test_proc)
186
+ rescue Minitest::Assertion
187
+ !@valid_test_case
188
+ rescue => e
189
+ if @valid_test_case
190
+ @status = Status.invalid
191
+ @exception = e
192
+ false
193
+ end
194
+ end
195
+ if success || !@valid_test_case
137
196
  @generated = []
138
- else
197
+ elsif @valid_test_case
139
198
  @result = @generated
140
199
  @status = Status.interesting
141
200
  end
@@ -156,6 +215,17 @@ module Minitest
156
215
  candidates = @generated.map(&:shrink_candidates)
157
216
  old_arbitrary = @arbitrary
158
217
 
218
+ local_variables = {}
219
+ tracepoint = TracePoint.new(:b_return) do |trace|
220
+ if trace.path == @filename && trace.method_id.to_s == @methodname
221
+ b = trace.binding
222
+ vs = b.local_variables
223
+ known = vs.to_h { |lv| [lv.to_s, b.local_variable_get(lv)] }
224
+ local_variables.delete_if { true }
225
+ local_variables.merge!(known)
226
+ end
227
+ end
228
+
159
229
  to_test = candidates
160
230
  .map { |x| x.map { |y| [y] } }
161
231
  .reduce { |c, e| c.flat_map { |a| e.map { |b| a + b } } }
@@ -174,15 +244,31 @@ module Minitest
174
244
  end
175
245
 
176
246
  while continue_shrink? && run[:run] < to_test.length
177
- @generated = []
178
- run[:index] = -1
247
+ @generated = []
248
+ run[:index] = -1
249
+ @valid_test_case = true
179
250
 
180
251
  @generator = ::Minitest::Proptest::Gen.new(@random)
181
252
  if to_test[run[:run]].map(&:first).reduce(&:+) < best_score
182
- unless instance_eval(&@test_proc)
183
- best_generated = @generated
184
- # The first hit is guaranteed to be the best scoring due to the
253
+ success = begin
254
+ tracepoint.enable
255
+ instance_eval(&@test_proc)
256
+ rescue Minitest::Assertion
257
+ false
258
+ rescue => e
259
+ next unless @valid_test_case
260
+
261
+ @status = Status.invalid
262
+ @excption = e
263
+ break
264
+ ensure
265
+ tracepoint.disable
266
+ end
267
+
268
+ if !success && @valid_test_case
269
+ # The first hit is guaranteed to be the best scoring since the
185
270
  # shrink candidates are pre-sorted.
271
+ best_generated = @generated
186
272
  break
187
273
  end
188
274
  end
@@ -190,20 +276,22 @@ module Minitest
190
276
  @calls += 1
191
277
  run[:run] += 1
192
278
  end
279
+
193
280
  # Clean up after we're done
194
- @generated = best_generated
195
- @result = best_generated
196
- @generator = old_generator
197
- @random = old_random
198
- @arbitrary = old_arbitrary
281
+ @generated = best_generated
282
+ @result = best_generated
283
+ @generator = old_generator
284
+ @random = old_random
285
+ @arbitrary = old_arbitrary
286
+ @local_variables = local_variables
199
287
  end
200
288
 
201
289
  def continue_iterate?
202
290
  !@trivial &&
203
291
  !@status.invalid? &&
204
292
  !@status.overrun? &&
205
- @valid_test_cases < @max_success &&
206
- @calls < @max_success * @max_discard_ratio
293
+ !@status.exhausted? &&
294
+ @valid_test_cases < @max_success
207
295
  end
208
296
 
209
297
  def continue_shrink?
@@ -4,6 +4,10 @@ module Minitest
4
4
  module Proptest
5
5
  # Sum type representing the possible statuses of a test run.
6
6
  # Invalid, Overrun, and Interesting represent different failure classes.
7
+ # Exhausted represents having generated too many invalid test cases to
8
+ # verify the property. This precipitates as a failure class (the property is
9
+ # not proved) but can be a matter of inappropriate predicates in `where`
10
+ # blocks.
7
11
  # Unknown represents a lack of information about the test run (typically
8
12
  # having not run to satisfaction).
9
13
  # Valid represents a test which has run to satisfaction.
@@ -23,16 +27,21 @@ module Minitest
23
27
  class Valid < Status
24
28
  end
25
29
 
30
+ class Exhausted < Status
31
+ end
32
+
26
33
  invalid = Invalid.new.freeze
27
34
  interesting = Interesting.new.freeze
28
35
  overrun = Overrun.new.freeze
29
36
  unknown = Unknown.new.freeze
37
+ exhausted = Exhausted.new.freeze
30
38
  valid = Valid.new.freeze
31
39
 
32
40
  define_singleton_method(:invalid) { invalid }
33
41
  define_singleton_method(:interesting) { interesting }
34
42
  define_singleton_method(:overrun) { overrun }
35
43
  define_singleton_method(:unknown) { unknown }
44
+ define_singleton_method(:exhausted) { exhausted }
36
45
  define_singleton_method(:valid) { valid }
37
46
 
38
47
  def invalid?
@@ -47,6 +56,10 @@ module Minitest
47
56
  self.is_a?(Unknown)
48
57
  end
49
58
 
59
+ def exhausted?
60
+ self.is_a?(Exhausted)
61
+ end
62
+
50
63
  def valid?
51
64
  self.is_a?(Valid)
52
65
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Minitest
4
4
  module Proptest
5
- VERSION = '0.1.0'
5
+ VERSION = '0.2.1'
6
6
  end
7
7
  end
@@ -50,8 +50,6 @@ module Minitest
50
50
 
51
51
  module Assertions
52
52
  def property(&f)
53
- self.assertions += 1
54
-
55
53
  random_thunk = if Proptest.instance_variable_defined?(:@_random_seed)
56
54
  r = Proptest.instance_variable_get(:@_random_seed)
57
55
  ->() { Proptest::DEFAULT_RANDOM.call(r) }
@@ -65,6 +63,8 @@ module Minitest
65
63
 
66
64
  prop = Minitest::Proptest::Property.new(
67
65
  f,
66
+ file,
67
+ methodname,
68
68
  random: random_thunk,
69
69
  max_success: Proptest.max_success,
70
70
  max_discard_ratio: Proptest.max_discard_ratio,
@@ -73,11 +73,14 @@ module Minitest
73
73
  previous_failure: Proptest.reporter.lookup(file, classname, methodname)
74
74
  )
75
75
  prop.run!
76
+ self.assertions += prop.calls
76
77
 
77
78
  if prop.status.valid? && !prop.trivial
78
79
  Proptest.strike_failure(file, classname, methodname)
79
80
  else
80
- Proptest.record_failure(file, classname, methodname, prop.result.map(&:value))
81
+ unless prop.status.exhausted? || prop.status.invalid?
82
+ Proptest.record_failure(file, classname, methodname, prop.result.map(&:value))
83
+ end
81
84
 
82
85
  raise Minitest::Assertion, prop.explain
83
86
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: minitest-proptest
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina Wuest
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-03-13 00:00:00.000000000 Z
11
+ date: 2024-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest