minitest-proptest 0.0.2 → 0.2.0

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: e01d66af6e62a9d5931cd1da501b08679e8f78a4aa625aa694c28029ae5228a9
4
- data.tar.gz: 3d99ae675bbeac0c89c807a535c7400fc0806a223a68c0d1a50eb2cb2917c148
3
+ metadata.gz: '0596e6f6a66614dba0199eccc4e18a5bf6b869fd0db46dff7636a8772eacb75b'
4
+ data.tar.gz: a956c579c5bb355a5b687f64109917918870dd7dac1323a18a72d44e4ca623a6
5
5
  SHA512:
6
- metadata.gz: 2a4abe803c73e8e254dfa2022e5bcbb84f3bdf6c1e502f7fa34d3d95c06dad05f44be371a935907135f8bb7f67cd1240b24835c7cf233e1fd85f52608ed53ac8
7
- data.tar.gz: dd81efa85c34580d0d29cfbf21ab5042f0af14f493b7041712a61fd491638c5a6e02d79e758a459a03671110bf057474f9fae34c96c96607e8c0f1e7853c2e31
6
+ metadata.gz: d6206c7a778b9b55e0674e204afea71721eb8b6e4fe2257691cc1cefd5e76885eff08d648f9a61172806872f1abe5f1b232186fcc803e6fcc560241fd71fac8c
7
+ data.tar.gz: 1ca1d04ddcdecb9f661e0af6cffc87dda30af6a9b06bb6fe406a5f27ec445a0cf3cd9ac4d9050b4a243ab40d76d066358f6cf9612181cc546a2076b76a7d9041
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Proptest
5
+ class Gen
6
+ # Methods for value generation
7
+ class ValueGenerator
8
+ attr_accessor :entropy
9
+ attr_writer :type_parameters
10
+
11
+ def self.with_shrink_function(&f)
12
+ define_method(:shrink_function, &f)
13
+ self
14
+ end
15
+
16
+ def self.with_score_function(&f)
17
+ define_method(:score_function, &f)
18
+ self
19
+ end
20
+
21
+ def self.with_append(bound_min, bound_max, &f)
22
+ define_singleton_method(:bound_min) { bound_min }
23
+ define_singleton_method(:bound_max) { bound_max }
24
+ define_method(:append) do |other|
25
+ @value = f.call(value, other.value)
26
+ self
27
+ end
28
+ self
29
+ end
30
+
31
+ def self.with_empty(&f)
32
+ define_singleton_method(:empty) do |gen|
33
+ temp = new(gen)
34
+ temp.instance_variable_set(:@value, f.call)
35
+ temp
36
+ end
37
+ self
38
+ end
39
+
40
+ def self.bound_max
41
+ 1
42
+ end
43
+
44
+ def self.bound_min
45
+ 0
46
+ end
47
+
48
+ # append is not expected to be called unless overridden
49
+ def append(_other)
50
+ self
51
+ end
52
+
53
+ def self.empty(gen)
54
+ self.new(gen)
55
+ end
56
+
57
+ def force(v)
58
+ temp = self.class.new(ArgumentError)
59
+ temp.instance_variable_set(:@value, v)
60
+ temp.type_parameters = @type_parameters
61
+ temp
62
+ end
63
+
64
+ def generate_value
65
+ gen = @generated.reduce(@generator) do |g, val|
66
+ g.call(val)
67
+ g
68
+ end
69
+
70
+ while gen.is_a?(Proc) || gen.is_a?(Method)
71
+ gen = gen.call(*@type_parameters.map(&:value))
72
+ gen = gen.value if gen.is_a?(ValueGenerator)
73
+ end
74
+
75
+ gen
76
+ end
77
+
78
+ def value
79
+ return false if @value == false
80
+
81
+ @value ||= generate_value
82
+ end
83
+
84
+ def prefix_entropy_generation(vals)
85
+ @generated = vals + @generated
86
+ end
87
+
88
+ def score
89
+ value
90
+ fs = @type_parameters.map { |x| x.method(:score_function) }
91
+ score_function(*fs, value)
92
+ end
93
+
94
+ def score_function(v)
95
+ v.to_i.abs
96
+ end
97
+
98
+ def shrink_candidates
99
+ fs = @type_parameters.map { |x| x.method(:shrink_function) }
100
+ os = score
101
+ candidates = shrink_function(*fs, value)
102
+ candidates
103
+ .map { |c| [force(c).score, c] }
104
+ .reject { |(s, _)| s > os }
105
+ .sort { |x, y| x.first <=> y.first }
106
+ .uniq
107
+ end
108
+
109
+ def shrink_function(x)
110
+ [x.itself]
111
+ end
112
+
113
+ def shrink_parameter(x)
114
+ @shrink_parameter.call(x)
115
+ end
116
+
117
+ # Generator helpers
118
+
119
+ def sized(n)
120
+ entropy.call(n + 1)
121
+ end
122
+
123
+ def one_of(r)
124
+ r.to_a[sized(r.to_a.length - 1)]
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -1,128 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/proptest/gen/value_generator'
4
+
1
5
  module Minitest
2
6
  module Proptest
7
+ # Generic value generation and shrinking implementations, and
8
+ # support for built-in types.
3
9
  class Gen
