zizek 0.1.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.
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The example database: a counterexample that outlives the run that found it.
4
+ #
5
+ # Demonstrates:
6
+ # - `database:` storing a shrunk failure's raw choice stream on disk;
7
+ # - the next run replaying the stored entry before generating anything, so
8
+ # yesterday's bug reproduces on the first attempt — no search, no seed;
9
+ # - the fixed bug cleaning itself up: the entry that no longer reproduces
10
+ # is deleted by the replay that failed to reproduce it; and
11
+ # - the on-disk layout (`<key digest>/<stream digest>`), which the Python
12
+ # and Go packages write identically, so a directory can be shared.
13
+ #
14
+ # The whole run works in a temporary directory and leaves nothing behind.
15
+
16
+ require "tmpdir"
17
+ require "zizek"
18
+
19
+ module DatabaseWorkflowExample
20
+ # A name both sides of a shared directory agree on. Left unset, the key is
21
+ # derived from where the property was written — stable across runs, but
22
+ # meaningless to another language's suite.
23
+ DATABASE_KEY = "sorted_merge_keeps_every_element"
24
+
25
+ module_function
26
+
27
+ # Merge two sorted arrays. Deliberate bug: the loop stops at the end of the
28
+ # shorter input, dropping whatever the longer one still holds.
29
+ def merge_buggy(left, right)
30
+ merged = []
31
+ a = 0
32
+ b = 0
33
+ while a < left.length && b < right.length
34
+ if left[a] <= right[b]
35
+ merged << left[a]
36
+ a += 1
37
+ else
38
+ merged << right[b]
39
+ b += 1
40
+ end
41
+ end
42
+ merged # BUG: the unconsumed tail of the longer input is lost
43
+ end
44
+
45
+ # The property, parameterized on the merge under test so the same one can
46
+ # first find the bug and then verify the fix.
47
+ def property(merge)
48
+ sorted = Zizek::Generators.lists(
49
+ Zizek::Generators.integers(0..9), max: 4
50
+ ).map(&:sort)
51
+
52
+ lambda do |test_case|
53
+ left = test_case.draw(sorted, name: :left)
54
+ right = test_case.draw(sorted, name: :right)
55
+ merged = merge.call(left, right)
56
+ unless merged.length == left.length + right.length
57
+ raise "merged #{left.inspect} and #{right.inspect} into #{merged.inspect}"
58
+ end
59
+ end
60
+ end
61
+
62
+ def stored_entries(root)
63
+ Dir.glob(File.join(root, "*", "*")).reject { |path| File.basename(path).start_with?(".") }
64
+ end
65
+
66
+ def run_workflow
67
+ Dir.mktmpdir("zizek-example-db") do |root|
68
+ # Run 1: the search finds the bug, shrinks it, and stores the shrunk
69
+ # choice stream under the database key.
70
+ first =
71
+ begin
72
+ Zizek.check(property(method(:merge_buggy)),
73
+ examples: 300, seed: 1,
74
+ database: root, database_key: DATABASE_KEY)
75
+ raise "the dropped-tail bug should be discovered"
76
+ rescue Zizek::Failure => failure
77
+ failure
78
+ end
79
+ entries = stored_entries(root)
80
+ raise "one counterexample should be stored" unless entries.length == 1
81
+
82
+ # Run 2: a different seed, but the stored entry is replayed before any
83
+ # generation — the bug reproduces on the first attempt.
84
+ second =
85
+ begin
86
+ Zizek.check(property(method(:merge_buggy)),
87
+ examples: 300, seed: 999,
88
+ database: root, database_key: DATABASE_KEY)
89
+ raise "the stored counterexample should reproduce"
90
+ rescue Zizek::Failure => failure
91
+ failure
92
+ end
93
+ raise "the stored entry should be the first attempt" unless second.examples_run == 1
94
+ raise "both runs should report the same blob" unless second.blob == first.blob
95
+
96
+ # Run 3: the bug is fixed (a correct merge keeps every element, and for
97
+ # this property concatenation is one). The stored entry no longer
98
+ # reproduces, so the run deletes it and explores normally — the
99
+ # directory keeps itself current.
100
+ Zizek.check(property(->(left, right) { left + right }),
101
+ examples: 100, seed: 1,
102
+ database: root, database_key: DATABASE_KEY)
103
+ unless stored_entries(root).empty?
104
+ raise "the fixed bug's entry should have been collected"
105
+ end
106
+
107
+ [first, second]
108
+ end
109
+ end
110
+ end
111
+
112
+ if $PROGRAM_NAME == __FILE__
113
+ first, second = DatabaseWorkflowExample.run_workflow
114
+ puts "== run 1: found, shrunk, and stored =="
115
+ puts first.message
116
+ puts "\n== run 2 (different seed): replayed from the database on attempt " \
117
+ "#{second.examples_run} =="
118
+ puts second.message
119
+ puts "\n== run 3 (bug fixed): the stale entry collected itself =="
120
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Port of Hegel's Die Hard water-jug state machine. The "failure" is a solution:
4
+ # generation finds operations leaving exactly four litres in the five-litre jug,
5
+ # then structural shrinking removes every unnecessary operation.
6
+
7
+ require "zizek"
8
+
9
+ module DieHardExample
10
+ LEGACY_ZIZEK_DIE_HARD_FAILURE =
11
+ "AAwAAAABAAAKAQAAAAEBAAAKAQAAAAUBAAAKAQAAAAIBAAAKAQAAAAUBAAAKAQAAAAEBAAAKAQAAAAU="
12
+ SHRUNK_DIE_HARD_FAILURE =
13
+ "AXicbYo5CgAwDMPkQpeu/f9fc0AIgXixLfQBiSciWX6L3J5npdM1FrQAhg=="
14
+ HEGEL_ZLIB_DIE_HARD_FAILURE =
15
+ "AXicbYoxCgBACMPSg1tc/f9fdRBFcCgNIQ4ILEed1OYPvtPuNgAVzACA"
16
+
17
+ class DieHard
18
+ def initialize
19
+ @small = 0
20
+ @big = 0
21
+ end
22
+
23
+ def fill_small(_test_case)
24
+ @small = 3
25
+ end
26
+
27
+ def fill_big(_test_case)
28
+ @big = 5
29
+ end
30
+
31
+ def empty_small(_test_case)
32
+ @small = 0
33
+ end
34
+
35
+ def empty_big(_test_case)
36
+ @big = 0
37
+ end
38
+
39
+ def pour_small_into_big(_test_case)
40
+ previous_big = @big
41
+ @big = [5, @big + @small].min
42
+ @small -= @big - previous_big
43
+ end
44
+
45
+ def pour_big_into_small(_test_case)
46
+ previous_small = @small
47
+ @small = [3, @small + @big].min
48
+ @big -= @small - previous_small
49
+ end
50
+
51
+ def physics_of_jugs(_test_case)
52
+ raise "small jug escaped its capacity" unless @small.between?(0, 3)
53
+ raise "big jug escaped its capacity" unless @big.between?(0, 5)
54
+ end
55
+
56
+ def die_hard_problem_not_solved(test_case)
57
+ test_case.note("small / big = #{@small} / #{@big}")
58
+ raise "the five-litre jug contains four litres" if @big == 4
59
+ end
60
+
61
+ def rules
62
+ %i[
63
+ fill_small fill_big empty_small empty_big
64
+ pour_small_into_big pour_big_into_small
65
+ ].map do |name|
66
+ Zizek::Stateful::Rule.new(name.to_s, ->(machine, test_case) { machine.public_send(name, test_case) })
67
+ end
68
+ end
69
+
70
+ def invariants
71
+ [
72
+ Zizek::Stateful::Rule.new(
73
+ "physics_of_jugs",
74
+ ->(machine, test_case) { machine.physics_of_jugs(test_case) }
75
+ ),
76
+ Zizek::Stateful::Rule.new(
77
+ "die_hard_problem_not_solved",
78
+ ->(machine, test_case) { machine.die_hard_problem_not_solved(test_case) }
79
+ )
80
+ ]
81
+ end
82
+ end
83
+
84
+ module_function
85
+
86
+ def property(test_case)
87
+ Zizek::Stateful.run(DieHard.new, test_case)
88
+ end
89
+
90
+ def find_and_replay_solution
91
+ found =
92
+ begin
93
+ Zizek.check(method(:property), examples: 2_000, seed: 0)
94
+ raise "the four-litre solution should be discovered"
95
+ rescue Zizek::Failure => failure
96
+ raise unless failure.property_cause.message.include?("four litres")
97
+ raise unless failure.blob == SHRUNK_DIE_HARD_FAILURE
98
+
99
+ failure
100
+ end
101
+
102
+ begin
103
+ Zizek.check(method(:property), reproduce: found.blob)
104
+ rescue Zizek::Failure => replayed
105
+ raise unless replayed.notes.last == "small / big = 3 / 4"
106
+ else
107
+ raise "the shrunk jug solution must replay"
108
+ end
109
+ [LEGACY_ZIZEK_DIE_HARD_FAILURE, HEGEL_ZLIB_DIE_HARD_FAILURE].each do |blob|
110
+ begin
111
+ Zizek.check(method(:property), reproduce: blob)
112
+ rescue Zizek::Failure => compatible
113
+ raise unless compatible.notes.last == "small / big = 3 / 4"
114
+ next
115
+ end
116
+ raise "a compatible jug solution did not replay"
117
+ end
118
+ found
119
+ end
120
+ end
121
+
122
+ puts DieHardExample.find_and_replay_solution if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Port of Hypothesis's introductory bounded-integer example. The property is
4
+ # deliberately false from 50 onward, so shrinking should report the first
5
+ # failing boundary rather than merely the random value which exposed it.
6
+
7
+ require "zizek"
8
+
9
+ module HypothesisQuickstartExample
10
+ SHRUNK_BOUNDARY_FAILURE = "AAEAAAAACgEAAAAy"
11
+
12
+ module_function
13
+
14
+ def property(test_case)
15
+ value = test_case.draw(
16
+ Zizek::Generators.integers.min_value(0).max_value(200)
17
+ )
18
+ raise "#{value} is not less than 50" unless value < 50
19
+ end
20
+
21
+ def find_and_replay_boundary
22
+ found =
23
+ begin
24
+ Zizek.check(method(:property), examples: 100, seed: 1)
25
+ raise "the boundary bug should be discovered"
26
+ rescue Zizek::Failure => failure
27
+ raise unless failure.property_cause.message == "50 is not less than 50"
28
+ raise unless failure.blob == SHRUNK_BOUNDARY_FAILURE
29
+
30
+ failure
31
+ end
32
+
33
+ begin
34
+ Zizek.check(method(:property), reproduce: found.blob)
35
+ rescue Zizek::Failure => replayed
36
+ raise unless replayed.property_cause.message == found.property_cause.message
37
+
38
+ return found
39
+ end
40
+ raise "the shrunk boundary failure must replay"
41
+ end
42
+ end
43
+
44
+ puts HypothesisQuickstartExample.find_and_replay_boundary if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Keyed draws: a reproduce blob that survives editing the property.
4
+ #
5
+ # Demonstrates:
6
+ # - naming a decision with `key: Zizek.key_hash(...)` so a replay finds it
7
+ # by name instead of by position;
8
+ # - a stored blob still reproducing its failure after a new draw is
9
+ # inserted into the property, with the new draw's miss counted and
10
+ # reported instead of raised; and
11
+ # - the positional version of the same edit, where inserting a draw shifts
12
+ # every choice after it and the old blob stops meaning what it recorded.
13
+ #
14
+ # The scenario is a client's retry policy. Version 1 draws a timeout and a
15
+ # retry count; the bug fires when the policy retries more than three times.
16
+ # Version 2 inserts a TLS flag between them — the kind of unrelated question
17
+ # a growing simulator adds every week.
18
+
19
+ require "zizek"
20
+
21
+ module KeyedDrawsExample
22
+ module_function
23
+
24
+ def check_policy(test_case, timeout, retries)
25
+ test_case.note("timeout=#{timeout}s retries=#{retries}")
26
+ raise "#{retries} retries would stampede the backend" if retries > 3
27
+ end
28
+
29
+ # Version 1: every decision is drawn under a stable name.
30
+ def keyed_v1
31
+ lambda do |test_case|
32
+ timeout = test_case.integer(1, 30, key: Zizek.key_hash(:timeout))
33
+ retries = test_case.integer(0, 9, key: Zizek.key_hash(:retries))
34
+ check_policy(test_case, timeout, retries)
35
+ end
36
+ end
37
+
38
+ # Version 2 inserts an unrelated keyed draw between the original two.
39
+ def keyed_v2
40
+ lambda do |test_case|
41
+ timeout = test_case.integer(1, 30, key: Zizek.key_hash(:timeout))
42
+ tls = test_case.boolean(key: Zizek.key_hash(:tls))
43
+ retries = test_case.integer(0, 9, key: Zizek.key_hash(:retries))
44
+ test_case.note("tls=#{tls}")
45
+ check_policy(test_case, timeout, retries)
46
+ end
47
+ end
48
+
49
+ # The same two versions, addressed positionally.
50
+ def positional_v1
51
+ lambda do |test_case|
52
+ timeout = test_case.integer(1, 30)
53
+ retries = test_case.integer(0, 9)
54
+ check_policy(test_case, timeout, retries)
55
+ end
56
+ end
57
+
58
+ def positional_v2
59
+ lambda do |test_case|
60
+ timeout = test_case.integer(1, 30)
61
+ tls = test_case.boolean
62
+ retries = test_case.integer(0, 9)
63
+ test_case.note("tls=#{tls}")
64
+ check_policy(test_case, timeout, retries)
65
+ end
66
+ end
67
+
68
+ def find_failure(property)
69
+ Zizek.check(property, examples: 300, seed: 5)
70
+ raise "the retry stampede should be discovered"
71
+ rescue Zizek::Failure => failure
72
+ failure
73
+ end
74
+
75
+ def replay(property, blob)
76
+ Zizek.check(property, reproduce: blob)
77
+ raise "the recorded failure should still reproduce"
78
+ rescue Zizek::Failure => failure
79
+ failure
80
+ end
81
+
82
+ # Find the bug in version 1, then replay its blob against version 2.
83
+ #
84
+ # The keyed pair: `timeout` and `retries` resolve by name, so they read
85
+ # exactly the values the blob recorded, wherever they now sit. The new
86
+ # `tls` draw has no entry under its name — that is an edit to the property,
87
+ # not a malformed blob — so it uses its simplest value and the failure
88
+ # reports one key miss.
89
+ def keyed_blob_survives_the_edit
90
+ found = find_failure(keyed_v1)
91
+ replayed = replay(keyed_v2, found.blob)
92
+ raise "expected exactly one key miss" unless replayed.key_misses == 1
93
+ raise "the recorded draws must be preserved" unless
94
+ replayed.notes.include?(found.notes.first)
95
+
96
+ [found, replayed]
97
+ end
98
+
99
+ # The same edit under positional addressing. The inserted boolean consumes
100
+ # the entry recorded for `retries`, every draw after it is off by one, and
101
+ # the stream ends before the property stops drawing: ReplayOverrun.
102
+ def positional_blob_breaks_on_the_edit
103
+ found = find_failure(positional_v1)
104
+ begin
105
+ Zizek.check(positional_v2, reproduce: found.blob)
106
+ raise "the shifted replay should not run to completion"
107
+ rescue Zizek::ReplayOverrun => overrun
108
+ [found, overrun]
109
+ end
110
+ end
111
+ end
112
+
113
+ if $PROGRAM_NAME == __FILE__
114
+ puts "== keyed: the blob outlives the edit =="
115
+ found, replayed = KeyedDrawsExample.keyed_blob_survives_the_edit
116
+ puts "version 1 failure:\n#{found.message}"
117
+ puts "\nversion 2 replay of the same blob:\n#{replayed.message}"
118
+
119
+ puts "\n== positional: the same edit shifts every later draw =="
120
+ found, overrun = KeyedDrawsExample.positional_blob_breaks_on_the_edit
121
+ puts "version 1 failure:\n#{found.message}"
122
+ puts "\nversion 2 replay of the same blob: #{overrun.class}: #{overrun.message}"
123
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zizek"
4
+
5
+ module LedgerExample
6
+ LIMIT = 1_000_000
7
+ PORTABLE_LEDGER_FAILURE =
8
+ "AXicVYyxCQAgEAMvYmXtILr/cipq+C/ChRykA4K2w4eohyMuWU0ryUWvXBSLdK4FQmgBCA=="
9
+
10
+ class Ledger
11
+ attr_reader :balances
12
+
13
+ def initialize
14
+ @balances = {}
15
+ end
16
+
17
+ def credit(account, amount)
18
+ balances[account] = balances.fetch(account, 0) + amount
19
+ end
20
+
21
+ def debit(account, amount)
22
+ balances[account] = balances.fetch(account, 0) - amount
23
+ end
24
+
25
+ def transfer(source, destination, amount)
26
+ source_balance = balances.fetch(source, 0)
27
+ return unless source != destination && amount - source_balance <= 1
28
+
29
+ debit(source, amount)
30
+ credit(destination, amount)
31
+ end
32
+ end
33
+
34
+ class LedgerTest
35
+ def initialize(accounts)
36
+ @ledger = Ledger.new
37
+ @accounts = accounts
38
+ end
39
+
40
+ def create_account(test_case)
41
+ account = test_case.draw(Zizek::Generators.text.min_size(1))
42
+ test_case.note("create account #{account.inspect}")
43
+ @accounts.add(test_case, account)
44
+ end
45
+
46
+ def credit(test_case)
47
+ account = test_case.draw(@accounts.values_reusable)
48
+ amount = test_case.draw(Zizek::Generators.integers.min_value(0).max_value(LIMIT))
49
+ test_case.note("credit #{account.inspect} with #{amount}")
50
+ @ledger.credit(account, amount)
51
+ end
52
+
53
+ def transfer(test_case)
54
+ source = test_case.draw(@accounts.values_reusable)
55
+ destination = test_case.draw(@accounts.values_reusable)
56
+ amount = test_case.draw(Zizek::Generators.integers.min_value(0).max_value(LIMIT))
57
+ test_case.note("transfer #{amount} from #{source.inspect} to #{destination.inspect}")
58
+ @ledger.transfer(source, destination, amount)
59
+ end
60
+
61
+ def nonnegative_balances(_test_case)
62
+ @ledger.balances.each do |account, balance|
63
+ raise "account #{account.inspect} has negative balance #{balance}" if balance.negative?
64
+ end
65
+ end
66
+
67
+ def rules
68
+ [
69
+ Zizek::Stateful::Rule.new("create_account", ->(machine, test_case) { machine.create_account(test_case) }),
70
+ Zizek::Stateful::Rule.new("credit", ->(machine, test_case) { machine.credit(test_case) }),
71
+ Zizek::Stateful::Rule.new("transfer", ->(machine, test_case) { machine.transfer(test_case) })
72
+ ]
73
+ end
74
+
75
+ def invariants
76
+ [
77
+ Zizek::Stateful::Rule.new(
78
+ "nonnegative_balances",
79
+ ->(machine, test_case) { machine.nonnegative_balances(test_case) }
80
+ )
81
+ ]
82
+ end
83
+ end
84
+
85
+ module_function
86
+
87
+ def property(test_case)
88
+ Zizek::Stateful.run(
89
+ LedgerTest.new(Zizek::Stateful.pool(test_case)),
90
+ test_case
91
+ )
92
+ end
93
+
94
+ def find_and_replay_bug
95
+ found =
96
+ begin
97
+ Zizek.check(method(:property), examples: 2_000, seed: 1)
98
+ raise "the ledger off-by-one bug should be discovered"
99
+ rescue Zizek::Failure => failure
100
+ raise unless failure.property_cause.message.include?("negative balance")
101
+
102
+ failure
103
+ end
104
+
105
+ begin
106
+ Zizek.check(method(:property), reproduce: found.blob)
107
+ rescue Zizek::Failure
108
+ return found
109
+ end
110
+ raise "the shrunk ledger failure must replay"
111
+ end
112
+ end
113
+
114
+ puts LedgerExample.find_and_replay_bug if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Port of Hegel's minimum-stack state machine. MinStack deliberately forgets
4
+ # duplicate minima: after push(0), push(0), pop, its cached minimum is empty
5
+ # while the reference stack still contains zero.
6
+
7
+ require "zizek"
8
+
9
+ module MinStackExample
10
+ SHRUNK_MIN_STACK_FAILURE =
11
+ "AXicdYixCQAACIB0jf6/tyJoy0UxASVkWHnhd9oFC6sAWQ=="
12
+
13
+ class MinStack
14
+ attr_reader :stack
15
+
16
+ def initialize
17
+ @stack = []
18
+ @minimums = []
19
+ end
20
+
21
+ def push(value)
22
+ stack << value
23
+ @minimums << value if minimum.nil? || value < minimum
24
+ end
25
+
26
+ def pop
27
+ value = stack.pop
28
+ @minimums.pop if !value.nil? && minimum == value
29
+ value
30
+ end
31
+
32
+ def minimum
33
+ @minimums.last
34
+ end
35
+
36
+ def reference_minimum
37
+ stack.min
38
+ end
39
+ end
40
+
41
+ class MinStackTest
42
+ def initialize
43
+ @stack = MinStack.new
44
+ end
45
+
46
+ def push(test_case)
47
+ value = test_case.draw(Zizek::Generators.integers)
48
+ test_case.note("push #{value}")
49
+ @stack.push(value)
50
+ end
51
+
52
+ def pop(test_case)
53
+ test_case.note("pop #{@stack.pop.inspect}")
54
+ end
55
+
56
+ def minimums_agree(_test_case)
57
+ return if @stack.minimum == @stack.reference_minimum
58
+
59
+ raise "cached minimum #{@stack.minimum.inspect}, expected #{@stack.reference_minimum.inspect}"
60
+ end
61
+
62
+ def rules
63
+ [
64
+ Zizek::Stateful::Rule.new("push", ->(machine, test_case) { machine.push(test_case) }),
65
+ Zizek::Stateful::Rule.new("pop", ->(machine, test_case) { machine.pop(test_case) })
66
+ ]
67
+ end
68
+
69
+ def invariants
70
+ [
71
+ Zizek::Stateful::Rule.new(
72
+ "minimums_agree",
73
+ ->(machine, test_case) { machine.minimums_agree(test_case) }
74
+ )
75
+ ]
76
+ end
77
+ end
78
+
79
+ module_function
80
+
81
+ def property(test_case)
82
+ Zizek::Stateful.run(MinStackTest.new, test_case)
83
+ end
84
+
85
+ def find_and_replay_bug
86
+ found =
87
+ begin
88
+ Zizek.check(method(:property), examples: 2_000, seed: 1)
89
+ raise "the duplicate-minimum bug should be discovered"
90
+ rescue Zizek::Failure => failure
91
+ raise unless failure.property_cause.message.include?("cached minimum")
92
+ raise unless failure.blob == SHRUNK_MIN_STACK_FAILURE
93
+
94
+ failure
95
+ end
96
+
97
+ begin
98
+ Zizek.check(method(:property), reproduce: found.blob)
99
+ rescue Zizek::Failure => replayed
100
+ raise unless replayed.property_cause.message == found.property_cause.message
101
+
102
+ return found
103
+ end
104
+ raise "the shrunk min-stack failure must replay"
105
+ end
106
+ end
107
+
108
+ puts MinStackExample.find_and_replay_bug if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Properties as ordinary Minitest tests.
4
+ #
5
+ # Demonstrates:
6
+ # - `property` defining real test methods, collected by any runner;
7
+ # - named draws, so the failure names the values instead of the property
8
+ # having to `note` them;
9
+ # - a failed assertion reported as a Minitest failure, carrying the
10
+ # reproduce blob; and
11
+ # - per-property settings.
12
+ #
13
+ # Run it directly:
14
+ #
15
+ # ruby -Ilib examples/minitest_suite.rb
16
+ #
17
+ # One test is expected to fail: that is the point of the example. It prints
18
+ # the falsifying example and a blob you can paste into `reproduce:`.
19
+
20
+ require "minitest/autorun"
21
+ require "zizek/minitest"
22
+
23
+ G = Zizek::Generators
24
+
25
+ module Bill
26
+ module_function
27
+
28
+ # Split `total` cents between `people`, giving the remainder to nobody.
29
+ # The remainder is the bug: the parts do not add back up to the total.
30
+ def split(total, people)
31
+ Array.new(people, total / people)
32
+ end
33
+ end
34
+
35
+ class BillTest < Minitest::Test
36
+ include Zizek::Minitest
37
+
38
+ # Passing: every share is non-negative and no larger than the total.
39
+ property "shares stay within the bill",
40
+ total: G.integers(0..10_000),
41
+ people: G.integers(1..50) do |total, people|
42
+ shares = Bill.split(total, people)
43
+ assert_equal people, shares.length
44
+ shares.each do |share|
45
+ assert_operator share, :>=, 0
46
+ assert_operator share, :<=, total
47
+ end
48
+ end
49
+
50
+ # Failing: shrinks to the smallest bill that cannot be divided evenly.
51
+ property "the split is exact",
52
+ total: G.integers(0..10_000),
53
+ people: G.integers(1..50) do |total, people|
54
+ assert_equal total, Bill.split(total, people).sum
55
+ end
56
+
57
+ # A property that wants the test case itself gets it when no generators are
58
+ # named, and settings ride along under the reserved `settings:` keyword.
59
+ property "shares are stable across calls", settings: { examples: 250, seed: 11 } do |test_case|
60
+ total = test_case.draw(G.integers(0..1_000), name: :total)
61
+ people = test_case.draw(G.integers(1..20), name: :people)
62
+ assert_equal Bill.split(total, people), Bill.split(total, people)
63
+ end
64
+ end