hegeltest 0.3.4 → 0.4.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 +4 -4
- data/CHANGELOG.md +182 -0
- data/README.ja.md +408 -0
- data/README.md +208 -102
- data/Rakefile +6 -3
- data/docs/README.md +9 -0
- data/docs/adr/0027-report-each-runs-test-location-from-the-callers-frame.md +63 -0
- data/docs/adr/0028-expose-settings-profiles-as-a-keyword-and-module-functions.md +91 -0
- data/docs/adr/0029-keep-notes-local-and-defer-run-error-kinds.md +72 -0
- data/docs/adr/0030-represent-a-duration-as-integer-nanoseconds.md +48 -0
- data/docs/adr/0031-take-the-alphabet-options-from-hegel-rust.md +52 -0
- data/docs/adr/0032-suppress-span-closes-while-an-abandoned-recursion-attempt-unwinds.md +147 -0
- data/docs/adr/0033-cache-compiled-grammars-for-the-length-of-a-run.md +80 -0
- data/docs/adr/0034-repeat-a-test-body-with-a-ruby-side-indent.md +120 -0
- data/docs/adr/0035-defer-concurrent-state-machines-and-the-engine-printer.md +136 -0
- data/docs/architecture.md +16 -1
- data/lib/hegel/errors.rb +24 -0
- data/lib/hegel/generator.rb +52 -25
- data/lib/hegel/generators.rb +715 -45
- data/lib/hegel/grammar_cache.rb +36 -0
- data/lib/hegel/lib_hegel/real.rb +320 -17
- data/lib/hegel/lib_hegel.rb +31 -6
- data/lib/hegel/libhegel_version.rb +1 -1
- data/lib/hegel/report.rb +13 -10
- data/lib/hegel/runner.rb +61 -27
- data/lib/hegel/settings.rb +92 -16
- data/lib/hegel/state_machine.rb +17 -4
- data/lib/hegel/stateful/pool.rb +52 -0
- data/lib/hegel/stateful.rb +2 -2
- data/lib/hegel/syntax/methods.rb +99 -15
- data/lib/hegel/test_case.rb +256 -35
- data/lib/hegel/test_location.rb +49 -0
- data/lib/hegel/version.rb +1 -1
- data/lib/hegel.rb +21 -5
- data/sig/hegel.rbs +295 -42
- data/skills/hegel-ruby/references/ruby/reference.md +463 -41
- metadata +13 -1
data/lib/hegel/runner.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
|
+
require_relative "grammar_cache"
|
|
4
5
|
require_relative "lib_hegel"
|
|
5
6
|
require_relative "report"
|
|
6
7
|
require_relative "settings"
|
|
@@ -97,10 +98,8 @@ module Hegel
|
|
|
97
98
|
|
|
98
99
|
module_function
|
|
99
100
|
|
|
100
|
-
# Runs +block+ as a Hegel property against +impl+, applying
|
|
101
|
-
#
|
|
102
|
-
# +phases+, +suppress_health_check+, and +report_multiple_failures+ (see
|
|
103
|
-
# Hegel::Settings) to a fresh settings handle first. Returns nil on a
|
|
101
|
+
# Runs +block+ as a Hegel property against +impl+, applying the
|
|
102
|
+
# settings keywords (see Hegel::Settings) to a fresh settings handle first. Returns nil on a
|
|
104
103
|
# passing run, re-raises the exception the smallest failing case's body
|
|
105
104
|
# raised on a failing run, and raises Hegel::Error for a run-level
|
|
106
105
|
# failure (ERROR status, a blob that no longer reproduces, or more than
|
|
@@ -119,6 +118,13 @@ module Hegel
|
|
|
119
118
|
# with its class and backtrace intact), so a hegel.toml profile or a
|
|
120
119
|
# HEGEL_* variable does not get to turn it off by accident.
|
|
121
120
|
#
|
|
121
|
+
# +profile+ names the engine profile the settings start from; nil starts
|
|
122
|
+
# from the default profile. The keywords apply on top either way, so one
|
|
123
|
+
# passed explicitly beats the profile's value.
|
|
124
|
+
#
|
|
125
|
+
# +test_location+ is a Hegel::TestLocation. It is identity rather than a
|
|
126
|
+
# setting, so it goes to the engine here and not through Settings.apply.
|
|
127
|
+
#
|
|
122
128
|
# +reproduce_failure+, when given, starts the run from that blob instead
|
|
123
129
|
# (see LibHegel::Real#run_start_blob): the engine replays it rather than
|
|
124
130
|
# exploring, and the same loop below drives the replay.
|
|
@@ -130,16 +136,27 @@ module Hegel
|
|
|
130
136
|
# `ensure` by the code that opened it, innermost first: hegel_context_free
|
|
131
137
|
# requires every other handle taken from the context to be freed first,
|
|
132
138
|
# and Ruby's GC gives finalizers no ordering guarantee to rely on instead.
|
|
133
|
-
|
|
134
|
-
|
|
139
|
+
#
|
|
140
|
+
# The grammars the run compiled are freed after the run and before the
|
|
141
|
+
# context (docs/adr/0033).
|
|
142
|
+
def run(impl:, test_location:, profile: nil, test_cases: nil, seed: nil, derandomize: nil, verbosity: nil, database: nil, database_key: nil,
|
|
143
|
+
phases: nil, suppress_health_check: nil, report_multiple_failures: false, stop_on_first_failure: nil,
|
|
144
|
+
print_blob: nil, show_statistics: nil, nondeterminism_strictness: nil, backend: nil,
|
|
135
145
|
output: $stderr, reproduce_failure: nil, &block)
|
|
136
146
|
quiet = verbosity == :quiet
|
|
137
147
|
LibHegel.with_context(impl) do |ctx|
|
|
138
|
-
settings =
|
|
148
|
+
settings = Settings.new_handle(impl, ctx, profile)
|
|
139
149
|
run = begin
|
|
140
150
|
Settings.apply(impl, ctx, settings, test_cases: test_cases, seed: seed, derandomize: derandomize,
|
|
141
151
|
verbosity: verbosity, database: database, database_key: database_key, phases: phases,
|
|
142
|
-
suppress_health_check: suppress_health_check, report_multiple_failures: report_multiple_failures
|
|
152
|
+
suppress_health_check: suppress_health_check, report_multiple_failures: report_multiple_failures,
|
|
153
|
+
stop_on_first_failure: stop_on_first_failure, print_blob: print_blob, show_statistics: show_statistics,
|
|
154
|
+
nondeterminism_strictness: nondeterminism_strictness, backend: backend)
|
|
155
|
+
impl.settings_set_test_location(ctx, settings, test_location.file, test_location.begin_line,
|
|
156
|
+
test_location.class_name, test_location.function)
|
|
157
|
+
# Read back rather than taken from the keyword, so that a profile
|
|
158
|
+
# or HEGEL_PRINT_BLOB decides when the keyword is nil.
|
|
159
|
+
print_blob = impl.settings_get_print_blob(ctx, settings)
|
|
143
160
|
if reproduce_failure
|
|
144
161
|
impl.run_start_blob(ctx, settings, reproduce_failure)
|
|
145
162
|
else
|
|
@@ -150,19 +167,26 @@ module Hegel
|
|
|
150
167
|
# hegel_run_start copies them.
|
|
151
168
|
impl.settings_free(ctx, settings)
|
|
152
169
|
end
|
|
153
|
-
|
|
170
|
+
grammars = GrammarCache.new
|
|
171
|
+
begin
|
|
172
|
+
run_and_finish(impl, ctx, run, grammars, reproducing: !reproduce_failure.nil?, quiet: quiet,
|
|
173
|
+
print_blob: print_blob, output: output, &block)
|
|
174
|
+
ensure
|
|
175
|
+
grammars.free_all(impl, ctx)
|
|
176
|
+
end
|
|
154
177
|
end
|
|
155
178
|
end
|
|
156
179
|
|
|
157
180
|
# Drives +run+ and hands its result to #finish.
|
|
158
|
-
def run_and_finish(impl, ctx, run, reproducing:, quiet:, output:, &block)
|
|
181
|
+
def run_and_finish(impl, ctx, run, grammars, reproducing:, quiet:, print_blob:, output:, &block)
|
|
159
182
|
stats = GenerationStats.new
|
|
160
183
|
captures = {}
|
|
161
|
-
drive(impl, ctx, run, stats, captures, &block)
|
|
184
|
+
drive(impl, ctx, run, grammars, stats, captures, &block)
|
|
162
185
|
|
|
163
186
|
result = impl.run_result(ctx, run)
|
|
164
187
|
begin
|
|
165
|
-
finish(impl, ctx, result, stats, captures, reproducing: reproducing, quiet: quiet,
|
|
188
|
+
finish(impl, ctx, result, stats, captures, reproducing: reproducing, quiet: quiet, print_blob: print_blob,
|
|
189
|
+
output: output)
|
|
166
190
|
ensure
|
|
167
191
|
impl.run_result_free(ctx, result)
|
|
168
192
|
end
|
|
@@ -178,16 +202,16 @@ module Hegel
|
|
|
178
202
|
# Pulls test cases from +run+ until hegel_next_test_case reports none
|
|
179
203
|
# left (a nil out-parameter, not an error). Never counts iterations
|
|
180
204
|
# itself: test_cases bounds generation, not how many times shrinking
|
|
181
|
-
# and rejected cases call the body. Measured against libhegel 0.
|
|
205
|
+
# and rejected cases call the body. Measured against libhegel 0.48.0, a
|
|
182
206
|
# run configured for 20 test cases whose body rejected nearly every case
|
|
183
|
-
# took
|
|
207
|
+
# took 782 to 2047 iterations over 30 runs. +stats+ does its own, different counting
|
|
184
208
|
# -- see GenerationStats above.
|
|
185
|
-
def drive(impl, ctx, run, stats, captures, &block)
|
|
209
|
+
def drive(impl, ctx, run, grammars, stats, captures, &block)
|
|
186
210
|
loop do
|
|
187
211
|
tc = impl.next_test_case(ctx, run)
|
|
188
212
|
break if tc.nil?
|
|
189
213
|
|
|
190
|
-
run_case(impl, ctx, tc, stats, captures, &block)
|
|
214
|
+
run_case(impl, ctx, tc, grammars, stats, captures, &block)
|
|
191
215
|
end
|
|
192
216
|
end
|
|
193
217
|
|
|
@@ -207,8 +231,8 @@ module Hegel
|
|
|
207
231
|
# The case records its draws only when the engine stamped it for
|
|
208
232
|
# capture. The stamp is read in here, inside the `ensure`, so the handle
|
|
209
233
|
# is freed even when reading it raises.
|
|
210
|
-
def with_test_case(impl, ctx, tc)
|
|
211
|
-
test_case = TestCase.new(impl, ctx, tc, record: impl.test_case_should_capture(ctx, tc))
|
|
234
|
+
def with_test_case(impl, ctx, tc, grammars)
|
|
235
|
+
test_case = TestCase.new(impl, ctx, tc, record: impl.test_case_should_capture(ctx, tc), grammars: grammars)
|
|
212
236
|
yield test_case
|
|
213
237
|
ensure
|
|
214
238
|
begin
|
|
@@ -228,8 +252,8 @@ module Hegel
|
|
|
228
252
|
# and still reaches #with_test_case's own `ensure`, so the handle is
|
|
229
253
|
# freed either way; its owner is this loop, not hegel_run_free (see
|
|
230
254
|
# #run_and_finish's comment above).
|
|
231
|
-
def run_case(impl, ctx, tc, stats, captures, &block)
|
|
232
|
-
with_test_case(impl, ctx, tc) do |test_case|
|
|
255
|
+
def run_case(impl, ctx, tc, grammars, stats, captures, &block)
|
|
256
|
+
with_test_case(impl, ctx, tc, grammars) do |test_case|
|
|
233
257
|
status, origin, exception, entries = classify(test_case, &block)
|
|
234
258
|
stats.record(status, origin)
|
|
235
259
|
keep_capture(captures, origin, exception, entries) if status == LibHegel::HEGEL_STATUS_INTERESTING
|
|
@@ -258,7 +282,7 @@ module Hegel
|
|
|
258
282
|
# would mean this binding does not recognise a hegel_run_status_t value
|
|
259
283
|
# the loaded engine returned, mirroring how LibHegel.check! names an
|
|
260
284
|
# unrecognised result code instead of silently doing nothing with it.
|
|
261
|
-
def finish(impl, ctx, result, stats, captures, reproducing:, quiet:, output:)
|
|
285
|
+
def finish(impl, ctx, result, stats, captures, reproducing:, quiet:, print_blob:, output:)
|
|
262
286
|
status = impl.run_result_status(ctx, result)
|
|
263
287
|
case status
|
|
264
288
|
when LibHegel::HEGEL_RUN_STATUS_PASSED
|
|
@@ -266,7 +290,7 @@ module Hegel
|
|
|
266
290
|
when LibHegel::HEGEL_RUN_STATUS_ERROR
|
|
267
291
|
raise Hegel::Error, impl.run_result_error(ctx, result) || UNKNOWN_RUN_ERROR_MESSAGE
|
|
268
292
|
when LibHegel::HEGEL_RUN_STATUS_FAILED
|
|
269
|
-
report_failures(impl, ctx, result, stats, captures, quiet: quiet, output: output)
|
|
293
|
+
report_failures(impl, ctx, result, stats, captures, quiet: quiet, print_blob: print_blob, output: output)
|
|
270
294
|
else
|
|
271
295
|
raise Hegel::Error, "hegel: run finished with an unrecognized status (#{status})"
|
|
272
296
|
end
|
|
@@ -286,13 +310,13 @@ module Hegel
|
|
|
286
310
|
# guard. The report is written before either raise, not after: a host
|
|
287
311
|
# framework that catches the re-raised exception would otherwise read
|
|
288
312
|
# its own output before this one, out of order.
|
|
289
|
-
def report_failures(impl, ctx, result, stats, captures, quiet:, output:)
|
|
313
|
+
def report_failures(impl, ctx, result, stats, captures, quiet:, print_blob:, output:)
|
|
290
314
|
exceptions = []
|
|
291
315
|
failures = []
|
|
292
316
|
impl.run_result_failure_count(ctx, result).times do |index|
|
|
293
317
|
failure = impl.run_result_failure(ctx, result, index)
|
|
294
318
|
begin
|
|
295
|
-
exception, report = failure_report(impl, ctx, failure, stats, captures)
|
|
319
|
+
exception, report = failure_report(impl, ctx, failure, stats, captures, print_blob: print_blob)
|
|
296
320
|
exceptions << exception
|
|
297
321
|
failures << report
|
|
298
322
|
ensure
|
|
@@ -310,13 +334,13 @@ module Hegel
|
|
|
310
334
|
# case the run produced, so every failure has a capture under its origin.
|
|
311
335
|
# A capture with no entries came from an unstamped case, whose draws
|
|
312
336
|
# went unrecorded, so its report lists no values.
|
|
313
|
-
def failure_report(impl, ctx, failure, stats, captures)
|
|
337
|
+
def failure_report(impl, ctx, failure, stats, captures, print_blob:)
|
|
314
338
|
origin = impl.failure_origin(ctx, failure)
|
|
315
339
|
capture = captures.fetch(origin)
|
|
316
340
|
test_cases, discarded = stats.for(origin)
|
|
317
341
|
report = Report::Failure.new(
|
|
318
342
|
test_cases: test_cases, discarded: discarded, entries: capture.entries || [],
|
|
319
|
-
blob: impl.failure_reproduction_blob(ctx, failure), caveat: impl.failure_caveat(ctx, failure)
|
|
343
|
+
blob: (impl.failure_reproduction_blob(ctx, failure) if print_blob), caveat: impl.failure_caveat(ctx, failure)
|
|
320
344
|
)
|
|
321
345
|
[capture.exception, report]
|
|
322
346
|
end
|
|
@@ -344,10 +368,20 @@ module Hegel
|
|
|
344
368
|
[LibHegel::HEGEL_STATUS_VALID, nil, nil, nil]
|
|
345
369
|
rescue *Hegel::FATAL_EXCEPTIONS
|
|
346
370
|
raise
|
|
347
|
-
|
|
371
|
+
# A LeafBudgetExceeded gets here only when a body swallowed the
|
|
372
|
+
# exception that replaced it mid-unwind and then drew again. It is
|
|
373
|
+
# library control flow, not a property failing, and its attempt never
|
|
374
|
+
# reached hegel_recursion_retry, so the case is discarded.
|
|
375
|
+
rescue Hegel::AssumeFailed, Hegel::LeafBudgetExceeded
|
|
348
376
|
[LibHegel::HEGEL_STATUS_INVALID, nil, nil, nil]
|
|
349
377
|
rescue Hegel::StopTest
|
|
350
378
|
[LibHegel::HEGEL_STATUS_OVERRUN, nil, nil, nil]
|
|
379
|
+
rescue Hegel::LoopDone
|
|
380
|
+
# Hegel::TestCase#repeat ran until the engine stopped it, with no
|
|
381
|
+
# failure; hegel-rust's run lifecycle maps its LoopDone to VALID too.
|
|
382
|
+
# .mutineer.yml ignores the mutants of its last two elements by id, for
|
|
383
|
+
# the reason given at the first VALID above.
|
|
384
|
+
[LibHegel::HEGEL_STATUS_VALID, nil, nil, nil]
|
|
351
385
|
rescue Exception => e
|
|
352
386
|
[LibHegel::HEGEL_STATUS_INTERESTING, origin_for(e), e, test_case.entries]
|
|
353
387
|
end
|
data/lib/hegel/settings.rb
CHANGED
|
@@ -41,11 +41,22 @@ module Hegel
|
|
|
41
41
|
large_initial_test_case: LibHegel::HEGEL_HC_LARGE_INITIAL_TEST_CASE
|
|
42
42
|
}.freeze
|
|
43
43
|
|
|
44
|
+
NONDETERMINISM_STRICTNESS_CODES = {
|
|
45
|
+
quiet: LibHegel::HEGEL_NONDETERMINISM_QUIET,
|
|
46
|
+
warn: LibHegel::HEGEL_NONDETERMINISM_WARN,
|
|
47
|
+
error: LibHegel::HEGEL_NONDETERMINISM_ERROR
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
BACKEND_CODES = {
|
|
51
|
+
default: LibHegel::HEGEL_BACKEND_DEFAULT,
|
|
52
|
+
urandom: LibHegel::HEGEL_BACKEND_URANDOM
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
44
55
|
module_function
|
|
45
56
|
|
|
46
57
|
# Applies every one of Hegel.test's settings keywords to +settings+ via
|
|
47
|
-
# +impl+.
|
|
48
|
-
#
|
|
58
|
+
# +impl+. Every keyword skips its setter when nil, except
|
|
59
|
+
# database/database_key and report_multiple_failures (below).
|
|
49
60
|
# +database+/+database_key+ follow #apply_database's own table, called
|
|
50
61
|
# unconditionally since even the nil/nil case has a setter to call (see
|
|
51
62
|
# its comment). +report_multiple_failures+ is called unconditionally too,
|
|
@@ -53,26 +64,96 @@ module Hegel
|
|
|
53
64
|
# it nil, so this method never sees nil for it -- see Hegel::Runner.run's
|
|
54
65
|
# own comment for why that default departs from every other keyword's
|
|
55
66
|
# nil-means-engine-default rule.
|
|
56
|
-
def apply(impl, ctx, settings,
|
|
57
|
-
|
|
67
|
+
def apply(impl, ctx, settings, database:, database_key:, report_multiple_failures:, **keywords)
|
|
68
|
+
apply_shared(impl, ctx, settings, **keywords)
|
|
69
|
+
apply_database(impl, ctx, settings, database: database, database_key: database_key)
|
|
70
|
+
impl.settings_set_report_multiple_failures(ctx, settings, report_multiple_failures)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The keywords a run and a registered profile have in common. The example
|
|
74
|
+
# database key and report_multiple_failures are left to the callers:
|
|
75
|
+
# a run always passes the latter, and a profile snapshot carries no key.
|
|
76
|
+
def apply_shared(impl, ctx, settings, test_cases:, seed:, derandomize:, verbosity:, phases:,
|
|
77
|
+
suppress_health_check:, stop_on_first_failure:, print_blob:, show_statistics:,
|
|
78
|
+
nondeterminism_strictness:, backend:)
|
|
58
79
|
impl.settings_set_test_cases(ctx, settings, test_cases) unless test_cases.nil?
|
|
59
80
|
impl.settings_set_seed(ctx, settings, seed, true) unless seed.nil?
|
|
60
81
|
impl.settings_set_derandomize(ctx, settings, derandomize) unless derandomize.nil?
|
|
61
82
|
apply_verbosity(impl, ctx, settings, verbosity) unless verbosity.nil?
|
|
62
|
-
apply_database(impl, ctx, settings, database: database, database_key: database_key)
|
|
63
83
|
apply_phases(impl, ctx, settings, phases) unless phases.nil?
|
|
64
84
|
apply_suppress_health_check(impl, ctx, settings, suppress_health_check) unless suppress_health_check.nil?
|
|
65
|
-
|
|
85
|
+
unless stop_on_first_failure.nil?
|
|
86
|
+
impl.settings_set_stop_on_first_failure(ctx, settings, stop_on_first_failure)
|
|
87
|
+
end
|
|
88
|
+
impl.settings_set_print_blob(ctx, settings, print_blob) unless print_blob.nil?
|
|
89
|
+
impl.settings_set_show_statistics(ctx, settings, show_statistics) unless show_statistics.nil?
|
|
90
|
+
unless nondeterminism_strictness.nil?
|
|
91
|
+
code = lookup(NONDETERMINISM_STRICTNESS_CODES, nondeterminism_strictness, "nondeterminism_strictness")
|
|
92
|
+
impl.settings_set_nondeterminism_strictness(ctx, settings, code)
|
|
93
|
+
end
|
|
94
|
+
impl.settings_set_backend(ctx, settings, lookup(BACKEND_CODES, backend, "backend")) unless backend.nil?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A settings handle for a run or a profile: from the default profile when
|
|
98
|
+
# +profile+ is nil, from the named one otherwise.
|
|
99
|
+
def new_handle(impl, ctx, profile)
|
|
100
|
+
profile.nil? ? impl.settings_new(ctx) : impl.settings_new_for_profile(ctx, profile)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Registers the settings built from +profile+ (nil: the default profile)
|
|
104
|
+
# and the keywords as the profile +name+, for the whole process. A later
|
|
105
|
+
# Hegel.test(profile: name) or set_default_profile(name) starts from them.
|
|
106
|
+
# Registration is not retroactive and replaces an earlier one of the same
|
|
107
|
+
# name. The keywords are Hegel.test's, each nil for "leave the starting
|
|
108
|
+
# value", minus database_key, which the engine leaves out of a snapshot,
|
|
109
|
+
# and with +database+ passed straight to the engine ("" disables it) and
|
|
110
|
+
# report_multiple_failures applied only when given: Hegel.test always
|
|
111
|
+
# passes its own, so a profile's value for that one never reaches a run
|
|
112
|
+
# (docs/adr/0028).
|
|
113
|
+
#
|
|
114
|
+
# The profile table is process-wide state in the engine, not on +impl+'s
|
|
115
|
+
# context, so a registration made here outlives the context this opens.
|
|
116
|
+
def register_profile(name, profile: nil, test_cases: nil, seed: nil, derandomize: nil, verbosity: nil,
|
|
117
|
+
database: nil, phases: nil, suppress_health_check: nil, report_multiple_failures: nil,
|
|
118
|
+
stop_on_first_failure: nil, print_blob: nil, show_statistics: nil, nondeterminism_strictness: nil,
|
|
119
|
+
backend: nil, impl: Hegel.default_impl)
|
|
120
|
+
LibHegel.with_context(impl) do |ctx|
|
|
121
|
+
settings = new_handle(impl, ctx, profile)
|
|
122
|
+
begin
|
|
123
|
+
apply_shared(impl, ctx, settings, test_cases: test_cases, seed: seed, derandomize: derandomize,
|
|
124
|
+
verbosity: verbosity, phases: phases, suppress_health_check: suppress_health_check,
|
|
125
|
+
stop_on_first_failure: stop_on_first_failure, print_blob: print_blob, show_statistics: show_statistics,
|
|
126
|
+
nondeterminism_strictness: nondeterminism_strictness, backend: backend)
|
|
127
|
+
impl.settings_set_database(ctx, settings, database) unless database.nil?
|
|
128
|
+
unless report_multiple_failures.nil?
|
|
129
|
+
impl.settings_set_report_multiple_failures(ctx, settings, report_multiple_failures)
|
|
130
|
+
end
|
|
131
|
+
impl.settings_register_profile(ctx, name, settings)
|
|
132
|
+
ensure
|
|
133
|
+
impl.settings_free(ctx, settings)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Makes +name+ the profile a nil +profile+ resolves to, for the whole
|
|
140
|
+
# process, ahead of HEGEL_DEFAULT_PROFILE and hegel.toml. "" clears it.
|
|
141
|
+
# The name need not be registered yet.
|
|
142
|
+
def set_default_profile(name, impl: Hegel.default_impl)
|
|
143
|
+
LibHegel.with_context(impl) { |ctx| impl.set_default_profile(ctx, name) }
|
|
144
|
+
nil
|
|
66
145
|
end
|
|
67
146
|
|
|
68
147
|
# Split from #apply so the VERBOSITY_CODES lookup (one of several
|
|
69
148
|
# keywords that can fail) is not buried inside the top-level sequence.
|
|
70
149
|
def apply_verbosity(impl, ctx, settings, verbosity)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
150
|
+
impl.settings_set_verbosity(ctx, settings, lookup(VERBOSITY_CODES, verbosity, "verbosity"))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def lookup(codes, value, keyword)
|
|
154
|
+
codes.fetch(value) do
|
|
155
|
+
raise Hegel::Error, "hegel: unknown #{keyword} #{value.inspect}; expected one of #{codes.keys.inspect}"
|
|
74
156
|
end
|
|
75
|
-
impl.settings_set_verbosity(ctx, settings, code)
|
|
76
157
|
end
|
|
77
158
|
|
|
78
159
|
# docs/adr/0009-turn-the-example-database-on-with-a-key.md decides this
|
|
@@ -148,12 +229,7 @@ module Hegel
|
|
|
148
229
|
"hegel: #{keyword} expects one or more of #{codes.keys.inspect}, got an empty Array"
|
|
149
230
|
end
|
|
150
231
|
|
|
151
|
-
values.reduce(0)
|
|
152
|
-
code = codes.fetch(value) do
|
|
153
|
-
raise Hegel::Error, "hegel: unknown #{keyword} #{value.inspect}; expected one of #{codes.keys.inspect}"
|
|
154
|
-
end
|
|
155
|
-
mask | code
|
|
156
|
-
end
|
|
232
|
+
values.reduce(0) { |mask, value| mask | lookup(codes, value, keyword) }
|
|
157
233
|
end
|
|
158
234
|
end
|
|
159
235
|
end
|
data/lib/hegel/state_machine.rb
CHANGED
|
@@ -23,6 +23,9 @@ module Hegel
|
|
|
23
23
|
# class's.
|
|
24
24
|
include Syntax::Methods
|
|
25
25
|
|
|
26
|
+
# One declared rule: its block, and its selection weight.
|
|
27
|
+
Rule = Data.define(:block, :weight)
|
|
28
|
+
|
|
26
29
|
# One declared invariant: its block, and whether it runs after every
|
|
27
30
|
# round rather than when the engine samples it.
|
|
28
31
|
Invariant = Data.define(:block, :always_run)
|
|
@@ -33,8 +36,17 @@ module Hegel
|
|
|
33
36
|
# instance being tested, and is handed the running Hegel::TestCase as
|
|
34
37
|
# its one argument -- ignored if the block declares no parameter, the
|
|
35
38
|
# ordinary Ruby block rule.
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
#
|
|
40
|
+
# +weight+ hints how often the engine picks this rule against the
|
|
41
|
+
# others: a positive, finite number, 1.0 by default. It is a guide, not
|
|
42
|
+
# a distribution: the engine enables a random subset of the rules in
|
|
43
|
+
# each test case, so a rule's frequency also depends on which others
|
|
44
|
+
# are enabled. The engine rejects a weight that is not finite and
|
|
45
|
+
# positive when the machine runs.
|
|
46
|
+
def rule(name, weight: 1.0, &block)
|
|
47
|
+
raise Hegel::Error, "hegel: rule weight must be a real number, got #{weight.inspect}" unless weight.is_a?(Numeric) && weight.real?
|
|
48
|
+
|
|
49
|
+
declare(:@rules, "rule", name, Rule.new(block: block, weight: weight))
|
|
38
50
|
end
|
|
39
51
|
|
|
40
52
|
# Declares an invariant named +name+, checked before the first rule
|
|
@@ -47,9 +59,10 @@ module Hegel
|
|
|
47
59
|
declare(:@invariants, "invariant", name, Invariant.new(block: block, always_run: always_run))
|
|
48
60
|
end
|
|
49
61
|
|
|
50
|
-
# name =>
|
|
62
|
+
# name => Rule, in declaration order, this class's own declarations
|
|
51
63
|
# merged over its ancestors'. Hegel::Stateful.run reads this directly
|
|
52
|
-
# to build the ordered rule-name
|
|
64
|
+
# to build the ordered rule-name and weight lists libhegel indexes by
|
|
65
|
+
# position.
|
|
53
66
|
def rule_definitions
|
|
54
67
|
merged_definitions(:@rules, :rule_definitions)
|
|
55
68
|
end
|
data/lib/hegel/stateful/pool.rb
CHANGED
|
@@ -103,6 +103,58 @@ module Hegel
|
|
|
103
103
|
@values.delete(variable_id)
|
|
104
104
|
value
|
|
105
105
|
end
|
|
106
|
+
|
|
107
|
+
# Consumes up to Filtered::MAX_ATTEMPTS ids and keeps only the one
|
|
108
|
+
# whose value +predicate+ accepts. hegel_pool_generate removes an id
|
|
109
|
+
# for good unless hegel_pool_restore puts it back, so a filter that
|
|
110
|
+
# drew through #do_draw would lose every value it rejected. Rejected
|
|
111
|
+
# ids stay consumed while the attempts run, so no attempt draws one
|
|
112
|
+
# again, and go back together once they are over. hegel-rust's
|
|
113
|
+
# ValuesConsumed (frontends/rust/src/stateful.rs) does the same.
|
|
114
|
+
def do_draw_filtered(tc, predicate)
|
|
115
|
+
attempted = []
|
|
116
|
+
accepted = nil
|
|
117
|
+
restore = true
|
|
118
|
+
begin
|
|
119
|
+
Filtered::MAX_ATTEMPTS.times do
|
|
120
|
+
# Once every value left was rejected, the engine has nothing
|
|
121
|
+
# more to draw.
|
|
122
|
+
break if @values.size == attempted.size
|
|
123
|
+
|
|
124
|
+
accepted = filtered_attempt(tc, predicate, attempted)
|
|
125
|
+
break if accepted
|
|
126
|
+
end
|
|
127
|
+
rescue *Hegel::FATAL_EXCEPTIONS
|
|
128
|
+
# The process is ending, and answering a NoMemoryError with
|
|
129
|
+
# native calls is the wrong move (see Hegel::Stateful.apply_rule).
|
|
130
|
+
restore = false
|
|
131
|
+
raise
|
|
132
|
+
ensure
|
|
133
|
+
(attempted - [accepted]).each { |variable_id| tc.pool_restore(@pool, variable_id) } if restore
|
|
134
|
+
end
|
|
135
|
+
raise Hegel::AssumeFailed unless accepted
|
|
136
|
+
|
|
137
|
+
@values.delete(accepted)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# One attempt, in a span of its own that closes as discarded unless
|
|
143
|
+
# the predicate accepts. Records the consumed id in +attempted+ before
|
|
144
|
+
# the predicate runs, so a predicate that raises still has its id put
|
|
145
|
+
# back. Returns the id when accepted, nil otherwise. +accepted+ stays
|
|
146
|
+
# nil, which discards the span, when a draw or the predicate raises.
|
|
147
|
+
def filtered_attempt(tc, predicate, attempted)
|
|
148
|
+
tc.start_span(Generator::FILTER_ATTEMPT_LABEL)
|
|
149
|
+
begin
|
|
150
|
+
variable_id = tc.pool_generate(@pool, true)
|
|
151
|
+
attempted << variable_id
|
|
152
|
+
accepted = predicate.call(@values.fetch(variable_id))
|
|
153
|
+
variable_id if accepted
|
|
154
|
+
ensure
|
|
155
|
+
tc.stop_span(discard: !accepted)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
106
158
|
end
|
|
107
159
|
end
|
|
108
160
|
end
|
data/lib/hegel/stateful.rb
CHANGED
|
@@ -43,7 +43,7 @@ module Hegel
|
|
|
43
43
|
|
|
44
44
|
invariants = machine.class.invariant_definitions
|
|
45
45
|
state_machine = tc.new_state_machine(
|
|
46
|
-
rules.keys, invariants.keys, invariants.values.map(&:always_run), step_count
|
|
46
|
+
rules.keys, rules.values.map(&:weight), invariants.keys, invariants.values.map(&:always_run), step_count
|
|
47
47
|
)
|
|
48
48
|
begin
|
|
49
49
|
tc.note { "Initial invariant check." }
|
|
@@ -81,7 +81,7 @@ module Hegel
|
|
|
81
81
|
name = rule_names[rule_index]
|
|
82
82
|
steps_attempted += 1
|
|
83
83
|
tc.note { "Step #{steps_attempted}: #{name}" }
|
|
84
|
-
rejected = true unless apply_rule(machine, rules.fetch(name), state_machine, tc)
|
|
84
|
+
rejected = true unless apply_rule(machine, rules.fetch(name).block, state_machine, tc)
|
|
85
85
|
end
|
|
86
86
|
tc.stop_span(discard: rejected)
|
|
87
87
|
run_sampled_invariants(machine, invariants, state_machine, tc)
|
data/lib/hegel/syntax/methods.rb
CHANGED
|
@@ -27,26 +27,34 @@ module Hegel
|
|
|
27
27
|
# A double in [min_value, max_value]. allow_nan and allow_infinity
|
|
28
28
|
# default to what the bounds imply: NaN when the caller passed
|
|
29
29
|
# neither bound, an infinity when the caller left either side open.
|
|
30
|
+
# allow_subnormal defaults to true whenever the bounds admit a
|
|
31
|
+
# subnormal.
|
|
30
32
|
def floats(min_value: nil, max_value: nil, allow_nan: nil, allow_infinity: nil, exclude_min: false,
|
|
31
|
-
exclude_max: false)
|
|
33
|
+
exclude_max: false, allow_subnormal: nil)
|
|
32
34
|
Generators::FloatGenerator.new(
|
|
33
35
|
min_value: min_value, max_value: max_value, allow_nan: allow_nan, allow_infinity: allow_infinity,
|
|
34
|
-
exclude_min: exclude_min, exclude_max: exclude_max
|
|
36
|
+
exclude_min: exclude_min, exclude_max: exclude_max, allow_subnormal: allow_subnormal
|
|
35
37
|
)
|
|
36
38
|
end
|
|
37
39
|
|
|
38
|
-
# A Unicode string of [min_size, max_size] characters
|
|
39
|
-
|
|
40
|
+
# A Unicode string of [min_size, max_size] characters; max_size
|
|
41
|
+
# defaults to 100, or min_size + 100 when min_size exceeds 100.
|
|
42
|
+
# +alphabet+ is a String of the permitted characters; it cannot be
|
|
43
|
+
# combined with the options after it.
|
|
44
|
+
def text(min_size: 0, max_size: nil, alphabet: nil, codec: nil, min_codepoint: nil, max_codepoint: nil,
|
|
45
|
+
categories: nil, exclude_categories: nil, include_characters: nil, exclude_characters: nil)
|
|
40
46
|
Generators::TextGenerator.new(
|
|
41
|
-
min_size: min_size, max_size: max_size, codec: codec,
|
|
42
|
-
min_codepoint: min_codepoint, max_codepoint: max_codepoint
|
|
47
|
+
min_size: min_size, max_size: max_size, alphabet: alphabet, codec: codec,
|
|
48
|
+
min_codepoint: min_codepoint, max_codepoint: max_codepoint, categories: categories,
|
|
49
|
+
exclude_categories: exclude_categories, include_characters: include_characters,
|
|
50
|
+
exclude_characters: exclude_characters
|
|
43
51
|
)
|
|
44
52
|
end
|
|
45
53
|
|
|
46
54
|
# An Array of values from +elements+, with [min_size, max_size]
|
|
47
|
-
# entries.
|
|
48
|
-
def arrays(elements, min_size: 0, max_size: nil)
|
|
49
|
-
Generators::ArrayGenerator.new(elements, min_size: min_size, max_size: max_size)
|
|
55
|
+
# entries. With unique: true, no two entries are equal.
|
|
56
|
+
def arrays(elements, min_size: 0, max_size: nil, unique: false)
|
|
57
|
+
Generators::ArrayGenerator.new(elements, min_size: min_size, max_size: max_size, unique: unique)
|
|
50
58
|
end
|
|
51
59
|
|
|
52
60
|
# Always +value+, drawing nothing.
|
|
@@ -59,6 +67,29 @@ module Hegel
|
|
|
59
67
|
Generators::SampledFromGenerator.new(collection)
|
|
60
68
|
end
|
|
61
69
|
|
|
70
|
+
# An Array holding every element of +elements+ (anything #sampled_from
|
|
71
|
+
# accepts) in an order the engine picks, shrinking towards the
|
|
72
|
+
# original one.
|
|
73
|
+
def permutations(elements)
|
|
74
|
+
Generators::PermutationsGenerator.new(elements)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# An Array holding [min_size, max_size] of the elements of +elements+,
|
|
78
|
+
# in their original order. Duplicate elements count as distinct.
|
|
79
|
+
def subsequences(elements, min_size: 0, max_size: nil)
|
|
80
|
+
Generators::SubsequencesGenerator.new(elements, min_size: min_size, max_size: max_size)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# An Array of [min_size, max_size] elements of +elements+. With
|
|
84
|
+
# +replacement+ (the default) each is drawn independently and the
|
|
85
|
+
# size is unbounded unless +max_size+ bounds it. Without it, each
|
|
86
|
+
# element is used at most once and the size is capped at the number
|
|
87
|
+
# of elements; hegel-rust names the two modes with_replacement and
|
|
88
|
+
# without_replacement.
|
|
89
|
+
def samples(elements, min_size: 0, max_size: nil, replacement: true)
|
|
90
|
+
Generators::SamplesGenerator.new(elements, min_size: min_size, max_size: max_size, replacement: replacement)
|
|
91
|
+
end
|
|
92
|
+
|
|
62
93
|
# A value drawn from one of +generators+, picked at random.
|
|
63
94
|
def one_of(*generators)
|
|
64
95
|
Generators::OneOfGenerator.new(generators)
|
|
@@ -88,20 +119,41 @@ module Hegel
|
|
|
88
119
|
|
|
89
120
|
# A String of exactly one character, sharing #text's own alphabet
|
|
90
121
|
# options.
|
|
91
|
-
def characters(codec: nil, min_codepoint: nil, max_codepoint: nil
|
|
92
|
-
|
|
122
|
+
def characters(codec: nil, min_codepoint: nil, max_codepoint: nil, categories: nil, exclude_categories: nil,
|
|
123
|
+
include_characters: nil, exclude_characters: nil)
|
|
124
|
+
Generators::CharactersGenerator.new(
|
|
125
|
+
codec: codec, min_codepoint: min_codepoint, max_codepoint: max_codepoint, categories: categories,
|
|
126
|
+
exclude_categories: exclude_categories, include_characters: include_characters,
|
|
127
|
+
exclude_characters: exclude_characters
|
|
128
|
+
)
|
|
93
129
|
end
|
|
94
130
|
|
|
95
|
-
# A byte String of [min_size, max_size] bytes
|
|
131
|
+
# A byte String of [min_size, max_size] bytes; max_size defaults as
|
|
132
|
+
# in #text.
|
|
96
133
|
def binary(min_size: 0, max_size: nil)
|
|
97
134
|
Generators::BinaryGenerator.new(min_size: min_size, max_size: max_size)
|
|
98
135
|
end
|
|
99
136
|
|
|
100
137
|
# A String matching +pattern+ (Python `re` syntax, not Ruby's
|
|
101
138
|
# Regexp syntax; see Generators::FromRegexGenerator). fullmatch
|
|
102
|
-
# requires the whole string to match, not just contain
|
|
103
|
-
|
|
104
|
-
|
|
139
|
+
# (the default) requires the whole string to match, not just contain
|
|
140
|
+
# a match.
|
|
141
|
+
# +alphabet+ is a #characters generator limiting every character of the
|
|
142
|
+
# string.
|
|
143
|
+
def from_regex(pattern, fullmatch: true, alphabet: nil)
|
|
144
|
+
Generators::FromRegexGenerator.new(pattern, fullmatch: fullmatch, alphabet: alphabet)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A String that +grammar+, written in the syntax of the Lark parsing
|
|
148
|
+
# library, accepts from its +start+ rule. +explicit+ maps terminal names
|
|
149
|
+
# to generators of the values drawn for them, +alphabet+ is a
|
|
150
|
+
# #characters generator limiting what terminal patterns draw, and
|
|
151
|
+
# +import_path+ is the directory %import reads files from.
|
|
152
|
+
def from_lark(grammar, start: nil, explicit: {}, alphabet: nil, max_size: nil, max_depth: nil, import_path: nil)
|
|
153
|
+
Generators::FromLarkGenerator.new(
|
|
154
|
+
grammar, start: start, explicit: explicit, alphabet: alphabet, max_size: max_size, max_depth: max_depth,
|
|
155
|
+
import_path: import_path
|
|
156
|
+
)
|
|
105
157
|
end
|
|
106
158
|
|
|
107
159
|
# An RFC 5321/5322 email address String.
|
|
@@ -152,6 +204,29 @@ module Hegel
|
|
|
152
204
|
Generators::DatetimesGenerator.new(min_value: min_value, max_value: max_value)
|
|
153
205
|
end
|
|
154
206
|
|
|
207
|
+
# A "YYYY-MM-DD" String, years 1 through 9999. Takes no options.
|
|
208
|
+
def date_strings
|
|
209
|
+
Generators::DateStringsGenerator.new
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# An "HH:MM:SS" String, with ".fffffffff" appended when the
|
|
213
|
+
# nanosecond is not zero. Takes no options.
|
|
214
|
+
def time_strings
|
|
215
|
+
Generators::TimeStringsGenerator.new
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# A "YYYY-MM-DDTHH:MM:SS[.fffffffff]" String, years 1 through 9999.
|
|
219
|
+
# Takes no options.
|
|
220
|
+
def datetime_strings
|
|
221
|
+
Generators::DatetimeStringsGenerator.new
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# A duration as Integer nanoseconds in [min_value, max_value], up to
|
|
225
|
+
# 2**64 - 1 (see Generators::DurationsGenerator and docs/adr/0030).
|
|
226
|
+
def durations(min_value: 0, max_value: (2**64) - 1)
|
|
227
|
+
Generators::DurationsGenerator.new(min_value: min_value, max_value: max_value)
|
|
228
|
+
end
|
|
229
|
+
|
|
155
230
|
# A value built from imperative code: +block+ receives a draw
|
|
156
231
|
# surface and may call #draw on it any number of times to assemble
|
|
157
232
|
# one value (see Generators::CompositeGenerator).
|
|
@@ -165,6 +240,15 @@ module Hegel
|
|
|
165
240
|
def deferred
|
|
166
241
|
Generators::DeferredGenerator.new
|
|
167
242
|
end
|
|
243
|
+
|
|
244
|
+
# A recursively defined value, such as a tree: either a value from
|
|
245
|
+
# +leaf+, or what the block's generator builds from sub-values of the
|
|
246
|
+
# same kind (see Generators::RecursiveGenerator). The block receives
|
|
247
|
+
# the generator of those sub-values and is called afresh for each
|
|
248
|
+
# branch.
|
|
249
|
+
def recursive(leaf, max_depth: 32, max_leaves: 100, &branch)
|
|
250
|
+
Generators::RecursiveGenerator.new(leaf, max_depth: max_depth, max_leaves: max_leaves, &branch)
|
|
251
|
+
end
|
|
168
252
|
end
|
|
169
253
|
end
|
|
170
254
|
|