4
- class ValueGenerator
5
- attr_accessor :entropy
6
- attr_writer :type_parameters
7
-
8
- def self.with_shrink_function(&f)
9
- define_method(:shrink_function, &f)
10
- self
11
- end
12
-
13
- def self.with_score_function(&f)
14
- define_method(:score_function, &f)
15
- self
16
- end
17
-
18
- def self.with_append(bound_min, bound_max, &f)
19
- define_singleton_method(:bound_max) { bound_max }
20
- define_singleton_method(:bound_min) { bound_min }
21
- define_method(:append) do |other|
22
- @value = f.call(value, other.value)
23
- self
24
- end
25
- self
26
- end
27
-
28
- def self.with_empty(&f)
29
- define_singleton_method(:empty) do |gen|
30
- temp = new(gen)
31
- temp.instance_variable_set(:@value, f.call)
32
- temp
33
- end
34
- self
35
- end
36
-
37
- def self.bound_max
38
- 1
39
- end
40
-
41
- def self.bound_min
42
- 0
43
- end
44
-
45
- # append is not expected to be called unless overridden
46
- def append(other)
47
- self
48
- end
49
-
50
- def self.empty(gen)
51
- self.new(gen)
52
- end
53
-
54
- def force(v)
55
- temp = self.class.new(ArgumentError)
56
- temp.instance_variable_set(:@value, v)
57
- temp.type_parameters = @type_parameters
58
- temp
59
- end
60
-
61
- def generate_value
62
- gen = @generated.reduce(@generator) do |gen, val|
63
- gen.call(val)
64
- gen
65
- end
66
-
67
- while gen.is_a?(Proc) || gen.is_a?(Method)
68
- gen = gen.call(*@type_parameters.map(&:value))
69
- if gen.is_a?(ValueGenerator)
70
- gen = gen.value
71
- end
72
- end
73
-
74
- gen
75
- end
76
-
77
- def value
78
- return false if @value == false
79
- @value ||= generate_value
80
- end
81
-
82
- def prefix_entropy_generation(vals)
83
- @generated = vals + @generated
84
- end
85
-
86
- def score
87
- value
88
- fs = @type_parameters.map { |x| x.method(:score_function) }
89
- score_function(*fs, value)
90
- end
91
-
92
- def score_function(v)
93
- v.to_i.abs
94
- end
95
-
96
- def shrink_candidates
97
- fs = @type_parameters.map { |x| x.method(:shrink_function) }
98
- os = score
99
- candidates = shrink_function(*fs, value)
100
- candidates
101
- .map { |c| [force(c).score, c] }
102
- .reject { |(s, _)| s > os }
103
- .sort { |x, y| x.first <=> y.first }
104
- .uniq
105
- end
106
-
107
- def shrink_function(x)
108
- [x.itself]
109
- end
110
-
111
- def shrink_parameter(x)
112
- @shrink_parameter.call(x)
113
- end
114
-
115
- # Generator helpers
116
-
117
- def sized(n)
118
- entropy.call(n + 1)
119
- end
120
-
121
- def one_of(r)
122
- r.to_a[sized(r.to_a.length - 1)]
123
- end
124
- end
125
-
126
10
  class Int8 < Integer; end
127
11
  class Int16 < Integer; end
128
12
  class Int32 < Integer; end
@@ -146,7 +30,7 @@ module Minitest
146
30
  instance_variable_set(:@_generators, {})
147
31
 
148
32
  def self.create_type_constructor(arity, classes)
149
- constructor = ->(c1) do
33
+ constructor = ->(_c1) do
150
34
  if classes.length == arity
151
35
  f.call(*classes)
152
36
  else
@@ -168,8 +52,8 @@ module Minitest
168
52
 
169
53
  new_class.define_method(:generator, &f)
170
54
 
171
- instance_variable_get(:@_generators)[klass] = new_class #.method(:new)
172
- self.const_set((klass.name + 'Gen').split('::').last, new_class)
55
+ instance_variable_get(:@_generators)[klass] = new_class
56
+ self.const_set("#{klass.name}Gen".split('::').last, new_class)
173
57
  new_class
174
58
  end
175
59
 
@@ -197,11 +81,11 @@ module Minitest
197
81
  end
198
82
  else
199
83
  classgen = ->() do
200
- classes[1..-1].map do |c|
201
- if c.is_a?(Array)
202
- self.for(*c)
84
+ classes[1..].map do |k|
85
+ if k.is_a?(Array)
86
+ self.for(*k)
203
87
  else
204
- self.for(c)
88
+ self.for(k)
205
89
  end
206
90
  end
207
91
  end
@@ -235,28 +119,60 @@ module Minitest
235
119
 
236
120
  until y == 0
237
121
  candidates << (x - y)
238
- candidates << y
239
- # Prevent negative integral from preventing termination
122
+ candidates << y if y.abs < x.abs
240
123
  y = (y / 2.0).to_i
241
124
  end
242
125
 
243
126
  candidates
244
- .flat_map { |i| [i - 1, i, i + 1] }
245
- .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
246
135
  end
247
136
 
248
137
  float_shrink = ->(x) do
138
+ return [] if x.nan? || x.infinite? || x.zero?
139
+
249
140
  candidates = [Float::NAN, Float::INFINITY]
