hegeltest 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 +22 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +264 -0
- data/Rakefile +19 -0
- data/docs/README.md +25 -0
- data/docs/adr/0001-bind-libhegel-through-fiddle.md +54 -0
- data/docs/adr/0002-ship-one-prebuilt-engine-per-platform-specific-gem.md +48 -0
- data/docs/adr/0003-publish-as-hegeltest-require-as-hegel.md +39 -0
- data/docs/adr/0004-expose-generators-through-a-mixin-with-keyword-options.md +42 -0
- data/docs/adr/0005-name-drawn-values-from-the-callers-source-with-prism.md +40 -0
- data/docs/adr/0006-verify-the-binding-in-seven-layers-with-full-coverage.md +51 -0
- data/docs/adr/0007-ship-a-thin-ruby-skill-shaped-for-donation.md +56 -0
- data/docs/adr/0008-revisit-the-binding-after-milestone-c-on-measurement.md +81 -0
- data/docs/adr/0009-turn-the-example-database-on-with-a-key.md +89 -0
- data/docs/adr/0010-declare-stateful-rules-with-a-class-macro.md +113 -0
- data/docs/adr/0011-let-the-test-case-own-every-pool-drawn-from-it.md +83 -0
- data/docs/adr/0012-build-a-failure-origin-from-the-callers-own-frame.md +72 -0
- data/docs/adr/0013-bind-libhegel-through-the-ffi-gem.md +102 -0
- data/docs/architecture.md +182 -0
- data/lib/hegel/draw_name.rb +109 -0
- data/lib/hegel/errors.rb +47 -0
- data/lib/hegel/generator.rb +98 -0
- data/lib/hegel/generators.rb +865 -0
- data/lib/hegel/lib_hegel/real.rb +1149 -0
- data/lib/hegel/lib_hegel.rb +269 -0
- data/lib/hegel/libhegel_version.rb +9 -0
- data/lib/hegel/locate.rb +188 -0
- data/lib/hegel/report.rb +87 -0
- data/lib/hegel/runner.rb +464 -0
- data/lib/hegel/settings.rb +164 -0
- data/lib/hegel/state_machine.rb +89 -0
- data/lib/hegel/stateful/pool.rb +111 -0
- data/lib/hegel/stateful.rb +120 -0
- data/lib/hegel/syntax/methods.rb +173 -0
- data/lib/hegel/test_case.rb +523 -0
- data/lib/hegel/version.rb +5 -0
- data/lib/hegel.rb +92 -0
- data/lib/hegeltest.rb +7 -0
- data/lib/tasks/libhegel.rake +112 -0
- data/lib/tasks/platform_gems.rake +111 -0
- data/sig/hegel.rbs +563 -0
- data/skills/hegel-ruby/SKILL.md +30 -0
- data/skills/hegel-ruby/references/ruby/reference.md +1210 -0
- metadata +113 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "draw_name"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "lib_hegel"
|
|
6
|
+
|
|
7
|
+
module Hegel
|
|
8
|
+
# Wraps one libhegel test-case handle: the native draw surface
|
|
9
|
+
# (#generate_integer, #start_span, #new_collection, and so on) every
|
|
10
|
+
# Hegel::Generator#do_draw is built on, plus the recording entry points a
|
|
11
|
+
# caller (or #draw) reaches for -- #draw_integer/#draw_boolean for the two
|
|
12
|
+
# primitive draws, #draw for a Hegel::Generator, #note for a message, and
|
|
13
|
+
# #assume/#reject to discard the case outright.
|
|
14
|
+
#
|
|
15
|
+
# The native methods below intentionally do not record: a Hegel::Generator
|
|
16
|
+
# composing several of them (Hegel::Generators::ArrayGenerator drawing one
|
|
17
|
+
# element per loop iteration, say) must produce exactly one report entry
|
|
18
|
+
# for the whole compound value, not one per native call it happens to
|
|
19
|
+
# make. Only #draw_integer, #draw_boolean, #draw, and #note record, each
|
|
20
|
+
# exactly once per call, tagged :draw or :note so a rendered report can
|
|
21
|
+
# tell the two apart while keeping the call order they share (see
|
|
22
|
+
# #record_draw and #note, and Hegel::Report.assign_names, which numbers
|
|
23
|
+
# only the :draw entries).
|
|
24
|
+
class TestCase
|
|
25
|
+
# #name_for's fallback when a draw has no better name (see below).
|
|
26
|
+
DEFAULT_DRAW_NAME = "draw"
|
|
27
|
+
|
|
28
|
+
# Frames from #name_for's own caller_locations call up to the user's own
|
|
29
|
+
# source line: #record_draw's call to #name_for (1), the public
|
|
30
|
+
# draw_integer/draw_boolean/draw call to #record_draw (2), and the
|
|
31
|
+
# user's own call to that public method (3). The same depth serves
|
|
32
|
+
# #draw as it does #draw_integer/#draw_boolean: #draw calls
|
|
33
|
+
# #record_draw only after Hegel::Generator#do_draw has already
|
|
34
|
+
# returned, so a generator's own frames (and any native calls it made)
|
|
35
|
+
# are off the stack by the time #record_draw runs, leaving the same
|
|
36
|
+
# three frames between #name_for and the user's own call site either
|
|
37
|
+
# way. Verified empirically (see test/hegel/test_runner.rb), not just
|
|
38
|
+
# reasoned about: Ruby's caller_locations counts real stack frames, and
|
|
39
|
+
# a wrong guess here would misname every drawn value, not just fail
|
|
40
|
+
# loudly.
|
|
41
|
+
DRAW_CALLER_DEPTH = 3
|
|
42
|
+
|
|
43
|
+
# +impl+ and +ctx+ are carried alongside +handle+ so each draw call can
|
|
44
|
+
# reach the same LibHegel implementation and context the run loop opened,
|
|
45
|
+
# without this class knowing anything about the native binding layer or
|
|
46
|
+
# the Fake.
|
|
47
|
+
#
|
|
48
|
+
# +record+ defaults to false: recording is Hegel::Runner's decision, made
|
|
49
|
+
# only for the one, already-shrunk replay that produces a failure report
|
|
50
|
+
# (see #record_draw for why every other iteration skips it).
|
|
51
|
+
def initialize(impl, ctx, handle, record: false)
|
|
52
|
+
@impl = impl
|
|
53
|
+
@ctx = ctx
|
|
54
|
+
@handle = handle
|
|
55
|
+
@record = record
|
|
56
|
+
@entries = record ? [] : nil
|
|
57
|
+
@pools = []
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The [:draw, name, value] / [:note, message] entries recorded so far, in
|
|
61
|
+
# call order, or nil when this instance was not built to record.
|
|
62
|
+
# Hegel::Runner reads this once, after the block that owns this test
|
|
63
|
+
# case has run to completion or raised. One tagged list rather than a
|
|
64
|
+
# draws list and a separate notes list, so a note recorded between two
|
|
65
|
+
# draws stays between them in the report -- the same interleaving
|
|
66
|
+
# hegel-rust gets for free by sending both to one output callback.
|
|
67
|
+
attr_reader :entries
|
|
68
|
+
|
|
69
|
+
# hegel_generate_integer: an integer in [min_value, max_value].
|
|
70
|
+
def draw_integer(min_value, max_value, label: nil)
|
|
71
|
+
value = generate_integer(min_value, max_value)
|
|
72
|
+
record_draw(label, value)
|
|
73
|
+
value
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# hegel_generate_boolean: true with probability +p+ (default 0.5).
|
|
77
|
+
def draw_boolean(p = 0.5, label: nil)
|
|
78
|
+
value = generate_boolean(p)
|
|
79
|
+
record_draw(label, value)
|
|
80
|
+
value
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Draws +generator+ (a Hegel::Generator) and records the single value it
|
|
84
|
+
# produced, however many native calls +generator+ made to produce it.
|
|
85
|
+
# +label+ is threaded through to #record_draw the same way it is for
|
|
86
|
+
# #draw_integer/#draw_boolean, wins over a recovered name the same way.
|
|
87
|
+
def draw(generator, label: nil)
|
|
88
|
+
value = generator.do_draw(self)
|
|
89
|
+
record_draw(label, value)
|
|
90
|
+
value
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Discards this test case (see #reject) unless +condition+ holds.
|
|
94
|
+
# +condition+ is read as Ruby truthiness, not restricted to true/false
|
|
95
|
+
# the way hegel-rust's TestCase::assume takes a bool: it lets a caller
|
|
96
|
+
# write assume(hash[:key]) directly instead of assume(!!hash[:key]).
|
|
97
|
+
def assume(condition)
|
|
98
|
+
reject unless condition
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Discards this test case unconditionally, with no reason attached (the
|
|
102
|
+
# same shape as hegel-rust's own TestCase::reject; distinct from
|
|
103
|
+
# #collection_reject, which rejects one drawn element rather than the
|
|
104
|
+
# whole case). Raises Hegel::AssumeFailed, which Hegel::Runner.classify
|
|
105
|
+
# already translates to HEGEL_STATUS_INVALID -- that translation is not
|
|
106
|
+
# this method's concern.
|
|
107
|
+
def reject
|
|
108
|
+
raise Hegel::AssumeFailed, "hegel: an assumption failed; this test case is discarded"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Records +message+ (or, from the block form, its return value) for the
|
|
112
|
+
# eventual failure report, interleaved with draws in call order (see
|
|
113
|
+
# #record_draw and the :entries tag on #entries). Takes exactly one of
|
|
114
|
+
# +message+ or a block; hegel-rust's own TestCase::note takes only a
|
|
115
|
+
# message, so the block form is this binding's own addition, kept to
|
|
116
|
+
# the same "note" name and String content once evaluated.
|
|
117
|
+
#
|
|
118
|
+
# The block form exists to skip building the string on an iteration
|
|
119
|
+
# that will not record: Hegel::Runner.drive's own comment measured
|
|
120
|
+
# roughly 1000 iterations for a 20-test-case run that fails every time,
|
|
121
|
+
# and a stateful test's step loop calls #note once per step, so the
|
|
122
|
+
# avoided work is not incidental.
|
|
123
|
+
#
|
|
124
|
+
# Validates before the #@record check, not after: checking only on the
|
|
125
|
+
# recording iteration would let a caller's mistake reach only the final
|
|
126
|
+
# replay instead of surfacing on the very first call.
|
|
127
|
+
#
|
|
128
|
+
# +message+ (or the block's value) is kept as given, not #to_s'd here --
|
|
129
|
+
# the same reason #record_draw keeps a drawn value un-#inspect'd.
|
|
130
|
+
# Hegel::Report does that formatting once, at report assembly, not on
|
|
131
|
+
# every recording pass.
|
|
132
|
+
#
|
|
133
|
+
# Decided simplification, unlike hegel-rust: this does not surface a
|
|
134
|
+
# note on every iteration under a higher verbosity. Two reasons. First,
|
|
135
|
+
# this binding runs with libhegel's own output callback set to NULL
|
|
136
|
+
# (Hegel::LibHegel::Real#run_start passes it, and says why), so there
|
|
137
|
+
# is no engine-printed line for a per-iteration note to line up with.
|
|
138
|
+
# Second, printing on every iteration would reintroduce the per-case
|
|
139
|
+
# cost the block form above exists to avoid.
|
|
140
|
+
def note(message = nil)
|
|
141
|
+
has_message = !message.nil?
|
|
142
|
+
if has_message == block_given?
|
|
143
|
+
raise Hegel::Error, "hegel: note requires exactly one of a message or a block"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
return unless @record
|
|
147
|
+
|
|
148
|
+
@entries << [:note, has_message ? message : yield]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# hegel_generate_integer, without recording. Hegel::Generators::
|
|
152
|
+
# IntegerGenerator's own primitive; #draw_integer is this plus
|
|
153
|
+
# recording.
|
|
154
|
+
def generate_integer(min_value, max_value)
|
|
155
|
+
@impl.generate_integer(@ctx, @handle, min_value, max_value)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# hegel_generate_integer_big, without recording. Hegel::Generators::
|
|
159
|
+
# IntegerGenerator's own primitive for bounds outside int64_t's range,
|
|
160
|
+
# used instead of #generate_integer only then; see IntegerGenerator#do_draw.
|
|
161
|
+
def generate_integer_big(min_value, max_value)
|
|
162
|
+
@impl.generate_integer_big(@ctx, @handle, min_value, max_value)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# hegel_generate_boolean, without recording. Hegel::Generators::
|
|
166
|
+
# BooleanGenerator's own primitive; #draw_boolean is this plus
|
|
167
|
+
# recording.
|
|
168
|
+
def generate_boolean(p = 0.5)
|
|
169
|
+
@impl.generate_boolean(@ctx, @handle, p, false, false)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# hegel_generate_float. Hegel::Generators::FloatGenerator's own
|
|
173
|
+
# primitive.
|
|
174
|
+
def generate_float(width, min_value, max_value, allow_nan:, allow_infinity:, exclude_min:, exclude_max:,
|
|
175
|
+
smallest_nonzero_magnitude:)
|
|
176
|
+
@impl.generate_float(@ctx, @handle, width, min_value, max_value, allow_nan, allow_infinity, exclude_min,
|
|
177
|
+
exclude_max, smallest_nonzero_magnitude)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# hegel_target: records +value+ as a numeric observation for libhegel's
|
|
181
|
+
# own hill-climbing between generation rounds, under +label+. Not
|
|
182
|
+
# recorded to the eventual failure report -- hegel-rust's own
|
|
183
|
+
# TestCase::target and hegel-java's own TestCase#target both leave an
|
|
184
|
+
# observation out of their report too, so this sits in the same
|
|
185
|
+
# "native surface that does not record" group as #generate_integer,
|
|
186
|
+
# #generate_boolean, and #generate_float above, rather than beside
|
|
187
|
+
# #draw_integer/#draw_boolean/#draw.
|
|
188
|
+
#
|
|
189
|
+
# +label+ defaults to "", matching hegel-go's own Target(value) and
|
|
190
|
+
# hegel-java's own target(double). hegel-rust's tc.target(expr) reaches
|
|
191
|
+
# a labelled call only because a macro rewrites it to
|
|
192
|
+
# target_labelled(expr, "expr") at compile time, using the call's own
|
|
193
|
+
# source text; Ruby has no such macro. Recovering a label from the call
|
|
194
|
+
# site the way #name_for does for an unlabelled draw was rejected: it
|
|
195
|
+
# would call caller_locations on every #target call, working against
|
|
196
|
+
# this class's own per-case cost concern (see #note's own comment on
|
|
197
|
+
# the same point), and it would make this default observably differ
|
|
198
|
+
# from hegel-go's and hegel-java's.
|
|
199
|
+
#
|
|
200
|
+
# +value+ is passed through as given, not #to_f'd: hegel_target's
|
|
201
|
+
# double-typed argument already accepts an Integer or a Float exactly
|
|
202
|
+
# as given (checked directly against libhegel, not assumed from the
|
|
203
|
+
# binding library's documentation alone), so converting here would only
|
|
204
|
+
# add a second Ruby-side step ahead of the one the binding already
|
|
205
|
+
# performs.
|
|
206
|
+
#
|
|
207
|
+
# No argument validation here. hegel_target's own HEGEL_E_INVALID_ARG
|
|
208
|
+
# messages -- a label recorded twice, a non-finite value -- are
|
|
209
|
+
# already specific, and Hegel::LibHegel.check! already translates them
|
|
210
|
+
# to Hegel::Error the same way it does for every other hegel_* call
|
|
211
|
+
# this class makes. Writing the same check on this side would only
|
|
212
|
+
# give one mistake two messages to drift apart from each other.
|
|
213
|
+
def target(value, label: "")
|
|
214
|
+
@impl.target(@ctx, @handle, value, label)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# hegel_start_span, labelled with one of the Hegel::LibHegel::
|
|
218
|
+
# HEGEL_LABEL_* constants. Every compound Hegel::Generator (map, filter,
|
|
219
|
+
# arrays) opens one of these around its own draw; see #stop_span.
|
|
220
|
+
def start_span(label)
|
|
221
|
+
@impl.start_span(@ctx, @handle, label)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# hegel_stop_span, closing the span #start_span most recently opened.
|
|
225
|
+
# +discard+ true marks it rejected (a filter predicate that did not
|
|
226
|
+
# hold), so libhegel retries from before the span opened.
|
|
227
|
+
def stop_span(discard: false)
|
|
228
|
+
@impl.stop_span(@ctx, @handle, discard)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# hegel_new_collection: Hegel::Generators::ArrayGenerator's own sizing
|
|
232
|
+
# primitive, paired with #collection_more and #collection_free.
|
|
233
|
+
def new_collection(min_size, max_size)
|
|
234
|
+
@impl.new_collection(@ctx, @handle, min_size, max_size)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# hegel_collection_more: whether to draw another element into
|
|
238
|
+
# +collection+.
|
|
239
|
+
def collection_more(collection)
|
|
240
|
+
@impl.collection_more(@ctx, @handle, collection)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# hegel_collection_reject: tells libhegel the element most recently
|
|
244
|
+
# drawn under +collection+ is invalid (a duplicate key or value, for
|
|
245
|
+
# Hegel::Generators::SetGenerator/HashGenerator), so the next
|
|
246
|
+
# #collection_more call offers another attempt at the same slot instead
|
|
247
|
+
# of treating the collection as one element closer to done.
|
|
248
|
+
def collection_reject(collection, why: nil)
|
|
249
|
+
@impl.collection_reject(@ctx, @handle, collection, why)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# hegel_collection_free.
|
|
253
|
+
def collection_free(collection)
|
|
254
|
+
@impl.collection_free(@ctx, collection)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Runs the block with a text generator handle scoped to this one call,
|
|
258
|
+
# freeing it before returning. See Hegel::Generators::TextGenerator for
|
|
259
|
+
# why this is built fresh per draw rather than cached on the generator
|
|
260
|
+
# instance. Named for what it builds (hegel_string_generator_text), to
|
|
261
|
+
# read the same way as #with_regex_generator/#with_email_generator/
|
|
262
|
+
# #with_url_generator/#with_domain_generator below, each named for its
|
|
263
|
+
# own hegel_string_generator_* call.
|
|
264
|
+
def with_text_generator(**kwargs, &block)
|
|
265
|
+
with_generator_handle(@impl.string_generator_text(@ctx, **kwargs), &block)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Runs the block with a regex-matching string generator handle built
|
|
269
|
+
# from +pattern+/+fullmatch+ (see #string_generator_regex), freeing it
|
|
270
|
+
# via #with_generator_handle whether the block returns or raises.
|
|
271
|
+
def with_regex_generator(pattern, fullmatch:, &block)
|
|
272
|
+
with_generator_handle(string_generator_regex(pattern, fullmatch), &block)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Runs the block with an email-address string generator handle (see
|
|
276
|
+
# #string_generator_email), freed the same way as #with_regex_generator.
|
|
277
|
+
def with_email_generator(&block)
|
|
278
|
+
with_generator_handle(string_generator_email, &block)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Runs the block with a URL string generator handle (see
|
|
282
|
+
# #string_generator_url), freed the same way as #with_regex_generator.
|
|
283
|
+
def with_url_generator(&block)
|
|
284
|
+
with_generator_handle(string_generator_url, &block)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Runs the block with a domain-name string generator handle built from
|
|
288
|
+
# +max_length+ (see #string_generator_domain), freed the same way as
|
|
289
|
+
# #with_regex_generator.
|
|
290
|
+
def with_domain_generator(max_length:, &block)
|
|
291
|
+
with_generator_handle(string_generator_domain(max_length), &block)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# hegel_generate_string against +generator+ (from #with_text_generator,
|
|
295
|
+
# #with_regex_generator, #with_email_generator, #with_url_generator, or
|
|
296
|
+
# #with_domain_generator).
|
|
297
|
+
def generate_string(generator)
|
|
298
|
+
@impl.generate_string(@ctx, @handle, generator)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# hegel_generate_bytes. A future Hegel::Generators::BinaryGenerator's
|
|
302
|
+
# own primitive, the bytes counterpart to #generate_string.
|
|
303
|
+
def generate_bytes(min_size, max_size)
|
|
304
|
+
@impl.generate_bytes(@ctx, @handle, min_size, max_size)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# hegel_generate_ipv4, returning the address's 4 raw bytes. Converting
|
|
308
|
+
# to a caller-facing address type (IPAddr or similar) is left to the
|
|
309
|
+
# generator built on top of this call.
|
|
310
|
+
def generate_ipv4
|
|
311
|
+
@impl.generate_ipv4(@ctx, @handle)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# hegel_generate_ipv6, returning the address's 16 raw bytes. Same
|
|
315
|
+
# division of labor as #generate_ipv4.
|
|
316
|
+
def generate_ipv6
|
|
317
|
+
@impl.generate_ipv6(@ctx, @handle)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# hegel_generate_uuid, returning 16 raw bytes. Hegel::Generators::
|
|
321
|
+
# UuidsGenerator's own primitive; converting the bytes to the standard
|
|
322
|
+
# hex String is that generator's job, the same division of labor
|
|
323
|
+
# #generate_ipv4/#generate_ipv6 already follow.
|
|
324
|
+
def generate_uuid(version, has_version)
|
|
325
|
+
@impl.generate_uuid(@ctx, @handle, version, has_version)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# hegel_string_generator_regex. +alphabet+ is an optional string
|
|
329
|
+
# generator handle (from #with_text_generator), or nil (the default)
|
|
330
|
+
# for the header's documented "no particular alphabet" case. Public,
|
|
331
|
+
# like the three constructors below it, because
|
|
332
|
+
# test/hegel/test_lib_hegel.rb's own layer-1 conformance test already
|
|
333
|
+
# calls all four (and #string_generator_free) directly against the
|
|
334
|
+
# Fake to prove this thin delegation reaches +@impl+ correctly; a
|
|
335
|
+
# Hegel::Generator's do_draw should reach it only through
|
|
336
|
+
# #with_regex_generator instead.
|
|
337
|
+
def string_generator_regex(pattern, fullmatch, alphabet = nil)
|
|
338
|
+
@impl.string_generator_regex(@ctx, pattern, fullmatch, alphabet)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# hegel_string_generator_email. Public for the same reason
|
|
342
|
+
# #string_generator_regex is; reached only through
|
|
343
|
+
# #with_email_generator otherwise.
|
|
344
|
+
def string_generator_email
|
|
345
|
+
@impl.string_generator_email(@ctx)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# hegel_string_generator_url. Public for the same reason
|
|
349
|
+
# #string_generator_regex is; reached only through #with_url_generator
|
|
350
|
+
# otherwise.
|
|
351
|
+
def string_generator_url
|
|
352
|
+
@impl.string_generator_url(@ctx)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# hegel_string_generator_domain. Public for the same reason
|
|
356
|
+
# #string_generator_regex is; reached only through
|
|
357
|
+
# #with_domain_generator otherwise.
|
|
358
|
+
def string_generator_domain(max_length)
|
|
359
|
+
@impl.string_generator_domain(@ctx, max_length)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# hegel_string_generator_free, for a handle built by
|
|
363
|
+
# #string_generator_regex, #string_generator_email,
|
|
364
|
+
# #string_generator_url, or #string_generator_domain. Public for the
|
|
365
|
+
# same reason #string_generator_regex is: the layer-1 conformance test
|
|
366
|
+
# calls it directly to prove the delegation. #with_generator_handle
|
|
367
|
+
# below is what pairs it with one of the four constructors for a
|
|
368
|
+
# do_draw.
|
|
369
|
+
def string_generator_free(generator)
|
|
370
|
+
@impl.string_generator_free(@ctx, generator)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# hegel_new_pool: opens a native pool handle and records it in @pools, so
|
|
374
|
+
# #free_pools can release it once this test case is done. Recorded here,
|
|
375
|
+
# not by a second call a caller must remember to make, so a caller of
|
|
376
|
+
# Hegel::Stateful::Pool.new cannot forget it and leak the handle --
|
|
377
|
+
# docs/adr/0011 has the ownership decision behind this.
|
|
378
|
+
def new_pool
|
|
379
|
+
pool = @impl.new_pool(@ctx, @handle)
|
|
380
|
+
@pools << pool
|
|
381
|
+
pool
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# hegel_pool_add: a fresh variable id from +pool+, for the caller to
|
|
385
|
+
# associate with the value it just generated. Hegel::Stateful::Pool#add's
|
|
386
|
+
# own primitive.
|
|
387
|
+
def pool_add(pool)
|
|
388
|
+
@impl.pool_add(@ctx, @handle, pool)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# hegel_pool_generate: the variable id libhegel chose from +pool+ (and
|
|
392
|
+
# can shrink which one it chose), +consume+ true removing it from the
|
|
393
|
+
# pool. Hegel::Stateful::Pool's two generators' own primitive. No
|
|
394
|
+
# emptiness check here: the header documents HEGEL_E_ASSUME as this
|
|
395
|
+
# call's own answer for an empty pool, and LibHegel.check! already
|
|
396
|
+
# translates that to Hegel::AssumeFailed, the same as every other
|
|
397
|
+
# assumption failure.
|
|
398
|
+
def pool_generate(pool, consume)
|
|
399
|
+
@impl.pool_generate(@ctx, @handle, pool, consume)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# hegel_pool_free. Not called directly by a caller of
|
|
403
|
+
# Hegel::Stateful::Pool -- only #free_pools below, which Hegel::Runner
|
|
404
|
+
# calls once this test case is done -- but kept its own wrapper the same
|
|
405
|
+
# as every other native call this class exposes.
|
|
406
|
+
def pool_free(pool)
|
|
407
|
+
@impl.pool_free(@ctx, pool)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Releases every pool #new_pool opened on this test case. Hegel::Runner
|
|
411
|
+
# calls this exactly once, in the same place it frees the test-case
|
|
412
|
+
# handle itself, before that handle goes: docs/adr/0011 decides the test
|
|
413
|
+
# case is a pool's owner, not whichever rule happened to call
|
|
414
|
+
# Hegel::Stateful::Pool.new, since that code has no place of its own to
|
|
415
|
+
# free one.
|
|
416
|
+
def free_pools
|
|
417
|
+
@pools.each { |pool| pool_free(pool) }
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# hegel_new_state_machine: opens the state-machine handle
|
|
421
|
+
# Hegel::Stateful.run drives for the rest of one stateful test. Kept
|
|
422
|
+
# here rather than on that module, the same reason every other native
|
|
423
|
+
# call is a method on this class: Hegel::Stateful never touches @impl or
|
|
424
|
+
# @ctx directly, only this handle-scoped surface.
|
|
425
|
+
def new_state_machine(rule_names, invariant_names)
|
|
426
|
+
@impl.new_state_machine(@ctx, @handle, rule_names, invariant_names)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# hegel_state_machine_next_rule: the index (into the +rule_names+
|
|
430
|
+
# #new_state_machine was given) of the next rule to run, or
|
|
431
|
+
# LibHegel::HEGEL_STATE_MACHINE_DONE once this test case's step budget
|
|
432
|
+
# is spent.
|
|
433
|
+
def state_machine_next_rule(state_machine)
|
|
434
|
+
@impl.state_machine_next_rule(@ctx, @handle, state_machine)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# hegel_state_machine_rule_rejected: tells libhegel the rule most
|
|
438
|
+
# recently returned by #state_machine_next_rule stopped early on a
|
|
439
|
+
# failed assumption, so it does not count toward the step budget.
|
|
440
|
+
def state_machine_rule_rejected(state_machine)
|
|
441
|
+
@impl.state_machine_rule_rejected(@ctx, @handle, state_machine)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# hegel_state_machine_free. Takes no test-case handle, unlike every
|
|
445
|
+
# other #state_machine_* method here: the header documents a
|
|
446
|
+
# state-machine handle as freeable through any handle of the same
|
|
447
|
+
# test-case family, and LibHegel::Real#state_machine_free's own bind
|
|
448
|
+
# reflects that by taking only +ctx+ and the state-machine handle.
|
|
449
|
+
def state_machine_free(state_machine)
|
|
450
|
+
@impl.state_machine_free(@ctx, state_machine)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# hegel_generate_date, without recording. Hegel::Generators::
|
|
454
|
+
# DatesGenerator's own primitive. +min_value+/+max_value+ are each a
|
|
455
|
+
# [year, month, day] Array; the return value is the same shape.
|
|
456
|
+
def generate_date(min_value, max_value)
|
|
457
|
+
@impl.generate_date(@ctx, @handle, min_value, max_value)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# hegel_generate_time, without recording. Hegel::Generators::
|
|
461
|
+
# TimesGenerator's own primitive. +min_value+/+max_value+ are each an
|
|
462
|
+
# [hour, minute, second, microsecond] Array; the return value is the
|
|
463
|
+
# same shape.
|
|
464
|
+
def generate_time(min_value, max_value)
|
|
465
|
+
@impl.generate_time(@ctx, @handle, min_value, max_value)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# hegel_generate_datetime, without recording. Hegel::Generators::
|
|
469
|
+
# DatetimesGenerator's own primitive. +min_date+/+max_date+ are each a
|
|
470
|
+
# [year, month, day] Array, +min_time+/+max_time+ each an
|
|
471
|
+
# [hour, minute, second, microsecond] Array; the return value is a
|
|
472
|
+
# [[year, month, day], [hour, minute, second, microsecond]] pair.
|
|
473
|
+
def generate_datetime(min_date, min_time, max_date, max_time)
|
|
474
|
+
@impl.generate_datetime(@ctx, @handle, min_date, min_time, max_date, max_time)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
private
|
|
478
|
+
|
|
479
|
+
# Runs the block with +generator+, freeing it via #string_generator_free
|
|
480
|
+
# whether the block returns or raises. The one #ensure
|
|
481
|
+
# #with_regex_generator/#with_email_generator/#with_url_generator/
|
|
482
|
+
# #with_domain_generator above share, so each of them stays a single
|
|
483
|
+
# line and none writes its own begin/ensure. Private, unlike the four
|
|
484
|
+
# methods that call it: a public version would take an already-built
|
|
485
|
+
# handle as its argument, the same shape a do_draw could otherwise call
|
|
486
|
+
# and forget to free. Keeping it private leaves #with_regex_generator
|
|
487
|
+
# and friends as the sanctioned path into a do_draw, each already
|
|
488
|
+
# pairing its own acquire with this release in one call.
|
|
489
|
+
def with_generator_handle(generator)
|
|
490
|
+
yield generator
|
|
491
|
+
ensure
|
|
492
|
+
string_generator_free(generator)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Records a :draw entry (name, value) for the eventual failure report. A
|
|
496
|
+
# no-op unless +record+ was true at #initialize: a run iterates the
|
|
497
|
+
# generation and shrink phases far more than once (Hegel::Runner.drive's
|
|
498
|
+
# own comment measured 1003 iterations for a 20-test-case run that
|
|
499
|
+
# always failed), so recording -- and later #inspect-ing -- every draw
|
|
500
|
+
# there would dominate a failing run's cost. Only the last,
|
|
501
|
+
# already-shrunk replay pays for it.
|
|
502
|
+
def record_draw(label, value)
|
|
503
|
+
return unless @record
|
|
504
|
+
|
|
505
|
+
@entries << [:draw, name_for(label, DRAW_CALLER_DEPTH), value]
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# The single place a draw's name is decided, tried in this order: the
|
|
509
|
+
# caller's own label:, the name Hegel::DrawName recovers from the
|
|
510
|
+
# caller's own source (see docs/adr/0005), then a generic fallback.
|
|
511
|
+
# +depth+ is how many caller_locations frames separate this call from
|
|
512
|
+
# the user's own source line (see DRAW_CALLER_DEPTH); +location+ is nil
|
|
513
|
+
# only when the stack is shallower than +depth+, which no real draw_*
|
|
514
|
+
# call site produces -- defensive, not a path this binding's own calls
|
|
515
|
+
# reach.
|
|
516
|
+
def name_for(label, depth)
|
|
517
|
+
return label if label
|
|
518
|
+
|
|
519
|
+
location = caller_locations(depth, 1)&.first
|
|
520
|
+
(location && DrawName.for(location.path, location.lineno)) || DEFAULT_DRAW_NAME
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
data/lib/hegel.rb
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "hegel/version"
|
|
4
|
+
require_relative "hegel/errors"
|
|
5
|
+
require_relative "hegel/runner"
|
|
6
|
+
require_relative "hegel/generator"
|
|
7
|
+
require_relative "hegel/generators"
|
|
8
|
+
require_relative "hegel/syntax/methods"
|
|
9
|
+
require_relative "hegel/state_machine"
|
|
10
|
+
require_relative "hegel/stateful"
|
|
11
|
+
require_relative "hegel/lib_hegel/real"
|
|
12
|
+
|
|
13
|
+
# Hegel is a property-based testing library for Ruby. It drives libhegel, the
|
|
14
|
+
# native engine that also backs the Rust, Go, TypeScript, Java, OCaml, and C++
|
|
15
|
+
# implementations of the Hegel protocol.
|
|
16
|
+
#
|
|
17
|
+
# The gem is named `hegeltest` while the namespace and require path are
|
|
18
|
+
# `hegel`, mirroring the Rust implementation: the published crate is
|
|
19
|
+
# `hegeltest` and callers write `use hegel::...`.
|
|
20
|
+
module Hegel
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Runs +block+ as a Hegel property, drawing test cases from the +tc+ it
|
|
24
|
+
# yields (see Hegel::TestCase). Returns nil on a passing run. On a failing
|
|
25
|
+
# run, writes a failure report to +output+ (see Hegel::Report) and then
|
|
26
|
+
# re-raises the exception the smallest failing case's body raised, class
|
|
27
|
+
# and backtrace intact, so a host test framework reports it as its own
|
|
28
|
+
# assertion failure rather than as a Hegel-specific one. Raises
|
|
29
|
+
# Hegel::Error for a run-level failure instead of a property failure.
|
|
30
|
+
#
|
|
31
|
+
# +test_cases+, +seed+, +derandomize+, +verbosity+, +phases+, and
|
|
32
|
+
# +suppress_health_check+ all default to nil, which means the same thing
|
|
33
|
+
# for each of them: do not call the matching libhegel setter, and let the
|
|
34
|
+
# engine's own default apply instead. See Hegel::Settings for the
|
|
35
|
+
# keyword-to-setter mapping, the verbosity Symbols it accepts, and the
|
|
36
|
+
# +phases+/+suppress_health_check+ Symbols each of those two accepts (as
|
|
37
|
+
# an Array; an empty Array raises Hegel::Error rather than silently
|
|
38
|
+
# meaning "none"). +verbosity: :quiet+ also silences the failure report
|
|
39
|
+
# itself, not just libhegel's own progress output.
|
|
40
|
+
#
|
|
41
|
+
# +database+ and +database_key+ opt a run into libhegel's example
|
|
42
|
+
# database: +database_key+ is the switch, +database+ only means something
|
|
43
|
+
# alongside it. Left at their shared default (nil, nil), a run stores
|
|
44
|
+
# nothing, matching every Hegel.test call before these two keywords
|
|
45
|
+
# existed. Given a String, +database_key+ scopes what a run stores and
|
|
46
|
+
# replays -- make it unique to the property, since two properties sharing
|
|
47
|
+
# a key share one replay scope. +database+ then chooses the directory
|
|
48
|
+
# (libhegel's own default, ./.hegel/examples/ outside CI, if left nil
|
|
49
|
+
# alongside a key). Passing +database+ without +database_key+ raises
|
|
50
|
+
# Hegel::Error. See docs/adr/0009 for the decision and the measurements
|
|
51
|
+
# behind it.
|
|
52
|
+
#
|
|
53
|
+
# +report_multiple_failures+ defaults to false, not nil, unlike every
|
|
54
|
+
# keyword above: see Hegel::Runner.run's own comment for why departing
|
|
55
|
+
# from libhegel's own default (true) is itself the decision here.
|
|
56
|
+
#
|
|
57
|
+
# +stateful_step_count+ bounds how many rules a Hegel::Stateful.run call
|
|
58
|
+
# applies per test case; left nil (the default) leaves libhegel's own
|
|
59
|
+
# default of 50 in place. hegel.h documents it as needing to be at least
|
|
60
|
+
# 1; like +tc.target+'s label, that requirement is left to the engine
|
|
61
|
+
# rather than re-checked here.
|
|
62
|
+
#
|
|
63
|
+
# +output+ (default $stderr) is where a failure report is written; a
|
|
64
|
+
# caller passes its own IO to capture that report instead (tests do).
|
|
65
|
+
#
|
|
66
|
+
# +reproduce_failure+, when given, replays the single case that blob
|
|
67
|
+
# (printed at the end of an earlier failure report) encodes, instead of
|
|
68
|
+
# starting a run: +test_cases+ and the other run-shaping keywords above
|
|
69
|
+
# have nothing to bound in that case. See Hegel::Runner.reproduce.
|
|
70
|
+
#
|
|
71
|
+
# +impl+ exists for tests: it lets Hegel::LibHegel::Fake stand in for the
|
|
72
|
+
# real engine. Ordinary callers never pass it. Its default expression
|
|
73
|
+
# (#default_impl) only runs when +impl+ is not given, so a test that does
|
|
74
|
+
# pass one never opens the native library at all.
|
|
75
|
+
def test(test_cases: nil, seed: nil, derandomize: nil, verbosity: nil, database: nil, database_key: nil,
|
|
76
|
+
phases: nil, suppress_health_check: nil, report_multiple_failures: false, stateful_step_count: nil,
|
|
77
|
+
output: $stderr, reproduce_failure: nil, impl: default_impl, &block)
|
|
78
|
+
Runner.run(impl: impl, test_cases: test_cases, seed: seed, derandomize: derandomize, verbosity: verbosity,
|
|
79
|
+
database: database, database_key: database_key, phases: phases, suppress_health_check: suppress_health_check,
|
|
80
|
+
report_multiple_failures: report_multiple_failures, stateful_step_count: stateful_step_count, output: output,
|
|
81
|
+
reproduce_failure: reproduce_failure, &block)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The Hegel::LibHegel::Real instance #test uses by default, built once and
|
|
85
|
+
# reused: Hegel::LibHegel::Real#initialize opens the native library, and
|
|
86
|
+
# that should not happen again on every #test call in a process that calls
|
|
87
|
+
# it more than once. Building it here rather than at load time also means a
|
|
88
|
+
# caller who always passes their own +impl:+ never opens the library.
|
|
89
|
+
def default_impl
|
|
90
|
+
@default_impl ||= LibHegel::Real.new
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/hegeltest.rb
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Bundler derives its automatic require from the gem name, so a gem published
|
|
4
|
+
# as `hegeltest` must answer to `require "hegeltest"`. The library itself lives
|
|
5
|
+
# in `hegel.rb` and the documented form is `require "hegel"`; this file exists
|
|
6
|
+
# so that `gem "hegeltest"` works without a `require:` option.
|
|
7
|
+
require_relative "hegel"
|