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,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest"
|
|
4
|
+
require_relative "../zizek"
|
|
5
|
+
|
|
6
|
+
module Zizek
|
|
7
|
+
# Property tests as ordinary Minitest tests.
|
|
8
|
+
#
|
|
9
|
+
# require "zizek/minitest"
|
|
10
|
+
#
|
|
11
|
+
# class SplitBillTest < Minitest::Test
|
|
12
|
+
# include Zizek::Minitest
|
|
13
|
+
#
|
|
14
|
+
# property "the split is exact",
|
|
15
|
+
# total: Zizek::Generators.integers(0..10_000),
|
|
16
|
+
# people: Zizek::Generators.integers(1..50) do |total, people|
|
|
17
|
+
# assert_equal total, split(total, people).sum
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Each `property` defines a real test method, so `rake test`, `-n`, seeds,
|
|
22
|
+
# reporters, and `setup`/`teardown` all work unchanged. The block runs with
|
|
23
|
+
# `self` bound to the test instance, so ordinary assertions are available and
|
|
24
|
+
# a passing property registers one — a property that never asserts is
|
|
25
|
+
# otherwise indistinguishable from a test that forgot to.
|
|
26
|
+
#
|
|
27
|
+
# Named draws are reported in the failure, so a counterexample says which
|
|
28
|
+
# values failed without the property having to `note` them itself.
|
|
29
|
+
module Minitest
|
|
30
|
+
# Reserved: run settings for this property, not a generator.
|
|
31
|
+
#
|
|
32
|
+
# property "converges", settings: { examples: 2_000, seed: 7 }
|
|
33
|
+
SETTINGS_KEYWORD = :settings
|
|
34
|
+
|
|
35
|
+
def self.included(base)
|
|
36
|
+
base.extend(ClassMethods)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private_class_method :included
|
|
40
|
+
|
|
41
|
+
module ClassMethods
|
|
42
|
+
def property(description, settings: {}, **generators, &block)
|
|
43
|
+
raise ArgumentError, "property #{description.inspect} needs a block" if block.nil?
|
|
44
|
+
|
|
45
|
+
name = Zizek::Minitest.__send__(:test_method_name, description)
|
|
46
|
+
if method_defined?(name) || private_method_defined?(name)
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"#{self} already defines #{name}; give this property a distinct description"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
define_method(name) do
|
|
52
|
+
Zizek::Minitest.__send__(:run_property, self, generators, settings, block)
|
|
53
|
+
end
|
|
54
|
+
name
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private_constant :ClassMethods, :SETTINGS_KEYWORD
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def test_method_name(description)
|
|
64
|
+
slug = description.to_s.strip.gsub(/[^0-9A-Za-z]+/, "_").gsub(/\A_+|_+\z/, "")
|
|
65
|
+
raise ArgumentError, "property description must contain a word character" if slug.empty?
|
|
66
|
+
|
|
67
|
+
:"test_#{slug}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Run one property inside a test instance.
|
|
71
|
+
#
|
|
72
|
+
# A failure whose cause is an assertion is reported as a Minitest
|
|
73
|
+
# failure, because that is what it is; anything else stays an error, so
|
|
74
|
+
# a raised exception does not get relabelled as a failed assertion.
|
|
75
|
+
def run_property(test, generators, settings, block)
|
|
76
|
+
Zizek.check(build(test, generators, block), **settings)
|
|
77
|
+
test.assert(true, "the property held")
|
|
78
|
+
rescue Zizek::Failure => failure
|
|
79
|
+
raise failure unless failure.property_cause.is_a?(::Minitest::Assertion)
|
|
80
|
+
|
|
81
|
+
test.flunk(failure.message)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build(test, generators, block)
|
|
85
|
+
return ->(test_case) { test.instance_exec(test_case, &block) } if generators.empty?
|
|
86
|
+
|
|
87
|
+
lambda do |test_case|
|
|
88
|
+
values = generators.map { |name, generator| test_case.draw(generator, name: name) }
|
|
89
|
+
test.instance_exec(*values, &block)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/zizek/rspec.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
require_relative "../zizek"
|
|
5
|
+
|
|
6
|
+
module Zizek
|
|
7
|
+
# Property tests as ordinary RSpec examples.
|
|
8
|
+
#
|
|
9
|
+
# require "zizek/rspec"
|
|
10
|
+
#
|
|
11
|
+
# RSpec.describe Splitter do
|
|
12
|
+
# property "the split is exact",
|
|
13
|
+
# total: Zizek::Generators.integers(0..10_000),
|
|
14
|
+
# people: Zizek::Generators.integers(1..50) do |total, people|
|
|
15
|
+
# expect(described_class.split(total, people).sum).to eq(total)
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# `property` defines a real example, so filters, tags, formatters, `let`, and
|
|
20
|
+
# `before` hooks all work unchanged. The block runs in the example's own
|
|
21
|
+
# context, so matchers and helpers are available.
|
|
22
|
+
#
|
|
23
|
+
# Requiring this file registers the DSL on every example group. To scope it
|
|
24
|
+
# instead, skip the require and `config.extend Zizek::RSpec::DSL, type: :prop`.
|
|
25
|
+
module RSpec
|
|
26
|
+
module DSL
|
|
27
|
+
def property(description, settings: {}, **generators, &block)
|
|
28
|
+
raise ArgumentError, "property #{description.inspect} needs a block" if block.nil?
|
|
29
|
+
|
|
30
|
+
it(description) do
|
|
31
|
+
Zizek::RSpec.__send__(:run_property, self, generators, settings, block)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def run_property(example, generators, settings, block)
|
|
40
|
+
Zizek.check(build(example, generators, block), **settings)
|
|
41
|
+
rescue Zizek::Failure => failure
|
|
42
|
+
raise failure unless expectation_failure?(failure.property_cause)
|
|
43
|
+
|
|
44
|
+
# Re-raise as the matcher error so RSpec renders it as a failed
|
|
45
|
+
# expectation rather than an unexpected exception, keeping the
|
|
46
|
+
# falsifying example and the blob in the message.
|
|
47
|
+
raise ::RSpec::Expectations::ExpectationNotMetError, failure.message
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build(example, generators, block)
|
|
51
|
+
return ->(test_case) { example.instance_exec(test_case, &block) } if generators.empty?
|
|
52
|
+
|
|
53
|
+
lambda do |test_case|
|
|
54
|
+
values = generators.map { |name, generator| test_case.draw(generator, name: name) }
|
|
55
|
+
example.instance_exec(*values, &block)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def expectation_failure?(error)
|
|
60
|
+
defined?(::RSpec::Expectations::ExpectationNotMetError) &&
|
|
61
|
+
error.is_a?(::RSpec::Expectations::ExpectationNotMetError)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
::RSpec.configure { |config| config.extend Zizek::RSpec::DSL }
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Zizek
|
|
6
|
+
# A label a completed run must have observed.
|
|
7
|
+
#
|
|
8
|
+
# A zero percent requires the label's condition to have held in at least one
|
|
9
|
+
# valid example; a positive percent requires at least that share of them.
|
|
10
|
+
class Coverage
|
|
11
|
+
attr_reader :label, :percent
|
|
12
|
+
|
|
13
|
+
def initialize(label, percent: 0)
|
|
14
|
+
@label = label
|
|
15
|
+
@percent = percent
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ==(other)
|
|
20
|
+
other.is_a?(Coverage) && other.label == label && other.percent == percent
|
|
21
|
+
end
|
|
22
|
+
alias eql? ==
|
|
23
|
+
|
|
24
|
+
def hash
|
|
25
|
+
[Coverage, label, percent].hash
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_s
|
|
29
|
+
percent.zero? ? "cover(#{label.inspect})" : "cover(#{label.inspect}, #{percent}%)"
|
|
30
|
+
end
|
|
31
|
+
alias inspect to_s
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# How a property run explores, rejects, and shrinks.
|
|
35
|
+
#
|
|
36
|
+
# A settings object is a frozen value: `with` returns a new one rather than
|
|
37
|
+
# mutating, so a profile can be shared between suites without one of them
|
|
38
|
+
# reconfiguring the other. Unknown names raise, so a misspelled option is a
|
|
39
|
+
# loud error at construction instead of an option that silently did nothing.
|
|
40
|
+
class Settings
|
|
41
|
+
# Every knob, with its default. The list is the schema: `Settings.new`
|
|
42
|
+
# rejects anything not in it, and `to_h` round-trips through `new`.
|
|
43
|
+
DEFAULTS = {
|
|
44
|
+
examples: 100,
|
|
45
|
+
# Initial rejection allowance; each valid example earns 100 more.
|
|
46
|
+
max_rejections: 458,
|
|
47
|
+
max_shrinks: 500,
|
|
48
|
+
max_stall: 200,
|
|
49
|
+
seed: nil,
|
|
50
|
+
# Derive the seed from the property's identity instead of system
|
|
51
|
+
# entropy, so a suite explores the same examples on every machine.
|
|
52
|
+
derandomize: false,
|
|
53
|
+
suppress_filter_too_much: false,
|
|
54
|
+
suppress_data_too_large: false,
|
|
55
|
+
suppress_large_base_example: false,
|
|
56
|
+
suppress_too_slow: false,
|
|
57
|
+
# Labels the completed run must have covered, whether or not any call
|
|
58
|
+
# site for them ever executed.
|
|
59
|
+
must_cover: [].freeze,
|
|
60
|
+
# Where counterexamples are stored between runs. A path, ":memory:",
|
|
61
|
+
# true for the default directory, nil to store nothing, or any object
|
|
62
|
+
# responding to fetch/save/delete. Off by default: a library call that
|
|
63
|
+
# wrote into the working directory unasked would be a surprise.
|
|
64
|
+
database: nil,
|
|
65
|
+
# The name a stored counterexample is filed under. A name chosen here is
|
|
66
|
+
# portable, so another language's suite replays the same entry; left
|
|
67
|
+
# unset, one is derived from where the property was written and stays
|
|
68
|
+
# local to Ruby.
|
|
69
|
+
database_key: nil
|
|
70
|
+
}.freeze
|
|
71
|
+
|
|
72
|
+
DEFAULTS.each_key { |name| attr_reader(name) }
|
|
73
|
+
|
|
74
|
+
def initialize(**overrides)
|
|
75
|
+
unknown = overrides.keys - DEFAULTS.keys
|
|
76
|
+
unless unknown.empty?
|
|
77
|
+
raise ArgumentError,
|
|
78
|
+
"unknown setting#{"s" if unknown.length > 1}: #{unknown.map(&:inspect).join(", ")}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
DEFAULTS.each do |name, default|
|
|
82
|
+
instance_variable_set(:"@#{name}", overrides.fetch(name, default))
|
|
83
|
+
end
|
|
84
|
+
@must_cover = normalize_coverage(@must_cover)
|
|
85
|
+
validate!
|
|
86
|
+
freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A copy with some fields replaced. Returns self when nothing changes, so
|
|
90
|
+
# the common "no overrides" path allocates nothing.
|
|
91
|
+
def with(**overrides)
|
|
92
|
+
return self if overrides.empty?
|
|
93
|
+
|
|
94
|
+
Settings.new(**to_h, **overrides)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def to_h
|
|
98
|
+
DEFAULTS.keys.each_with_object({}) { |name, out| out[name] = public_send(name) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def ==(other)
|
|
102
|
+
other.is_a?(Settings) && other.to_h == to_h
|
|
103
|
+
end
|
|
104
|
+
alias eql? ==
|
|
105
|
+
|
|
106
|
+
def hash
|
|
107
|
+
[Settings, to_h].hash
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def inspect
|
|
111
|
+
changed = to_h.reject { |name, value| DEFAULTS[name] == value }
|
|
112
|
+
return "#<Zizek::Settings (defaults)>" if changed.empty?
|
|
113
|
+
|
|
114
|
+
"#<Zizek::Settings #{changed.map { |name, value| "#{name}=#{value.inspect}" }.join(", ")}>"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The seed a random run will use and report back.
|
|
118
|
+
#
|
|
119
|
+
# An explicit seed wins. A derandomized run derives one from the property's
|
|
120
|
+
# identity, so the same test explores the same examples on every machine.
|
|
121
|
+
# Otherwise one is drawn from system entropy — reported on the failure, so
|
|
122
|
+
# a run that found something can at least be repeated.
|
|
123
|
+
def effective_seed(property)
|
|
124
|
+
return seed unless seed.nil?
|
|
125
|
+
return Random.new_seed & MASK64 unless derandomize
|
|
126
|
+
|
|
127
|
+
Digest::SHA256.digest(Settings.__send__(:property_identity, property))
|
|
128
|
+
.unpack1("Q<")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# What a derandomized seed is derived from: where the property was written,
|
|
132
|
+
# so moving a test to another machine keeps its examples and writing a
|
|
133
|
+
# second test in the same file gets its own.
|
|
134
|
+
def self.property_identity(property)
|
|
135
|
+
location = property.source_location if property.respond_to?(:source_location)
|
|
136
|
+
return "#{location[0]}:#{location[1]}" if location
|
|
137
|
+
|
|
138
|
+
property.inspect
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
MASK64 = (1 << 64) - 1
|
|
142
|
+
private_constant :MASK64
|
|
143
|
+
private :effective_seed
|
|
144
|
+
private_class_method :property_identity
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def normalize_coverage(entries)
|
|
149
|
+
entries.map do |entry|
|
|
150
|
+
next entry if entry.is_a?(Coverage)
|
|
151
|
+
next Coverage.new(entry) if entry.is_a?(String) || entry.is_a?(Symbol)
|
|
152
|
+
|
|
153
|
+
raise ArgumentError,
|
|
154
|
+
"must_cover takes Zizek::Coverage entries or label strings, got #{entry.inspect}"
|
|
155
|
+
end.freeze
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def validate!
|
|
159
|
+
raise ArgumentError, "examples must be positive" unless examples.is_a?(Integer) && examples.positive?
|
|
160
|
+
raise ArgumentError, "max_rejections must be non-negative" if max_rejections.negative?
|
|
161
|
+
raise ArgumentError, "max_shrinks must be non-negative" if max_shrinks.negative?
|
|
162
|
+
raise ArgumentError, "max_stall must be non-negative" if max_stall.negative?
|
|
163
|
+
raise ArgumentError, "seed must be an Integer or nil" unless seed.nil? || seed.is_a?(Integer)
|
|
164
|
+
|
|
165
|
+
# Resolving here rather than at run time makes a bad database a loud
|
|
166
|
+
# error where it was configured, like every other option.
|
|
167
|
+
Database.__send__(:resolve, database)
|
|
168
|
+
|
|
169
|
+
must_cover.each do |required|
|
|
170
|
+
next if required.percent >= 0 && required.percent <= 100
|
|
171
|
+
|
|
172
|
+
raise ArgumentError, "required coverage percent must be between 0 and 100"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Named settings profiles.
|
|
178
|
+
#
|
|
179
|
+
# A suite registers the shapes it runs in once, then selects one — from a
|
|
180
|
+
# spec_helper, an environment variable, or a block — instead of repeating
|
|
181
|
+
# the same six keywords at every call site.
|
|
182
|
+
#
|
|
183
|
+
# Zizek.register_profile(:ci, examples: 1_000, derandomize: true)
|
|
184
|
+
# Zizek.load_profile(:ci) if ENV["CI"]
|
|
185
|
+
#
|
|
186
|
+
# The active profile is process-global, like Hypothesis's. Prefer
|
|
187
|
+
# `Zizek.with_profile` over `load_profile` inside a suite: it restores the
|
|
188
|
+
# previous profile even if the block raises.
|
|
189
|
+
module Profiles
|
|
190
|
+
DEFAULT = :default
|
|
191
|
+
|
|
192
|
+
class << self
|
|
193
|
+
def registry
|
|
194
|
+
@registry ||= { DEFAULT => Settings.new }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def active
|
|
198
|
+
@active ||= DEFAULT
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
attr_writer :active
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
module_function
|
|
206
|
+
|
|
207
|
+
# Register a profile and return it. Overwriting an existing name is allowed:
|
|
208
|
+
# a spec_helper reloaded in the same process should not raise.
|
|
209
|
+
def register_profile(name, base: nil, **overrides)
|
|
210
|
+
base ||= Settings.new
|
|
211
|
+
settings = base.with(**overrides)
|
|
212
|
+
Profiles.registry[name.to_sym] = settings
|
|
213
|
+
settings
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def profile(name)
|
|
217
|
+
Profiles.registry.fetch(name.to_sym) do
|
|
218
|
+
known = Profiles.registry.keys.map(&:inspect).join(", ")
|
|
219
|
+
raise KeyError, "unknown profile #{name.inspect}; registered: #{known}"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Make a profile the default for subsequent `check` calls, and return it.
|
|
224
|
+
def load_profile(name)
|
|
225
|
+
settings = profile(name)
|
|
226
|
+
Profiles.active = name.to_sym
|
|
227
|
+
settings
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The settings `check` uses when a call passes no overrides.
|
|
231
|
+
def current_settings
|
|
232
|
+
profile(Profiles.active)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def current_profile
|
|
236
|
+
Profiles.active
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Run a block under a profile, restoring the previous one afterwards.
|
|
240
|
+
def with_profile(name)
|
|
241
|
+
previous = Profiles.active
|
|
242
|
+
load_profile(name)
|
|
243
|
+
yield
|
|
244
|
+
ensure
|
|
245
|
+
Profiles.active = previous
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private_constant :Profiles
|
|
249
|
+
end
|