250
141
  y = x
251
142
 
252
- until y == 0 || y
143
+ until y.zero? || y.to_f.infinite? || y.to_f.nan?
253
144
  candidates << (x - y)
254
145
  y = (y / 2.0).to_i
255
146
  end
256
147
 
257
- candidates
258
- .flat_map { |i| [i - 1, i, i + 1] }
259
- .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
260
176
  end
261
177
 
262
178
  # List shrink adapted from QuickCheck
@@ -268,7 +184,7 @@ module Minitest
268
184
  elsif xs2.empty?
269
185
  [[]]
270
186
  else
271
- [xs2] + list_remove.call(k, (n-k), xs2).map { |ys| xs1 + ys }
187
+ [xs2] + list_remove.call(k, (n - k), xs2).map { |ys| xs1 + ys }
272
188
  end
273
189
  end
274
190
 
@@ -308,11 +224,39 @@ module Minitest
308
224
  else
309
225
  h1 = xs1.reduce({}) { |c, e| c.merge({ e => h[e] }) }
310
226
  h2 = xs2.reduce({}) { |c, e| c.merge({ e => h[e] }) }
311
- [h1, h2] + list_remove.call(k, (n-k), h2).map { |ys| h1.merge(ys.to_h) }
227
+ [h1, h2] + list_remove.call(k, (n - k), h2).map { |ys| h1.merge(ys.to_h) }
228
+ end
229
+ end
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
312
256
  end
313
257
  end
314
258
 
315
- hash_shrink = ->(fk, fv, h) do
259
+ hash_shrink = ->(_fk, _fv, h) do
316
260
  candidates = []
317
261
  n = h.length
318
262
  k = n
@@ -333,40 +277,40 @@ module Minitest
333
277
  -(((r & (MAX_SIZE ^ SIGN_BIT)) - 1) ^ (MAX_SIZE ^ SIGN_BIT))
334
278
  end
335
279
  end.with_shrink_function do |i|
336
- i = if (i & SIGN_BIT).zero?
280
+ j = if (i & SIGN_BIT).zero?
337
281
  i
338
282
  else
339
283
  -(((i & (MAX_SIZE ^ SIGN_BIT)) - 1) ^ (MAX_SIZE ^ SIGN_BIT))
340
284
  end
341
- integral_shrink.call(i)
285
+ integral_shrink.call(j)
342
286
  end
343
287
 
344
288
  generator_for(Int8) do
345
289
  r = sized(0xff)
346
290
  (r & 0x80).zero? ? r : -(((r & 0x7f) - 1) ^ 0x7f)
347
291
  end.with_shrink_function do |i|
348
- i = (i & 0x80).zero? ? i : -(((i & 0x7f) - 1) ^ 0x7f)
349
- integral_shrink.call(i)
292
+ j = (i & 0x80).zero? ? i : -(((i & 0x7f) - 1) ^ 0x7f)
293
+ integral_shrink.call(j)
350
294
  end
351
295
 
352
296
  generator_for(Int16) do
353
297
  r = sized(0xffff)
354
298
  (r & 0x8000).zero? ? r : -(((r & 0x7fff) - 1) ^ 0x7fff)
355
299
  end.with_shrink_function do |i|
356
- i = (i & 0x8000).zero? ? i : -(((i & 0x7fff) - 1) ^ 0x7fff)
357
- integral_shrink.call(i)
300
+ j = (i & 0x8000).zero? ? i : -(((i & 0x7fff) - 1) ^ 0x7fff)
301
+ integral_shrink.call(j)
358
302
  end
359
303
 
360
304
  generator_for(Int32) do
361
305
  r = sized(0xffffffff)
362
306
  (r & 0x80000000).zero? ? r : -(((r & 0x7fffffff) - 1) ^ 0x7fffffff)
363
307
  end.with_shrink_function do |i|
364
- i = if (i & 0x80000000).zero?
308
+ j = if (i & 0x80000000).zero?
365
309
  i
366
310
  else
367
311
  -(((i & 0x7fffffff) - 1) ^ 0x7fffffff)
368
312
  end
369
- integral_shrink.call(i)
313
+ integral_shrink.call(j)
370
314
  end
371
315
 
372
316
  generator_for(Int64) do
@@ -377,12 +321,12 @@ module Minitest
377
321
  -(((r & 0x7fffffffffffffff) - 1) ^ 0x7fffffffffffffff)
378
322
  end
379
323
  end.with_shrink_function do |i|
380
- i = if (i & 0x8000000000000000).zero?
324
+ j = if (i & 0x8000000000000000).zero?
381
325
  i
382
326
  else
383
327
  -(((i & 0x7fffffffffffffff) - 1) ^ 0x7fffffffffffffff)
384
328
  end
385
- integral_shrink.call(i)
329
+ integral_shrink.call(j)
386
330
  end
387
331
 
388
332
  generator_for(UInt8) do
@@ -416,67 +360,51 @@ module Minitest
416
360
  (0..3)
417
361
  .map { |y| ((bits & (0xff << (8 * y))) >> (8 * y)).chr }
418
362
  .join
419
- .unpack('f')
420
- .first
363
+ .unpack1('f')
421
364
  end.with_shrink_function do |f|
422
365
  float_shrink.call(f)
