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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +182 -0
  3. data/README.ja.md +408 -0
  4. data/README.md +208 -102
  5. data/Rakefile +6 -3
  6. data/docs/README.md +9 -0
  7. data/docs/adr/0027-report-each-runs-test-location-from-the-callers-frame.md +63 -0
  8. data/docs/adr/0028-expose-settings-profiles-as-a-keyword-and-module-functions.md +91 -0
  9. data/docs/adr/0029-keep-notes-local-and-defer-run-error-kinds.md +72 -0
  10. data/docs/adr/0030-represent-a-duration-as-integer-nanoseconds.md +48 -0
  11. data/docs/adr/0031-take-the-alphabet-options-from-hegel-rust.md +52 -0
  12. data/docs/adr/0032-suppress-span-closes-while-an-abandoned-recursion-attempt-unwinds.md +147 -0
  13. data/docs/adr/0033-cache-compiled-grammars-for-the-length-of-a-run.md +80 -0
  14. data/docs/adr/0034-repeat-a-test-body-with-a-ruby-side-indent.md +120 -0
  15. data/docs/adr/0035-defer-concurrent-state-machines-and-the-engine-printer.md +136 -0
  16. data/docs/architecture.md +16 -1
  17. data/lib/hegel/errors.rb +24 -0
  18. data/lib/hegel/generator.rb +52 -25
  19. data/lib/hegel/generators.rb +715 -45
  20. data/lib/hegel/grammar_cache.rb +36 -0
  21. data/lib/hegel/lib_hegel/real.rb +320 -17
  22. data/lib/hegel/lib_hegel.rb +31 -6
  23. data/lib/hegel/libhegel_version.rb +1 -1
  24. data/lib/hegel/report.rb +13 -10
  25. data/lib/hegel/runner.rb +61 -27
  26. data/lib/hegel/settings.rb +92 -16
  27. data/lib/hegel/state_machine.rb +17 -4
  28. data/lib/hegel/stateful/pool.rb +52 -0
  29. data/lib/hegel/stateful.rb +2 -2
  30. data/lib/hegel/syntax/methods.rb +99 -15
  31. data/lib/hegel/test_case.rb +256 -35
  32. data/lib/hegel/test_location.rb +49 -0
  33. data/lib/hegel/version.rb +1 -1
  34. data/lib/hegel.rb +21 -5
  35. data/sig/hegel.rbs +295 -42
  36. data/skills/hegel-ruby/references/ruby/reference.md +463 -41
  37. 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 +test_cases+,
101
- # +seed+, +derandomize+, +verbosity+, +database+, +database_key+,
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
- def run(impl:, test_cases: nil, seed: nil, derandomize: nil, verbosity: nil, database: nil, database_key: nil,
134
- phases: nil, suppress_health_check: nil, report_multiple_failures: false,
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 = impl.settings_new(ctx)
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
- run_and_finish(impl, ctx, run, reproducing: !reproduce_failure.nil?, quiet: quiet, output: output, &block)
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, output: output)
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.47.1, a
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 811 to 1847 iterations. +stats+ does its own, different counting
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
- rescue Hegel::AssumeFailed
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
@@ -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+. +test_cases+, +seed+, +derandomize+, +verbosity+, +phases+, and
48
- # +suppress_health_check+ all skip their setter when left nil.
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, test_cases:, seed:, derandomize:, verbosity:, database:, database_key:, phases:,
57
- suppress_health_check:, report_multiple_failures:)
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
- impl.settings_set_report_multiple_failures(ctx, settings, report_multiple_failures)
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
- code = VERBOSITY_CODES.fetch(verbosity) do
72
- raise Hegel::Error,
73
- "hegel: unknown verbosity #{verbosity.inspect}; expected one of #{VERBOSITY_CODES.keys.inspect}"
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) do |mask, value|
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
@@ -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
- def rule(name, &block)
37
- declare(:@rules, "rule", name, block)
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 => block, in declaration order, this class's own declarations
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 list libhegel indexes by position.
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
@@ -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
@@ -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)
@@ -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
- def text(min_size: 0, max_size: nil, codec: nil, min_codepoint: nil, max_codepoint: nil)
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
- Generators::CharactersGenerator.new(codec: codec, min_codepoint: min_codepoint, max_codepoint: max_codepoint)
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 a match.
103
- def from_regex(pattern, fullmatch: false)
104
- Generators::FromRegexGenerator.new(pattern, fullmatch: fullmatch)
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