rspock 2.3.1 → 2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0a24add88d2e5100613beaedf3c8b158e3d463a04e17377c99f2a2c98b63157
4
- data.tar.gz: d179439aaf99e0e50a6565cadae7c2541b3ae2e399fb847cc073023ad130e7d3
3
+ metadata.gz: 2e4d05a65bb9d040615d65ceee976bc3b0fef696d54921b9fb47160c07c18dc2
4
+ data.tar.gz: 8d2b4a392f6ea3cff0133278a7994b2934fea9d2a3d695da4a9da66d2727ae25
5
5
  SHA512:
6
- metadata.gz: bfbf1995a238d569be2c5cb598e02808a77847d8bc3a0a785a5448af6bcebfc7d222e746da44892e4fa2e17c2c74153049ed4d1297cc815900c173e385b9ef3e
7
- data.tar.gz: 76b576315949d04b5579ff7ead6b6d85330d0030e3045680a4eb0a369b721d1c40b53d3353d5696f02c182fd0ce4b026ab7ba9fa53dd70569aa2ef33d35bea23
6
+ metadata.gz: c59e0798461d56be30b9579cd09b72b373a2b70039139295cc42bf610031e73be438e93f0645963cfb29d6617cab0096b0de978a954950ec6f9c21c13e1d510a
7
+ data.tar.gz: 2d36558c145a2684e169331d7f277f2aa7b09b39c642b331dcf4ec36757228a0ccf6c8ab1ce5b8be7408b157061ae30ef0b7a45108ea012a1462742cbb480b87
data/CHANGELOG.md CHANGED
@@ -1,46 +1,157 @@
1
1
  # Changelog
2
+
2
3
  All notable changes to this project will be documented in this file.
3
4
 
4
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
7
 
7
- ## [UNRELEASED]
8
+ ## [Unreleased]
9
+
10
+ ## [2.4.0] - 2026-02-28
11
+
8
12
  ### Added
9
- - Interaction-based testing: Mock with expectations in the Then block.
13
+
14
+ - Spock-style implicit assertions: every non-assignment statement in Then/Expect blocks is now an assertion — no assertion API needed.
15
+ - Binary operator assertions: `=~`, `!~`, `>`, `<`, `>=`, `<=` with clear error messages.
16
+ - General statement assertions: bare boolean expressions (e.g. `obj.valid?`) with the original source text in the error message.
17
+ - Negation support: `!expr` is detected automatically and produces a clear error message.
18
+ - `>> raises(...)` syntax for exception stubbing in interactions.
10
19
 
11
20
  ### Changed
21
+
22
+ - Renamed `ConditionParser` to `StatementParser` and `ConditionToAssertionTransformation` to `StatementToAssertionTransformation` for consistency with Spock's model.
23
+ - Then and Expect block parsers now use `StatementParser` for statement classification.
24
+
25
+ ### Removed
26
+
27
+ - `ComparisonToAssertionTransformation` — replaced by `StatementToAssertionTransformation`.
28
+
29
+ ## [2.3.1] - 2026-02-27
30
+
31
+ ### Fixed
32
+
33
+ - Require `block_capture` so it is available at runtime.
34
+
35
+ ## [2.3.0] - 2026-02-27
36
+
37
+ ### Added
38
+
39
+ - Interaction transformations and block identity verification via `&` operator.
40
+ - RSpock AST node hierarchy (`Node`, `InteractionNode`, `BodyNode`, etc.) for type-safe AST handling.
41
+ - `TestMethodParser` extracted from `TestMethodTransformation` for separation of parsing and transformation.
42
+
43
+ ### Changed
44
+
45
+ - Restructured block classes into `Parser` namespace and converted `InteractionParser` to a class.
46
+ - Introduced `BodyNode` and removed legacy interaction transformations.
47
+
48
+ ## [2.2.0] - 2026-02-25
49
+
50
+ ### Added
51
+
52
+ - Interaction stubbing with `>>` for return value stubbing in Then block interactions.
53
+
54
+ ### Fixed
55
+
56
+ - Pry and pry-byebug compatibility.
57
+ - Failing test on Ruby 3+.
58
+ - `filter_string` for `ast_transform` 2.1.4 source mapping change.
59
+
60
+ ## [2.1.0] - 2026-02-21
61
+
62
+ ### Added
63
+
64
+ - Ruby 4.0 support.
65
+
66
+ ### Fixed
67
+
68
+ - Codecov badge URL to use master branch.
69
+
70
+ ## [2.0.0] - 2026-02-21
71
+
72
+ ### Changed
73
+
74
+ - Minimum Ruby version bumped to 3.2.
75
+ - Upgraded to Ruby 3.x compatibility.
76
+ - Use `ast_transform` 2.0.0 from RubyGems.
77
+ - CI modernization and release workflow improvements.
78
+
79
+ ## [1.0.0] - 2020-07-09
80
+
81
+ ### Added
82
+
83
+ - Interaction-based testing: mock with expectations in the Then block.
84
+ - Travis CI and code coverage.
85
+
86
+ ### Changed
87
+
12
88
  - Test names now have the test index and line number as suffix instead of prefix.