423
- end.with_score_function do |f|
424
- if f.nan? || f.infinite?
425
- 0
426
- else
427
- f.abs.ceil
428
- end
429
- end
366
+ end.with_score_function(&score_float)
430
367
 
431
- generator_for(Float64) do
432
- bits = sized(0xffffffffffffffff)
368
+ float64build = ->(bits) do
433
369
  (0..7)
434
370
  .map { |y| ((bits & (0xff << (8 * y))) >> (8 * y)).chr }
435
371
  .join
436
- .unpack('d')
437
- .first
372
+ .unpack1('d')
373
+ end
374
+
375
+ generator_for(Float64) do
376
+ bits = sized(0xffffffffffffffff)
377
+ float64build.call(bits)
438
378
  end.with_shrink_function do |f|
439
379
  float_shrink.call(f)
440
- end.with_score_function do |f|
441
- if f.nan? || f.infinite?
442
- 0
443
- else
444
- f.abs.ceil
445
- end
446
- end
380
+ end.with_score_function(&score_float)
447
381
 
448
382
  generator_for(Float) do
449
383
  bits = sized(0xffffffffffffffff)
450
- (0..7)
451
- .map { |y| ((bits & (0xff << (8 * y))) >> (8 * y)).chr }
452
- .join
453
- .unpack('d')
454
- .first
384
+ float64build.call(bits)
455
385
  end.with_shrink_function do |f|
456
386
  float_shrink.call(f)
457
- end.with_score_function do |f|
458
- if f.nan? || f.infinite?
459
- 0
460
- else
461
- f.abs.ceil
462
- end
463
- 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)
464
396
 
465
397
  generator_for(ASCIIChar) do
466
398
  sized(0x7f).chr
467
399
  end.with_shrink_function do |c|
468
400
  integral_shrink.call(c.ord).reject(&:negative?).map(&:chr)
469
- end.with_score_function do |c|
470
- c.ord
471
- end
401
+ end.with_score_function(&:ord)
472
402
 
473
403
  generator_for(Char) do
474
404
  sized(0xff).chr
475
405
  end.with_shrink_function do |c|
476
406
  integral_shrink.call(c.ord).reject(&:negative?).map(&:chr)
477
- end.with_score_function do |c|
478
- c.ord
479
- end
407
+ end.with_score_function(&:ord)
480
408
 
481
409
  generator_for(String) do
482
410
  sized(0xff).chr
@@ -490,7 +418,7 @@ module Minitest
490
418
  end
491
419
  end.with_append(0, 0x20) do |x, y|
492
420
  x + y
493
- end.with_empty { "" }
421
+ end.with_empty { '' }
494
422
 
495
423
  generator_for(Array) do |x|
496
424
  [x]
@@ -513,10 +441,33 @@ module Minitest
513
441
  end
514
442
  end.with_append(0, 0x10) do |xm, ym|
515
443
  xm.merge(ym)
516
- end.with_empty { Hash.new }
444
+ end.with_empty { {} }
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)
517
468
 
518
469
  generator_for(Bool) do
519
- sized(0x1).even? ? false : true
470
+ sized(0x1).odd?
520
471
  end.with_score_function do |_|
521
472
  1
522
473
  end
@@ -1,8 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Minitest
2
4
  module Proptest
5
+ # Property evaluation - status, scoring, shrinking
3
6
  class Property
7
+ require 'minitest/assertions'
8
+ include Minitest::Assertions
9
+
10
+ attr_reader :calls, :result, :status, :trivial
11
+
12
+ attr_accessor :assertions
4
13
 
5
- attr_reader :result, :status, :trivial
6
14
  def initialize(
7
15
  # The function which proves the property
8
16
  test_proc,
@@ -19,7 +27,10 @@ module Minitest
19
27
  max_size: 0x100,
20
28
  # Maximum number of shrink attempts (default of half of max unsigned int
21
29
  # on the system architecture adopted from QuickCheck
22
- max_shrinks: 0x7fffffffffffffff
30
+ max_shrinks: 0x7fffffffffffffff,
31
+ # Previously discovered counter-example. If this exists, it should be
32
+ # run before any test cases are generated.
33
+ previous_failure: []
23
34
  )
24
35
  @test_proc = test_proc
25
36
  @random = random.call
@@ -30,15 +41,19 @@ module Minitest
30
41
  @max_shrinks = max_shrinks
31
42
  @status = Status.unknown
32
43
  @trivial = false
44
+ @valid_test_case = true
33
45
  @result = nil
34
46
  @exception = nil
35
47
  @calls = 0
48
+ @assertions = 0
36
49
  @valid_test_cases = 0
37
50
  @generated = []
38
51
  @arbitrary = nil
52
+ @previous_failure = previous_failure.to_a
39
53
  end
40
54
 
41
55
  def run!
56
+ rerun!
42
57
  iterate!
43
58
  shrink!
44
59
  end
@@ -54,33 +69,43 @@ module Minitest
54
69
  end
55
70
  end
56
71
 
72
+ def where(&b)
73
+ @valid_test_case &= b.call
74
+ end
75
+
57
76
  def explain
58
77
  prop = if @status.valid?
