hegeltest 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +182 -0
- data/README.ja.md +408 -0
- data/README.md +208 -102
- data/Rakefile +6 -3
- data/docs/README.md +9 -0
- data/docs/adr/0027-report-each-runs-test-location-from-the-callers-frame.md +63 -0
- data/docs/adr/0028-expose-settings-profiles-as-a-keyword-and-module-functions.md +91 -0
- data/docs/adr/0029-keep-notes-local-and-defer-run-error-kinds.md +72 -0
- data/docs/adr/0030-represent-a-duration-as-integer-nanoseconds.md +48 -0
- data/docs/adr/0031-take-the-alphabet-options-from-hegel-rust.md +52 -0
- data/docs/adr/0032-suppress-span-closes-while-an-abandoned-recursion-attempt-unwinds.md +147 -0
- data/docs/adr/0033-cache-compiled-grammars-for-the-length-of-a-run.md +80 -0
- data/docs/adr/0034-repeat-a-test-body-with-a-ruby-side-indent.md +120 -0
- data/docs/adr/0035-defer-concurrent-state-machines-and-the-engine-printer.md +136 -0
- data/docs/architecture.md +16 -1
- data/lib/hegel/errors.rb +24 -0
- data/lib/hegel/generator.rb +52 -25
- data/lib/hegel/generators.rb +715 -45
- data/lib/hegel/grammar_cache.rb +36 -0
- data/lib/hegel/lib_hegel/real.rb +320 -17
- data/lib/hegel/lib_hegel.rb +31 -6
- data/lib/hegel/libhegel_version.rb +1 -1
- data/lib/hegel/report.rb +13 -10
- data/lib/hegel/runner.rb +61 -27
- data/lib/hegel/settings.rb +92 -16
- data/lib/hegel/state_machine.rb +17 -4
- data/lib/hegel/stateful/pool.rb +52 -0
- data/lib/hegel/stateful.rb +2 -2
- data/lib/hegel/syntax/methods.rb +99 -15
- data/lib/hegel/test_case.rb +256 -35
- data/lib/hegel/test_location.rb +49 -0
- data/lib/hegel/version.rb +1 -1
- data/lib/hegel.rb +21 -5
- data/sig/hegel.rbs +295 -42
- data/skills/hegel-ruby/references/ruby/reference.md +463 -41
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 19a8534cce74c0577ca61891ead16d02d336efd87f3178a8672d6482bed4c6bb
|
|
4
|
+
data.tar.gz: 65a01d9c2400e3890cab677cb3ada046341305caa514f9d18ddd069f8e537fb3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9a2c433f9d32ba001b0c7667ce248ffebf8dab48c48a0655050cf80e8c0113305a0c957436ad510dca730b5b9b583ed99f3388cd959831765a10327310a4d7fb
|
|
7
|
+
data.tar.gz: 715fc4b8c0a810e09382593cd2d0eba009da5b78bd6747bb23fdd599bd6d89c8262ce6ab0d7e70b24c0067b6387c1b57ce3420e4b2c1863ce9392eb463561110
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,187 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.4.0] - 2026-10-21
|
|
6
|
+
|
|
7
|
+
### Engine: libhegel 0.48.0 (was 0.47.2)
|
|
8
|
+
|
|
9
|
+
The [libhegel 0.48.0 release notes](https://github.com/hegeldev/hegel/releases/tag/libhegel/0.48.0)
|
|
10
|
+
describe 0.48.0.
|
|
11
|
+
|
|
12
|
+
Each platform gem carries `libhegel` 0.48.0. The engine adds one function to
|
|
13
|
+
its C interface, `hegel_set_unicode_version`, and changes no other function.
|
|
14
|
+
These bindings do not call it yet, so the string generators continue to use
|
|
15
|
+
the newest Unicode tables of the engine, as before. A caller has nothing to
|
|
16
|
+
change in code. The engine move changes these results for a caller:
|
|
17
|
+
|
|
18
|
+
- With the default `report_multiple_failures: false`, a run can find a
|
|
19
|
+
different, smaller failure while it shrinks the first one. The run now
|
|
20
|
+
shrinks that failure too before it reports it. In 0.47.2, the run reported
|
|
21
|
+
that failure without shrinking it.
|
|
22
|
+
- `from_regex` accepts the end-of-string anchor `\z`. In 0.47.2, a pattern
|
|
23
|
+
with `\z` raised `Hegel::Error` with `HEGEL_E_INVALID_ARG`.
|
|
24
|
+
- `floats(min_value: Float::INFINITY, allow_infinity: false)` and
|
|
25
|
+
`floats(max_value: -Float::INFINITY, allow_infinity: false)` leave no value
|
|
26
|
+
to draw. The draw now raises `Hegel::Error` with `HEGEL_E_INVALID_ARG` and
|
|
27
|
+
a message that contains "leaves no value". In 0.47.2, the draw raised
|
|
28
|
+
`Hegel::Error` with `HEGEL_E_INTERNAL`.
|
|
29
|
+
|
|
30
|
+
### Profiles
|
|
31
|
+
|
|
32
|
+
`Hegel.test` takes `profile:`, the name of a settings profile to start the
|
|
33
|
+
run from: a shipped one, one in `hegel.toml`, or one you register. Left
|
|
34
|
+
`nil`, the run starts from the default profile as before, and a keyword
|
|
35
|
+
passed alongside still beats the profile.
|
|
36
|
+
|
|
37
|
+
`Hegel::Settings.register_profile(name, profile: nil, **keywords)`
|
|
38
|
+
registers a profile for the whole process, and
|
|
39
|
+
`Hegel::Settings.set_default_profile(name)` makes a profile the default
|
|
40
|
+
for every run; `""` clears it. They follow hegel-rust's
|
|
41
|
+
`Settings::register_profile` and `set_default_profile`. An unknown or
|
|
42
|
+
reserved name raises `Hegel::Error` with the engine's message. See
|
|
43
|
+
[ADR 0028](docs/adr/0028-expose-settings-profiles-as-a-keyword-and-module-functions.md).
|
|
44
|
+
|
|
45
|
+
### Settings
|
|
46
|
+
|
|
47
|
+
`Hegel.test` takes five more keywords, with the names and meanings of
|
|
48
|
+
hegel-rust's `Settings` methods. Each one left `nil` takes its value from
|
|
49
|
+
the engine's settings profile, like `test_cases:` and `seed:`.
|
|
50
|
+
|
|
51
|
+
- `stop_on_first_failure: false` keeps generating after a failure and
|
|
52
|
+
spends the whole `test_cases` budget before reporting.
|
|
53
|
+
- `print_blob: false` leaves the `reproduce_failure:` line out of the
|
|
54
|
+
failure report. Left `nil`, a profile or `HEGEL_PRINT_BLOB` decides.
|
|
55
|
+
- `show_statistics: true` makes the engine print, at the end of the run,
|
|
56
|
+
what `tc.event` and `tc.event_value` recorded.
|
|
57
|
+
- `nondeterminism_strictness:` takes `:quiet`, `:warn`, or `:error`.
|
|
58
|
+
`:error` ends a run whose test behaves differently on a replay with
|
|
59
|
+
`Hegel::Error`.
|
|
60
|
+
- `backend:` takes `:default` or `:urandom`.
|
|
61
|
+
|
|
62
|
+
### Test location
|
|
63
|
+
|
|
64
|
+
Every `Hegel.test` run now reports where its test lives to libhegel, with
|
|
65
|
+
`hegel_settings_set_test_location`. Under Antithesis the engine writes one
|
|
66
|
+
`always` assertion, "<class>::<function> passes properties", for each
|
|
67
|
+
location; elsewhere it uses nothing, and no keyword changes. The file is
|
|
68
|
+
relative to the working directory when it lies under it, and the class and
|
|
69
|
+
function come from the frame that called `Hegel.test`. A call inside a
|
|
70
|
+
block takes the name of the method holding the block, and a call outside
|
|
71
|
+
any method, such as an RSpec example, takes the file without `.rb` as the
|
|
72
|
+
class and `line <n>` as the function, so each example in a file has its
|
|
73
|
+
own name.
|
|
74
|
+
|
|
75
|
+
### Test cases
|
|
76
|
+
|
|
77
|
+
`tc.event(label)` and `tc.event_value(label, value)` record what a test
|
|
78
|
+
reached, for the statistics that `show_statistics: true` prints. They follow
|
|
79
|
+
hegel-rust's `TestCase#event` and `TestCase#event_value`.
|
|
80
|
+
|
|
81
|
+
### Generators
|
|
82
|
+
|
|
83
|
+
- `date_strings`, `time_strings`, and `datetime_strings` generate ISO 8601
|
|
84
|
+
date, time, and datetime Strings, with no options, as hegel-rust does.
|
|
85
|
+
A time keeps nanoseconds: nine fractional digits appear when the
|
|
86
|
+
nanosecond is not zero.
|
|
87
|
+
- `permutations`, `subsequences`, and `samples` draw reorderings, ordered
|
|
88
|
+
subsets, and samples of a given sequence, as hegel-rust does. Its
|
|
89
|
+
`with_replacement` and `without_replacement` setters are one keyword here,
|
|
90
|
+
`samples(replacement: true)`, which is the default.
|
|
91
|
+
- `durations(min_value: 0, max_value: 2**64 - 1)` draws a duration as an
|
|
92
|
+
`Integer` count of nanoseconds, as hegel-rust's `durations` does for a
|
|
93
|
+
`Duration`. It follows [ADR 0030](docs/adr/0030-represent-a-duration-as-integer-nanoseconds.md).
|
|
94
|
+
- `arrays(elements, unique: true)` draws an array with no two equal entries,
|
|
95
|
+
as hegel-rust's `vecs(...).unique(true)` does. An element equal to one
|
|
96
|
+
already drawn is rejected and drawn again, so `min_size` still counts
|
|
97
|
+
distinct entries.
|
|
98
|
+
- `floats(allow_subnormal: nil)` allows or rules out subnormal values, as
|
|
99
|
+
hegel-rust's `allow_subnormal` does. Left `nil`, it allows them whenever
|
|
100
|
+
the bounds admit one. A `floats` call whose bounds admit none, such as
|
|
101
|
+
`floats(min_value: 1.0, max_value: 2.0)`, now tells the engine so.
|
|
102
|
+
- `recursive(leaf, max_depth: 32, max_leaves: 100) { |subtrees| ... }`
|
|
103
|
+
draws a recursively defined value, such as a tree, through the engine's
|
|
104
|
+
recursion calls, as hegel-rust's `recursive` does. The engine decides each
|
|
105
|
+
leaf-or-branch choice and owns both budgets. An attempt that outgrows
|
|
106
|
+
`max_leaves` is drawn again from the root, and the spans it opened are
|
|
107
|
+
discarded, not kept. See
|
|
108
|
+
[ADR 0032](docs/adr/0032-suppress-span-closes-while-an-abandoned-recursion-attempt-unwinds.md).
|
|
109
|
+
|
|
110
|
+
### Text generators
|
|
111
|
+
|
|
112
|
+
`text` and `characters` take `categories:`, `exclude_categories:`,
|
|
113
|
+
`include_characters:`, and `exclude_characters:`, with the names hegel-rust
|
|
114
|
+
gives them. `text` also takes `alphabet:`, a `String` of the permitted
|
|
115
|
+
characters, and raises `Hegel::Error` at draw
|
|
116
|
+
time when it is combined with another character option. `from_regex` takes
|
|
117
|
+
`alphabet:`, a `characters` generator that limits every character of the
|
|
118
|
+
string, padding included. An unknown `codec:` now raises `"text: invalid
|
|
119
|
+
codec: ..."` (or `characters:`) before the engine is called, and a
|
|
120
|
+
surrogate category in `categories:` raises too.
|
|
121
|
+
|
|
122
|
+
### Grammar strings
|
|
123
|
+
|
|
124
|
+
`from_lark(grammar)` draws strings that a grammar written in the syntax of
|
|
125
|
+
the [Lark](https://lark-parser.readthedocs.io/en/stable/grammar.html)
|
|
126
|
+
parsing library accepts, as hegel-rust's `from_lark` does. It takes
|
|
127
|
+
`start:`, `explicit:` (a `Hash` from terminal name to a generator of the
|
|
128
|
+
values drawn for it), `alphabet:` (a `characters` generator),
|
|
129
|
+
`max_size:`, `max_depth:`, and `import_path:`. An invalid option raises
|
|
130
|
+
`Hegel::Error` at draw time, with hegel-rust's message where it has one.
|
|
131
|
+
A grammar is compiled on its first draw in a run and freed when the run
|
|
132
|
+
ends.
|
|
133
|
+
|
|
134
|
+
### Added
|
|
135
|
+
|
|
136
|
+
- `rule(name, weight: 1.0)` sets how often the engine picks a rule relative to
|
|
137
|
+
the machine's other rules, as `#[rule(weight = w)]` does in hegel-rust. A
|
|
138
|
+
weight that is not finite and positive raises `Hegel::Error` when the machine
|
|
139
|
+
runs. `Hegel::StateMachine.rule_definitions` now maps each name to a
|
|
140
|
+
`Hegel::StateMachine::Rule` with `block` and `weight`, where it mapped to the
|
|
141
|
+
block alone.
|
|
142
|
+
|
|
143
|
+
### Fixed
|
|
144
|
+
|
|
145
|
+
- A filter on `Pool#values_consumed` no longer loses the values it rejects. They
|
|
146
|
+
go back into the pool through `hegel_pool_restore` once the draw is over, and
|
|
147
|
+
only the accepted value is consumed, as in hegel-rust.
|
|
148
|
+
|
|
149
|
+
### Changed
|
|
150
|
+
|
|
151
|
+
Three defaults now match hegel-rust, which defines the interface. The results
|
|
152
|
+
change for a caller who relied on the old default.
|
|
153
|
+
|
|
154
|
+
- `from_regex` defaults to `fullmatch: true`, where it was `false`. The whole
|
|
155
|
+
drawn string matches the pattern, instead of merely containing a match. To
|
|
156
|
+
keep the old behaviour, pass `fullmatch: false`.
|
|
157
|
+
- `text` defaults `max_size` to 100, where it was unbounded. When `min_size`
|
|
158
|
+
is above 100, the default is `min_size + 100`. Strings drawn without a
|
|
159
|
+
`max_size` are shorter than before. To draw longer strings, pass the
|
|
160
|
+
larger `max_size` you need.
|
|
161
|
+
- `binary` defaults `max_size` the same way, to 100 or `min_size + 100`.
|
|
162
|
+
|
|
163
|
+
- A filter over a filter now spends 3 attempts between the two predicates, as
|
|
164
|
+
hegel-rust does, where each level used to spend 3 of its own.
|
|
165
|
+
- `Hegel::Generator#do_draw_filtered(tc, predicate)` is the draw behind
|
|
166
|
+
`#filter`. A generator whose draws have effects that a rejected value must
|
|
167
|
+
not keep overrides it.
|
|
168
|
+
|
|
169
|
+
### Repeat a test body
|
|
170
|
+
|
|
171
|
+
`tc.repeat { ... }` runs its block again and again, for as long as the
|
|
172
|
+
engine keeps the loop going, the way hegel-rust's `TestCase::repeat` does.
|
|
173
|
+
The engine chooses how many times it runs, and shrinks a failure toward
|
|
174
|
+
fewer iterations. Each iteration shows in the failure report as a
|
|
175
|
+
`// Repetition #N` line, with what the iteration recorded indented under
|
|
176
|
+
it. A `tc.assume` or `tc.reject` inside the block skips that iteration
|
|
177
|
+
instead of discarding the whole test case.
|
|
178
|
+
|
|
179
|
+
The loop does not return when it ends. It raises `Hegel::LoopDone`, which
|
|
180
|
+
`Hegel.test` counts as a passing test case. It descends from `Exception`,
|
|
181
|
+
like the other control exceptions, so a `rescue => e` in the body does not
|
|
182
|
+
catch it. A `break` in the block ends the loop and returns from
|
|
183
|
+
`tc.repeat`. See
|
|
184
|
+
[ADR 0034](docs/adr/0034-repeat-a-test-body-with-a-ruby-side-indent.md).
|
|
185
|
+
|
|
5
186
|
## [0.3.4] - 2026-10-20
|
|
6
187
|
|
|
7
188
|
### Engine: libhegel 0.47.2 (was 0.47.1)
|
|
@@ -273,6 +454,7 @@ The engine is called through the `ffi` gem. Each platform gem carries the
|
|
|
273
454
|
matching `libhegel` 0.32.5 build. The platform-independent gem carries none,
|
|
274
455
|
so a run on it needs `HEGEL_LIBHEGEL_PATH` pointing at a local build.
|
|
275
456
|
|
|
457
|
+
[0.4.0]: https://github.com/meganemura/hegel-ruby/releases/tag/v0.4.0
|
|
276
458
|
[0.3.4]: https://github.com/meganemura/hegel-ruby/releases/tag/v0.3.4
|
|
277
459
|
[0.3.3]: https://github.com/meganemura/hegel-ruby/releases/tag/v0.3.3
|
|
278
460
|
[0.3.2]: https://github.com/meganemura/hegel-ruby/releases/tag/v0.3.2
|
data/README.ja.md
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
# Hegel for Ruby
|
|
2
|
+
|
|
3
|
+
[English README](README.md)
|
|
4
|
+
|
|
5
|
+
> [!IMPORTANT]
|
|
6
|
+
> **これは非公式のサードパーティ実装です。** Hegel プロジェクトと `libhegel`
|
|
7
|
+
> エンジンは Antithesis, LLC のものです。このリポジトリは Antithesis とも
|
|
8
|
+
> `hegeldev` organization とも関係がなく、先方のレビューや承認も受けていません。
|
|
9
|
+
>
|
|
10
|
+
> 公式実装は
|
|
11
|
+
> [Rust](https://github.com/hegeldev/hegel/tree/main/frontends/rust)、
|
|
12
|
+
> [Go](https://github.com/hegeldev/hegel-go)、
|
|
13
|
+
> [C++](https://github.com/hegeldev/hegel-cpp)、
|
|
14
|
+
> [TypeScript](https://github.com/hegeldev/hegel-typescript)、
|
|
15
|
+
> [Java](https://github.com/hegeldev/hegel-java)、
|
|
16
|
+
> [OCaml](https://github.com/hegeldev/hegel-ocaml) です。この gem の問題は
|
|
17
|
+
> 公式実装ではなく、このリポジトリに報告してください。
|
|
18
|
+
|
|
19
|
+
> [!NOTE]
|
|
20
|
+
> Hegel 自体はベータ版で、今後も互換性のない変更が入る予定です。
|
|
21
|
+
> <https://hegel.dev/compatibility> を参照してください。
|
|
22
|
+
|
|
23
|
+
Hegel は [Hypothesis](https://github.com/hypothesisworks/hypothesis) を土台にした
|
|
24
|
+
プロパティベーステストのフレームワークです。手で選んだ入力でテストを書く代わりに、
|
|
25
|
+
どの入力でも成り立つべき性質を書きます。Hegel は入力を生成して性質を破ろうとし、
|
|
26
|
+
失敗を見つけたら最小の反例まで縮めます。
|
|
27
|
+
|
|
28
|
+
この gem は、公式実装も使っているネイティブエンジン
|
|
29
|
+
[libhegel](https://hegel.dev/reference/libhegel) を
|
|
30
|
+
[`ffi`](https://github.com/ffi/ffi) gem 経由で同じプロセス内で動かします。
|
|
31
|
+
サーバーも Python も要りません。
|
|
32
|
+
|
|
33
|
+
## インストール
|
|
34
|
+
|
|
35
|
+
gem 名は [`hegeltest`](https://rubygems.org/gems/hegeltest) です。
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# Gemfile
|
|
39
|
+
gem "hegeltest", group: :test
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
require パスは `hegel`、名前空間は `Hegel` です。Bundler の自動 require のために
|
|
43
|
+
`require "hegeltest"` も使えます。
|
|
44
|
+
|
|
45
|
+
Ruby 3.4 以上が必要です。各プラットフォーム向け gem には対応する `libhegel` の
|
|
46
|
+
ビルドが同梱されているので、インストール時にコンパイルは不要で、エンジンを
|
|
47
|
+
別に用意する必要もありません。
|
|
48
|
+
|
|
49
|
+
| プラットフォーム | gem のプラットフォーム |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| Linux x64 | `x86_64-linux` |
|
|
52
|
+
| Linux arm64 | `aarch64-linux` |
|
|
53
|
+
| macOS arm64 | `arm64-darwin` |
|
|
54
|
+
| Windows x64 | `x64-mingw-ucrt` |
|
|
55
|
+
| Windows arm64 | `aarch64-mingw-ucrt` |
|
|
56
|
+
|
|
57
|
+
Hegel のメンテナーは、サードパーティによるリリースバイナリの再配布を認めています
|
|
58
|
+
([hegeldev/hegel-rust#411])。
|
|
59
|
+
|
|
60
|
+
すでに `Gemfile.lock` があるプロジェクトでは、もう一手間要ります。Bundler は
|
|
61
|
+
`bundle install` を実行したマシンのプラットフォームしか lock に足さないので、
|
|
62
|
+
Mac で作った lock には Linux が載りません。そのままだと Linux の CI で gem が
|
|
63
|
+
見つかりません。CI で使うプラットフォームを追加してください。
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
bundle lock --add-platform x86_64-linux
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Intel Mac のように `libhegel` のビルドが公開されていないプラットフォームでは、
|
|
70
|
+
エンジンを自分でビルドし、`HEGEL_LIBHEGEL_PATH` にそのライブラリファイルか、
|
|
71
|
+
それを含むディレクトリを指定してください。同じ変数で、どのプラットフォームでも
|
|
72
|
+
同梱のエンジンの代わりにローカルビルドを使えます。
|
|
73
|
+
|
|
74
|
+
[hegeldev/hegel-rust#411]: https://github.com/hegeldev/hegel-rust/issues/411
|
|
75
|
+
|
|
76
|
+
### どの libhegel が入っているか
|
|
77
|
+
|
|
78
|
+
gem の各リリースは libhegel のリリースを 1 つ固定しています。インストール済みの
|
|
79
|
+
gem が固定しているバージョンは次のコマンドで確認できます。
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
ruby -rhegel -e 'puts Hegel::LIBHEGEL_VERSION'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
[CHANGELOG](CHANGELOG.md) の各リリースの節は、エンジンの更新があればその内容から
|
|
86
|
+
始まり、そのエンジンのリリースノートにリンクしています。
|
|
87
|
+
[GitHub Releases](https://github.com/meganemura/hegel-ruby/releases) にも
|
|
88
|
+
同じ内容が載ります。
|
|
89
|
+
|
|
90
|
+
libhegel の新しいリリースが出ると、それに追従する pull request が作られます。
|
|
91
|
+
gem のバージョンとエンジンのバージョンは独立していて、gem のリリースには
|
|
92
|
+
エンジンの更新、Ruby コードの変更、またはその両方が含まれます。
|
|
93
|
+
|
|
94
|
+
## クイックスタート
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
# spec/spec_helper.rb
|
|
98
|
+
require "hegel"
|
|
99
|
+
|
|
100
|
+
RSpec.configure do |config|
|
|
101
|
+
config.include Hegel::Syntax::Methods
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# spec/my_sort_spec.rb
|
|
107
|
+
def my_sort(ls) = ls.sort.uniq # oops: uniq drops duplicates
|
|
108
|
+
|
|
109
|
+
RSpec.describe "my_sort" do
|
|
110
|
+
it "matches the builtin sort" do
|
|
111
|
+
Hegel.test do |tc|
|
|
112
|
+
xs = tc.draw(arrays(integers))
|
|
113
|
+
expect(my_sort(xs)).to eq(xs.sort)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
このテストは失敗し、Hegel はバグを再現する最小の入力まで縮めます。
|
|
120
|
+
レポートは次のようになります。
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
Falsified after 11 test cases (0 discarded):
|
|
124
|
+
|
|
125
|
+
xs = [0, 0]
|
|
126
|
+
|
|
127
|
+
To reproduce this failure, pass the blob below to Hegel.test:
|
|
128
|
+
reproduce_failure: "AXicY2VgYGBkZOBiZEBhMAAAAd8AIQ=="
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
テストケースの数と blob は実行ごと、エンジンのリリースごとに変わります。
|
|
132
|
+
見るべきなのは縮めた値で、重複が 2 つあれば十分だとわかります。Hegel は
|
|
133
|
+
draw を書いた行を読んで値に `xs` という名前を付け、RSpec 自身の expectation
|
|
134
|
+
失敗を投げ直すので、RSpec は普段どおりの失敗として報告します。
|
|
135
|
+
|
|
136
|
+
`Hegel::Syntax::Methods` を include すると、FactoryBot の `create` のように
|
|
137
|
+
ジェネレーターを接頭辞なしで使えます。include しなくても
|
|
138
|
+
`Hegel::Generators.arrays(...)` として使えます。
|
|
139
|
+
|
|
140
|
+
独自のヘルパーを持つ example group にジェネレーター名を持ち込みたくない場合は、
|
|
141
|
+
タグを付けた group だけに include します。
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
config.include Hegel::Syntax::Methods, :hegel
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Hegel は値を命令的に draw するので、普通の Ruby メソッドで複数の draw を
|
|
148
|
+
プロパティ間で共有できます。メソッドの引数には、RuboCop の既定の命名規則に
|
|
149
|
+
合う説明的な名前を付けてください。
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
def draw_sorted_pair(test_case)
|
|
153
|
+
low = test_case.draw(integers(min_value: 0, max_value: 100))
|
|
154
|
+
high = test_case.draw(integers(min_value: low, max_value: 200))
|
|
155
|
+
[low, high]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
Hegel.test do |tc|
|
|
159
|
+
low, high = draw_sorted_pair(tc)
|
|
160
|
+
expect(low).to be <= high
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Minitest
|
|
165
|
+
|
|
166
|
+
Hegel はテストフレームワークに何も要求しません。ジェネレーターのメソッドを
|
|
167
|
+
一度 include し、普通のテストの中にプロパティを書きます。
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
# test/test_helper.rb
|
|
171
|
+
require "hegel"
|
|
172
|
+
|
|
173
|
+
class Minitest::Test
|
|
174
|
+
include Hegel::Syntax::Methods
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
# test/my_sort_test.rb
|
|
180
|
+
class MySortTest < Minitest::Test
|
|
181
|
+
def test_matches_the_builtin_sort
|
|
182
|
+
Hegel.test do |tc|
|
|
183
|
+
xs = tc.draw(arrays(integers))
|
|
184
|
+
assert_equal xs.sort, my_sort(xs)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Minitest は縮めたケースを、テストを書いた行での通常の assertion 失敗として
|
|
191
|
+
報告します。
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
1) Failure:
|
|
195
|
+
MySortTest#test_matches_the_builtin_sort [test/my_sort_test.rb:5]:
|
|
196
|
+
Expected: [0, 0]
|
|
197
|
+
Actual: [0]
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## ジェネレーター
|
|
201
|
+
|
|
202
|
+
| 種類 | ジェネレーター |
|
|
203
|
+
| --- | --- |
|
|
204
|
+
| 数値と真偽値 | `integers`, `floats`, `booleans` |
|
|
205
|
+
| 文字列とバイト列 | `text`, `characters`, `binary`, `from_regex`, `from_lark` |
|
|
206
|
+
| コレクション | `arrays`, `sets`, `hashes`, `tuples` |
|
|
207
|
+
| 選択 | `just`, `sampled_from`, `permutations`, `subsequences`, `samples`, `one_of`, `optional` |
|
|
208
|
+
| 書式 | `emails`, `urls`, `domains`, `ip_addresses`, `uuids` |
|
|
209
|
+
| 日時 | `dates`, `times`, `datetimes`, `date_strings`, `time_strings`, `datetime_strings`, `durations` |
|
|
210
|
+
| 自作 | `composite`, `deferred`, `recursive` |
|
|
211
|
+
|
|
212
|
+
どれも `map` と `filter` で組み合わせられます。オプションはキーワード引数で、
|
|
213
|
+
`integers(min_value: 0, max_value: 9)`、`arrays(text, max_size: 5)`、`arrays(integers, unique: true)` のように、
|
|
214
|
+
他の Hegel 実装と同じ名前を使います。オプションの組み合わせが不正な場合は、
|
|
215
|
+
ジェネレーターを作ったときではなく draw したときに例外になります。
|
|
216
|
+
|
|
217
|
+
## ステートフルテスト
|
|
218
|
+
|
|
219
|
+
入力 1 つではなく、操作の列で初めて出るバグもあります。操作を
|
|
220
|
+
`Hegel::StateMachine` のルールとして宣言すると、Hegel が次に実行するルールを選び、
|
|
221
|
+
途中で不変条件を確かめ、失敗をそれが再現する最短の操作列まで縮めます。
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
class StackMachine < Hegel::StateMachine
|
|
225
|
+
def initialize
|
|
226
|
+
@stack = BoundedStack.new(3)
|
|
227
|
+
@model = []
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
rule :push do |tc|
|
|
231
|
+
x = tc.draw(integers(min_value: 0, max_value: 9))
|
|
232
|
+
@stack.push(x)
|
|
233
|
+
@model.push(x) if @model.size < 3
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
rule :pop do |tc|
|
|
237
|
+
tc.assume(!@model.empty?)
|
|
238
|
+
raise "pop disagreed" unless @stack.pop == @model.pop
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
invariant :size_agrees do
|
|
242
|
+
raise "size disagreed" unless @stack.size == @model.size
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
Hegel.test { |tc| Hegel::Stateful.run(StackMachine.new, tc) }
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
ルールの中の `tc.assume` は、テストケース全体を捨てるのではなく、そのルールだけを
|
|
250
|
+
却下して別のルールを選ばせます。`alloc` が返したハンドルを解放するなど、前のルールが
|
|
251
|
+
作った値を使うルールでは、値を `Hegel::Stateful::Pool` に入れて、そこから draw します。
|
|
252
|
+
|
|
253
|
+
すべての不変条件は最初のルールの前と最後のルールの後に実行されます。ルールの間では
|
|
254
|
+
エンジンが各不変条件をサンプリングします。毎回のルールの後に確かめたい不変条件は
|
|
255
|
+
`always_run: true` 付きで宣言してください。
|
|
256
|
+
`Hegel::Stateful.run(machine, tc, step_count: 50)` で 1 テストケースあたりの最大
|
|
257
|
+
ルール数を指定できます。既定は 50 です。`rule :name, weight: 2.5` と書くと、そのルールが
|
|
258
|
+
他より選ばれやすくなります。既定の重みは 1.0 です。
|
|
259
|
+
|
|
260
|
+
## 実行の設定
|
|
261
|
+
|
|
262
|
+
`Hegel.test` は次のキーワードを受け取ります。
|
|
263
|
+
|
|
264
|
+
| キーワード | 効果 |
|
|
265
|
+
| --- | --- |
|
|
266
|
+
| `profile` | 実行の出発点にする設定プロファイル。[プロファイル](#プロファイル)を参照 |
|
|
267
|
+
| `test_cases` | 生成するテストケースの数 |
|
|
268
|
+
| `seed` | ランダム生成のシード |
|
|
269
|
+
| `derandomize` | 生成を決定的にする |
|
|
270
|
+
| `verbosity` | エンジンの出力量。`:quiet` は失敗レポートも隠す |
|
|
271
|
+
| `phases` | 生成や縮小など、実行するエンジンのフェーズ |
|
|
272
|
+
| `suppress_health_check` | 無効にするヘルスチェック |
|
|
273
|
+
| `report_multiple_failures` | 異なる失敗をそれぞれ報告する。既定は `false` |
|
|
274
|
+
| `stop_on_first_failure` | `false` にすると `test_cases` を使い切ってから報告する |
|
|
275
|
+
| `print_blob` | `false` にすると失敗レポートから再現用の行を外す |
|
|
276
|
+
| `show_statistics` | `tc.event` と `tc.event_value` で記録した内容を実行の最後に表示する |
|
|
277
|
+
| `nondeterminism_strictness` | 再実行で振る舞いが変わるテストへの反応。`:quiet`、`:warn`、`:error` |
|
|
278
|
+
| `backend` | `:default`、または値を引くたびに外部のエントロピーを使う `:urandom`(Antithesis 向け) |
|
|
279
|
+
| `database_key` | このプロパティで example database を有効にする |
|
|
280
|
+
| `database` | example database のディレクトリ |
|
|
281
|
+
| `reproduce_failure` | レポートの blob から 1 つの失敗を再生する |
|
|
282
|
+
|
|
283
|
+
指定しなかったキーワードは、エンジンの設定プロファイルの値になります。これは
|
|
284
|
+
`hegel.toml` や `HEGEL_*` 環境変数で変えない限り libhegel 自身の既定値です。
|
|
285
|
+
CI サーバー上ではエンジンが `ci` プロファイルを使い、実行を決定的にして
|
|
286
|
+
example database を無効にします。
|
|
287
|
+
|
|
288
|
+
example database は失敗したケースを保存し、次回最初に再生します。
|
|
289
|
+
`database_key` で有効になり、`database` でディレクトリを選びます。
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
Hegel.test(database_key: "my_sort matches the builtin sort") { |tc| ... }
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
プロパティごとに別のキーを付けてください。ディレクトリではなくキーで有効になる
|
|
296
|
+
理由は [ADR 0009](docs/adr/0009-turn-the-example-database-on-with-a-key.md) に
|
|
297
|
+
あります。
|
|
298
|
+
|
|
299
|
+
テストの中では、`tc.note` で失敗レポートに出すメッセージを記録でき、
|
|
300
|
+
`tc.target` で Hegel が探索を寄せる方向を指定でき、`tc.repeat` で
|
|
301
|
+
Hegel が選んだ回数だけブロックを繰り返し実行できます。
|
|
302
|
+
|
|
303
|
+
`tc.event` と `tc.event_value` は、テストが狙ったケースに届いているかを確かめる
|
|
304
|
+
ためのものです。`show_statistics: true` にすると、各イベントが起きた割合と、
|
|
305
|
+
各値の分布の要約をエンジンが表示します。数値は実行ごとに変わります。
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
Hegel.test(show_statistics: true) do |tc|
|
|
309
|
+
xs = tc.draw(arrays(integers))
|
|
310
|
+
tc.event("empty") if xs.empty?
|
|
311
|
+
tc.event_value("size", xs.size)
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
```
|
|
316
|
+
Statistics (over 100 test cases):
|
|
317
|
+
* empty: 6.0% of test cases
|
|
318
|
+
* size: count 100, min 0, median 6, mean 7.99, p90 19, max 20
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
## プロファイル
|
|
322
|
+
|
|
323
|
+
プロファイルは名前の付いた設定の組です。エンジンは `development`、`ci`、
|
|
324
|
+
`workload` を同梱しており、`hegel.toml` でさらに定義できます。`profile:` を
|
|
325
|
+
渡すと 1 回の実行をそのプロファイルから始められ、独自のプロファイルは
|
|
326
|
+
`Hegel::Settings.register_profile` で登録できます。
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
Hegel::Settings.register_profile("nightly", profile: "ci", test_cases: 10_000)
|
|
330
|
+
|
|
331
|
+
Hegel.test(profile: "nightly") { |tc| ... }
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`Hegel.test` に渡したキーワードはプロファイルより優先されます。名前を指定した
|
|
335
|
+
プロファイルは既定のプロファイルを継承するので、設定していない項目は既定の
|
|
336
|
+
プロファイルから来ます。`Hegel::Settings.set_default_profile("nightly")` は
|
|
337
|
+
そのプロセスのすべての実行の既定にし、`set_default_profile("")` で解除します。
|
|
338
|
+
登録と既定はプロセス全体に効き、すでに始まった実行は変わりません。
|
|
339
|
+
`register_profile` は `database_key` を除く `Hegel.test` のキーワードを受け取ります。
|
|
340
|
+
プロファイルは `database_key` を持たないためです。また `Hegel.test` は常に
|
|
341
|
+
`report_multiple_failures` を渡すので、プロファイル側の値は使われません。
|
|
342
|
+
未知の名前は `Hegel::Error` になります。
|
|
343
|
+
[ADR 0028](docs/adr/0028-expose-settings-profiles-as-a-keyword-and-module-functions.md)
|
|
344
|
+
を参照してください。
|
|
345
|
+
|
|
346
|
+
## エージェント向けスキル
|
|
347
|
+
|
|
348
|
+
2 つのスキルを組み合わせて使います。Hegel のメンテナーによる公式の
|
|
349
|
+
[hegel-skill](https://github.com/hegeldev/hegel-skill) は、どの性質を探すか、
|
|
350
|
+
エージェントが書いたテストをどう確かめるかといった手法を教えるもので、
|
|
351
|
+
どの Hegel 実装でも使えます。[`skills/hegel-ruby`](skills/hegel-ruby/SKILL.md) は
|
|
352
|
+
Ruby の部分、つまりこの gem の API と RSpec・Minitest での使い方を足します。
|
|
353
|
+
両方をインストールしてください。
|
|
354
|
+
|
|
355
|
+
Claude Code では、それぞれのリポジトリをマーケットプレイスとして追加し、
|
|
356
|
+
プラグインをインストールします。
|
|
357
|
+
|
|
358
|
+
```
|
|
359
|
+
/plugin marketplace add hegeldev/hegel-skill
|
|
360
|
+
/plugin install hegel-skill@hegeldev
|
|
361
|
+
/plugin marketplace add meganemura/hegel-ruby
|
|
362
|
+
/plugin install hegel-ruby@meganemura
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Codex などほかのエージェントでのインストール方法は、
|
|
366
|
+
[hegel-skill の README](https://github.com/hegeldev/hegel-skill#installation) を
|
|
367
|
+
参照してください。この gem のスキルは、同じ手順で
|
|
368
|
+
<https://github.com/meganemura/hegel-ruby/tree/main/skills/hegel-ruby> を指定すればインストールできます。
|
|
369
|
+
|
|
370
|
+
## 開発
|
|
371
|
+
|
|
372
|
+
```bash
|
|
373
|
+
bin/setup # 依存関係をインストール
|
|
374
|
+
bundle exec rake libhegel:fetch # 固定したエンジンを SHA-256 で検証してダウンロード
|
|
375
|
+
bundle exec rake # カバレッジ付きのテストとリンター
|
|
376
|
+
bundle exec rake mutation # ミューテーションテスト
|
|
377
|
+
bin/console # 対話プロンプト
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`just` のレシピは同じ Rake タスクを呼ぶので、他の Hegel 実装で `just` を使っている
|
|
381
|
+
人は `just check`、`just test`、`just lint` をそのまま使えます。
|
|
382
|
+
|
|
383
|
+
CI は pull request と main への push ごとに、上の表のすべてのプラットフォームで
|
|
384
|
+
Ruby 3.4 と 4.0 のテストを実行します。
|
|
385
|
+
|
|
386
|
+
設計の記録は [docs/](docs/README.md) にあります。
|
|
387
|
+
|
|
388
|
+
### リリース
|
|
389
|
+
|
|
390
|
+
main へのマージで `lib/hegel/version.rb` が変わると、リリースが始まります。
|
|
391
|
+
Release ワークフローは `release` 環境の承認を待ち、承認されるとテストを実行し、
|
|
392
|
+
gem を RubyGems に公開し、タグ `v<version>` と GitHub Release を作ります。
|
|
393
|
+
リリースノートは [CHANGELOG.md](CHANGELOG.md) のそのバージョンの節から作られます。
|
|
394
|
+
[ADR 0024](docs/adr/0024-release-when-a-merge-changes-the-gem-version.md) を
|
|
395
|
+
参照してください。
|
|
396
|
+
|
|
397
|
+
## コントリビュート
|
|
398
|
+
|
|
399
|
+
バグ報告と pull request は <https://github.com/meganemura/hegel-ruby> へ。
|
|
400
|
+
コントリビューターは[行動規範](CODE_OF_CONDUCT.md)に従ってください。
|
|
401
|
+
|
|
402
|
+
## ライセンス
|
|
403
|
+
|
|
404
|
+
この gem は [MIT License](LICENSE.txt) で利用できます。
|
|
405
|
+
|
|
406
|
+
リリースされた gem には `libhegel` 自身のライセンス(MIT、copyright Antithesis, LLC)
|
|
407
|
+
も含まれます。各プラットフォーム向け gem はエンジンの隣に `NOTICE-libhegel.txt` を
|
|
408
|
+
同梱しており、その全文が入っています。
|