13
- - Cleanup transformed code output.
89
+ - Removed unnecessary ensure block when Cleanup block is empty; moved source map wrapper to class scope.
90
+ - Bump `ast_transform` to release 1.0.0.
14
91
 
15
92
  ### Fixed
16
- - Fixed source mapping for transformed assertion nodes.
17
93
 
18
- ## [0.2.5] 2019-05-28
94
+ - Source mapping for transformed assertion nodes.
95
+ - Truth table generator command with proper escaping.
96
+
97
+ ## [0.2.5] - 2019-05-28
98
+
19
99
  ### Fixed
20
- - Fixed BacktraceFilter so that source mapping works again
21
100
 
22
- ## [0.2.4] 2019-05-27
101
+ - BacktraceFilter so that source mapping works again.
102
+
103
+ ## [0.2.4] - 2019-05-27
104
+
23
105
  ### Changed
24
- - Bump Unparser dependency from ~> 0.2.8 to ~> 0.4
25
106
 
26
- ## [0.2.3] 2018-11-09
107
+ - Bump Unparser dependency from `~> 0.2.8` to `~> 0.4`.
108
+
109
+ ## [0.2.3] - 2018-11-09
110
+
27
111
  ### Fixed
28
- - Cleanup block can now contain more than one node
29
112
 
30
- ## [0.2.2] 2018-11-08
113
+ - Cleanup block can now contain more than one node.
114
+
115
+ ## [0.2.2] - 2018-11-08
116
+
31
117
  ### Changed
32
- - Extracted ASTTransform to its own gem: `ast_transform`
33
118
 
34
- ## [0.2.1] 2018-10-09
119
+ - Extracted ASTTransform to its own gem: `ast_transform`.
120
+
121
+ ## [0.2.1] - 2018-10-09
122
+
35
123
  ### Added
36
- - _line_number_ is now displayed in the test name, and is available in test scope for debugging purposes
124
+
125
+ - `_line_number_` is now displayed in the test name and available in test scope for debugging.
37
126
 
38
127
  ### Changed
39
- - Renamed test_index to _test_index_
40
128
 
41
- ## [0.2.0] 2018-09-21
129
+ - Renamed `test_index` to `_test_index_`.
130
+
131
+ ## [0.2.0] - 2018-09-21
132
+
42
133
  ### Added
134
+
43
135
  - Truth table generator Rake task.
44
136
 
45
- ## [0.1.1] 2018-09-19
46
- ### Initial Release!
137
+ ## [0.1.1] - 2018-09-18
138
+
139
+ ### Added
140
+
141
+ - Initial release.
142
+
143
+ [Unreleased]: https://github.com/rspockframework/rspock/compare/v2.4.0...HEAD
144
+ [2.4.0]: https://github.com/rspockframework/rspock/compare/v2.3.1...v2.4.0
145
+ [2.3.1]: https://github.com/rspockframework/rspock/compare/v2.3.0...v2.3.1
146
+ [2.3.0]: https://github.com/rspockframework/rspock/compare/v2.2.0...v2.3.0
147
+ [2.2.0]: https://github.com/rspockframework/rspock/compare/v2.1.0...v2.2.0
148
+ [2.1.0]: https://github.com/rspockframework/rspock/compare/v2.0.0...v2.1.0
149
+ [2.0.0]: https://github.com/rspockframework/rspock/compare/1.0.0...v2.0.0
150
+ [1.0.0]: https://github.com/rspockframework/rspock/compare/0.2.5...1.0.0
151
+ [0.2.5]: https://github.com/rspockframework/rspock/compare/0.2.4...0.2.5
152
+ [0.2.4]: https://github.com/rspockframework/rspock/compare/0.2.3...0.2.4
153
+ [0.2.3]: https://github.com/rspockframework/rspock/compare/0.2.2...0.2.3
154
+ [0.2.2]: https://github.com/rspockframework/rspock/compare/0.2.1...0.2.2
155
+ [0.2.1]: https://github.com/rspockframework/rspock/compare/0.2.0...0.2.1
156
+ [0.2.0]: https://github.com/rspockframework/rspock/compare/0.1.1...0.2.0
157
+ [0.1.1]: https://github.com/rspockframework/rspock/releases/tag/0.1.1
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rspock (2.3.1)
4
+ rspock (2.4.0)
5
5
  ast_transform (~> 2.0)
