hegeltest 0.1.1 → 0.2.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +32 -9
  4. data/Rakefile +81 -0
  5. data/docs/README.md +9 -0
  6. data/docs/adr/0015-follow-the-hegeldev-interface-and-take-changes-upstream-first.md +101 -0
  7. data/docs/adr/0016-run-mutation-testing-with-mutineer.md +87 -0
  8. data/docs/adr/0017-raise-the-ruby-floor-to-3-4.md +43 -0
  9. data/docs/adr/0018-gate-mutation-testing-on-a-committed-baseline.md +74 -0
  10. data/docs/adr/0019-report-failures-from-the-cases-the-engine-stamps.md +70 -0
  11. data/docs/adr/0020-derive-span-labels-from-generator-names.md +64 -0
  12. data/docs/adr/0021-run-a-state-machine-in-rounds-with-its-own-step-count.md +59 -0
  13. data/docs/adr/0022-keep-microsecond-times-over-a-nanosecond-engine.md +45 -0
  14. data/docs/adr/0023-leave-unset-settings-to-the-engines-profile.md +50 -0
  15. data/docs/architecture.md +11 -6
  16. data/lib/hegel/draw_name.rb +3 -1
  17. data/lib/hegel/generator.rb +50 -9
  18. data/lib/hegel/generators.rb +134 -102
  19. data/lib/hegel/lib_hegel/real.rb +193 -94
  20. data/lib/hegel/lib_hegel.rb +30 -56
  21. data/lib/hegel/libhegel_version.rb +1 -1
  22. data/lib/hegel/report.rb +17 -8
  23. data/lib/hegel/runner.rb +153 -201
  24. data/lib/hegel/settings.rb +9 -14
  25. data/lib/hegel/state_machine.rb +15 -8
  26. data/lib/hegel/stateful/pool.rb +0 -2
  27. data/lib/hegel/stateful.rb +64 -36
  28. data/lib/hegel/syntax/methods.rb +3 -2
  29. data/lib/hegel/test_case.rb +22 -8
  30. data/lib/hegel/version.rb +1 -1
  31. data/lib/hegel.rb +11 -17
  32. data/lib/tasks/libhegel.rake +9 -2
  33. data/sig/hegel.rbs +58 -60
  34. data/skills/hegel-ruby/references/ruby/reference.md +96 -52
  35. metadata +11 -2
