properb 0.0.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.
data/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # Properb
2
+
3
+ Property-based testing in Ruby, with RSpec integration.
4
+
5
+ ## Installation
6
+
7
+ This project
8
+
9
+ ## Usage
10
+
11
+ To install, add the following to your `spec/spec_helper.rb` file:
12
+
13
+ ```ruby
14
+ RSpec.configure do |config|
15
+ Properb.rspec_install(config)
16
+ end
17
+ ```
18
+
19
+ Then you can use `generate` and `it_always` in your tests to create property-based tests:
20
+
21
+ ```ruby
22
+ describe "#sort" do
23
+ generate(strings: array(string))
24
+
25
+ it_always "preserves the number of elements", num_tests: 200 do
26
+ expect(strings.sort.length).to be == strings.length
27
+ end
28
+
29
+ it_always "has the first and last elements in sorted order" do
30
+ assume(strings).to_not be_empty
31
+ sorted = strings.sort
32
+ expect(sorted[0]).to be <= sorted[-1]
33
+ end
34
+
35
+ it_always "creates pairwise sorted elements", num_tests: 200 do
36
+ # assume(strings.length).to be >= 2
37
+ assume(strings.uniq).to eq(strings)
38
+ sorted = strings.sort
39
+ (0...sorted.length - 1).each do |i|
40
+ expect(sorted[i]).to be < sorted[i + 1]
41
+ end
42
+ end
43
+ end
44
+ ```
45
+
46
+ ## Development
47
+
48
+ This project uses Guix as its main dependency management system. A development environment can be created by running:
49
+
50
+ ```sh
51
+ guix shell --development --file=guix.scm
52
+ ```
53
+
54
+ ## Contributing
55
+
56
+ Bug reports and changes are welcome on SourceHut at <https://sr.ht/~czan/properb/>.
@@ -0,0 +1,29 @@
1
+ module Properb
2
+ class Generator
3
+ def to_properb_generator
4
+ self
5
+ end
6
+
7
+ def map(&block)
8
+ Generators::MappedGenerator.new(self, &block)
9
+ end
10
+
11
+ def select(num_attempts: 10, &block)
12
+ Generators::SelectedGenerator.new(self, num_attempts: num_attempts, &block)
13
+ end
14
+
15
+ def reject(num_attempts: 10, &block)
16
+ select(num_attempts: num_attempts) do |*args|
17
+ !block.call(*args)
18
+ end
19
+ end
20
+
21
+ def or(generator)
22
+ Generators::ChoiceGenerator.new([self, Generators.gen(generator)])
23
+ end
24
+
25
+ def sized(size)
26
+ Generators::SizedGenerator.new(self, size)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,75 @@
1
+ module Properb
2
+ module Generators
3
+ class ArrayGenerator < Generator
4
+ def initialize(generator, length: 0...)
5
+ @generator = generator
6
+ @length = length
7
+ end
8
+
9
+ def generate_value(random, size)
10
+ if @length.is_a?(Range)
11
+ length = random.random_number(@length.min..(@length.end || (@length.min + size)))
12
+ min_length = @length.min
13
+ else
14
+ length = min_length = @length
15
+ end
16
+ values = (0...length).map { @generator.generate_value(random, size) }
17
+ ShrinkTree.new(
18
+ values.map(&:value),
19
+ shrink(values, min_length)
20
+ )
21
+ end
22
+
23
+ def shrink(values, min_length)
24
+ Enumerator.new do |out|
25
+ cutoff = (values.length / 2).ceil
26
+ if cutoff > min_length
27
+ out << ShrinkTree.new(
28
+ values[cutoff..].map(&:value),
29
+ shrink(values[cutoff..], min_length)
30
+ )
31
+ out << ShrinkTree.new(
32
+ values[...-cutoff].map(&:value),
33
+ shrink(values[...-cutoff], min_length)
34
+ )
35
+ end
36
+ if values.length > min_length
37
+ # Try removing individual items in the vector
38
+ (0...values.length).each do |i|
39
+ new_values = values[0...i] + values[i + 1...]
40
+ out << ShrinkTree.new(
41
+ new_values.map(&:value),
42
+ shrink(new_values, min_length)
43
+ )
44
+ end
45
+ end
46
+ # Try shrinking the individual items in the vector
47
+ (0...values.length).each do |i|
48
+ values[i].children.each do |value|
49
+ new_values = values[0...i] + [value] + values[i + 1...]
50
+ out << ShrinkTree.new(
51
+ new_values.map(&:value),
52
+ shrink(new_values, min_length)
53
+ )
54
+ end
55
+ end
56
+ # Try shrinking pairs of items in the vector
57
+ (0...values.length).each do |i| # rubocop:disable Style/CombinableLoops
58
+ values[i].children.each do |first_value|
59
+ new_values = values[0...i] + [first_value] + values[i + 1...]
60
+ (0...values.length).each do |j|
61
+ new_values[j].children.each do |second_value|
62
+ newer_values = new_values[0...j] + [second_value] + new_values[j + 1...]
63
+ out << ShrinkTree.new(
64
+ newer_values.map(&:value),
65
+ shrink(newer_values, min_length)
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,26 @@
1
+ module Properb
2
+ module Generators
3
+ class ChoiceGenerator < Generator
4
+ def initialize(generators)
5
+ @generators = generators
6
+ end
7
+
8
+ # Overriding the superclass method to make the distribution even
9
+ def or(generator)
10
+ ChoiceGenerator.new([*@generators, generator])
11
+ end
12
+
13
+ def generate_value(random, size)
14
+ integer = IntegerGenerator.new(0...@generators.length)
15
+ .generate_value(random, size)
16
+ saved_random = random.dup
17
+ integer.flat_map do |choice|
18
+ # We want all the sub-generators use the same random number
19
+ # sequence, so we duplicate the generator
20
+ new_random = saved_random.dup
21
+ @generators[choice].generate_value(new_random, size)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,13 @@
1
+ module Properb
2
+ module Generators
3
+ class ConstantGenerator < Generator
4
+ def initialize(value)
5
+ @value = value
6
+ end
7
+
8
+ def generate_value(_random, _size)
9
+ ShrinkTree.new(@value, [])
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ module Properb
2
+ module Generators
3
+ class DeferredGenerator < Generator
4
+ def initialize(&block)
5
+ @block = block
6
+ @generator = nil
7
+ end
8
+
9
+ def generate_value(random, size)
10
+ @generator = Generators.gen(@block.call)
11
+ @generator.generate_value(random, size)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,26 @@
1
+ module Properb
2
+ module Generators
3
+ class FrequenciesGenerator < Generator
4
+ def initialize(frequencies)
5
+ @frequencies = frequencies
6
+ end
7
+
8
+ def generate_value(random, size)
9
+ integer = IntegerGenerator.new(0...@frequencies.values.sum)
10
+ .generate_value(random, size)
11
+ saved_random = random.dup
12
+ integer.flat_map do |choice|
13
+ new_random = saved_random.dup
14
+ running = 0
15
+ generator = @frequencies.find do |_generator, weight|
16
+ running += weight
17
+ running > choice
18
+ end.first
19
+ raise "No generator found in `frequencies' - insufficient weights?" unless generator
20
+
21
+ generator.generate_value(new_random, size)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,30 @@
1
+ module Properb
2
+ module Generators
3
+ class IntegerGenerator < Generator
4
+ def initialize(range = 0..)
5
+ @range = range
6
+ end
7
+
8
+ def generate_value(random, _size)
9
+ value = random.random_number(@range)
10
+ ShrinkTree.new(
11
+ value,
12
+ Enumerator.new do |out|
13
+ out << ShrinkTree.new(@range.min, [])
14
+ shrink(value, @range.min).each { out << _1 }
15
+ end
16
+ )
17
+ end
18
+
19
+ def shrink(value, min)
20
+ midpoint = ((value + min) / 2).floor
21
+ return [] if midpoint == min # termination
22
+
23
+ Enumerator.new do |out|
24
+ out << ShrinkTree.new(midpoint, shrink(midpoint, min))
25
+ shrink(value, midpoint).each { out << _1 }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,14 @@
1
+ module Properb
2
+ module Generators
3
+ class MappedGenerator < Generator
4
+ def initialize(generator, &block)
5
+ @generator = generator
6
+ @block = block
7
+ end
8
+
9
+ def generate_value(random, size)
10
+ @generator.generate_value(random, size).map(&@block)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ module Properb
2
+ module Generators
3
+ class RecursiveGenerator < Generator
4
+ def initialize(&block)
5
+ @block = block
6
+ end
7
+
8
+ def generate_value(random, size)
9
+ @block
10
+ .call(size, RecursiveGenerator.new(&@block).sized((size / 2).floor))
11
+ .generate_value(random, size)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ module Properb
2
+ module Generators
3
+ class SelectedGenerator < Generator
4
+ def initialize(generator, num_attempts: 10, &block)
5
+ @generator = generator
6
+ @num_attempts = num_attempts
7
+ @block = block
8
+ end
9
+
10
+ def generate_value(random, size)
11
+ @num_attempts.times do
12
+ generated = @generator.generate_value(random, size)
13
+ return generated.select(&@block).first if @block.call(generated.value)
14
+ end
15
+ raise "Could not generate a value after #{@num_attempts} attempts"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ module Properb
2
+ module Generators
3
+ class SizedGenerator < Generator
4
+ def initialize(generator, size)
5
+ @generator = generator
6
+ @size = size
7
+ end
8
+
9
+ def generate_value(random, _size)
10
+ @generator.generate_value(random, @size)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,41 @@
1
+ module Properb
2
+ module Generators
3
+ class TupleGenerator < Generator
4
+ def initialize(generators)
5
+ @generators = generators
6
+ end
7
+
8
+ def generate_value(random, size)
9
+ values = @generators.map { _1.generate_value(random, size) }
10
+ ShrinkTree.new(values.map(&:value), shrink(values))
11
+ end
12
+
13
+ def shrink(values)
14
+ Enumerator.new do |out|
15
+ # Try shrinking the individual items in the vector
16
+ (0...values.length).each do |i|
17
+ values[i].children.each do |value|
18
+ new_values = values[0...i] + [value] + values[i + 1...]
19
+ out << ShrinkTree.new(new_values.map(&:value), shrink(new_values))
20
+ end
21
+ end
22
+ # Try shrinking pairs of items in the vector
23
+ (0...values.length).each do |i|
24
+ values[i].children.each do |value|
25
+ new_values = values[0...i] + [value] + values[i + 1...]
26
+ (0...values.length).each do |j|
27
+ new_values[j].children.each do |value|
28
+ newer_values = new_values[0...j] + [value] + new_values[j + 1...]
29
+ out << ShrinkTree.new(
30
+ newer_values.map(&:value),
31
+ shrink(newer_values)
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,83 @@
1
+ require_relative "generators/array_generator"
2
+ require_relative "generators/choice_generator"
3
+ require_relative "generators/constant_generator"
4
+ require_relative "generators/deferred_generator"
5
+ require_relative "generators/frequencies_generator"
6
+ require_relative "generators/integer_generator"
7
+ require_relative "generators/mapped_generator"
8
+ require_relative "generators/recursive_generator"
9
+ require_relative "generators/selected_generator"
10
+ require_relative "generators/sized_generator"
11
+ require_relative "generators/tuple_generator"
12
+
13
+ module Properb
14
+ module Generators
15
+ extend Generators # Bring all these methods onto the module itself
16
+
17
+ def defer(&block)
18
+ DeferredGenerator.new(&block)
19
+ end
20
+
21
+ def gen(object)
22
+ if object.respond_to?(:to_properb_generator)
23
+ object.to_properb_generator
24
+ else
25
+ const(object)
26
+ end
27
+ end
28
+
29
+ def one_of(*options)
30
+ ChoiceGenerator.new(options.map { gen(_1) })
31
+ end
32
+
33
+ def int(range = 0..(2 << 32))
34
+ IntegerGenerator.new(range)
35
+ end
36
+
37
+ # By default, generate printable ASCII characters
38
+ def string(char_generator = int(32..126).map { _1.chr }, length: 0..)
39
+ array(char_generator, length: length).map { _1.join }
40
+ end
41
+
42
+ def const(value)
43
+ ConstantGenerator.new(value)
44
+ end
45
+
46
+ def boolean
47
+ const(false).or(const(true))
48
+ end
49
+
50
+ def maybe(generator)
51
+ ChoiceGenerator.new([const(nil), gen(generator)])
52
+ end
53
+
54
+ def frequencies(**generators_and_weights)
55
+ FrequenciesGenerator.new(generators_and_weights.transform_values { gen(_1) })
56
+ end
57
+
58
+ def array(generator, length: 0..)
59
+ ArrayGenerator.new(gen(generator), length: length)
60
+ end
61
+
62
+ def tuple(*generators)
63
+ TupleGenerator.new(generators.map { gen(_1) })
64
+ end
65
+
66
+ def hashmap(key_generator, value_generator = nil)
67
+ if value_generator.nil?
68
+ unless key_generator.is_a?(Hash)
69
+ raise "Single argument to `hashmap' must be a hash with generator values, not #{key_generator || "nil"}"
70
+ end
71
+
72
+ tuple(*key_generator.values.map { gen(_1) })
73
+ .map { key_generator.keys.zip(_1).to_h }
74
+ else
75
+ array(tuple(key_generator, value_generator)).map(&:to_h)
76
+ end
77
+ end
78
+
79
+ def recursive(&block)
80
+ RecursiveGenerator.new(&block)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,64 @@
1
+ module Properb
2
+ module RSpec
3
+ module ExampleGroupMethods
4
+ class PropertyExample < ::RSpec::Core::Example
5
+ private
6
+
7
+ def with_around_and_singleton_context_hooks
8
+ Properb.for_all(metadata[:generators], **metadata.slice(:size, :num_tests, :assumption_limit)) do |data|
9
+ @example_group_instance.instance_variable_set("@properb_generated_data", data)
10
+ super
11
+ if display_exception
12
+ ex = display_exception
13
+ self.display_exception = nil
14
+ raise ex
15
+ end
16
+ ensure
17
+ @example_group_instance.instance_variable_set("@properb_generated_data", nil)
18
+ end
19
+ end
20
+ end
21
+
22
+ def generate(**methods_and_generators)
23
+ @properb_generators ||= {}
24
+ @properb_generators.merge!(methods_and_generators)
25
+ @properb_generators.keys.each do |key|
26
+ define_method(key) do
27
+ raise "Cannot call #{key} outside of a property" unless @properb_generated_data
28
+
29
+ @properb_generated_data[key]
30
+ end
31
+ end
32
+ end
33
+
34
+ def it_always(description, **options, &block)
35
+ PropertyExample.new(self,
36
+ description,
37
+ { property: true,
38
+ generators: @properb_generators,
39
+ **options },
40
+ block)
41
+ end
42
+
43
+ def fit_always(description, **options, &block)
44
+ PropertyExample.new(self,
45
+ description,
46
+ { property: true,
47
+ generators: @properb_generators,
48
+ focus: true,
49
+ **options },
50
+ block)
51
+ end
52
+
53
+ def xit_always(description, **options, &block)
54
+ PropertyExample.new(self,
55
+ description,
56
+ { property: true,
57
+ generators: @properb_generators,
58
+ skip: "Temporarily skipped with xit_always",
59
+ **options },
60
+ block)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,40 @@
1
+ module Properb
2
+ module RSpec
3
+ module ExampleMethods
4
+ class AssumptionViolated < StandardError; end
5
+
6
+ class Assumption
7
+ def initialize(target)
8
+ @target = target
9
+ end
10
+
11
+ def to(matcher = nil, message = nil, &block)
12
+ handle_positive_matcher(@target, matcher, message, &block)
13
+ end
14
+
15
+ def to_not(matcher = nil, message = nil, &block)
16
+ handle_negative_matcher(@target, matcher, message, &block)
17
+ end
18
+ alias not_to to_not
19
+
20
+ private
21
+
22
+ def handle_positive_matcher(actual, matcher, custom_message, &block)
23
+ return if matcher.matches?(actual, &block)
24
+
25
+ raise AssumptionViolated, custom_message || matcher.failure_message
26
+ end
27
+
28
+ def handle_negative_matcher(actual, matcher, custom_message, &block)
29
+ return unless matcher.matches?(actual, &block)
30
+
31
+ raise AssumptionViolated, custom_message || matcher.failure_message
32
+ end
33
+ end
34
+
35
+ def assume(value)
36
+ Assumption.new(value)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,2 @@
1
+ require_relative "rspec/example_methods"
2
+ require_relative "rspec/example_group_methods"
@@ -0,0 +1,44 @@
1
+ module Properb
2
+ class ShrinkTree
3
+ attr_reader :value, :children
4
+
5
+ def initialize(value, children)
6
+ @value = value
7
+ @children = children.lazy
8
+ end
9
+
10
+ def map(&block)
11
+ ShrinkTree.new(
12
+ block.call(@value),
13
+ @children.map { _1.map(&block) }
14
+ )
15
+ end
16
+
17
+ def flat_map(&block)
18
+ result = block.call(@value)
19
+ # First try to shrink the parent generator, then try to shrink
20
+ # the child.
21
+ combined_children = (children.map { _1.flat_map(&block) } + result.children)
22
+ ShrinkTree.new(result.value, combined_children)
23
+ end
24
+
25
+ # Return an array of nodes to use to replace the current node,
26
+ # filtered by calling the provided block on the node's values.
27
+ def select(&block)
28
+ if block.call(@value)
29
+ [
30
+ ShrinkTree.new(
31
+ @value,
32
+ @children.flat_map { _1.select(&block) }
33
+ )
34
+ ]
35
+ else
36
+ @children.flat_map { _1.select(&block) }
37
+ end
38
+ end
39
+
40
+ def debug
41
+ { @value => @children.map(&:debug).to_a }
42
+ end
43
+ end
44
+ end