59
- "The property was proved to satsfaction across " +
78
+ 'The property was proved to satsfaction across ' \
60
79
  "#{@valid_test_cases} assertions."
61
80
  elsif @status.invalid?
62
- "The property was determined to be invalid due to " +
63
- "#{@exception.class.name}: #{@exception.message}\n" +
64
- @exception.backtrace.map { |l| " #{l}" }.join("\n")
81
+ 'The property was determined to be invalid due to ' \
82
+ "#{@exception.class.name}: #{@exception.message}\n" \
83
+ "#{@exception.backtrace.map { |l| " #{l}" }.join("\n")}"
65
84
  elsif @status.overrun?
66
- "The property attempted to generate more than #{@max_size} " +
67
- "bytes of entropy, violating the property's maximum size." +
68
- "This might be rectified by increasing max_size."
85
+ "The property attempted to generate more than #{@max_size} " \
86
+ "bytes of entropy, violating the property's maximum " \
87
+ 'size. This might be rectified by increasing max_size.'
69
88
  elsif @status.unknown?
70
- "The property has not yet been tested."
89
+ 'The property has not yet been tested.'
71
90
  elsif @status.interesting?
72
- "The property has found the following counterexample after " +
73
- "#{@valid_test_cases} valid " +
74
- "example#{@valid_test_cases == 1 ? '' : 's'}:\n" +
75
- @generated.map(&:value).inspect
91
+ 'The property has found the following counterexample after ' \
92
+ "#{@valid_test_cases} valid " \
93
+ "example#{@valid_test_cases == 1 ? '' : 's'}:\n" \
94
+ "#{@generated.map(&:value).inspect}"
95
+ elsif @status.exhausted?
96
+ "The property was unable to generate #{@max_success} test " \
97
+ 'cases before generating ' \
98
+ "#{@max_success * @max_discard_ratio} rejected test " \
99
+ "cases. This might be a problem with the property's " \
100
+ '`where` blocks.'
76
101
  end
77
102
  trivial = if @trivial
78
- "\nThe test does not appear to use any generated values " +
79
- "and as such is likely not generating much value. " +
80
- "Consider reworking this test to make use of arbitrary " +
81
- "data."
103
+ "\nThe test does not appear to use any generated values " \
104
+ 'and as such is likely not generating much value. ' \
105
+ 'Consider reworking this test to make use of arbitrary ' \
106
+ 'data.'
82
107
  else
83
- ""
108
+ ''
84
109
  end
85
110
  prop + trivial
86
111
  end
@@ -88,27 +113,84 @@ module Minitest
88
113
  private
89
114
 
90
115
  def iterate!
91
- while continue_iterate? && @result.nil? && @valid_test_cases <= @max_success / 2
116
+ while continue_iterate? && @result.nil? && @valid_test_cases <= @max_success
117
+ @valid_test_case = true
92
118
  @generated = []
93
119
  @generator = ::Minitest::Proptest::Gen.new(@random)
94
120
  @calls += 1
95
- if instance_eval(&@test_proc)
121
+
122
+ success = begin
123
+ instance_eval(&@test_proc)
124
+ rescue Minitest::Assertion
125
+ if @valid_test_case
126
+ @result = @generated
127
+ @status = Status.interesting
128
+ end
129
+ rescue => e
130
+ raise e if @valid_test_case
131
+ end
132
+ if @valid_test_case && success
96
133
  @status = Status.valid if @status.unknown?
97
134
  @valid_test_cases += 1
98
- else
135
+ elsif @valid_test_case
99
136
  @result = @generated
100
137
  @status = Status.interesting
101
138
  end
139
+
140
+ @status = Status.exhausted if @calls >= @max_success * (@max_discard_ratio + 1)
102
141
  @trivial = true if @generated.empty?
103
142
  end
104
143
  rescue => e
105
144
  @status = Status.invalid
106
145
  @exception = e
107
- raise e
146
+ end
147
+
148
+ def rerun!
149
+ return if @previous_failure.empty?
150
+
151
+ old_generator = @generator
152
+ old_random = @random
153
+ old_arbitrary = @arbitrary
154
+
155
+ index = -1
156
+ @arbitrary = ->(*classes) do
157
+ index += 1
158
+ raise IndexError if index >= @previous_failure.length
159
+
160
+ a = @generator.for(*classes)
161
+ a = a.force(@previous_failure[index])
162
+ @generated << a
163
+ @previous_failure[index]
164
+ end
165
+
166
+ @generator = ::Minitest::Proptest::Gen.new(@random)
167
+ success = begin
168
+ instance_eval(&@test_proc)
169
+ rescue Minitest::Assertion
170
+ !@valid_test_case
171
+ rescue => e
172
+ if @valid_test_case
173
+ @status = Status.invalid
174
+ @exception = e
175
+ false
176
+ end
177
+ end
178
+ if success || !@valid_test_case
179
+ @generated = []
180
+ elsif @valid_test_case
181
+ @result = @generated
182
+ @status = Status.interesting
183
+ end
184
+
185
+ # Clean up after we're done
186
+ @generator = old_generator
187
+ @random = old_random
188
+ @arbitrary = old_arbitrary
108
189
  end
109
190
 
110
191
  def shrink!
111
192
  return if @result.nil?
193
+
112
194
  old_random = @random
113
195
  old_generator = @generator
