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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +115 -0
- data/GUIDE.md +774 -0
- data/LICENSE +201 -0
- data/README.md +201 -0
- data/docs/assets/zizek-rb-logo-dark.svg +16 -0
- data/docs/assets/zizek-rb-logo.svg +16 -0
- data/examples/combinators.rb +93 -0
- data/examples/coverage_assertions.rb +138 -0
- data/examples/database_workflow.rb +120 -0
- data/examples/die_hard.rb +122 -0
- data/examples/hypothesis_quickstart.rb +44 -0
- data/examples/keyed_draws.rb +123 -0
- data/examples/ledger.rb +114 -0
- data/examples/min_stack.rb +108 -0
- data/examples/minitest_suite.rb +64 -0
- data/examples/multi.rb +46 -0
- data/examples/queue.rb +122 -0
- data/examples/roundtrip.rb +71 -0
- data/examples/stack.rb +68 -0
- data/examples/tour.rb +126 -0
- data/lib/zizek/database.rb +211 -0
- data/lib/zizek/gen.rb +10377 -0
- data/lib/zizek/generators.rb +879 -0
- data/lib/zizek/minitest.rb +94 -0
- data/lib/zizek/rspec.rb +67 -0
- data/lib/zizek/settings.rb +249 -0
- data/lib/zizek/shell.rb +2938 -0
- data/lib/zizek/stateful.rb +349 -0
- data/lib/zizek/version.rb +8 -0
- data/lib/zizek.rb +8 -0
- metadata +109 -0
|
@@ -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
|
data/examples/ledger.rb
ADDED
|
@@ -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
|