6
6
  minitest (~> 5.0)
7
7
  mocha (>= 1.0)
data/README.md CHANGED
@@ -16,8 +16,10 @@ Note: RSpock is heavily inspired by Spock for the Groovy programming language.
16
16
 
17
17
  * BDD-style code blocks: Given, When, Then, Expect, Cleanup, Where
18
18
  * Data-driven testing with incredibly expressive table-based Where blocks
19
- * Expressive assertions: Use familiar comparison operators `==` and `!=` for assertions!
20
- * [Interaction-based testing](#mocking-with-interactions), i.e. `1 * object.receive("message")` in Then blocks, with optional [return value stubbing](#stubbing-return-values) via `>>` and [block forwarding verification](#block-forwarding-verification) via `&block`
19
+ * Spock-style implicit assertions: every statement in Then/Expect blocks is an assertion (no assertion API needed)
20
+ * Binary operator assertions: `==`, `!=`, `=~`, `!~`, `>`, `<`, `>=`, `<=` with clear error messages
21
+ * General statement assertions: bare boolean expressions (e.g. `obj.valid?`, `list.include?(x)`) and negation (`!obj.empty?`) with source-text error messages
22
+ * [Interaction-based testing](#mocking-with-interactions), i.e. `1 * object.receive("message")` in Then blocks, with optional [return value stubbing](#stubbing-return-values) via `>>`, [exception stubbing](#stubbing-exceptions) via `>> raises(...)`, and [block forwarding verification](#block-forwarding-verification) via `&block`
21
23
  * (Planned) BDD-style custom reporter that outputs information from Code Blocks
22
24
  * (Planned) Capture all Then block violations
23
25
 
@@ -150,15 +152,25 @@ The When block describes the stimulus to be applied to the system under test. It
150
152
 
151
153
  ```ruby
152
154
  Then "The product is added to the cart"
155
+ !cart.products.empty?
153
156
  cart.products.size == 1
154
157
  cart.products.first == product
158
+ cart.products.size > 0
155
159
  ```
156
160
 
157
- The Then block describes the response from the stimulus. Any comparison operators used in the Then block (`==` or `!=`) is transformed to assert_equal / refute_equal under the hood. By convention, the __LHS__ operand is considered the __actual__ value, while the __RHS__ operand is considered the __expected__ value.
161
+ The Then block describes the response from the stimulus. Following Spock's core model, **every statement is an assertion** unless it's a variable assignment. No assertion API is needed.
162
+
163
+ **Binary operators** (`==`, `!=`, `=~`, `!~`, `>`, `<`, `>=`, `<=`) produce clear error messages on failure. By convention, the **LHS** operand is the **actual** value and the **RHS** is the **expected** value.
164
+
165
+ **General statements** (bare boolean expressions like `obj.valid?`, `list.include?(x)`) include the original source text in the error message, so you see exactly which expression failed. **Negation** (`!expr`) is detected automatically.
166
+
167
+ **Variable assignments** pass through unchanged and execute in source order after the stimulus.
158
168
 
159
169
  #### Expect Block
160
170
 
161
- The Expect block is useful when expressing the stimulus and the response in one statement is more natural. For example, let's compare two equivalent ways of describing some behaviour:
171
+ The Expect block is useful when expressing the stimulus and the response in one statement is more natural. The same assertion rules apply as in Then blocks every statement is an assertion unless it's a variable assignment.
172
+
173
+ For example, let's compare two equivalent ways of describing some behaviour:
162
174
 
163
175
  ##### When + Then
164
176
  ```ruby
@@ -175,6 +187,17 @@ Expect "absolute of -2 is 2"
175
187
  -2.abs == 2
176
188
  ```
177
189
 
190
+ The Expect block supports the full range of assertion expressions:
191
+
192
+ ```ruby
193
+ Expect "string matching and predicates"
194
+ str =~ /potato/
195
+ str.include?("pot")
196
+ !str.empty?
197
+ str.length > 3
198
+ str.length == 6
199
+ ```
200
+
178
201
  A good rule of thumb is using When + Then blocks to describe methods with side-effects and Expect blocks to describe purely functional methods.
179
202
 
180
203
  #### Cleanup Block
@@ -306,12 +329,12 @@ test "#publish sends a message to all subscribers" do
306
329
  end
307
330
  ```
308
331
 
309
- The above ___Then___ block contains 2 interactions, each of which has 4 parts: the _cardinality_, the _receiver_, the _message_ and its _arguments_. Optionally, a _return value_ can be specified using the `>>` operator, and _block forwarding_ can be verified using the `&` operator.
332
+ The above ___Then___ block contains 2 interactions, each of which has 4 parts: the _cardinality_, the _receiver_, the _message_ and its _arguments_. Optionally, an _outcome_ can be specified using the `>>` operator (either a return value or `raises(...)` for exceptions), and _block forwarding_ can be verified using the `&` operator.
310
333
 
311
334
  ```
312
335
  1 * receiver.message('hello', &blk) >> "result"
313
336
  | | | | | |
314
- | | | | | return value (optional)
337
+ | | | | | outcome (optional): value or raises(...)
315
338
  | | | | block forwarding (optional)
316
339
  | | | argument(s) (optional)
317
340
  | | message
@@ -421,6 +444,39 @@ _ * cache.fetch("key") >> expensive_result # a variable
421
444
 
422
445
  **Note**: Without `>>`, an interaction sets up an expectation only (the method will return `nil` by default). Use `>>` when the code under test depends on the return value.
423
446
 
447
+ #### Stubbing Exceptions
448
+
449
+ When the code under test needs a collaborator to raise an exception, use `>> raises(...)` instead of a return value. This sets up the mock to raise the given exception when called.
450
+
451
+ ```ruby
452
+ test "#fetch raises when the record is not found" do
453
+ Given
454
+ repository = mock
455
+ service = Service.new(repository)
456
+
457
+ When
458
+ service.fetch(42)
459
+
460
+ Then
461
+ 1 * repository.find(42) >> raises(RecordNotFound)
462
+ end
463
+ ```
464
+
465
+ You can also pass a message or an exception instance:
466
+
467
+ ```ruby
468
+ 1 * repository.find(42) >> raises(RecordNotFound, "not found") # class + message
469
+ 1 * repository.find(42) >> raises(RecordNotFound.new("not found")) # instance
470
+ ```
471
+
472
+ This works with all interaction features — cardinality, arguments, and ranges:
473
+
474
+ ```ruby
475
+ (1..3) * service.call(anything) >> raises(TimeoutError)
476
+ ```
477
+
478
+ **Note**: Without `raises(...)`, the `>>` operator stubs a return value. With `raises(...)`, it stubs an exception instead.
479
+
424
480
  #### Block Forwarding Verification
425
481
 
426
482
  When the code under test forwards a block (or proc) to a collaborator, you may want to verify that the _exact_ block was passed through. RSpock supports this with the `&` operator in interactions, performing an identity check on the block reference.
@@ -527,20 +583,24 @@ To install this gem onto your local machine, run `bundle exec rake install`.
527
583
 
528
584
  ## Releasing a New Version
529
585
 
530
- There are two ways to create a release. Both require that `version.rb` has already been updated and merged to main.
586
+ There are two ways to create a release. Both require that `version.rb` has already been updated, `CHANGELOG.md` has been updated, and changes have been merged to main.
531
587
 
532
588
  ### Via GitHub UI
533
589
 
534
- 1. Update `VERSION` in `lib/rspock/version.rb` and run `bundle install` to regenerate `Gemfile.lock`, commit, open a PR, and merge to main
535
- 2. Go to the repo on GitHub **Releases** **Draft a new release**
536
- 3. Enter a new tag (e.g. `v2.0.0`), select `main` as the target branch
537
- 4. Add a title and release notes (GitHub can auto-generate these from merged PRs)
538
- 5. Click **Publish release**
590
+ 1. Update `VERSION` in `lib/rspock/version.rb` and run `bundle install` to regenerate `Gemfile.lock`
591
+ 2. Move the `[Unreleased]` section in `CHANGELOG.md` to a new version heading with today's date (e.g. `## [2.4.0] - 2026-03-01`) and add a fresh empty `[Unreleased]` section above it. Update the comparison links at the bottom of the file.
592
+ 3. Commit, open a PR, and merge to main
593
+ 4. Go to the repo on GitHub **Releases** **Draft a new release**
594
+ 5. Enter a new tag (e.g. `v2.4.0`), select `main` as the target branch
595
+ 6. Add a title and release notes (GitHub can auto-generate these from merged PRs)
596
+ 7. Click **Publish release**
539
597
 
540
598
  ### Via CLI
541
599
 
542
- 1. Update `VERSION` in `lib/rspock/version.rb` and run `bundle install` to regenerate `Gemfile.lock`, commit, open a PR, and merge to main
543
- 2. Tag and push:
600
+ 1. Update `VERSION` in `lib/rspock/version.rb` and run `bundle install` to regenerate `Gemfile.lock`
601
+ 2. Move the `[Unreleased]` section in `CHANGELOG.md` to a new version heading with today's date and add a fresh empty `[Unreleased]` section above it. Update the comparison links at the bottom of the file.
602
+ 3. Commit, open a PR, and merge to main
603
+ 4. Tag and push:
544
604
  ```
545
605
  git checkout main && git pull
546
606
  git tag v2.0.0
@@ -6,11 +6,20 @@ module RSpock
6
6
  module AST
7
7
  # Transforms an :rspock_interaction node into Mocha mock setup code.
8
8
  #
9
- # Input: s(:rspock_interaction, cardinality, receiver, sym, args, return_value, block_pass)
9
+ # Input: s(:rspock_interaction, cardinality, receiver, sym, args, outcome, block_pass)
10
10
  # Output: receiver.expects(:message).with(*args).times(n).returns(value)
11
11
  #
12
+ # The outcome node type maps directly to the Mocha chain method:
13
+ # :rspock_returns -> .returns(value)
14
+ # :rspock_raises -> .raises(exception_class, ...)
15
+ #
12
16
  # When block_pass is present, wraps the expects chain with a BlockCapture.capture call.
13
17
  class InteractionToMochaMockTransformation < ASTTransform::AbstractTransformation
18
+ OUTCOME_METHODS = {
19
+ rspock_returns: :returns,
20
+ rspock_raises: :raises,
21
+ }.freeze
22
+
14
23
  def initialize(index = 0)
15
24
  @index = index
16
25
  end
@@ -21,7 +30,7 @@ module RSpock
21
30
  result = chain_call(interaction.receiver, :expects, s(:sym, interaction.message))
22
31
  result = chain_call(result, :with, *interaction.args.children) if interaction.args
23
32
  result = build_cardinality(result, interaction.cardinality)
24
- result = chain_call(result, :returns, interaction.return_value) if interaction.return_value
33
+ result = chain_call(result, OUTCOME_METHODS.fetch(interaction.outcome.type), *interaction.outcome.children) if interaction.outcome
25
34
 
26
35
  if interaction.block_pass
27
36
  build_block_capture_setup(result, interaction.receiver, interaction.message)
@@ -70,6 +70,17 @@ module RSpock
70
70
  end
71
71
  end
72
72
 
73
+ class OutcomeNode < Node
74
+ end
75
+
76
+ class ReturnsNode < OutcomeNode
77
+ register :rspock_returns
78
+ end
79
+
80
+ class RaisesNode < OutcomeNode
81
+ register :rspock_raises
82
+ end
83
+
73
84
  class InteractionNode < Node
74
85
  register :rspock_interaction
75
86
 
@@ -78,10 +89,25 @@ module RSpock
78
89
  def message_sym = children[2]
79
90
  def message = message_sym.children[0]
80
91
  def args = children[3]
81
- def return_value = children[4]
92
+ def outcome = children[4]
82
93
  def block_pass = children[5]
83
94
  end
84
95
 
96
+ class BinaryStatementNode < Node
97
+ register :rspock_binary_statement
98
+
99
+ def lhs = children[0]
100
+ def operator = children[1]
101
+ def rhs = children[2]
102
+ end
103
+
104
+ class StatementNode < Node
105
+ register :rspock_statement
106
+
107
+ def expression = children[0]
108
+ def source = children[1]
109
+ end
110
+
85
111
  module NodeBuilder
86
112
  include ASTTransform::TransformationHelper
87
113
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require 'rspock/ast/parser/block'
3
+ require 'rspock/ast/parser/statement_parser'
3
4
 
4
5
  module RSpock
5
6
  module AST
@@ -20,6 +21,12 @@ module RSpock
20
21
  def successors
21
22
  @successors ||= [:Cleanup, :Where].freeze
22
23
  end
24
+
25
+ def to_rspock_node
26
+ statement_parser = StatementParser.new
27
+ spock_children = @children.map { |child| statement_parser.parse(child) }
28
+ s(:rspock_expect, *spock_children)
29
+ end
23
30
  end
24
31
  end
25
32
  end
@@ -7,14 +7,14 @@ module RSpock
7
7
  # Parses raw Ruby AST interaction nodes into structured :rspock_interaction nodes.
8
8
  #
9
9
  # Input: 1 * receiver.message("arg", &blk) >> "result"
10
- # Output: s(:rspock_interaction, cardinality, receiver, sym, args, return_value, block_pass)
10
+ # Output: s(:rspock_interaction, cardinality, receiver, sym, args, outcome, block_pass)
11
11
  #
12
12
  # :rspock_interaction children:
13
13
  # [0] cardinality - e.g. s(:int, 1), s(:begin, s(:irange, ...)), s(:send, nil, :_)
14
14
  # [1] receiver - e.g. s(:send, nil, :subscriber)
15
15
  # [2] message - e.g. s(:sym, :receive)
16
16
  # [3] args - nil if no args, s(:array, *arg_nodes) otherwise
17
- # [4] return_value - nil if no >>, otherwise the value node
17
+ # [4] outcome - nil if no >>, otherwise s(:rspock_returns, value) or s(:rspock_raises, *args)
18
18
  # [5] block_pass - nil if no &, otherwise s(:block_pass, ...)
19
19
  class InteractionParser
20
20
  include RSpock::AST::NodeBuilder
@@ -35,7 +35,7 @@ module RSpock
35
35
  return node unless interaction_node?(node)
36
36
 
37
37
  if return_value_node?(node)
38
- return_value = node.children[2]
38
+ outcome = parse_outcome(node.children[2])
39
39
  node = node.children[0]
40
40
  end
41
41
 
@@ -50,7 +50,7 @@ module RSpock
50
50
  receiver,
51
51
  s(:sym, message),
52
52
  args,
53
- return_value,
53
+ outcome,
54
54
  block_pass
55
55
  )
56
56
  end
@@ -61,6 +61,14 @@ module RSpock
61
61
  node.type == :send && node.children[1] == :>> && interaction_node?(node.children[0])
62
62
  end
63
63
 
64
+ def parse_outcome(node)
65
+ if node.type == :send && node.children[0].nil? && node.children[1] == :raises
66
+ s(:rspock_raises, *node.children[2..])
67
+ else
68
+ s(:rspock_returns, node)
69
+ end
70
+ end
71
+
64
72
  def validate_cardinality(node)
65
73
  case node.type
66
74
  when *ALLOWED_CARDINALITY_NODES
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+ require 'rspock/ast/node'
3
+
4
+ module RSpock
5
+ module AST
6
+ module Parser
7
+ # Classifies raw Ruby AST statements into RSpock node types for Then/Expect blocks.
8
+ #
9
+ # - Assignments pass through as raw AST (no wrapping).
10
+ # - Binary operators (==, !=, =~, etc.) become :rspock_binary_statement nodes.
11
+ # - Everything else becomes :rspock_statement nodes with the original source text captured.
12
+ class StatementParser
13
+ include RSpock::AST::NodeBuilder
14
+
15
+ BINARY_OPERATORS = %i[== != =~ !~ > < >= <=].freeze
16
+ ASSIGNMENT_TYPES = %i[lvasgn masgn op_asgn or_asgn and_asgn].freeze
17
+
18
+ def parse(node)
19
+ return node if assignment?(node)
20
+ return build_binary_statement(node) if binary_statement?(node)
21
+
22
+ build_statement(node)
23
+ end
24
+
25
+ private
26
+
27
+ def assignment?(node)
28
+ ASSIGNMENT_TYPES.include?(node.type)
29
+ end
30
+
31
+ def binary_statement?(node)
32
+ node.type == :send &&
33
+ node.children.length == 3 &&
34
+ BINARY_OPERATORS.include?(node.children[1])
35
+ end
36
+
37
+ def build_binary_statement(node)
38
+ s(:rspock_binary_statement, node.children[0], s(:sym, node.children[1]), node.children[2])
39
+ end
40
+
41
+ def build_statement(node)
42
+ source = node.loc&.expression&.source || node.inspect
43
+ s(:rspock_statement, node, s(:str, source))
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require 'rspock/ast/parser/block'
3
3
  require 'rspock/ast/parser/interaction_parser'
4
+ require 'rspock/ast/parser/statement_parser'
4
5
 
5
6
  module RSpock
6
7
  module AST
@@ -19,8 +20,16 @@ module RSpock
19
20
  end
20
21
 
21
22
  def to_rspock_node
22
- parser = InteractionParser.new
23
- spock_children = @children.map { |child| parser.parse(child) }
23
+ interaction_parser = InteractionParser.new
24
+ statement_parser = StatementParser.new
25
+
26
+ spock_children = @children.map do |child|
27
+ parsed = interaction_parser.parse(child)
28
+ next parsed unless parsed.equal?(child)
29
+
30
+ statement_parser.parse(child)
31
+ end
32
+
24
33
  s(:rspock_then, *spock_children)
25
34
  end
26
35
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+ require 'rspock/ast/node'
3
+
4
+ module RSpock
5
+ module AST
6
+ # Transforms :rspock_binary_statement and :rspock_statement nodes into Minitest assertion calls.
7
+ #
8
+ # Binary statements dispatch to specialized assertions (assert_equal, assert_match, assert_operator).
9
+ # General statements use assert_equal(true/false, expr, source_message) with negation detection.
10
+ class StatementToAssertionTransformation
11
+ include RSpock::AST::NodeBuilder
12
+
13
+ BINARY_DISPATCH = {
14
+ :== => :assert_equal,
15
+ :!= => :refute_equal,
16
+ :=~ => :assert_match,
17
+ :'!~' => :refute_match,
18
+ }.freeze
19
+
20
+ OPERATOR_ASSERTIONS = %i[> < >= <=].freeze
21
+
22
+ def run(node)
23
+ case node.type
24
+ when :rspock_binary_statement
25
+ transform_binary_statement(node)
26
+ when :rspock_statement
27
+ transform_statement(node)
28
+ else
29
+ node
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def transform_binary_statement(node)
36
+ lhs = node.lhs
37
+ op = node.operator.children[0]
38
+ rhs = node.rhs
39
+
40
+ if (assertion = BINARY_DISPATCH[op])
41
+ s(:send, nil, assertion, rhs, lhs)
42
+ elsif OPERATOR_ASSERTIONS.include?(op)
43
+ s(:send, nil, :assert_operator, lhs, s(:sym, op), rhs)
44
+ else
45
+ s(:send, nil, :assert_operator, lhs, s(:sym, op), rhs)
46
+ end
47
+ end
48
+
49
+ def transform_statement(node)
50
+ expr = node.expression
51
+ source_text = node.source.children[0]
52
+
53
+ if negated?(expr)
54
+ inner = expr.children[0]
55
+ message = "Expected \"#{source_text}\" to be false"
56
+ s(:send, nil, :assert_equal, s(:false), inner, s(:str, message))
57
+ else
58
+ message = "Expected \"#{source_text}\" to be true"
59
+ s(:send, nil, :assert_equal, s(:true), expr, s(:str, message))
60
+ end
61
+ end
62
+
63
+ def negated?(node)
64
+ node.type == :send && node.children[1] == :! && node.children.length == 2
65
+ end
66
+ end
67
+ end
68
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require 'ast_transform/abstract_transformation'
3
3
  require 'rspock/ast/node'
4
- require 'rspock/ast/comparison_to_assertion_transformation'
4
+ require 'rspock/ast/statement_to_assertion_transformation'
5
5
  require 'rspock/ast/header_nodes_transformation'
6
6
  require 'rspock/ast/interaction_to_mocha_mock_transformation'
7
7
  require 'rspock/ast/interaction_to_block_identity_assertion_transformation'
@@ -14,7 +14,7 @@ module RSpock
14
14
  class TestMethodTransformation < ASTTransform::AbstractTransformation
15
15
  def initialize(block_registry, strict: true)
16
16
  @parser = Parser::TestMethodParser.new(block_registry, strict: strict)
17
- @comparison_transformation = ComparisonToAssertionTransformation.new(:_test_index_, :_line_number_)
17
+ @statement_transformation = StatementToAssertionTransformation.new
18
18
  end
19
19
 
20
20
  def run(node)
@@ -59,7 +59,7 @@ module RSpock
59
59
  interaction_setups << setup
60
60
  then_children << assertion unless assertion.equal?(child)
61
61
  else
62
- then_children << @comparison_transformation.run(child)
62
+ then_children << transform_statement_or_passthrough(child)
63
63
  end
64
64
  end
65
65
 
@@ -77,10 +77,19 @@ module RSpock
77
77
  end
78
78
 
79
79
  def transform_expect_block(expect_node)
80
- new_children = expect_node.children.map { |child| @comparison_transformation.run(child) }
80
+ new_children = expect_node.children.map { |child| transform_statement_or_passthrough(child) }
81
81
  expect_node.updated(nil, new_children)
82
82
  end
83
83
 
84
+ def transform_statement_or_passthrough(child)
85
+ case child.type
86
+ when :rspock_binary_statement, :rspock_statement
87
+ @statement_transformation.run(child)
88
+ else
89
+ child
90
+ end
91
+ end
92
+
84
93
  # --- Build final Ruby AST ---
85
94
 
86
95
  def build_ruby_ast(method_call, method_args, body_node, where, hoisted_setups)
@@ -1,3 +1,3 @@
1
1
  module RSpock
2
- VERSION = "2.3.1"
2
+ VERSION = "2.4.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspock
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.1
4
+ version: 2.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean-Philippe Duchesne
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-27 00:00:00.000000000 Z
11
+ date: 2026-02-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -207,7 +207,6 @@ files:
207
207
  - lib/generators/templates/rspock_initializer.rb
208
208
  - lib/minitest/rspock_plugin.rb
209
209
  - lib/rspock.rb
210
- - lib/rspock/ast/comparison_to_assertion_transformation.rb
211
210
  - lib/rspock/ast/header_nodes_transformation.rb
212
211
  - lib/rspock/ast/interaction_to_block_identity_assertion_transformation.rb
213
212
  - lib/rspock/ast/interaction_to_mocha_mock_transformation.rb
@@ -218,10 +217,12 @@ files:
218
217
  - lib/rspock/ast/parser/expect_block.rb
219
218
  - lib/rspock/ast/parser/given_block.rb
220
219
  - lib/rspock/ast/parser/interaction_parser.rb
220
+ - lib/rspock/ast/parser/statement_parser.rb
221
221
  - lib/rspock/ast/parser/test_method_parser.rb
222
222
  - lib/rspock/ast/parser/then_block.rb
223
223
  - lib/rspock/ast/parser/when_block.rb
224
224
  - lib/rspock/ast/parser/where_block.rb
225
+ - lib/rspock/ast/statement_to_assertion_transformation.rb
225
226
  - lib/rspock/ast/test_method_def_transformation.rb
226
227
  - lib/rspock/ast/test_method_dstr_transformation.rb
227
228
  - lib/rspock/ast/test_method_transformation.rb
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
- require 'ast_transform/abstract_transformation'
3
- require 'rspock/ast/method_call_to_lvar_transformation'
4
-
5
- module RSpock
6
- module AST
7
- class ComparisonToAssertionTransformation < ASTTransform::AbstractTransformation
8
- def initialize(*ignored_method_call_symbols)
9
- @method_call_transformation = RSpock::AST::MethodCallToLVarTransformation.new(*ignored_method_call_symbols)
10
- end
11
-
12
- def on_send(node)
13
- if node.children.count == 3 && node.children[1] == :== && ignored_method_call_node?(node)
14
- transform_to_assert_equal(node)
15
- elsif node.children.count == 3 && node.children[1] == :!= && ignored_method_call_node?(node)
16
- transform_to_refute_equal(node)
17
- else
18
- node.updated(nil, process_all(node))
19
- end
20
- end
21
-
22
- private
23
-
24
- def ignored_method_call_node?(node)
25
- return false unless node.is_a?(::Parser::AST::Node)
26
-
27
- !@method_call_transformation.method_call_node?(node.children[0]) &&
28
- !@method_call_transformation.method_call_node?(node.children[2])
29
- end
30
-
31
- def transform_to_assert_equal(node)
32
- node.updated(nil, [nil, :assert_equal, node.children[2], node.children[0]])
33
- end
34
-
35
- def transform_to_refute_equal(node)
36
- node.updated(nil, [nil, :refute_equal, node.children[2], node.children[0]])
37
- end
38
- end
39
- end
40
- end