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,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
@@ -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