114
196
  best_score = @generated.map(&:score).reduce(&:+)
@@ -117,10 +199,10 @@ module Minitest
117
199
  old_arbitrary = @arbitrary
118
200
 
119
201
  to_test = candidates
120
- .map { |x| x.map { |y| [y] } }
121
- .reduce { |c, e| c.flat_map { |a| e.map { |b| a + b } } }
122
- .sort { |x, y| x.map(&:first).reduce(&:+) <=> y.map(&:first).reduce(&:+) }
123
- .uniq
202
+ .map { |x| x.map { |y| [y] } }
203
+ .reduce { |c, e| c.flat_map { |a| e.map { |b| a + b } } }
204
+ .sort { |x, y| x.map(&:first).reduce(&:+) <=> y.map(&:first).reduce(&:+) }
205
+ .uniq
124
206
  run = { run: 0, index: -1 }
125
207
 
126
208
  @arbitrary = ->(*classes) do
@@ -134,15 +216,28 @@ module Minitest
134
216
  end
135
217
 
136
218
  while continue_shrink? && run[:run] < to_test.length
137
- @generated = []
138
- run[:index] = -1
219
+ @generated = []
220
+ run[:index] = -1
221
+ @valid_test_case = true
139
222
 
140
223
  @generator = ::Minitest::Proptest::Gen.new(@random)
141
224
  if to_test[run[:run]].map(&:first).reduce(&:+) < best_score
142
- unless instance_eval(&@test_proc)
225
+ success = begin
226
+ instance_eval(&@test_proc)
227
+ rescue Minitest::Assertion
228
+ false
229
+ rescue => e
230
+ next unless @valid_test_case
231
+
232
+ @status = Status.invalid
233
+ @excption = e
234
+ break
235
+ end
236
+
237
+ if !success && @valid_test_case
238
+ # The first hit is guaranteed to be the best scoring due to the
239
+ # shrink candidates are pre-sorted.
143
240
  best_generated = @generated
144
- # Because we pre-sorted our shrink candidates, the first hit is
145
- # necessarily the best scoring
146
241
  break
147
242
  end
148
243
  end
@@ -162,14 +257,14 @@ module Minitest
162
257
  !@trivial &&
163
258
  !@status.invalid? &&
164
259
  !@status.overrun? &&
165
- @valid_test_cases < @max_success &&
166
- @calls < @max_success * @max_discard_ratio
260
+ !@status.exhausted? &&
261
+ @valid_test_cases < @max_success
167
262
  end
168
263
 
169
264
  def continue_shrink?
170
265
  !@trivial &&
171
266
  !@status.invalid? &&
172
- !@status.overrun?
267
+ !@status.overrun? &&
173
268
  @calls < @max_shrinks
174
269
  end
175
270
  end
@@ -1,7 +1,13 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Minitest
2
4
  module Proptest
3
5
  # Sum type representing the possible statuses of a test run.
4
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.
5
11
  # Unknown represents a lack of information about the test run (typically
6
12
  # having not run to satisfaction).
7
13
  # Valid represents a test which has run to satisfaction.
@@ -21,16 +27,21 @@ module Minitest
21
27
  class Valid < Status
22
28
  end
23
29
 
30
+ class Exhausted < Status
31
+ end
32
+
24
33
  invalid = Invalid.new.freeze
25
34
  interesting = Interesting.new.freeze
26
35
  overrun = Overrun.new.freeze
27
36
  unknown = Unknown.new.freeze
37
+ exhausted = Exhausted.new.freeze
28
38
  valid = Valid.new.freeze
29
39
 
30
40
  define_singleton_method(:invalid) { invalid }
31
41
  define_singleton_method(:interesting) { interesting }
32
42
  define_singleton_method(:overrun) { overrun }
33
43
  define_singleton_method(:unknown) { unknown }
44
+ define_singleton_method(:exhausted) { exhausted }
34
45
  define_singleton_method(:valid) { valid }
35
46
 
36
47
  def invalid?
@@ -45,6 +56,10 @@ module Minitest
45
56
  self.is_a?(Unknown)
46
57
  end
47
58
 
59
+ def exhausted?
60
+ self.is_a?(Exhausted)
61
+ end
62
+
48
63
  def valid?
49
64
  self.is_a?(Valid)
50
65
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Minitest
4
4
  module Proptest
5
- VERSION = '0.0.2'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -1,20 +1,150 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'minitest'
2
4
  require 'minitest/proptest/gen'
3
5
  require 'minitest/proptest/property'
4
6
  require 'minitest/proptest/status'
5
7
  require 'minitest/proptest/version'
8
+ require 'yaml'
6
9
 
7
10
  module Minitest
