pbt 0.5.1 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f0585c0152a9703256b3265830dd9105b33ebc65877555517af3ad643a6b8839
4
- data.tar.gz: ac62f147919ac2e68642d70d9e0bb9d2c8010ef54b2aefc4d6c679fe1b9d7197
3
+ metadata.gz: 3275eafb6d07373c24f422feafa7300962213568a387cd21fbcd648c85de4193
4
+ data.tar.gz: 7a3b902bb323251050ec023ec8318244d777c0bc2584ad0c8bad2504d93888f0
5
5
  SHA512:
6
- metadata.gz: 54535a35fa3097b1fe3f4893f83ef9c646598b385c9d4c32f3eb2686aab3727ca22154a87edef748f94090682acee6f7090ecb4d51233e66e861b30bd20df0d3
7
- data.tar.gz: e8126c1c5f1b044d83f66b6d0a734783d2843f16f0b24f9a1172797e5b144a630a26c642bfc606f57de9911935f1b25ff0c1d8af48ef92486680c80e264308b0
6
+ metadata.gz: e72d6278580f540b7d0aad1aefb168c2fbc7a3e64d31a90a4929695b979d3d1c29e3dc06fb22fb0fc6d1aadfc121605176cbee7146e298201f9091c0fa2ea647
7
+ data.tar.gz: 8967245b883d22dfb00d79b0433acc53502e3e9ecdb07e89bae04002ce413fd67d126f695f4a4e12a965fc7adf171b5cd8e1c9fd3e6d8d28e8ff4444113cbea5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.0] - 2026-04-04
4
+
5
+ - [Breaking change] Simplify stateful command protocol: `arguments` must now accept `state` parameter, and `applicable?` must now accept both `state` and `args` parameters
6
+ - Make `rng` optional in all arbitrary `generate` methods with default `Random.new` [#44](https://github.com/ohbarye/pbt/pull/44)
7
+ - Mark stateful testing API as stable (no longer experimental)
8
+
9
+ ## [0.6.0] - 2026-03-15
10
+
11
+ - Add experimental `Pbt.stateful` API for model-based stateful property testing [#38](https://github.com/ohbarye/pbt/pull/38)
12
+ - Support state-aware and arg-aware stateful command protocols, including argument shrinking and generation edge cases [#40](https://github.com/ohbarye/pbt/pull/40)
13
+ - Validate stateful model/command contracts more consistently and improve diagnostics [#39](https://github.com/ohbarye/pbt/pull/39)
14
+ - Add Ruby 4.0 to CI test matrix
15
+
3
16
  ## [0.5.1] - 2025-06-29
4
17
 
5
18
  - Fix `IntegerArbitrary#shrink` to respect min/max bounds [#36](https://github.com/ohbarye/pbt/pull/36)
data/CLAUDE.md ADDED
@@ -0,0 +1,104 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ PBT (Property-Based Testing) is a Ruby gem that provides property-based testing capabilities with experimental features for parallel test execution using Ractor. The gem allows developers to specify properties that code should satisfy and automatically generates test cases to verify these properties.
8
+
9
+ ## Development Commands
10
+
11
+ ### Setup
12
+ ```bash
13
+ bundle install
14
+ ```
15
+
16
+ ### Testing
17
+ ```bash
18
+ # Run all tests
19
+ bundle exec rspec
20
+
21
+ # Run a specific test file
22
+ bundle exec rspec spec/path/to/spec.rb
23
+
24
+ # Run tests matching a pattern
25
+ bundle exec rspec -e "pattern"
26
+ ```
27
+
28
+ ### Linting
29
+ ```bash
30
+ # Check code style
31
+ bundle exec rake standard
32
+
33
+ # Auto-fix code style issues
34
+ bundle exec rake standard:fix
35
+ ```
36
+
37
+ ### Combined Testing and Linting
38
+ ```bash
39
+ # Default rake task runs both tests and linting
40
+ bundle exec rake
41
+ ```
42
+
43
+ ### Benchmarking
44
+ ```bash
45
+ # Run all benchmarks
46
+ bundle exec rake benchmark:all
47
+
48
+ # Run specific benchmark categories
49
+ bundle exec rake benchmark:success:simple
50
+ bundle exec rake benchmark:success:cpu_bound
51
+ bundle exec rake benchmark:success:io_bound
52
+ bundle exec rake benchmark:failure:simple
53
+ ```
54
+
55
+ ### Building and Releasing
56
+ ```bash
57
+ # Build the gem
58
+ bundle exec rake build
59
+
60
+ # Install locally
61
+ bundle exec rake install
62
+
63
+ # Release to RubyGems (maintainers only)
64
+ bundle exec rake release
65
+ ```
66
+
67
+ ## Architecture
68
+
69
+ ### Core Components
70
+
71
+ 1. **Arbitrary** (`lib/pbt/arbitrary/`)
72
+ - Base classes and modules for generating random values
73
+ - Implements various arbitraries: integer, array, tuple, hash, etc.
74
+ - Each arbitrary knows how to generate values and shrink them
75
+
76
+ 2. **Check** (`lib/pbt/check/`)
77
+ - `Property`: Defines properties to test
78
+ - `Runner`: Executes properties with different concurrency methods
79
+ - `Configuration`: Global and per-run configuration
80
+ - `Tosser`: Manages the generation and shrinking process
81
+
82
+ 3. **Reporter** (`lib/pbt/reporter/`)
83
+ - Handles test result reporting
84
+ - Provides verbose mode for detailed output
85
+
86
+ ### Key Design Patterns
87
+
88
+ - **Shrinking**: When a test fails, PBT attempts to find the minimal failing case by systematically reducing the input
89
+ - **Concurrency**: Supports both serial (`:none`) and parallel (`:ractor`) execution
90
+ - **Configuration**: Uses both global configuration and per-assertion overrides
91
+
92
+ ### Testing Approach
93
+
94
+ The codebase uses RSpec for testing and follows these patterns:
95
+ - Unit tests for each arbitrary type in `spec/pbt/arbitrary/`
96
+ - Integration tests in `spec/e2e/`
97
+ - Property-based tests are used to test the library itself
98
+
99
+ ### Important Notes
100
+
101
+ - Ruby 3.1+ is required due to Ractor usage
102
+ - When using `:ractor` worker, be aware of Ractor limitations (no shared state, limited object sharing)
103
+ - The gem uses Standard Ruby for code style (configured in `.standard.yml`)
104
+ - CI runs tests against Ruby 3.1, 3.2, 3.3, and 3.4
data/README.md CHANGED
@@ -34,7 +34,7 @@ Add this line to your application's Gemfile and run `bundle install`.
34
34
  gem 'pbt'
35
35
  ```
36
36
 
37
- Off course you can install with `gem intstall pbt`.
37
+ Of course you can install with `gem install pbt`.
38
38
 
39
39
  ## Basic Usage
40
40
 
@@ -108,38 +108,134 @@ There are many built-in arbitraries in `Pbt`. You can use them to generate rando
108
108
  #### Primitives
109
109
 
110
110
  ```ruby
111
- rng = Random.new
111
+ Pbt.integer.generate # => 42
112
+ Pbt.integer(min: -1, max: 8).generate # => Integer between -1 and 8
112
113
 
113
- Pbt.integer.generate(rng) # => 42
114
- Pbt.integer(min: -1, max: 8).generate(rng) # => Integer between -1 and 8
114
+ Pbt.symbol.generate # => :atq
115
115
 
116
- Pbt.symbol.generate(rng) # => :atq
116
+ Pbt.ascii_char.generate # => "a"
117
+ Pbt.ascii_string.generate # => "aagjZfao"
117
118
 
118
- Pbt.ascii_char.generate(rng) # => "a"
119
- Pbt.ascii_string.generate(rng) # => "aagjZfao"
119
+ Pbt.boolean.generate # => true or false
120
+ Pbt.constant(42).generate # => 42 always
121
+ ```
122
+
123
+ You can also pass a custom random number generator if needed:
120
124
 
121
- Pbt.boolean.generate(rng) # => true or false
122
- Pbt.constant(42).generate(rng) # => 42 always
125
+ ```ruby
126
+ rng = Random.new(42) # with a specific seed for reproducibility
127
+ Pbt.integer.generate(rng)
123
128
  ```
124
129
 
125
130
  #### Composites
126
131
 
127
132
  ```ruby
128
- rng = Random.new
133
+ Pbt.array(Pbt.integer).generate # => [121, -13141, 9825]
134
+ Pbt.array(Pbt.integer, max: 1, empty: true).generate # => [] or [42] etc.
129
135
 
130
- Pbt.array(Pbt.integer).generate(rng) # => [121, -13141, 9825]
131
- Pbt.array(Pbt.integer, max: 1, empty: true).generate(rng) # => [] or [42] etc.
136
+ Pbt.tuple(Pbt.symbol, Pbt.integer).generate # => [:atq, 42]
132
137
 
133
- Pbt.tuple(Pbt.symbol, Pbt.integer).generate(rng) # => [:atq, 42]
138
+ Pbt.fixed_hash(x: Pbt.symbol, y: Pbt.integer).generate # => {x: :atq, y: 42}
139
+ Pbt.hash(Pbt.symbol, Pbt.integer).generate # => {atq: 121, ygab: -1142}
134
140
 
135
- Pbt.fixed_hash(x: Pbt.symbol, y: Pbt.integer).generate(rng) # => {x: :atq, y: 42}
136
- Pbt.hash(Pbt.symbol, Pbt.integer).generate(rng) # => {atq: 121, ygab: -1142}
137
-
138
- Pbt.one_of(:a, 1, 0.1).generate(rng) # => :a or 1 or 0.1
141
+ Pbt.one_of(:a, 1, 0.1).generate # => :a or 1 or 0.1
139
142
  ````
140
143
 
141
144
  See [ArbitraryMethods](https://github.com/ohbarye/pbt/blob/main/lib/pbt/arbitrary/arbitrary_methods.rb) module for more details.
142
145
 
146
+ ## Stateful Testing (Experimental)
147
+
148
+ `Pbt` also provides an experimental stateful property API for model-based / command-based testing.
149
+ It is designed as a property-compatible object (`generate`, `shrink`, `run`) so it works with the existing runner (`Pbt.assert` / `Pbt.check`) without changing the runner API.
150
+
151
+ This API is still experimental. Expect interface refinements and behavior changes in future releases.
152
+
153
+ ### Minimal usage
154
+
155
+ ```ruby
156
+ class CounterModel
157
+ def initialize
158
+ @inc = IncrementCommand.new
159
+ end
160
+
161
+ def initial_state
162
+ 0
163
+ end
164
+
165
+ def commands(_state)
166
+ [@inc]
167
+ end
168
+ end
169
+
170
+ class IncrementCommand
171
+ def name
172
+ :increment
173
+ end
174
+
175
+ def arguments(_state)
176
+ Pbt.nil
177
+ end
178
+
179
+ def applicable?(_state, _args)
180
+ true
181
+ end
182
+
183
+ def next_state(state, _args)
184
+ state + 1
185
+ end
186
+
187
+ def run!(sut, _args)
188
+ sut.increment
189
+ end
190
+
191
+ def verify!(before_state:, after_state:, args: _, result:, sut:)
192
+ raise "unexpected result" unless result == after_state
193
+ raise "state mismatch" unless after_state == before_state + 1
194
+ raise "sut mismatch" unless sut.value == after_state
195
+ end
196
+ end
197
+
198
+ class Counter
199
+ attr_reader :value
200
+
201
+ def initialize
202
+ @value = 0
203
+ end
204
+
205
+ def increment
206
+ @value += 1
207
+ end
208
+ end
209
+
210
+ Pbt.assert do
211
+ Pbt.stateful(
212
+ model: CounterModel.new,
213
+ sut: -> { Counter.new },
214
+ max_steps: 20
215
+ )
216
+ end
217
+ ```
218
+
219
+ ### Expected interfaces
220
+
221
+ `Pbt.stateful(model:, sut:, max_steps:)` expects the following duck-typed interfaces:
222
+
223
+ - `model.initial_state`
224
+ - `model.commands(state)` -> `Array<command>`
225
+ - `command.name`
226
+ - `command.arguments(state)` (a `Pbt` arbitrary, may depend on current model state)
227
+ - `command.applicable?(state, args)` -> `true` / `false`
228
+ - `command.next_state(state, args)` -> next model state
229
+ - `command.run!(sut, args)` -> command result
230
+ - `command.verify!(before_state:, after_state:, args:, result:, sut:)`
231
+
232
+ ### Current limitations (MVP)
233
+
234
+ - `Pbt.stateful` runs sequentially by default, even if the global worker is `:ractor`.
235
+ - You can still pass `worker: :none` explicitly if you want to make that choice obvious in a test.
236
+ - `worker: :ractor` is currently unsupported and raises `Pbt::InvalidConfiguration`.
237
+ - Shrinking supports shorter prefixes and command-argument shrinking (using `command.arguments.shrink(args)`).
238
+
143
239
  ## What if property-based tests fail?
144
240
 
145
241
  Once a test fails it's time to debug. `Pbt` provides some features to help you debug.
@@ -328,7 +424,7 @@ Once this project finishes the following, we will release v1.0.0.
328
424
  - [ ] Statistics feature to aggregate generated values
329
425
  - [ ] Decide DSL
330
426
  - [ ] Try Fiber
331
- - [ ] Stateful property-based testing
427
+ - [x] Stateful property-based testing (experimental, via `Pbt.stateful`)
332
428
 
333
429
  ## Development
334
430
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Pbt
4
4
  module Arbitrary
5
+ class EmptyDomainError < ArgumentError; end
6
+
5
7
  # Abstract class for generating random values on type `T`.
6
8
  #
7
9
  # @abstract
@@ -9,9 +11,9 @@ module Pbt
9
11
  # Generate a value of type `T`, based on the provided random number generator.
10
12
  #
11
13
  # @abstract
12
- # @param rng [Random] Random number generator.
14
+ # @param rng [Random] Random number generator. Defaults to a new Random instance.
13
15
  # @return [Object] Random value of type `T`.
14
- def generate(rng)
16
+ def generate(rng = Random.new)
15
17
  raise NotImplementedError
16
18
  end
17
19
 
@@ -29,7 +31,7 @@ module Pbt
29
31
  #
30
32
  # @example
31
33
  # integer_generator = Pbt.integer
32
- # num_str_generator = integer_arb.map(->(n){ n.to_s }, ->(s) {s.to_i})
34
+ # num_str_generator = integer_generator.map(->(n){ n.to_s }, ->(s) {s.to_i})
33
35
  #
34
36
  # @param mapper [Proc] Proc to map generated values. Mainly used for generation.
35
37
  # @param unmapper [Proc] Proc to unmap generated values. Used for shrinking.
@@ -45,8 +47,8 @@ module Pbt
45
47
  #
46
48
  # @example
47
49
  # integer_generator = Pbt.integer
48
- # even_integer_generator = integer_arb.filter { |x| x.even? }
49
- # # or `integer_arb.filter(&:even?)`
50
+ # even_integer_generator = integer_generator.filter { |x| x.even? }
51
+ # # or `integer_generator.filter(&:even?)`
50
52
  #
51
53
  # @param refinement [Proc] Predicate proc to test each produced element. Return true to keep the element, false otherwise.
52
54
  # @return [FilterArbitrary] New arbitrary filtered using `refinement`.
@@ -20,7 +20,7 @@ module Pbt
20
20
  end
21
21
 
22
22
  # @see Arbitrary#generate
23
- def generate(rng)
23
+ def generate(rng = Random.new)
24
24
  length = @length_arb.generate(rng)
25
25
  length.times.map { @value_arb.generate(rng) }
26
26
  end
@@ -10,7 +10,7 @@ module Pbt
10
10
  end
11
11
 
12
12
  # @see Arbitrary#generate
13
- def generate(rng)
13
+ def generate(rng = Random.new)
14
14
  rng.rand(@range)
15
15
  end
16
16
 
@@ -10,7 +10,7 @@ module Pbt
10
10
  end
11
11
 
12
12
  # @see Arbitrary#generate
13
- def generate(rng)
13
+ def generate(rng = Random.new)
14
14
  @val
15
15
  end
16
16
 
@@ -12,7 +12,7 @@ module Pbt
12
12
  end
13
13
 
14
14
  # @see Arbitrary#generate
15
- def generate(rng)
15
+ def generate(rng = Random.new)
16
16
  loop do
17
17
  val = @arb.generate(rng)
18
18
  return val if @refinement.call(val)
@@ -11,7 +11,7 @@ module Pbt
11
11
  end
12
12
 
13
13
  # @see Arbitrary#generate
14
- def generate(rng)
14
+ def generate(rng = Random.new)
15
15
  values = @arb.generate(rng)
16
16
  @keys.zip(values).to_h
17
17
  end
@@ -15,8 +15,12 @@ module Pbt
15
15
  end
16
16
 
17
17
  # @see Arbitrary#generate
18
- def generate(rng)
18
+ def generate(rng = Random.new)
19
19
  rng.rand(@min..@max)
20
+ rescue ArgumentError => e
21
+ raise EmptyDomainError, e.message if @min > @max
22
+
23
+ raise
20
24
  end
21
25
 
22
26
  # @see Arbitrary#shrink
@@ -14,7 +14,7 @@ module Pbt
14
14
  end
15
15
 
16
16
  # @see Arbitrary#generate
17
- def generate(rng)
17
+ def generate(rng = Random.new)
18
18
  @mapper.call(@arb.generate(rng))
19
19
  end
20
20
 
@@ -11,7 +11,7 @@ module Pbt
11
11
  end
12
12
 
13
13
  # @see Arbitrary#generate
14
- def generate(rng)
14
+ def generate(rng = Random.new)
15
15
  @choices[@idx_arb.generate(rng)]
16
16
  end
17
17
 
@@ -10,7 +10,7 @@ module Pbt
10
10
  end
11
11
 
12
12
  # @see Arbitrary#generate
13
- def generate(rng)
13
+ def generate(rng = Random.new)
14
14
  @arbs.map { |arb| arb.generate(rng) }
15
15
  end
16
16
 
@@ -16,7 +16,7 @@ module Pbt
16
16
  #
17
17
  # @param rng [Random] Random number generator.
18
18
  # @return [Object]
19
- def generate(rng)
19
+ def generate(rng = Random.new)
20
20
  @arb.generate(rng)
21
21
  end
22
22
 
@@ -36,6 +36,10 @@ module Pbt
36
36
  property = property.call
37
37
  config = Pbt.configuration.to_h.merge(options.to_h)
38
38
 
39
+ if property.respond_to?(:stateful?) && property.stateful? && !options.key?(:worker)
40
+ config[:worker] = :none
41
+ end
42
+
39
43
  initial_values = toss(property, config[:seed])
40
44
  source_values = Enumerator.new(config[:num_runs]) do |y|
41
45
  config[:num_runs].times do
@@ -115,14 +119,35 @@ module Pbt
115
119
  runner.map.with_index { |val, index|
116
120
  Case.new(val:, index:, ractor: property.run_in_ractor(val))
117
121
  }.each do |c|
118
- c.ractor.take
122
+ wait_ractor_result(c.ractor)
119
123
  runner.handle_result(c)
120
124
  rescue => e
121
- c.exception = e.cause # Ractor error is wrapped in a Ractor::RemoteError. We need to get the cause.
125
+ c.exception = unwrap_ractor_exception(e)
122
126
  runner.handle_result(c)
123
127
  break # Ignore the rest of the cases. Just pick up the first failure.
124
128
  end
125
129
  end
130
+
131
+ # Ractor errors can wrap the original exception in a `cause` (e.g. `Ractor::RemoteError`).
132
+ # If no cause exists, keep the original exception instance.
133
+ #
134
+ # @param error [Exception]
135
+ # @return [Exception]
136
+ def unwrap_ractor_exception(error)
137
+ error.cause || error
138
+ end
139
+
140
+ # Ruby 4 removed Ractor#take in favor of Ractor#value.
141
+ #
142
+ # @param ractor [Ractor]
143
+ # @return [Object]
144
+ def wait_ractor_result(ractor)
145
+ if ractor.respond_to?(:value)
146
+ ractor.value
147
+ else
148
+ ractor.take
149
+ end
150
+ end
126
151
  end
127
152
  end
128
153
  end
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pbt
4
+ module Stateful
5
+ # Property-compatible wrapper for command-based stateful testing.
6
+ # It provides `generate`, `shrink` and `run`, so existing runners can execute it.
7
+ class Property
8
+ ARG_AWARE_GENERATION_ATTEMPTS = 5
9
+
10
+ REQUIRED_COMMAND_METHODS = %i[
11
+ name
12
+ arguments
13
+ applicable?
14
+ next_state
15
+ run!
16
+ verify!
17
+ ].freeze
18
+
19
+ Step = Struct.new(:command, :args, keyword_init: true) do
20
+ def inspect
21
+ "#<Pbt::Stateful::Step command=#{command_label}, args=#{args.inspect}>"
22
+ end
23
+
24
+ private
25
+
26
+ def command_label
27
+ if command.respond_to?(:name)
28
+ command.name.inspect
29
+ else
30
+ command.class.name || command.class.inspect
31
+ end
32
+ end
33
+ end
34
+
35
+ # @param model [Object]
36
+ # @param sut [Proc]
37
+ # @param max_steps [Integer]
38
+ def initialize(model:, sut:, max_steps:)
39
+ validate_model!(model)
40
+ raise Pbt::InvalidConfiguration, "sut must be callable" unless sut.respond_to?(:call)
41
+ raise Pbt::InvalidConfiguration, "max_steps must be an Integer" unless max_steps.is_a?(Integer)
42
+ raise Pbt::InvalidConfiguration, "max_steps must be non-negative" if max_steps.negative?
43
+
44
+ @model = model
45
+ @sut_factory = sut
46
+ @max_steps = max_steps
47
+ end
48
+
49
+ # Generate a sequence of commands valid for the current model state.
50
+ #
51
+ # @param rng [Random]
52
+ # @return [Array<Step>]
53
+ def generate(rng = Random.new)
54
+ length = rng.rand(0..@max_steps)
55
+ state = @model.initial_state
56
+ sequence = []
57
+
58
+ length.times do
59
+ candidates = generate_candidates_for(state, rng, context: "generate")
60
+ break if candidates.empty?
61
+
62
+ command, args = candidates[rng.rand(candidates.length)]
63
+ sequence << Step.new(command:, args:)
64
+ state = command.next_state(state, args)
65
+ end
66
+
67
+ sequence
68
+ end
69
+
70
+ # Shrink a sequence by trying shorter prefixes first.
71
+ #
72
+ # @param sequence [Array<Hash, Step>]
73
+ # @return [Enumerator<Array<Hash, Step>>]
74
+ def shrink(sequence)
75
+ Enumerator.new do |y|
76
+ seen = {}
77
+ state = @model.initial_state
78
+
79
+ (sequence.length - 1).downto(0) do |length|
80
+ yield_shrink_candidate(y, seen, sequence.first(length))
81
+ end
82
+
83
+ sequence.each_with_index do |step, index|
84
+ command, args = unpack_step(step)
85
+ validate_command_protocol!(command, state:, context: "shrink step #{index}")
86
+ break unless applicable?(command, state, args, context: "shrink step #{index}")
87
+
88
+ arbitrary_for(command, state, context: "shrink step #{index}").shrink(args).each do |shrunk_args|
89
+ candidate = replace_step(sequence, index, command:, args: shrunk_args)
90
+ next unless valid_sequence?(candidate)
91
+
92
+ yield_shrink_candidate(y, seen, candidate)
93
+ end
94
+
95
+ state = command.next_state(state, args)
96
+ end
97
+ end
98
+ end
99
+
100
+ # @return [Boolean]
101
+ def stateful?
102
+ true
103
+ end
104
+
105
+ # Stateful properties currently require sequential execution because the model,
106
+ # commands and SUT factory are ordinary Ruby objects and are not guaranteed to be
107
+ # Ractor-shareable.
108
+ #
109
+ # @param _sequence [Array<Hash, Step>]
110
+ # @raise [Pbt::InvalidConfiguration]
111
+ def run_in_ractor(_sequence)
112
+ raise Pbt::InvalidConfiguration, "Pbt.stateful does not support worker: :ractor yet; use worker: :none"
113
+ end
114
+
115
+ # Run the command sequence against a fresh SUT and verify each step.
116
+ #
117
+ # @param sequence [Array<Hash, Step>]
118
+ # @return [void]
119
+ def run(sequence)
120
+ state = @model.initial_state
121
+ sut = @sut_factory.call
122
+
123
+ sequence.each_with_index do |step, index|
124
+ command, args = unpack_step(step)
125
+ validate_command_protocol!(command, state:, context: "run step #{index}")
126
+
127
+ unless applicable?(command, state, args, context: "run step #{index}")
128
+ raise "invalid stateful sequence at step #{index}: #{command_name(command)}"
129
+ end
130
+
131
+ before_state = state
132
+
133
+ begin
134
+ after_state = command.next_state(before_state, args)
135
+ result = command.run!(sut, args)
136
+ command.verify!(
137
+ before_state:,
138
+ after_state:,
139
+ args:,
140
+ result:,
141
+ sut:
142
+ )
143
+ rescue Exception => e # standard:disable Lint/RescueException:
144
+ raise e.class,
145
+ "stateful step #{index} (#{command_name(command)}): #{e.message} [args=#{args.inspect}]",
146
+ e.backtrace
147
+ end
148
+
149
+ state = after_state
150
+ end
151
+ end
152
+
153
+ private
154
+
155
+ # @param step [Hash, Step]
156
+ # @return [Array<Object, Object>]
157
+ def unpack_step(step)
158
+ case step
159
+ in Step(command:, args:)
160
+ [command, args]
161
+ in {command:, args:}
162
+ [command, args]
163
+ else
164
+ raise ArgumentError, "invalid stateful step: #{step.inspect}"
165
+ end
166
+ end
167
+
168
+ # @param command [Object]
169
+ # @return [String]
170
+ def command_name(command)
171
+ command.respond_to?(:name) ? command.name.to_s : (command.class.name || command.class.inspect)
172
+ end
173
+
174
+ # @param model [Object]
175
+ # @return [void]
176
+ def validate_model!(model)
177
+ missing_methods = %i[initial_state commands].reject { |method_name| model.respond_to?(method_name) }
178
+ return if missing_methods.empty?
179
+
180
+ raise Pbt::InvalidConfiguration,
181
+ "Pbt.stateful model must respond to #{missing_methods.join(", ")}"
182
+ end
183
+
184
+ # @param state [Object]
185
+ # @param context [String]
186
+ # @return [Array<Object>]
187
+ def commands_for(state, context:)
188
+ commands = @model.commands(state)
189
+
190
+ unless commands.is_a?(Array)
191
+ raise Pbt::InvalidConfiguration,
192
+ "Pbt.stateful model.commands(state) must return Array, got #{commands.class} (context=#{context})"
193
+ end
194
+
195
+ commands.each { |command| validate_command_protocol!(command, state:, context:) }
196
+ commands
197
+ end
198
+
199
+ # @param command [Object]
200
+ # @param state [Object]
201
+ # @param context [String]
202
+ # @return [void]
203
+ def validate_command_protocol!(command, state:, context:)
204
+ missing_methods = REQUIRED_COMMAND_METHODS.reject { |method_name| command.respond_to?(method_name) }
205
+ unless missing_methods.empty?
206
+ raise Pbt::InvalidConfiguration,
207
+ "Pbt.stateful command protocol mismatch for #{command.class} " \
208
+ "(name=#{safe_command_label(command)}, missing: #{missing_methods.join(", ")}, context=#{context})"
209
+ end
210
+ end
211
+
212
+ # @param command [Object]
213
+ # @param arguments [Object]
214
+ # @param context [String]
215
+ # @return [void]
216
+ def validate_arguments_protocol!(command, arguments, context:)
217
+ missing_methods = %i[generate shrink].reject { |method_name| arguments.respond_to?(method_name) }
218
+ return if missing_methods.empty?
219
+
220
+ raise Pbt::InvalidConfiguration,
221
+ "Pbt.stateful command arguments protocol mismatch for #{command.class} " \
222
+ "(name=#{safe_command_label(command)}, missing: #{missing_methods.join(", ")}, context=#{context})"
223
+ end
224
+
225
+ # @param command [Object]
226
+ # @return [String]
227
+ def safe_command_label(command)
228
+ command.respond_to?(:name) ? command.name.inspect : "<unknown>"
229
+ end
230
+
231
+ # @param state [Object]
232
+ # @param rng [Random]
233
+ # @param context [String]
234
+ # @return [Array<Array<Object, Object>>]
235
+ def generate_candidates_for(state, rng, context:)
236
+ commands_for(state, context:).filter_map do |command|
237
+ args = generate_applicable_args(command, state, rng, context:)
238
+ next if args.equal?(NoApplicableArgs)
239
+
240
+ [command, args]
241
+ end
242
+ end
243
+
244
+ # @param command [Object]
245
+ # @param state [Object]
246
+ # @param rng [Random]
247
+ # @param context [String]
248
+ # @return [Object]
249
+ def generate_applicable_args(command, state, rng, context:)
250
+ arbitrary = arbitrary_for(command, state, context:)
251
+
252
+ ARG_AWARE_GENERATION_ATTEMPTS.times do
253
+ args = generate_args_for(command, arbitrary, rng)
254
+ return NoApplicableArgs if args.equal?(NoApplicableArgs)
255
+
256
+ return args if applicable?(command, state, args, context:)
257
+ end
258
+
259
+ NoApplicableArgs
260
+ end
261
+
262
+ # @param command [Object]
263
+ # @param state [Object]
264
+ # @param context [String]
265
+ # @return [Object]
266
+ def arguments_for(command, state, context:)
267
+ command.arguments(state)
268
+ end
269
+
270
+ # @param command [Object]
271
+ # @param state [Object]
272
+ # @param context [String]
273
+ # @return [Object]
274
+ def arbitrary_for(command, state, context:)
275
+ arguments = arguments_for(command, state, context:)
276
+ validate_arguments_protocol!(command, arguments, context:)
277
+ arguments
278
+ end
279
+
280
+ # @param command [Object]
281
+ # @param arbitrary [Object]
282
+ # @param rng [Random]
283
+ # @return [Object]
284
+ def generate_args_for(command, arbitrary, rng)
285
+ arbitrary.generate(rng)
286
+ rescue Pbt::Arbitrary::EmptyDomainError
287
+ NoApplicableArgs
288
+ end
289
+
290
+ # @param command [Object]
291
+ # @param state [Object]
292
+ # @param args [Object]
293
+ # @param context [String]
294
+ # @return [Boolean]
295
+ def applicable?(command, state, args, context:)
296
+ command.applicable?(state, args)
297
+ end
298
+
299
+ # @param sequence [Array<Hash, Step>]
300
+ # @param index [Integer]
301
+ # @param command [Object]
302
+ # @param args [Object]
303
+ # @return [Array<Hash, Step>]
304
+ def replace_step(sequence, index, command:, args:)
305
+ candidate = sequence.dup
306
+ candidate[index] = rebuild_step(sequence[index], command:, args:)
307
+ candidate
308
+ end
309
+
310
+ # @param step [Hash, Step]
311
+ # @param command [Object]
312
+ # @param args [Object]
313
+ # @return [Hash, Step]
314
+ def rebuild_step(step, command:, args:)
315
+ case step
316
+ in Step
317
+ Step.new(command:, args:)
318
+ in Hash
319
+ {command:, args:}
320
+ else
321
+ raise ArgumentError, "invalid stateful step: #{step.inspect}"
322
+ end
323
+ end
324
+
325
+ # @param sequence [Array<Hash, Step>]
326
+ # @return [Boolean]
327
+ def valid_sequence?(sequence)
328
+ state = @model.initial_state
329
+
330
+ sequence.each do |step|
331
+ command, args = unpack_step(step)
332
+ return false unless applicable?(command, state, args, context: "validate sequence")
333
+
334
+ state = command.next_state(state, args)
335
+ end
336
+
337
+ true
338
+ rescue
339
+ false
340
+ end
341
+
342
+ # @param y [Enumerator::Yielder]
343
+ # @param seen [Hash{Array<Hash, Step> => true}]
344
+ # @param candidate [Array<Hash, Step>]
345
+ # @return [void]
346
+ def yield_shrink_candidate(y, seen, candidate)
347
+ return if seen[candidate]
348
+
349
+ seen[candidate] = true
350
+ y << candidate
351
+ end
352
+
353
+ NoApplicableArgs = Object.new
354
+ private_constant :NoApplicableArgs
355
+ end
356
+ end
357
+ end
data/lib/pbt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pbt
4
- VERSION = "0.5.1"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/pbt.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "pbt/arbitrary/arbitrary_methods"
5
5
  require_relative "pbt/check/runner_methods"
6
6
  require_relative "pbt/check/property"
7
7
  require_relative "pbt/check/configuration"
8
+ require_relative "pbt/stateful/property"
8
9
 
9
10
  module Pbt
10
11
  # Represents a property-based test failure.
@@ -44,6 +45,29 @@ module Pbt
44
45
  Check::Property.new(arb, &predicate)
45
46
  end
46
47
 
48
+ # Create a stateful property-based test backed by a model and a SUT factory.
49
+ # The returned object is compatible with `Pbt.assert` / `Pbt.check`.
50
+ #
51
+ # The model object is expected to provide:
52
+ # - `initial_state`
53
+ # - `commands(state)` -> Array of command objects
54
+ #
55
+ # Each command object is expected to provide:
56
+ # - `name`
57
+ # - `arguments` (an arbitrary) or `arguments(state)`
58
+ # - `applicable?(state)` -> bool or `applicable?(state, args)` -> bool
59
+ # - `next_state(state, args)`
60
+ # - `run!(sut, args)` -> result
61
+ # - `verify!(before_state:, after_state:, args:, result:, sut:)`
62
+ #
63
+ # @param model [Object]
64
+ # @param sut [Proc] Factory proc that returns a fresh SUT per run.
65
+ # @param max_steps [Integer]
66
+ # @return [Pbt::Stateful::Property]
67
+ def self.stateful(model:, sut:, max_steps: 20)
68
+ Stateful::Property.new(model:, sut:, max_steps:)
69
+ end
70
+
47
71
  class << self
48
72
  private
49
73
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pbt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ohbarye
@@ -18,6 +18,7 @@ files:
18
18
  - ".rspec"
19
19
  - ".standard.yml"
20
20
  - CHANGELOG.md
21
+ - CLAUDE.md
21
22
  - CODE_OF_CONDUCT.md
22
23
  - LICENSE.txt
23
24
  - README.md
@@ -50,6 +51,7 @@ files:
50
51
  - lib/pbt/reporter/run_details.rb
51
52
  - lib/pbt/reporter/run_details_reporter.rb
52
53
  - lib/pbt/reporter/run_execution.rb
54
+ - lib/pbt/stateful/property.rb
53
55
  - lib/pbt/version.rb
54
56
  - sig/pbt.rbs
55
57
  homepage: https://github.com/ohbarye/pbt
@@ -73,7 +75,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
73
75
  - !ruby/object:Gem::Version
74
76
  version: '0'
75
77
  requirements: []
76
- rubygems_version: 3.6.7
78
+ rubygems_version: 3.6.9
77
79
  specification_version: 4
78
80
  summary: Property-Based Testing tool for Ruby, utilizing Ractor for parallelizing
79
81
  test cases.