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
@@ -4,9 +4,9 @@
4
4
 
5
5
  - [Setup](#setup)
6
6
  - [Test Structure](#test-structure): `Hegel.test`, its block, return value, failure behavior
7
- - [Settings](#settings): `test_cases:` `seed:` `derandomize:` `verbosity:` `database:` `database_key:` `phases:` `suppress_health_check:` `report_multiple_failures:` `output:` `reproduce_failure:`
8
- - [TestCase Methods](#testcase-methods): `draw`, `draw_integer`, `draw_boolean`, `assume`, `reject`, `note`, `target`
9
- - [Generator Reference](#generator-reference): `booleans`, `integers`, `floats`, `text`, `arrays`, `just`, `sampled_from`, `one_of`, `optional`, `tuples`, `sets`, `hashes`, `characters`, `binary`, `from_regex`, `emails`, `urls`, `domains`, `ip_addresses`, `uuids`, `dates`, `times`, `datetimes`, `composite`, `deferred`
7
+ - [Settings](#settings): `profile:` `test_cases:` `seed:` `derandomize:` `verbosity:` `database:` `database_key:` `phases:` `suppress_health_check:` `report_multiple_failures:` `stop_on_first_failure:` `print_blob:` `show_statistics:` `nondeterminism_strictness:` `backend:` `output:` `reproduce_failure:`
8
+ - [TestCase Methods](#testcase-methods): `draw`, `draw_integer`, `draw_boolean`, `assume`, `reject`, `note`, `repeat`, `target`, `event`, `event_value`
9
+ - [Generator Reference](#generator-reference): `booleans`, `integers`, `floats`, `text`, `arrays`, `just`, `sampled_from`, `permutations`, `subsequences`, `samples`, `one_of`, `optional`, `tuples`, `sets`, `hashes`, `characters`, `binary`, `from_regex`, `from_lark`, `emails`, `urls`, `domains`, `ip_addresses`, `uuids`, `dates`, `times`, `datetimes`, `date_strings`, `time_strings`, `datetime_strings`, `durations`, `composite`, `deferred`, `recursive`
10
10
  - [Stateful Testing](#stateful-testing): `Hegel::StateMachine`, `rule`, `invariant`, `Hegel::Stateful.run`, `Hegel::Stateful::Pool`
11
11
  - [Combinator Methods](#combinator-methods): `.map`, `.filter`
12
12
  - [Gotchas](#gotchas)
@@ -34,7 +34,7 @@ include Hegel::Syntax::Methods
34
34
  ```
35
35
 
36
36
  `Hegel::Syntax::Methods` makes every generator method (`booleans` through
37
- `deferred`; see [Generator Reference](#generator-reference)) callable bare,
37
+ `recursive`; see [Generator Reference](#generator-reference)) callable bare,
38
38
  the way FactoryBot's own `Syntax::Methods` makes `create` callable bare.
39
39
  With Minitest, include it once on the base test class:
40
40
 
@@ -142,6 +142,7 @@ the reproduction line.
142
142
 
143
143
  | Keyword | Type | Default | Purpose |
144
144
  |---|---|---|---|
145
+ | `profile` | `String` or `nil` | `nil` (the default profile) | Settings profile the run starts from: `"development"`, `"ci"`, `"workload"`, one in `hegel.toml`, or one from `Hegel::Settings.register_profile`; an unknown name raises `Hegel::Error`; a keyword passed alongside still wins |
145
146
  | `test_cases` | `Integer` or `nil` | `nil` (libhegel's own default: 100) | Number of test cases to run |
146
147
  | `seed` | `Integer` or `nil` | `nil` (libhegel picks its own) | Fixed RNG seed; pair with `derandomize: true` for a reproducible sequence |
147
148
  | `derandomize` | `true`/`false` or `nil` | `nil` (libhegel's own default) | Derive the seed deterministically from the test itself, instead of drawing a random one |
@@ -151,12 +152,19 @@ the reproduction line.
151
152
  | `phases` | `Array` of `Symbol` or `nil` | `nil` (libhegel's own default: every phase) | Which run phases to enable: `:explicit`, `:reuse`, `:generate`, `:target`, `:shrink` |
152
153
  | `suppress_health_check` | `Array` of `Symbol` or `nil` | `nil` (no suppression) | Which health checks to turn off: `:filter_too_much`, `:too_slow`, `:test_cases_too_large`, `:large_initial_test_case` |
153
154
  | `report_multiple_failures` | `true`/`false` | `false` | `true` summarizes every distinct failure into one `Hegel::Error` instead of re-raising a single failure's own exception |
155
+ | `stop_on_first_failure` | `true`/`false` or `nil` | `nil` (libhegel's own default: `true`) | `false` keeps generating after a failure and spends the whole `test_cases` budget before reporting |
156
+ | `print_blob` | `true`/`false` or `nil` | `nil` (libhegel's own default: `true`) | `false` leaves the `reproduce_failure:` line out of the failure report |
157
+ | `show_statistics` | `true`/`false` or `nil` | `nil` (libhegel's own default: `false`) | `true` prints the statistics `event` and `event_value` recorded, on stderr, at the end of the run |
158
+ | `nondeterminism_strictness` | `Symbol` or `nil` | `nil` (libhegel's own default: `:quiet`) | How a run reacts to a test that behaves differently on a replay: `:quiet`, `:warn` (print a notice), or `:error` (raise `Hegel::Error`) |
159
+ | `backend` | `Symbol` or `nil` | `nil` (libhegel's own default: `:default`) | `:urandom` takes fresh entropy on every draw, for Antithesis; `:default` is the seeded generator |
154
160
  | `output` | `IO` | `$stderr` | Where a failure report is written |
155
161
  | `reproduce_failure` | `String` or `nil` | `nil` | Replays the blob as a run until a replay fails, instead of running a full property |
156
162
 
157
163
  `nil` means the same thing for `test_cases`, `seed`, `derandomize`,
158
- `verbosity`, `phases`, and `suppress_health_check`: do not call the matching
159
- libhegel setter, and let the engine's settings profile decide. That is
164
+ `verbosity`, `phases`, `suppress_health_check`, `stop_on_first_failure`,
165
+ `print_blob`, `show_statistics`, `nondeterminism_strictness`, and `backend`:
166
+ do not call the matching libhegel setter, and let the engine's settings
167
+ profile decide. That is
160
168
  libhegel's own default, unless a `hegel.toml` or a `HEGEL_*` environment
161
169
  variable (such as `HEGEL_TEST_CASES`) sets the value. On a CI server the
162
170
  engine picks its `ci` profile, which derandomizes the run, turns the example
@@ -194,6 +202,21 @@ Hegel.test(phases: []) { |tc| tc.draw(integers) }
194
202
  # :reuse, :generate, :target, :shrink], got an empty Array"
195
203
  ```
196
204
 
205
+ `Hegel::Settings.register_profile(name, profile: nil, **keywords)` registers
206
+ the settings built from `profile` (default profile when `nil`) and the
207
+ keywords as a profile for the whole process. It takes the keywords above
208
+ except `database_key` (a profile does not carry it), and passes `database:`
209
+ straight to the engine (`""` disables the database). `report_multiple_failures:`
210
+ is applied only when given, and `Hegel.test` overrides it anyway.
211
+ `Hegel::Settings.set_default_profile(name)` makes a profile the default for
212
+ every run in the process, and `""` clears it. Neither changes a run that has
213
+ already started.
214
+
215
+ ```ruby
216
+ Hegel::Settings.register_profile("nightly", profile: "ci", test_cases: 10_000)
217
+ Hegel.test(profile: "nightly") { |tc| ... }
218
+ ```
219
+
197
220
  `report_multiple_failures:` defaults to `false`, and is always passed, so a
198
221
  profile cannot turn it on by accident. With the default `false`, a run
199
222
  stops at its first failing example and re-raises that failure's own
@@ -252,7 +275,10 @@ written to it.
252
275
  | `assume` | `assume(condition)` | Discard this test case (see `reject`) unless `condition` holds, read as Ruby truthiness |
253
276
  | `reject` | `reject` | Discard this test case unconditionally |
254
277
  | `note` | `note(message = nil, &block)` | Record `message` (or the block's return value) for the failure report, interleaved with draws in call order |
278
+ | `repeat` | `repeat { ... }` | Run the block again and again, for as many iterations as the engine chooses; never returns unless the block calls `break` |
255
279
  | `target` | `target(value, label: "")` | Feed `value` to libhegel's own hill-climbing search between generation rounds |
280
+ | `event` | `event(label)` | Record `label` for the statistics that `show_statistics: true` prints |
281
+ | `event_value` | `event_value(label, value)` | Record a numeric `value` under `label` for the same statistics |
256
282
 
257
283
  ```ruby
258
284
  result = Hegel.test(test_cases: 10, verbosity: :quiet) do |tc|
@@ -356,6 +382,48 @@ To reproduce this failure, pass the blob below to Hegel.test:
356
382
  reproduce_failure: "AAEAAAAACgIAAAD1AQ=="
357
383
  ```
358
384
 
385
+ `repeat` runs its block again and again, for as many iterations as the
386
+ engine chooses, and shrinks a failure toward fewer iterations. State kept
387
+ outside the block carries from one iteration to the next. The report shows
388
+ each iteration as a `// Repetition #N` line, with what the iteration drew or
389
+ noted indented under it:
390
+
391
+ ```ruby
392
+ output = StringIO.new
393
+ begin
394
+ Hegel.test(output: output, seed: 1, derandomize: true) do |tc|
395
+ stack = []
396
+ tc.repeat do
397
+ n = tc.draw(integers(min_value: 0, max_value: 10))
398
+ stack.push(n)
399
+ raise "stack sum too big: #{stack.sum}" if stack.sum > 10
400
+ end
401
+ end
402
+ rescue => e
403
+ e.message # => "stack sum too big: 11"
404
+ end
405
+ output.string
406
+ ```
407
+
408
+ ```
409
+ Falsified after 2 test cases (0 discarded):
410
+
411
+ // Repetition #1
412
+ n_1 = 1
413
+ // Repetition #2
414
+ n_2 = 10
415
+
416
+ To reproduce this failure, pass the blob below to Hegel.test:
417
+ reproduce_failure: "AXicY2MAAi5GEMnICGEwwhhAAgADbwA4"
418
+ ```
419
+
420
+ A `tc.assume` or `tc.reject` inside the block skips that iteration, not the
421
+ whole test case. When the engine ends the loop, `repeat` raises
422
+ `Hegel::LoopDone` instead of returning, and `Hegel.test` counts the test
423
+ case as passing; code after `tc.repeat` runs only when the block calls
424
+ `break`. `Hegel::LoopDone` descends from `Exception`, so `rescue => e` does
425
+ not catch it.
426
+
359
427
  `target` feeds `value` to libhegel's own hill-climbing search between
360
428
  generation rounds, under `label` (default `""`); it does not appear in the
361
429
  failure report. The same `label` used twice on one test case raises
@@ -377,6 +445,24 @@ Hegel.test(test_cases: 5, verbosity: :quiet) { |tc| tc.target(1, label: "score")
377
445
  # observed at most once per test case"
378
446
  ```
379
447
 
448
+ `event` and `event_value` record what a test reached, for the statistics
449
+ that `show_statistics: true` prints on stderr at the end of the run. `event`
450
+ counts the share of generated test cases that recorded a label. `event_value`
451
+ summarizes the values recorded under a label, which can be recorded any
452
+ number of times per test case. Neither steers generation, and neither
453
+ appears in the failure report. A non-finite `value` raises `Hegel::Error`:
454
+
455
+ ```ruby
456
+ Hegel.test(show_statistics: true) do |tc|
457
+ xs = tc.draw(arrays(integers))
458
+ tc.event("empty") if xs.empty?
459
+ tc.event_value("size", xs.size)
460
+ end
461
+ # Statistics (over 100 test cases):
462
+ # * empty: 6.0% of test cases
463
+ # * size: count 100, min 0, median 6, mean 7.99, p90 19, max 20
464
+ ```
465
+
380
466
  ## Generator Reference
381
467
 
382
468
  Every generator method lives in `Hegel::Syntax::Methods` (bare, once
@@ -435,7 +521,7 @@ Hegel.test(verbosity: :quiet) { |tc| tc.draw(integers(min_value: 10**30, max_val
435
521
  # => nil
436
522
  ```
437
523
 
438
- ### `floats(min_value: nil, max_value: nil, allow_nan: nil, allow_infinity: nil, exclude_min: false, exclude_max: false)`
524
+ ### `floats(min_value: nil, max_value: nil, allow_nan: nil, allow_infinity: nil, exclude_min: false, exclude_max: false, allow_subnormal: nil)`
439
525
 
440
526
  A double in `[min_value, max_value]`, unbounded by default.
441
527
 
@@ -445,6 +531,12 @@ side open, `false` when both bounds are set. hegel-rust and
445
531
  hegel-typescript's `floats()` default the same way. Pass `true` or `false` to
446
532
  override either (see [Gotchas](#gotchas)).
447
533
 
534
+ `allow_subnormal` defaults to `true` whenever the bounds admit a subnormal
535
+ (a double below `Float::MIN` in magnitude, other than zero), and to `false`
536
+ when every value the bounds allow is at least `Float::MIN` in magnitude.
537
+ Pass `false` for code that may run with flush-to-zero floating point, where
538
+ a subnormal input silently becomes zero.
539
+
448
540
  ```ruby
449
541
  Hegel.test(test_cases: 30, verbosity: :quiet) do |tc|
450
542
  f = tc.draw(floats(min_value: 0.0, max_value: 1.0, exclude_min: true, exclude_max: true))
@@ -465,10 +557,24 @@ Three combinations raise `Hegel::Error` at draw time:
465
557
  - `allow_infinity: true` with both bounds:
466
558
  `"floats: cannot have allow_infinity=true with both min_value and max_value"`
467
559
 
468
- ### `text(min_size: 0, max_size: nil, codec: nil, min_codepoint: nil, max_codepoint: nil)`
560
+ Three more concern `allow_subnormal`, each prefixed `"floats: InvalidArgument:
561
+ "`:
562
+
563
+ - `allow_subnormal: true` with a `min_value` of `Float::MIN` or more:
564
+ `"allow_subnormal=true, but min_value excludes all values below the
565
+ smallest positive normal 2.2250738585072014e-308"`
566
+ - `allow_subnormal: true` with a `max_value` of `-Float::MIN` or less:
567
+ `"allow_subnormal=true, but max_value excludes all values above the
568
+ smallest negative normal -2.2250738585072014e-308"`
569
+ - `allow_subnormal: false` with both bounds, no zero between them, and only
570
+ subnormals inside: `"allow_subnormal=false leaves no 64-bit floating-point
571
+ values in [5.0e-324, 1.0e-310]"`
572
+
573
+ ### `text(min_size: 0, max_size: nil, alphabet: nil, codec: nil, min_codepoint: nil, max_codepoint: nil, categories: nil, exclude_categories: nil, include_characters: nil, exclude_characters: nil)`
469
574
 
470
- A Unicode string of `[min_size, max_size]` characters, unbounded above by
471
- default.
575
+ A Unicode string of `[min_size, max_size]` characters. `max_size` defaults
576
+ to 100, as in hegel-rust, or to `min_size + 100` when `min_size` is above
577
+ 100.
472
578
 
473
579
  ```ruby
474
580
  Hegel.test(test_cases: 30, verbosity: :quiet) do |tc|
@@ -487,28 +593,71 @@ Hegel.test(test_cases: 30, verbosity: :quiet) do |tc|
487
593
  end
488
594
  ```
489
595
 
490
- `max_size < min_size` raises `Hegel::Error` at draw time: `"text: max_size <
491
- min_size"`. `alphabet:`, `categories:`, `include_characters:`, and
492
- `exclude_characters:` (character-filtering options other Hegel bindings
493
- expose) are not available in this binding; `codec:`, `min_codepoint:`, and
494
- `max_codepoint:` are the alphabet controls it exposes today.
596
+ The alphabet options select which characters a string can hold:
597
+
598
+ ```ruby
599
+ Hegel.test(test_cases: 30, verbosity: :quiet) do |tc|
600
+ s = tc.draw(text(alphabet: "ßaİ", max_size: 4))
601
+ raise "left the alphabet" unless s.match?(/\A[ßaİ]*\z/)
602
+ end
603
+
604
+ Hegel.test(test_cases: 30, verbosity: :quiet) do |tc|
605
+ s = tc.draw(text(categories: ["Lu"], min_size: 1, max_size: 5))
606
+ raise "not uppercase" unless s.match?(/\A\p{Lu}+\z/)
607
+ end
495
608
 
496
- Other generators cover most character-set jobs. These results were measured
497
- with libhegel 0.47.1:
609
+ Hegel.test(test_cases: 30, verbosity: :quiet) do |tc|
610
+ s = tc.draw(text(exclude_characters: ",\t\n", max_size: 6))
611
+ raise "drew an excluded character" if s.match?(/[,\t\n]/)
612
+ end
613
+ ```
614
+
615
+ - `alphabet:` is a `String` of the permitted characters. It cannot be combined
616
+ with any of the options below.
617
+ - `codec:` is `"ascii"`, `"latin-1"` (or `"iso-8859-1"`), or `"utf-8"`. It
618
+ sets the starting range, which `min_codepoint:` and `max_codepoint:`
619
+ narrow.
620
+ - `categories:` keeps the union of the named Unicode general categories
621
+ (`"Lu"`, `"Nd"`, and so on); `[]` is an empty alphabet. `exclude_categories:`
622
+ removes categories. When both are given, `categories:` wins and
623
+ `exclude_categories:` is ignored, as in hegel-rust.
624
+ - `include_characters:` adds characters to the alphabet whatever the other
625
+ options say; `exclude_characters:` removes them. A character in both raises
626
+ `Hegel::Error`.
627
+
628
+ Strings never hold surrogates, and `categories:` rejects `"Cs"` and `"C"`.
629
+ The engine's Unicode tables can be newer than Ruby's, so a check with
630
+ `\p{...}` in a test can disagree with the engine on a recently assigned
631
+ codepoint.
632
+
633
+ These cases raise `Hegel::Error` at draw time:
634
+
635
+ - `max_size < min_size`: `"text: max_size < min_size"`.
636
+ - An `alphabet:` beside another character option: `"text: Cannot combine
637
+ .alphabet() with character methods."`.
638
+ - An `alphabet:` that is not a `String`: `"text: alphabet must be a String,
639
+ not a Integer"`.
640
+ - An unknown codec: `"text: invalid codec: utf-16"`.
641
+ - A surrogate category in `categories:`: `"text: Category \"Cs\" includes
642
+ surrogate codepoints (Cs), which a valid UTF-8 String cannot hold."`.
643
+ - An unknown category, an empty range (`min_codepoint:` above
644
+ `max_codepoint:`, or every character excluded), and an overlap between
645
+ `include_characters:` and `exclude_characters:`: the engine writes the
646
+ message, as `"HEGEL_E_INVALID_ARG (-5): \"foo\" is not a valid Unicode
647
+ category"`.
648
+
649
+ A regular expression still suits a pattern that is more than an alphabet.
650
+ These results were measured with libhegel 0.47.1:
498
651
 
499
652
  | Job | Generator | Measured output |
500
653
  |---|---|---|
501
654
  | Characters to include | `from_regex("[ぁ-んA-Z0-9]{1,5}", fullmatch: true)` | `"0"`, `"D"`, `"Yもで"`; 30 draws all matched the pattern |
502
655
  | Characters to exclude | `from_regex("[^,\\t\\n]{0,6}", fullmatch: true)` | `""`, `"RÈbÁ¡"`; 30 draws contained no comma, tab, or newline |
503
- | A contiguous block | `text(min_codepoint: 0x300, max_codepoint: 0x36F)` | Every character of 100 draws was in U+0300 through U+036F |
504
- | An arbitrary alphabet | `arrays(sampled_from(%w[ß a İ]), max_size: 4).map(&:join)` | 30 draws, each built only from `ß`, `a`, and `İ` |
505
656
 
506
- The `categories:` option remains an open request. A category such as any `Lu`
507
- or any `Mn` spans codepoint ranges that a codepoint bound cannot select. A
508
- regular expression character class requires those ranges to be written out.
509
- The `.filter` method is not a substitute. In a measured run of 50 test cases,
510
- filtering `text` to `\p{Han}` produced no valid inputs. The engine filtered 50
511
- inputs and reported this health check:
657
+ `.filter` is not a substitute for an alphabet option. In a measured run of 50
658
+ test cases, filtering `text` to `\p{Han}` produced no valid inputs. A script
659
+ such as Han is not a general category, so `categories:` cannot select it
660
+ either. The engine filtered 50 inputs and reported this health check:
512
661
 
513
662
  ```
514
663
  FailedHealthCheck: FilterTooMuch — it looks like this test is filtering out
@@ -525,10 +674,11 @@ Unconfigured `text` draws from a much wider Unicode range. In 200 draws of
525
674
  characters, and control characters. The highest codepoint was U+10A78F. A
526
675
  property that accepts any string needs no character-set options.
527
676
 
528
- ### `arrays(elements, min_size: 0, max_size: nil)`
677
+ ### `arrays(elements, min_size: 0, max_size: nil, unique: false)`
529
678
 
530
679
  An `Array` of values from the `elements` generator, with `[min_size,
531
- max_size]` entries, unbounded above by default.
680
+ max_size]` entries, unbounded above by default. With `unique: true`, no two
681
+ entries are equal.
532
682
 
533
683
  ```ruby
534
684
  Hegel.test(test_cases: 30, verbosity: :quiet) do |tc|
@@ -539,9 +689,23 @@ end
539
689
 
540
690
  `max_size < min_size` raises `Hegel::Error` at draw time: `"arrays:
541
691
  max_size < min_size"`. A negative `min_size` also raises at draw time:
542
- `"arrays: min_size must not be negative"`. `unique:` (deduplicating
543
- elements) is not available on `arrays`; `sets` (below) draws distinct
544
- elements instead.
692
+ `"arrays: min_size must not be negative"`.
693
+
694
+ With `unique: true`, an element equal (`==`) to one already drawn is
695
+ rejected and drawn again, so `min_size` still counts distinct entries.
696
+ Three slots over three values hold each exactly once:
697
+
698
+ ```ruby
699
+ xs = nil
700
+ Hegel.test(test_cases: 1, verbosity: :quiet) do |tc|
701
+ xs = tc.draw(arrays(sampled_from([0, 1, 2]), min_size: 3, max_size: 3, unique: true))
702
+ end
703
+ xs.sort # => [0, 1, 2]
704
+ ```
705
+
706
+ A `min_size` the elements cannot satisfy, such as `arrays(just(1), min_size:
707
+ 5, unique: true)`, ends the run with a `Hegel::Error` from the FilterTooMuch
708
+ health check.
545
709
 
546
710
  ### `just(value)`
547
711
 
@@ -570,6 +734,62 @@ result # => nil
570
734
  An empty `collection` raises `Hegel::Error` at draw time:
571
735
  `"sampled_from: collection must not be empty"`.
572
736
 
737
+ ### `permutations(elements)`
738
+
739
+ An `Array` holding every element of `elements` in an order Hegel picks,
740
+ shrinking towards the original order. `elements` is anything `sampled_from`
741
+ accepts.
742
+
743
+ ```ruby
744
+ seen = []
745
+ Hegel.test(test_cases: 3, seed: 1, derandomize: true, verbosity: :quiet) do |tc|
746
+ seen << tc.draw(permutations([1, 2, 3]))
747
+ end
748
+ seen # => [[1, 2, 3], [2, 3, 1], [3, 2, 1]]
749
+ ```
750
+
751
+ ### `subsequences(elements, min_size: 0, max_size: nil)`
752
+
753
+ An `Array` of `[min_size, max_size]` of the `elements`, in their original
754
+ order. Duplicate elements count as distinct.
755
+
756
+ ```ruby
757
+ seen = []
758
+ Hegel.test(test_cases: 3, seed: 1, derandomize: true, verbosity: :quiet) do |tc|
759
+ seen << tc.draw(subsequences([1, 2, 3, 4], min_size: 1))
760
+ end
761
+ seen # => [[1], [3], [1, 2, 3, 4]]
762
+ ```
763
+
764
+ `max_size < min_size` raises `Hegel::Error` at draw time: `"subsequences:
765
+ Cannot have max_size < min_size"`. So does a `min_size` above the number of
766
+ elements: `"subsequences: Cannot generate a subsequence: min_size 4 is larger
767
+ than the 3 elements in the sequence"`.
768
+
769
+ ### `samples(elements, min_size: 0, max_size: nil, replacement: true)`
770
+
771
+ An `Array` of `[min_size, max_size]` of the `elements`, in the order drawn.
772
+ With `replacement: true` each is drawn independently from all of `elements`,
773
+ so a value can repeat, and the size is unbounded unless `max_size` bounds
774
+ it. With `replacement: false` each element is used at most once, duplicates
775
+ count as distinct, and the size is capped at the number of elements.
776
+
777
+ ```ruby
778
+ seen = []
779
+ Hegel.test(test_cases: 3, seed: 1, derandomize: true, verbosity: :quiet) do |tc|
780
+ seen << tc.draw(samples([1, 2, 3], max_size: 4))
781
+ end
782
+ seen # => [[], [1, 3], []]
783
+ ```
784
+
785
+ Three errors are raised at draw time, each beginning `"samples: "`:
786
+ `max_size < min_size` gives `"Cannot have max_size < min_size"`; a
787
+ non-empty sample from an empty `elements` gives `"Cannot generate a
788
+ non-empty sample from an empty sequence"`; and with `replacement: false`, a
789
+ `min_size` above the number of elements gives `"Cannot generate a sample
790
+ without replacement: min_size 4 is larger than the 3 elements in the
791
+ sequence"`.
792
+
573
793
  ### `one_of(*generators)`
574
794
 
575
795
  A value drawn from one of `generators`, picked at random.
@@ -677,9 +897,10 @@ seen.to_a # => [[5, false], [3, false]]
677
897
  max_size < min_size"`. A negative `min_size` also raises at draw time:
678
898
  `"hashes: min_size must not be negative"`.
679
899
 
680
- ### `characters(codec: nil, min_codepoint: nil, max_codepoint: nil)`
900
+ ### `characters(codec: nil, min_codepoint: nil, max_codepoint: nil, categories: nil, exclude_categories: nil, include_characters: nil, exclude_characters: nil)`
681
901
 
682
902
  A `String` of exactly one character, sharing `text`'s own alphabet options.
903
+ Pass it as `from_regex(alphabet:)` to limit the characters of a match.
683
904
 
684
905
  ```ruby
685
906
  result = Hegel.test(test_cases: 20, verbosity: :quiet) do |tc|
@@ -695,10 +916,13 @@ end
695
916
  seen # => ["A", "Z", "E", "T", "G"]
696
917
  ```
697
918
 
919
+ The options and their draw-time errors are the ones listed under `text`,
920
+ with `characters:` in place of `text:` in each message.
921
+
698
922
  ### `binary(min_size: 0, max_size: nil)`
699
923
 
700
- A byte `String` of `[min_size, max_size]` bytes, unbounded above by
701
- default.
924
+ A byte `String` of `[min_size, max_size]` bytes. `max_size` defaults to 100,
925
+ as in hegel-rust, or to `min_size + 100` when `min_size` is above 100.
702
926
 
703
927
  ```ruby
704
928
  result = Hegel.test(test_cases: 20, verbosity: :quiet) do |tc|
@@ -717,22 +941,24 @@ seen # => ["\x00\x00", "\xCA\x95", "\xD5N"]
717
941
  `max_size < min_size` raises `Hegel::Error` at draw time: `"binary:
718
942
  max_size < min_size"`.
719
943
 
720
- ### `from_regex(pattern, fullmatch: false)`
944
+ ### `from_regex(pattern, fullmatch: true, alphabet: nil)`
721
945
 
722
946
  A `String` matching `pattern` (Python `re` syntax, not Ruby's `Regexp`
723
- syntax). `fullmatch` requires the whole string to match, not just contain
724
- a match.
947
+ syntax). `fullmatch`, true by default as in hegel-rust, requires the whole
948
+ string to match; `fullmatch: false` lets the string merely contain a match.
949
+ `alphabet` is a `characters` generator that limits every character of the
950
+ string, padding around a partial match included.
725
951
 
726
952
  ```ruby
727
953
  result = Hegel.test(test_cases: 20, verbosity: :quiet) do |tc|
728
- v = tc.draw(from_regex("[a-z]{3}"))
954
+ v = tc.draw(from_regex("[a-z]{3}", fullmatch: false))
729
955
  raise "no match" unless v.match?(/[a-z]{3}/)
730
956
  end
731
957
  result # => nil
732
958
 
733
959
  seen = []
734
960
  Hegel.test(test_cases: 3, seed: 1, derandomize: true, verbosity: :quiet) do |tc|
735
- seen << tc.draw(from_regex("[a-z]{3}", fullmatch: true))
961
+ seen << tc.draw(from_regex("[a-z]{3}"))
736
962
  end
737
963
  seen # => ["aaa", "pgq", "iqy"]
738
964
  ```
@@ -744,6 +970,18 @@ translated): `from_regex(/[a-z]{3}/)` raises `Hegel::Error` at draw time,
744
970
  Regexp"`. Pass `my_regexp.source` explicitly for a pattern confirmed to
745
971
  need no flags and to use only syntax the two grammars share.
746
972
 
973
+ ```ruby
974
+ Hegel.test(test_cases: 30, verbosity: :quiet) do |tc|
975
+ v = tc.draw(from_regex("[a-z]+", fullmatch: true, alphabet: characters(codec: "ascii")))
976
+ raise "no match" unless v.match?(/\A[a-z]+\z/)
977
+ end
978
+ ```
979
+
980
+ An `alphabet` that is not a `characters` generator raises `Hegel::Error` at
981
+ draw time: `"from_regex: alphabet must be a characters generator, not a
982
+ String"`. A pattern that cannot match any string over the alphabet raises
983
+ with the engine's message, `"... matches no string over the alphabet ..."`.
984
+
747
985
  A pattern that no string matches, such as `"(?!)"`, also raises
748
986
  `Hegel::Error` at draw time. The engine writes the message, and it names
749
987
  the pattern: `from_regex("(?!)")` raises
@@ -751,6 +989,68 @@ the pattern: `from_regex("(?!)")` raises
751
989
  generator can produce"`. Since libhegel 0.45.7 the engine makes this
752
990
  check.
753
991
 
992
+ ### `from_lark(grammar, start: nil, explicit: {}, alphabet: nil, max_size: nil, max_depth: nil, import_path: nil)`
993
+
994
+ A `String` that `grammar`, written in the syntax of the
995
+ [Lark](https://lark-parser.readthedocs.io/en/stable/grammar.html) parsing
996
+ library, accepts from its `start` rule (`"start"` when `start` is nil).
997
+ `%import` of Lark's bundled `common`, `python`, `unicode`, and `lark`
998
+ grammars works without `import_path`; `import_path` (a `String` or a
999
+ `Pathname`) is the one directory other imports are read from, `%import
1000
+ a.b.X` reading `a/b.lark` there. Strings run up to 8192 characters by
1001
+ default; `max_size` caps the characters (at most 1048576) and `max_depth`
1002
+ how deeply rules nest (default 200, at most 500). `alphabet` is a
1003
+ `characters` generator limiting what a terminal's pattern draws.
1004
+
1005
+ ```ruby
1006
+ INT_LIST = "start: INT (\",\" INT)*\n%import common.INT\n"
1007
+
1008
+ result = Hegel.test(test_cases: 20, verbosity: :quiet) do |tc|
1009
+ v = tc.draw(from_lark(INT_LIST))
1010
+ raise "not an int list" unless v.match?(/\A\d+(,\d+)*\z/)
1011
+ end
1012
+ result # => nil
1013
+
1014
+ seen = []
1015
+ Hegel.test(test_cases: 3, seed: 1, derandomize: true, verbosity: :quiet) do |tc|
1016
+ seen << tc.draw(from_lark(INT_LIST, max_size: 12))
1017
+ end
1018
+ seen # => ["0", "7", "62010192143"]
1019
+ ```
1020
+
1021
+ `explicit` maps a terminal's name, a `String` or a `Symbol`, to a generator
1022
+ of `String`s drawn in its place. Every terminal the grammar `%declare`s
1023
+ needs one; any other terminal may be given one to override its pattern.
1024
+ The values count toward `max_size`: a string they push over it is drawn
1025
+ again with less room for the grammar's own text, and after three tries the
1026
+ test case is rejected, as a failed `assume` is.
1027
+
1028
+ ```ruby
1029
+ seen = []
1030
+ Hegel.test(test_cases: 3, seed: 1, derandomize: true, verbosity: :quiet) do |tc|
1031
+ seen << tc.draw(from_lark("start: WORD \"=\" NUM\n%declare NUM\nWORD: /[a-z]+/\n",
1032
+ explicit: {NUM: integers(min_value: 0, max_value: 99).map(&:to_s)}, max_size: 10))
1033
+ end
1034
+ seen # => ["a=0", "t=99", "tokndf=53"]
1035
+ ```
1036
+
1037
+ The grammar is compiled on the first draw of a run and kept until the run
1038
+ ends, so a `from_lark` built inside the test body compiles once per run too.
1039
+
1040
+ Each of these raises `Hegel::Error` at draw time, with a message starting
1041
+ `"from_lark: "`: a `grammar` or `start` that is not a `String`;
1042
+ `"max_size must be at least 1"` and `"max_depth must be at least 1"`; an
1043
+ `explicit` that is not a `Hash`, a name that is not a `String` or a
1044
+ `Symbol`, or a value that is not a generator; `"explicit terminal 'NUM' is
1045
+ given more than one generator"`, for `{:NUM => g, "NUM" => h}`; an `alphabet`
1046
+ that is not a `characters` generator; an `import_path` that is not a
1047
+ `String` or a `Pathname`; an explicit generator that draws something other
1048
+ than a `String`; and an import that exists but cannot be read, or is not
1049
+ UTF-8 (`"Could not read grammar"`, naming the file). A grammar the engine rejects
1050
+ raises with the engine's message: `from_lark("start: NUM\n%declare NUM\n")`
1051
+ raises `"HEGEL_E_INVALID_ARG (-5): Terminal 'NUM' is declared but has no
1052
+ definition: supply a generator for it with explicit"`.
1053
+
754
1054
  ### `emails`
755
1055
 
756
1056
  An RFC 5321/5322 email address `String`.
@@ -930,6 +1230,72 @@ seen # => ["2020-01-01 00:00:00 UTC", "2020-07-23 00:00:00 UTC", "2020-04-28 00:
930
1230
  `max_value < min_value` raises `Hegel::Error` at draw time: `"datetimes:
931
1231
  max_value < min_value"`.
932
1232
 
1233
+ ### `date_strings`
1234
+
1235
+ A `"YYYY-MM-DD"` String over year 1 through year 9999. It takes no options.
1236
+
1237
+ ```ruby
1238
+ seen = []
1239
+ Hegel.test(test_cases: 3, seed: 1, derandomize: true, verbosity: :quiet) do |tc|
1240
+ seen << tc.draw(date_strings)
1241
+ end
1242
+ seen # => ["2000-01-01", "2000-08-26", "2028-11-11"]
1243
+ ```
1244
+
1245
+ ### `time_strings`
1246
+
1247
+ An `"HH:MM:SS"` String over the whole day, with `".fffffffff"` (nine digits)
1248
+ appended when the nanosecond is not zero. It takes no options. Unlike
1249
+ `times`, it keeps the engine's nanoseconds.
1250
+
1251
+ ```ruby
1252
+ seen = []
1253
+ Hegel.test(test_cases: 3, seed: 1, derandomize: true, verbosity: :quiet) do |tc|
1254
+ seen << tc.draw(time_strings)
1255
+ end
1256
+ seen # => ["00:00:00", "00:00:00.000080023", "00:00:00.030484623"]
1257
+ ```
1258
+
1259
+ ### `datetime_strings`
1260
+
1261
+ A `"YYYY-MM-DDTHH:MM:SS"` String over year 1 through year 9999, formatted as
1262
+ `date_strings` and `time_strings` are, with no timezone. It takes no options.
1263
+
1264
+ ```ruby
1265
+ seen = []
1266
+ Hegel.test(test_cases: 3, seed: 1, derandomize: true, verbosity: :quiet) do |tc|
1267
+ seen << tc.draw(datetime_strings)
1268
+ end
1269
+ seen # => ["2000-01-01T00:00:00", "2000-08-26T00:00:00.000002047", "2028-11-11T00:00:00.000383651"]
1270
+ ```
1271
+
1272
+ ### `durations(min_value: 0, max_value: 2**64 - 1)`
1273
+
1274
+ A duration as an `Integer` count of nanoseconds in `[min_value,
1275
+ max_value]`. The largest duration is `2**64 - 1` nanoseconds, about 584
1276
+ years. `Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)` returns
1277
+ the same unit.
1278
+
1279
+ ```ruby
1280
+ Hegel.test(test_cases: 20, verbosity: :quiet) do |tc|
1281
+ v = tc.draw(durations(min_value: 1_000_000_000, max_value: 60_000_000_000))
1282
+ raise "out of range" unless v.between?(1_000_000_000, 60_000_000_000)
1283
+ end
1284
+
1285
+ seen = []
1286
+ Hegel.test(test_cases: 3, seed: 1, derandomize: true, verbosity: :quiet) do |tc|
1287
+ seen << tc.draw(durations(min_value: 1_000_000_000, max_value: 60_000_000_000))
1288
+ end
1289
+ seen # => [1000000000, 7679018401, 21643530102]
1290
+ ```
1291
+
1292
+ A `max_value` above `2**64 - 1` is treated as `2**64 - 1`. A `min_value`
1293
+ above it raises `Hegel::Error` at draw time: `"durations: min_value exceeds
1294
+ the largest generatable Duration (u64::MAX nanoseconds, about 584 years)"`.
1295
+ `max_value < min_value` raises `"durations: Cannot have max_value <
1296
+ min_value"`, and a negative `min_value` raises `"durations: min_value must
1297
+ not be negative"`.
1298
+
933
1299
  ### `composite(&block)`
934
1300
 
935
1301
  A value built from imperative code: `block` receives a draw surface and
@@ -989,6 +1355,52 @@ Drawing from it before `#set` raises `Hegel::Error` at draw time:
989
1355
  `"deferred: draw called before set"`. Calling `#set` a second time raises
990
1356
  immediately (not at draw time): `"deferred: set called more than once"`.
991
1357
 
1358
+ ### `recursive(leaf, max_depth: 32, max_leaves: 100, &branch)`
1359
+
1360
+ A recursively defined value, such as a tree: either a value from `leaf`, or
1361
+ what the block's generator builds from sub-values of the same kind. The
1362
+ block receives the generator of those sub-values and returns a generator
1363
+ that combines some number of them. It is called afresh for each branch.
1364
+
1365
+ ```ruby
1366
+ tree = recursive(integers(min_value: 0, max_value: 10), max_leaves: 10) do |subtrees|
1367
+ arrays(subtrees, max_size: 3)
1368
+ end
1369
+
1370
+ result = Hegel.test(test_cases: 20, verbosity: :quiet) do |tc|
1371
+ v = tc.draw(tree)
1372
+ raise "wrong type" unless v.is_a?(Integer) || v.is_a?(Array)
1373
+ end
1374
+ result # => nil
1375
+
1376
+ seen = []
1377
+ Hegel.test(test_cases: 3, seed: 1, derandomize: true, verbosity: :quiet) do |tc|
1378
+ seen << tc.draw(tree)
1379
+ end
1380
+ seen # => [0, [[]], []]
1381
+ ```
1382
+
1383
+ The engine decides each leaf-or-branch choice. `max_depth` caps how deeply
1384
+ branches nest; at that depth every sub-value is a leaf, so `max_depth: 0`
1385
+ draws only leaves. An attempt that draws more than `max_leaves` leaves is
1386
+ discarded and drawn again, and the test case is discarded, as by `assume`,
1387
+ when several attempts in a row do not fit. A tree shrinks toward one of its
1388
+ own subtrees.
1389
+
1390
+ Draw the sub-values only through the generator the block returns. Drawing
1391
+ the block's argument after the recursive draw has ended raises
1392
+ `Hegel::Error`: `"recursive: a subtree generator was drawn outside the draw
1393
+ that made it"`. These raise at draw time:
1394
+
1395
+ - no block: `"recursive: block is required"`
1396
+ - a `leaf` that is not a generator: `"recursive: leaf must be a generator,
1397
+ got 5"`
1398
+ - a bound that is not an Integer from 0 to 2\*\*64 - 1: `"recursive:
1399
+ max_depth must be an Integer between 0 and 2**64 - 1, got -1"`, and the
1400
+ same for `max_leaves`
1401
+ - a block that returns something other than a generator: `"recursive: the
1402
+ block must return a generator, got 5"`
1403
+
992
1404
  ## Stateful Testing
993
1405
 
994
1406
  A stateful test compares a real object against a simplified model of it,
@@ -1000,7 +1412,7 @@ block: `Hegel.test { |tc| Hegel::Stateful.run(machine, tc) }` (see
1000
1412
 
1001
1413
  ### Declaring a machine
1002
1414
 
1003
- `rule(name, &block)` and `invariant(name, &block)` are class-level
1415
+ `rule(name, weight: 1.0, &block)` and `invariant(name, &block)` are class-level
1004
1416
  declarations. `block` runs via `instance_exec` against the machine instance
1005
1417
  under test, and is handed the running `Hegel::TestCase` as its one argument
1006
1418
  (ignored if the block takes none). So it reads and writes the machine's own
@@ -1074,6 +1486,13 @@ A test case with more than one declared rule enables only a random subset of
1074
1486
  them, and picks each step from that subset. So a machine with two rules can
1075
1487
  run a case where one of them never appears at all.
1076
1488
 
1489
+ `rule(:name, weight: 2.5)` makes the engine pick a rule more often than the
1490
+ others in the cases that enable it. The weight must be a finite number above
1491
+ zero. A weight that is not a number raises `Hegel::Error` at declaration, and
1492
+ one that is zero, negative, NaN, or infinite raises it when the machine runs.
1493
+ It is a hint, not a distribution: which rules a case enables also decides how
1494
+ often each runs.
1495
+
1077
1496
  Inside a rule, `tc.assume(false)` means something narrower than it does
1078
1497
  everywhere else in this library: it discards only that one rule application
1079
1498
  (the loop moves on and tries another rule), not the whole test case, unlike
@@ -1152,7 +1571,9 @@ pool = Hegel::Stateful::Pool.new(tc)
1152
1571
  - `#values_reusable` is a `Hegel::Generator` over the pool's values that
1153
1572
  leaves the chosen value in place, so it can be drawn again.
1154
1573
  - `#values_consumed` is a `Hegel::Generator` over the pool's values that
1155
- removes the chosen value, so it is never drawn again.
1574
+ removes the chosen value, so it is never drawn again. A `.filter` on it
1575
+ consumes only the value the predicate accepts: the values it rejected go
1576
+ back into the pool.
1156
1577
  - `#size` and `#empty?` read the pool's own count directly.
1157
1578
 
1158
1579
  ```ruby
@@ -1206,7 +1627,8 @@ end
1206
1627
  ```
1207
1628
 
1208
1629
  `.filter` retries its source generator up to 3 times per draw, then
1209
- discards the test case. A predicate that (almost) never holds does not loop
1630
+ discards the test case. A filter over a filter spends those 3 attempts
1631
+ between the two predicates, not 3 per filter. A predicate that (almost) never holds does not loop
1210
1632
  forever: libhegel's own health check aborts a run that discards too many
1211
1633
  test cases in a row, surfacing as `Hegel::Error`:
1212
1634