11
+ class ResultsDatabase < Minitest::AbstractReporter
12
+ def initialize(pathname)
13
+ super()
14
+
15
+ results = if File.file?(pathname)
16
+ YAML.load_file(pathname)
17
+ else
18
+ {}
19
+ end
20
+ self.class.instance_variable_set(:@_results, results) unless self.class.instance_variable_defined?(:@_results)
21
+ end
22
+
23
+ def report
24
+ return unless Proptest.use_db?
25
+
26
+ File.write(Proptest.result_db, self.class.instance_variable_get(:@_results).to_yaml)
27
+ end
28
+
29
+ def lookup(file, classname, methodname)
30
+ self.class.instance_variable_get(:@_results)
31
+ .dig(file, classname, methodname)
32
+ .to_a
33
+ end
34
+
35
+ def record_failure(file, classname, methodname, generated)
36
+ return unless Proptest.use_db?
37
+
38
+ results = self.class.instance_variable_get(:@_results)
39
+ results[file] ||= {}
40
+ results[file][classname] ||= {}
41
+ results[file][classname][methodname] = generated
42
+ end
43
+
44
+ def strike_failure(file, classname, methodname)
45
+ return unless Proptest.use_db?
46
+
47
+ results = self.class.instance_variable_get(:@_results)
48
+ return unless results.key?(file)
49
+
50
+ return unless results[file].key?(classname)
51
+
52
+ results[file][classname].delete(methodname)
53
+ results[file].delete(classname) if results[file][classname].empty?
54
+ results.delete(file) if results[file].empty?
55
+ end
56
+ end
57
+
8
58
  module Proptest
9
59
  DEFAULT_RANDOM = Random.method(:new)
10
60
  DEFAULT_MAX_SUCCESS = 100
11
61
  DEFAULT_MAX_DISCARD_RATIO = 10
12
62
  DEFAULT_MAX_SIZE = 0x100
13
63
  DEFAULT_MAX_SHRINKS = (((1 << (1.size * 8)) - 1) / 2)
64
+ DEFAULT_DB_LOCATION = File.join(Dir.pwd, '.proptest_failures.yml')
65
+
66
+ self.instance_variable_set(:@_random, DEFAULT_RANDOM)
67
+ self.instance_variable_set(:@_max_success, DEFAULT_MAX_SUCCESS)
68
+ self.instance_variable_set(:@_max_discard_ratio, DEFAULT_MAX_DISCARD_RATIO)
69
+ self.instance_variable_set(:@_max_size, DEFAULT_MAX_SIZE)
70
+ self.instance_variable_set(:@_max_shrinks, DEFAULT_MAX_SHRINKS)
71
+ self.instance_variable_set(:@_result_db, DEFAULT_DB_LOCATION)
72
+ self.instance_variable_set(:@_use_db, false)
14
73
 
15
74
  def self.set_seed(seed)
16
75
  self.instance_variable_set(:@_random_seed, seed)
17
76
  end
77
+
78
+ def self.max_success=(success)
79
+ self.instance_variable_set(:@_max_success, success)
80
+ end
81
+
82
+ def self.max_discard_ratio=(discards)
83
+ self.instance_variable_set(:@_max_discard_ratio, discards)
84
+ end
85
+
86
+ def self.max_size=(size)
87
+ self.instance_variable_set(:@_max_size, size)
88
+ end
89
+
90
+ def self.max_shrinks=(shrinks)
91
+ self.instance_variable_set(:@_max_shrinks, shrinks)
92
+ end
93
+
94
+ def self.result_db=(location)
95
+ self.instance_variable_set(:@_result_db, File.expand_path(location))
96
+ end
97
+
98
+ def self.use_db!(use = true)
99
+ self.instance_variable_set(:@_use_db, use)
100
+ end
101
+
102
+ def self.seed
103
+ self.instance_variable_get(:@_random_seed)
104
+ end
105
+
106
+ def self.max_success
107
+ self.instance_variable_get(:@_max_success)
108
+ end
109
+
110
+ def self.max_discard_ratio
111
+ self.instance_variable_get(:@_max_discard_ratio)
112
+ end
113
+
114
+ def self.max_size
115
+ self.instance_variable_get(:@_max_size)
116
+ end
117
+
118
+ def self.max_shrinks
119
+ self.instance_variable_get(:@_max_shrinks)
120
+ end
121
+
122
+ def self.result_db
123
+ self.instance_variable_get(:@_result_db)
124
+ end
125
+
126
+ def self.use_db?
127
+ self.instance_variable_get(:@_use_db)
128
+ end
129
+
130
+ def self.record_failure(file, classname, methodname, generated)
131
+ self.instance_variable_get(:@_results)
132
+ .record_failure(file, classname, methodname, generated)
133
+ end
134
+
135
+ def self.strike_failure(file, classname, methodname)
136
+ self.instance_variable_get(:@_results)
137
+ .strike_failure(file, classname, methodname)
138
+ end
139
+
140
+ def self.reporter
141
+ return self.instance_variable_get(:@_results) if self.instance_variable_defined?(:@_results)
142
+
143
+ reporter = Minitest::ResultsDatabase.new(result_db)
144
+ self.instance_variable_set(:@_results, reporter)
145
+
146
+ reporter
147
+ end
18
148
  end
19
149
  end
20
150
 
@@ -1,9 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'minitest'
2
4
  require 'minitest/proptest'
3
5
  require 'minitest/proptest/gen'
4
6
  require 'minitest/proptest/property'
5
7
  require 'minitest/proptest/status'
6
8
  require 'minitest/proptest/version'
9
+ require 'yaml'
7
10
 
8
11
  module Minitest
9
12
  def self.plugin_proptest_init(options)
@@ -18,15 +21,35 @@ module Minitest
18
21
  end
19
22
  end
20
23
 