@@ -4,7 +4,7 @@
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:` `stateful_step_count:` `output:` `reproduce_failure:`
7
+ - [Settings](#settings): `test_cases:` `seed:` `derandomize:` `verbosity:` `database:` `database_key:` `phases:` `suppress_health_check:` `report_multiple_failures:` `output:` `reproduce_failure:`
8
8
  - [TestCase Methods](#testcase-methods): `draw`, `draw_integer`, `draw_boolean`, `assume`, `reject`, `note`, `target`
9
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`
10
10
  - [Stateful Testing](#stateful-testing): `Hegel::StateMachine`, `rule`, `invariant`, `Hegel::Stateful.run`, `Hegel::Stateful::Pool`
@@ -104,7 +104,7 @@ output.string
104
104
  `output.string` holds:
105
105
 
106
106
  ```
107
- Falsified after 3 test cases (0 discarded):
107
+ Falsified after 12 test cases (0 discarded):
108
108
 
109
109
  xs = [0, 0]
110
110
 
@@ -114,7 +114,9 @@ To reproduce this failure, pass the blob below to Hegel.test:
114
114
 
115
115
  Hegel names each drawn value (`xs` above) by reading the line the `draw`
116
116
  call was written on. Pass the blob back through `reproduce_failure:` to
117
- replay that exact failing case without a full run:
117
+ replay that failure without a full run. The engine replays it until a
118
+ replay fails, within its own budget, and raises `Hegel::Error` when none
119
+ does:
118
120
 
119
121
  ```ruby
120
122
  Hegel.test(reproduce_failure: "AXicY2VgYGBkZOBiZEBhMAAAAd8AIQ==", verbosity: :quiet) do |tc|
@@ -123,6 +125,17 @@ Hegel.test(reproduce_failure: "AXicY2VgYGBkZOBiZEBhMAAAAd8AIQ==", verbosity: :qu
123
125
  end
124
126
  ```
125
127
 
128
+ A body that fails only some of the time is reported like any other failure,
129
+ with one more line from the engine that says how reliably it reproduced:
130
+
131
+ ```
132
+ note: nondeterministic failure, confirmed: failed 6 of 20 replays at confirmation and 1 of 3 at report time
133
+ ```
134
+
135
+ The blob of such a failure still works with `reproduce_failure:`. A failure
136
+ the engine could not confirm comes with no blob, and its report leaves out
137
+ the reproduction line.
138
+
126
139
  ## Settings
127
140
 
128
141
  `Hegel.test` takes these keywords, all optional:
@@ -138,15 +151,19 @@ end
138
151
  | `phases` | `Array` of `Symbol` or `nil` | `nil` (libhegel's own default: every phase) | Which run phases to enable: `:explicit`, `:reuse`, `:generate`, `:target`, `:shrink` |
139
152
  | `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` |
140
153
  | `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 |
141
- | `stateful_step_count` | `Integer` or `nil` | `nil` (libhegel's own default: 50) | Steps per test case a `Hegel::Stateful.run` call applies, for a stateful test |
142
154
  | `output` | `IO` | `$stderr` | Where a failure report is written |
143
- | `reproduce_failure` | `String` or `nil` | `nil` | Replays the single case the blob encodes, instead of running a full property |
155
+ | `reproduce_failure` | `String` or `nil` | `nil` | Replays the blob as a run until a replay fails, instead of running a full property |
144
156
 
145
157
  `nil` means the same thing for `test_cases`, `seed`, `derandomize`,
146
- `verbosity`, `phases`, `suppress_health_check`, and `stateful_step_count`: do
147
- not call the matching libhegel setter, and let the engine's own default
148
- apply. `database`/`database_key` and `report_multiple_failures` each follow
149
- their own rule instead, covered below.
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
160
+ libhegel's own default, unless a `hegel.toml` or a `HEGEL_*` environment
161
+ variable (such as `HEGEL_TEST_CASES`) sets the value. On a CI server the
162
+ engine picks its `ci` profile, which derandomizes the run, turns the example
163
+ database off, and suppresses the `too_slow` health check. A keyword passed
164
+ explicitly wins over all of these. `database`/`database_key` and
165
+ `report_multiple_failures` each follow their own rule instead, covered
166
+ below.
150
167
 
151
168
  `database_key:` is the switch that turns libhegel's example database on;
152
169
  `database:` only chooses where it writes, and means nothing without a key.
@@ -162,7 +179,9 @@ Hegel.test(database: "/tmp/wherever") { |tc| tc.draw(integers) }
162
179
  Give `database_key:` a value unique to the property under test. Two
163
180
  `Hegel.test` calls that share a key share one replay scope, so an unrelated
164
181
  property can read or overwrite what this one stored. Left at their shared
165
- default (`nil`, `nil`), a run stores nothing.
182
+ default (`nil`, `nil`), a run stores nothing. With a key and no
183
+ `database:`, the profile chooses the directory, and the `ci` profile turns
184
+ the database off; pass `database:` to store on CI too.
166
185
 
167
186
  `phases:` and `suppress_health_check:` each take an `Array` of `Symbol`, and
168
187
  each raises `Hegel::Error` at run time for an empty one. An empty `Array`
@@ -175,13 +194,13 @@ Hegel.test(phases: []) { |tc| tc.draw(integers) }
175
194
  # :reuse, :generate, :target, :shrink], got an empty Array"
176
195
  ```
177
196
 
178
- `report_multiple_failures:` defaults to `false`, which is not libhegel's own
179
- default (`true`). With the default `false`, a run stops at its first failing
180
- example and re-raises that failure's own exception, class and backtrace
181
- intact, so a host framework reports it as its own. `report_multiple_failures:
182
- true` keeps generating afterwards to surface other distinct bugs, and then
183
- raises `Hegel::Error` naming the count instead of any one of the individual
184
- exceptions:
197
+ `report_multiple_failures:` defaults to `false`, and is always passed, so a
198
+ profile cannot turn it on by accident. With the default `false`, a run
199
+ stops at its first failing example and re-raises that failure's own
200
+ exception, class and backtrace intact, so a host framework reports it as
201
+ its own. `report_multiple_failures: true` keeps generating afterwards to
202
+ surface other distinct bugs, and then raises `Hegel::Error` naming the count
203
+ instead of any one of the individual exceptions:
185
204
 
186
205
  ```ruby
187
206
  Hegel.test(test_cases: 10, report_multiple_failures: true, verbosity: :quiet) do |tc|
@@ -195,11 +214,6 @@ end
195
214
  # raises Hegel::Error, "Property-based test failed with 2 distinct failures."
196
215
  ```
197
216
 
198
- `stateful_step_count:` bounds how many rules one `Hegel::Stateful.run` call
199
- applies per test case (see [Stateful Testing](#stateful-testing)); the
200
- engine documents its own default as 50, and requires the value be at least
201
- 1.
202
-
203
217
  `seed:` and `derandomize: true` together make a run reproducible:
204
218
 
205
219
  ```ruby
@@ -310,9 +324,11 @@ body; `assume`/`reject` discard from anywhere in the block.
310
324
 
311
325
  `note` records a message for the eventual failure report, interleaved with
312
326
  draws in the order both were called. Pass a message directly, or a block.
313
- The block form is evaluated only on the one, already-shrunk replay that
314
- produces the report, so it is the cheaper choice when building the message
315
- itself costs something. Passing both, or neither, raises `Hegel::Error`:
327
+ The block form is evaluated only on the cases the engine stamps for the
328
+ report: its final replay of each failure, the replays that first confirm
329
+ one, every replay of a `reproduce_failure:` blob, and, for a
330
+ nondeterministic body, the replays that confirm it. So it is the cheaper
331
+ choice when building the message itself costs something. Passing both, or neither, raises `Hegel::Error`:
316
332
 
317
333
  ```ruby
318
334
  output = StringIO.new
@@ -419,12 +435,15 @@ Hegel.test(verbosity: :quiet) { |tc| tc.draw(integers(min_value: 10**30, max_val
419
435
  # => nil
420
436
  ```
421
437
 
422
- ### `floats(min_value: nil, max_value: nil, allow_nan: false, allow_infinity: false, exclude_min: false, exclude_max: false)`
438
+ ### `floats(min_value: nil, max_value: nil, allow_nan: nil, allow_infinity: nil, exclude_min: false, exclude_max: false)`
439
+
440
+ A double in `[min_value, max_value]`, unbounded by default.
423
441
 
424
- A double in `[min_value, max_value]`, unbounded (the full finite range) by
425
- default. Unlike hegel-rust's and hegel-typescript's `floats()`, `allow_nan`
426
- and `allow_infinity` both default to **`false`** here even when fully
427
- unbounded (see [Gotchas](#gotchas)).
442
+ `allow_nan` defaults to `true` when the caller passes neither bound, `false`
443
+ otherwise. `allow_infinity` defaults to `true` when the caller leaves either
444
+ side open, `false` when both bounds are set. hegel-rust and
445
+ hegel-typescript's `floats()` default the same way. Pass `true` or `false` to
446
+ override either (see [Gotchas](#gotchas)).
428
447
 
429
448
  ```ruby
430
449
  Hegel.test(test_cases: 30, verbosity: :quiet) do |tc|
@@ -433,12 +452,18 @@ Hegel.test(test_cases: 30, verbosity: :quiet) do |tc|
433
452
  end
434
453
 
435
454
  Hegel.test(test_cases: 200, verbosity: :quiet) do |tc|
436
- tc.draw(floats(allow_nan: true)) # can now draw NaN
455
+ tc.draw(floats) # unbounded: draws NaN and both infinities
456
+ tc.draw(floats(allow_nan: false, allow_infinity: false)) # finite doubles only
437
457
  end
438
458
  ```
439
459
 
440
- `max_value < min_value` raises `Hegel::Error` at draw time:
441
- `"floats: max_value < min_value"`.
460
+ Three combinations raise `Hegel::Error` at draw time:
461
+
462
+ - `max_value < min_value`: `"floats: max_value < min_value"`
463
+ - `allow_nan: true` with either bound:
464
+ `"floats: cannot have allow_nan=true with min_value or max_value"`
465
+ - `allow_infinity: true` with both bounds:
466
+ `"floats: cannot have allow_infinity=true with both min_value and max_value"`
442
467
 
443
468
  ### `text(min_size: 0, max_size: nil, codec: nil, min_codepoint: nil, max_codepoint: nil)`
444
469
 
@@ -469,14 +494,14 @@ expose) are not available in this binding; `codec:`, `min_codepoint:`, and
469
494
  `max_codepoint:` are the alphabet controls it exposes today.
470
495
 
471
496
  Other generators cover most character-set jobs. These results were measured
472
- with libhegel 0.32.5 on this repository's main branch:
497
+ with libhegel 0.45.0:
473
498
 
474
499
  | Job | Generator | Measured output |
475
500
  |---|---|---|
476
- | Characters to include | `from_regex("[ぁ-んA-Z0-9]{1,5}", fullmatch: true)` | `"げLだづは"`, `"あぐA"` |
477
- | Characters to exclude | `from_regex("[^,\\t\\n]{0,6}", fullmatch: true)` | `"Ë3ù*掆&"`, `"MßzæŜ\u0002"`; 30 draws contained no comma, tab, or newline |
478
- | A contiguous block | `text(min_codepoint: 0x300, max_codepoint: 0x36F)` | Every character was in U+0300 through U+036F |
479
- | An arbitrary alphabet | `arrays(sampled_from(%w[ß a İ]), max_size: 4).map(&:join)` | `"İaİß"`, `"ßßß"` |
501
+ | Characters to include | `from_regex("[ぁ-んA-Z0-9]{1,5}", fullmatch: true)` | `"ぁ"`, `"Hわ9ごう"`; 30 draws all matched the pattern |
502
+ | Characters to exclude | `from_regex("[^,\\t\\n]{0,6}", fullmatch: true)` | `""`, `"\u008FBՔ\u0014੐C"`; 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 `İ` |
480
505
 
481
506
  The `categories:` option remains an open request. A category such as any `Lu`
482
507
  or any `Mn` spans codepoint ranges that a codepoint bound cannot select. A
@@ -498,7 +523,7 @@ the same setting as `suppress_health_check: [:filter_too_much]`, listed in
498
523
 
499
524
  Unconfigured `text` draws from a much wider Unicode range. In 200 draws of
500
525
  `text(min_size: 1, max_size: 4)`, the results included emoji, private-use
501
- characters, and control characters. The highest codepoint was U+10F11A. A
526
+ characters, and control characters. The highest codepoint was U+10A78F. A
502
527
  property that accepts any string needs no character-set options.
503
528
 
504
529
  ### `arrays(elements, min_size: 0, max_size: nil)`
@@ -978,8 +1003,28 @@ instance variables directly, and calls a generator method (`integers`,
978
1003
  surrounding class can once it includes `Hegel::Syntax::Methods`;
979
1004
  `Hegel::StateMachine` already includes that module itself.
980
1005
 
981
- An invariant runs once before the first rule, and again after every rule
982
- application that completes without its own assumption failing.
1006
+ An invariant runs before the first rule and after the last. Between rules,
1007
+ the engine samples it with probability 1 / `step_count`, so it runs about
1008
+ once in a test case that runs every step. `invariant(name, always_run:
1009
+ true, &block)` checks it after every rule instead:
1010
+
1011
+ ```ruby
1012
+ class CountingMachine < Hegel::StateMachine
1013
+ rule(:step) { |_tc| }
1014
+ invariant(:cheap, always_run: true) { |_tc| }
1015
+ invariant(:expensive) { |_tc| }
1016
+ end
1017
+ ```
1018
+
1019
+ `Hegel::Stateful.run(machine, tc, step_count: 50)` takes the most rules one
1020
+ test case runs. It defaults to 50, and the engine rejects a value below 1:
1021
+
1022
+ ```ruby
1023
+ Hegel.test(test_cases: 1, verbosity: :quiet) do |tc|
1024
+ Hegel::Stateful.run(CountingMachine.new, tc, step_count: 0)
1025
+ end
1026
+ # raises Hegel::Error, naming HEGEL_E_INVALID_ARG
1027
+ ```
983
1028
 
984
1029
  A rule or invariant reports a failure the same way any other test-case body
985
1030
  does: raise. This library brings no assertion methods of its own. Include
@@ -1068,7 +1113,7 @@ output.string
1068
1113
  ```
1069
1114
 
1070
1115
  ```
1071
- Falsified after 1 test case (0 discarded):
1116
+ Falsified after 2 test cases (0 discarded):
1072
1117
 
1073
1118
  Initial invariant check.
1074
1119
  Step 1: push
@@ -1079,7 +1124,7 @@ Falsified after 1 test case (0 discarded):
1079
1124
  draw_3 = 0
1080
1125
 
1081
1126
  To reproduce this failure, pass the blob below to Hegel.test:
1082
- reproduce_failure: "AXiclcaxDQAACMMwd0Xi/3dh4QCGKG6E2tzywQAOIgBh"
1127
+ reproduce_failure: "AXicjYghEgAACIOkmvz/a9Ww5RE4jqmjeYPCJ30sEXQAaw=="
1083
1128
  ```
1084
1129
 
1085
1130
  The shrunk report names each step by its rule (`Step 1: push`), and shrinks
@@ -1181,11 +1226,11 @@ result # => nil
1181
1226
 
1182
1227
  ## Gotchas
1183
1228
 
1184
- 1. **`floats` defaults `allow_nan: false, allow_infinity: false`, even when
1185
- fully unbounded.** hegel-rust's and hegel-typescript's `floats()` default
1186
- both to `true` when neither bound is set. This binding always starts
1187
- both off; pass `allow_nan: true` and/or `allow_infinity: true`
1188
- deliberately if the code under test needs to see them.
1229
+ 1. **Float defaults include NaN and infinity when unbounded.** `floats` with
1230
+ no bounds draws `NaN` and both infinities. If your code does not handle
1231
+ them, pass `allow_nan: false, allow_infinity: false` -- but consider
1232
+ whether the code *should* handle them first. A bound turns each one off:
1233
+ NaN needs both bounds absent, an infinity needs one side open.
1189
1234
 
1190
1235
  2. **`integers`'s default range, when a bound is omitted, is still the
1191
1236
  signed 64-bit range (`-2**63..(2**63 - 1)`), even though arbitrary
@@ -1255,7 +1300,6 @@ result # => nil
1255
1300
  generator's own draw, discards the whole test case.
1256
1301
 
1257
1302
  10. **The suite runs against the real engine on Linux, macOS, and Windows.**
1258
- Every push to main runs it on Ruby 3.3, 3.4, and 4.0, across Linux x64
1259
- and arm64, macOS arm64, and Windows x64 and arm64. Windows arm64 starts
1260
- at Ruby 3.4, the oldest Ruby that RubyInstaller publishes an arm64 build
1261
- for. Alpine (musl) is the platform this binding has not run on.
1303
+ Every push to main runs it on Ruby 3.4 and 4.0, across Linux x64 and
1304
+ arm64, macOS arm64, and Windows x64 and arm64. Alpine (musl) is the
1305
+ platform this binding has not run on.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hegeltest
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - meganemura
@@ -60,6 +60,15 @@ files:
60
60
  - docs/adr/0012-build-a-failure-origin-from-the-callers-own-frame.md
61
61
  - docs/adr/0013-bind-libhegel-through-the-ffi-gem.md
62
62
  - docs/adr/0014-name-a-drawn-value-only-when-the-draw-is-the-whole-assigned-value.md
63
+ - docs/adr/0015-follow-the-hegeldev-interface-and-take-changes-upstream-first.md
64
+ - docs/adr/0016-run-mutation-testing-with-mutineer.md
65
+ - docs/adr/0017-raise-the-ruby-floor-to-3-4.md
66
+ - docs/adr/0018-gate-mutation-testing-on-a-committed-baseline.md
67
+ - docs/adr/0019-report-failures-from-the-cases-the-engine-stamps.md
68
+ - docs/adr/0020-derive-span-labels-from-generator-names.md
69
+ - docs/adr/0021-run-a-state-machine-in-rounds-with-its-own-step-count.md
70
+ - docs/adr/0022-keep-microsecond-times-over-a-nanosecond-engine.md
71
+ - docs/adr/0023-leave-unset-settings-to-the-engines-profile.md
63
72
  - docs/architecture.md
64
73
  - lib/hegel.rb
65
74
  - lib/hegel/draw_name.rb
@@ -101,7 +110,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
101
110
  requirements:
102
111
  - - ">="
103
112
  - !ruby/object:Gem::Version
104
- version: 3.3.0
113
+ version: 3.4.0
105
114
  required_rubygems_version: !ruby/object:Gem::Requirement
106
115
  requirements:
107
116
  - - ">="