21
- if options.has_key?(:seed)
22
- Proptest.set_seed(options[:seed])
24
+ self.reporter << Proptest.reporter
25
+
26
+ Proptest.set_seed(options[:seed]) if options.key?(:seed)
27
+ end
28
+
29
+ def self.plugin_proptest_options(opts, _options)
30
+ opts.on('--max-success', Integer, "Maximum number of successful cases to verify for each property (Default: #{Minitest::Proptest::DEFAULT_MAX_SUCCESS})") do |max_success|
31
+ Proptest.max_success = max_success
32
+ end
33
+ opts.on('--max-discard-ratio', Integer, "Maximum ratio of successful cases versus discarded cases per property (Default: #{Minitest::Proptest::DEFAULT_MAX_DISCARD_RATIO}:1)") do |max_success|
34
+ Proptest.max_success = max_success
35
+ end
36
+ opts.on('--max-size', Integer, "Maximum amount of entropy a single case may use in bytes (Default: #{Minitest::Proptest::DEFAULT_MAX_SIZE} bytes)") do |max_size|
37
+ Proptest.max_size = max_size
38
+ end
39
+ opts.on('--max-shrinks', Integer, "Maximum number of shrink iterations a single failure reduction may use (Default: #{Minitest::Proptest::DEFAULT_MAX_SHRINKS})") do |max_shrinks|
40
+ Proptest.max_shrinks = max_shrinks
41
+ end
42
+ opts.on('--results-db', String, "Location of the file to persist most recent failure cases. Implies --use-db. (Default: #{Minitest::Proptest::DEFAULT_DB_LOCATION})") do |db_path|
43
+ Proptest.result_db = db_path
44
+ Proptest.use_db!
45
+ end
46
+ opts.on('--use-db', 'Persist previous failures in a database and use them before generating new values. Helps prevent flaky builds. (Default: false)') do
47
+ Proptest.use_db!
23
48
  end
24
49
  end
25
50
 
26
51
  module Assertions
27
52
  def property(&f)
28
- self.assertions += 1
29
-
30
53
  random_thunk = if Proptest.instance_variable_defined?(:@_random_seed)
31
54
  r = Proptest.instance_variable_get(:@_random_seed)
32
55
  ->() { Proptest::DEFAULT_RANDOM.call(r) }
@@ -34,17 +57,29 @@ module Minitest
34
57
  Proptest::DEFAULT_RANDOM
35
58
  end
36
59
 
60
+ file, methodname = caller.first.split(/:\d+:in +/)
61
+ classname = self.class.name
62
+ methodname.gsub!(/(?:^`|'$)/, '')
63
+
37
64
  prop = Minitest::Proptest::Property.new(
38
65
  f,
39
66
  random: random_thunk,
40
- max_success: Proptest::DEFAULT_MAX_SUCCESS,
41
- max_discard_ratio: Proptest::DEFAULT_MAX_DISCARD_RATIO,
42
- max_size: Proptest::DEFAULT_MAX_SIZE,
43
- max_shrinks: Proptest::DEFAULT_MAX_SHRINKS
67
+ max_success: Proptest.max_success,
68
+ max_discard_ratio: Proptest.max_discard_ratio,
69
+ max_size: Proptest.max_size,
70
+ max_shrinks: Proptest.max_shrinks,
71
+ previous_failure: Proptest.reporter.lookup(file, classname, methodname)
44
72
  )
45
73
  prop.run!
74
+ self.assertions += prop.calls
75
+
76
+ if prop.status.valid? && !prop.trivial
77
+ Proptest.strike_failure(file, classname, methodname)
78
+ else
79
+ unless prop.status.exhausted? || prop.status.invalid?
80
+ Proptest.record_failure(file, classname, methodname, prop.result.map(&:value))
81
+ end
46
82
 
47
- unless prop.status.valid? && !prop.trivial
48
83
  raise Minitest::Assertion, prop.explain
49
84
  end
50
85
  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.0.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina Wuest
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-09-15 00:00:00.000000000 Z
11
+ date: 2024-03-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -33,6 +33,7 @@ extra_rdoc_files: []
33
33
  files:
34
34
  - lib/minitest/proptest.rb
35
35
  - lib/minitest/proptest/gen.rb
36
+ - lib/minitest/proptest/gen/value_generator.rb
36
37
  - lib/minitest/proptest/property.rb
37
38
  - lib/minitest/proptest/status.rb
38
39
  - lib/minitest/proptest/version.rb
@@ -44,6 +45,7 @@ metadata:
44
45
  homepage_uri: https://github.com/wuest/minitest-proptest
45
46
  source_code_uri: https://github.com/wuest/minitest-proptest
46
47
  changelog_uri: https://github.com/wuest/minitest-proptest/blob/main/CHANGELOG.md
48
+ rubygems_mfa_required: 'true'
47
49
  post_install_message:
48
50
  rdoc_options: []
49
51
  require_paths:
@@ -59,7 +61,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
59
61
  - !ruby/object:Gem::Version
60
62
  version: '0'
61
63
  requirements: []
62
- rubygems_version: 3.3.7
64
+ rubygems_version: 3.5.3
63
65
  signing_key:
64
66
  specification_version: 4
65
67
  summary: Property testing in Minitest