rspock 2.5.0 → 3.0.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.
@@ -3,15 +3,13 @@ require 'ast_transform/abstract_transformation'
3
3
 
4
4
  module RSpock
5
5
  module AST
6
+ # dstr counterpart of TestMethodDefTransformation: appends the row index and source line interpolations to an
7
+ # already-interpolated test name.
6
8
  class TestMethodDstrTransformation < ASTTransform::AbstractTransformation
7
- TEST_INDEX_AST = s(:begin,
8
- s(:lvar, :_test_index_))
9
-
10
- LINE_NUMBER_AST = s(:begin,
11
- s(:lvar, :_line_number_))
9
+ ROW_INDEX_AST = s(:begin, s(:lvar, :__rspock_row_index__))
10
+ ROW_LINE_AST = s(:begin, s(:lvar, :__rspock_row_line__))
12
11
 
13
12
  SPACE_STR_AST = s(:str, " ")
14
-
15
13
  LINE_NUMBER_STR_AST = s(:str, " line ")
16
14
 
17
15
  def on_dstr(node)
@@ -24,7 +22,7 @@ module RSpock
24
22
  children << SPACE_STR_AST
25
23
  end
26
24
 
27
- children.push(TEST_INDEX_AST, LINE_NUMBER_STR_AST, LINE_NUMBER_AST)
25
+ children.push(ROW_INDEX_AST, LINE_NUMBER_STR_AST, ROW_LINE_AST)
28
26
  node.updated(nil, children)
29
27
  end
30
28
  end
@@ -5,7 +5,6 @@ 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'
8
- require 'rspock/ast/method_call_to_lvar_transformation'
9
8
  require 'rspock/ast/test_method_def_transformation'
10
9
  require 'rspock/ast/parser/test_method_parser'
11
10
 
@@ -26,158 +25,208 @@ module RSpock
26
25
  private
27
26
 
28
27
  def transform(rspock_ast)
29
- hoisted_setups = []
30
-
31
28
  method_call = rspock_ast.def_node.method_call
32
29
  method_args = rspock_ast.def_node.args
33
30
  where = rspock_ast.where_node
34
31
 
35
- transformed_blocks = rspock_ast.body_node.children.map do |block_node|
36
- case block_node.type
37
- when :rspock_then
38
- transform_then_block(block_node, hoisted_setups)
39
- when :rspock_expect
40
- transform_expect_block(block_node)
41
- else
42
- block_node
43
- end
32
+ body = build_test_body(rspock_ast.body_node)
33
+ build_ruby_ast(method_call, method_args, body, where)
34
+ end
35
+
36
+ # --- Test body assembly ---
37
+ #
38
+ # Statements are assembled in SOURCE order so line-aligned emission keeps each one on its own line.
39
+ # Execution-order requirements that source order cannot express (interaction setups in Then must run before the
40
+ # When body they observe) are carried by ast-transform's thunk facility (run_after / thunk) instead of by
41
+ # textual hoisting.
42
+ def build_test_body(body_node)
43
+ blocks = body_node.children
44
+ sections = blocks.map { |block_node| transform_block(block_node) }
45
+
46
+ when_statements = statements_of_type(blocks, sections, :rspock_when)
47
+ cleanup_statements = statements_of_type(blocks, sections, :rspock_cleanup)
48
+ interaction_setups = sections.flat_map(&:interaction_setups)
49
+ raises_node = blocks.filter_map { |block_node| find_raises(block_node) }.first
50
+
51
+ source_order = blocks.zip(sections).flat_map do |block_node, section|
52
+ next [] if block_node.type == :rspock_cleanup
53
+
54
+ section.statements.reject { |statement| statement.type == :rspock_raises }
44
55
  end
45
56
 
46
- transformed_body = rspock_ast.body_node.updated(nil, transformed_blocks)
47
- build_ruby_ast(method_call, method_args, transformed_body, where, hoisted_setups)
48
- end
57
+ body_children = order_execution(source_order, when_statements, interaction_setups, raises_node)
49
58
 
50
- def transform_then_block(then_node, hoisted_setups)
51
- interaction_setups = []
52
- then_children = []
59
+ ast = s(:begin, *body_children)
60
+ cleanup_statements.empty? ? ast : s(:kwbegin, s(:ensure, ast, s(:begin, *cleanup_statements)))
61
+ end
53
62
 
54
- then_node.children.each_with_index do |child, idx|
55
- if child.type == :rspock_interaction
56
- setup = InteractionToMochaMockTransformation.new(idx).run(child)
57
- assertion = InteractionToBlockIdentityAssertionTransformation.new(idx).run(child)
63
+ Section = Data.define(:statements, :interaction_setups)
58
64
 
59
- interaction_setups << setup
60
- then_children << assertion unless assertion.equal?(child)
61
- else
62
- then_children << transform_statement_or_passthrough(child)
63
- end
65
+ def transform_block(block_node)
66
+ case block_node.type
67
+ when :rspock_then, :rspock_expect
68
+ transform_assertion_block(block_node)
69
+ else
70
+ Section.new(statements: block_node.children, interaction_setups: [])
64
71
  end
72
+ end
65
73
 
66
- unless interaction_setups.empty?
67
- interaction_setups.each do |node|
68
- if node.type == :begin
69
- hoisted_setups.concat(node.children)
70
- else
71
- hoisted_setups << node
72
- end
74
+ # Then/Expect children become plain Ruby in place: interactions lower to Mocha setups anchored at the
75
+ # interaction's own source line (plus an identity assertion for &block forwarding), statements become
76
+ # assertions at their own lines.
77
+ #
78
+ # Within the section, ALL setups come before all assertions: the thunked When body executes right after the
79
+ # last setup, and every assertion (identity or otherwise) observes the When body's effects, so none may
80
+ # precede that point. Setups keep source order and their anchors, so alignment holds; assertions after them
81
+ # are either synthetic (identity assertions, loc-less, pack anywhere) or textually below the interactions in
82
+ # the common case.
83
+ def transform_assertion_block(block_node)
84
+ setups = []
85
+ assertions = []
86
+ interaction_index = 0
87
+
88
+ block_node.children.each do |child|
89
+ case child.type
90
+ when :rspock_interaction
91
+ interaction_setups, identity_assertions = lower_interaction(child, interaction_index)
92
+ interaction_index += 1
93
+ setups.concat(interaction_setups)
94
+ assertions.concat(identity_assertions)
95
+ when :rspock_binary_statement, :rspock_statement
96
+ assertions << anchored_at(child, @statement_transformation.run(child))
97
+ else
98
+ assertions << child
73
99
  end
74
100
  end
75
101
 
76
- then_node.updated(nil, then_children)
102
+ Section.new(statements: setups + assertions, interaction_setups: setups)
77
103
  end
78
104
 
79
- def transform_expect_block(expect_node)
80
- new_children = expect_node.children.map { |child| transform_statement_or_passthrough(child) }
81
- expect_node.updated(nil, new_children)
105
+ # @return [Array(Array, Array)] Mocha setup statements, identity assertions
106
+ def lower_interaction(interaction, index)
107
+ setup = InteractionToMochaMockTransformation.new(index).run(interaction)
108
+ assertion = InteractionToBlockIdentityAssertionTransformation.new(index).run(interaction)
109
+
110
+ setups = setup.type == :begin ? setup.children.dup : [setup]
111
+ setups[0] = anchored_at(interaction, setups[0])
112
+
113
+ [setups, assertion.equal?(interaction) ? [] : [assertion]]
82
114
  end
83
115
 
84
- def transform_statement_or_passthrough(child)
85
- case child.type
86
- when :rspock_binary_statement, :rspock_statement
87
- @statement_transformation.run(child)
116
+ # Reorders execution (not text) where required:
117
+ # - interactions without raises: run the When body after the last interaction setup (run_after — the paved
118
+ # road).
119
+ # - raises without interactions: the When body inlines directly into assert_raises; no thunk needed.
120
+ # - raises with interactions: the When body is thunked into the assert_raises block inserted after the last
121
+ # setup; the lowering re-emits the body at its own source lines.
122
+ def order_execution(source_order, when_statements, interaction_setups, raises_node)
123
+ if raises_node
124
+ build_raises_body(source_order, when_statements, interaction_setups, raises_node)
125
+ elsif interaction_setups.any? && when_statements.any?
126
+ run_after(source_order, run: when_statements, after: interaction_setups.last)
88
127
  else
89
- child
128
+ source_order
90
129
  end
91
130
  end
92
131
 
93
- # --- Build final Ruby AST ---
94
-
95
- def build_ruby_ast(method_call, method_args, body_node, where, hoisted_setups)
96
- if where
97
- test_def = s(:block,
98
- TestMethodDefTransformation.new.run(method_call),
99
- method_args,
100
- build_test_body(body_node, hoisted_setups)
101
- )
102
- test_def = HeaderNodesTransformation.new(where.header).run(test_def)
132
+ def build_raises_body(source_order, when_statements, interaction_setups, raises_node)
133
+ if interaction_setups.any?
134
+ assertion = build_assert_raises(raises_node, thunk(*when_statements))
103
135
 
104
- s(:block,
105
- build_where_iterator(where.data_rows),
106
- build_where_args(where.header),
107
- test_def
108
- )
136
+ reordered = replace_run(source_order, when_statements, [])
137
+ insert_after(reordered, interaction_setups.last, assertion)
109
138
  else
110
- s(:block,
111
- method_call,
112
- method_args,
113
- build_test_body(body_node, hoisted_setups)
114
- )
139
+ when_body = when_statements.length == 1 ? when_statements[0] : s(:begin, *when_statements)
140
+ assertion = build_assert_raises(raises_node, when_body)
141
+
142
+ replace_run(source_order, when_statements, [assertion])
115
143
  end
116
144
  end
117
145
 
118
- def build_test_body(body_node, hoisted_setups)
119
- body_children = []
120
- blocks = body_node.children
146
+ def build_assert_raises(raises_node, body)
147
+ assert_raises_call = s(:block,
148
+ s(:send, nil, :assert_raises, raises_node.exception_class),
149
+ s(:args),
150
+ body
151
+ )
121
152
 
122
- blocks.each_with_index do |block_node, i|
123
- case block_node.type
124
- when :rspock_given
125
- body_children.concat(block_node.children)
126
- when :rspock_when
127
- body_children.concat(hoisted_setups)
128
- raises_node = find_raises_in_next_then(blocks, i)
129
-
130
- if raises_node
131
- body_children << build_assert_raises(block_node, raises_node)
132
- else
133
- body_children.concat(block_node.children)
134
- end
135
- when :rspock_then, :rspock_expect
136
- block_node.children.each do |child|
137
- body_children << child unless child.type == :rspock_raises
138
- end
139
- when :rspock_cleanup
140
- # handled below as ensure
141
- end
153
+ if raises_node.capture_name
154
+ s(:lvasgn, raises_node.capture_name, assert_raises_call)
155
+ else
156
+ assert_raises_call
142
157
  end
158
+ end
143
159
 
144
- ast = s(:begin, *body_children)
160
+ def find_raises(block_node)
161
+ return nil unless block_node.type == :rspock_then
145
162
 
146
- cleanup = body_node.children.find { |n| n.type == :rspock_cleanup }
147
- if cleanup && !cleanup.children.empty?
148
- ensure_node = s(:begin, *cleanup.children)
149
- ast = s(:kwbegin, s(:ensure, ast, ensure_node))
150
- end
163
+ block_node.children.find { |child| child.type == :rspock_raises }
164
+ end
151
165
 
152
- MethodCallToLVarTransformation.new(:_test_index_, :_line_number_).run(ast)
166
+ def statements_of_type(blocks, sections, type)
167
+ blocks.zip(sections)
168
+ .select { |block_node, _section| block_node.type == type }
169
+ .flat_map { |_block_node, section| section.statements }
153
170
  end
154
171
 
155
- # --- Raises condition helpers ---
172
+ # --- Identity-based sequence edits (non-thunk counterparts of run_after) ---
156
173
 
157
- def find_raises_in_next_then(blocks, current_index)
158
- next_block = blocks[current_index + 1]
159
- return nil unless next_block&.type == :rspock_then
174
+ def replace_run(statements, run, replacement)
175
+ start = statements.index { |statement| statement.equal?(run.first) }
176
+ raise ArgumentError, "run is not part of statements" unless start
160
177
 
161
- next_block.children.find { |c| c.type == :rspock_raises }
178
+ result = statements.dup
179
+ result[start, run.length] = replacement
180
+ result
162
181
  end
163
182
 
164
- def build_assert_raises(when_node, raises_node)
165
- when_body = when_node.children.length == 1 ? when_node.children[0] : s(:begin, *when_node.children)
183
+ def insert_after(statements, anchor, insertion)
184
+ index = statements.index { |statement| statement.equal?(anchor) }
185
+ raise ArgumentError, "anchor is not part of statements" unless index
166
186
 
167
- assert_raises_call = s(:block,
168
- s(:send, nil, :assert_raises, raises_node.exception_class),
169
- s(:args),
170
- when_body
171
- )
187
+ result = statements.dup
188
+ result.insert(index + 1, insertion)
189
+ result
190
+ end
172
191
 
173
- if raises_node.capture_name
174
- s(:lvasgn, raises_node.capture_name, assert_raises_call)
192
+ # Re-anchors +node+ at +anchor+'s source location so emission places it on the anchor's line.
193
+ # No-op for anchors without locations.
194
+ def anchored_at(anchor, node)
195
+ return node unless anchor.loc&.expression
196
+
197
+ s_at(anchor, node.type, *node.children)
198
+ end
199
+
200
+ # --- Build final Ruby AST ---
201
+
202
+ def build_ruby_ast(method_call, method_args, body_node, where)
203
+ if where
204
+ test_def = anchored_at(method_call, s(:block,
205
+ TestMethodDefTransformation.new.run(method_call),
206
+ method_args,
207
+ body_node
208
+ ))
209
+ test_def = HeaderNodesTransformation.new(where.header).run(test_def)
210
+
211
+ s(:block,
212
+ build_where_iterator(where.data_rows),
213
+ build_where_args(where.header),
214
+ test_def
215
+ )
175
216
  else
176
- assert_raises_call
217
+ anchored_at(method_call, s(:block,
218
+ method_call,
219
+ method_args,
220
+ body_node
221
+ ))
177
222
  end
178
223
  end
179
224
 
180
225
  # --- Where block helpers ---
226
+ #
227
+ # Each data row carries its source line as a trailing element, surfaced in the generated test NAME only
228
+ # (uniqueness for identical rows + the -n selector target) through internal block parameters. There is no
229
+ # user-facing runtime variable: isolate a row by running its generated test by name, then break normally.
181
230
 
182
231
  def build_where_iterator(data_rows)
183
232
  s(:send,
@@ -192,15 +241,16 @@ module RSpock
192
241
  def build_where_data_row(row)
193
242
  children = row.dup
194
243
  children << s(:int, row.first&.loc&.expression&.line)
195
- s(:array, *children)
244
+ anchor = row.first
245
+ anchor&.loc&.expression ? s_at(anchor, :array, *children) : s(:array, *children)
196
246
  end
197
247
 
198
248
  def build_where_args(header)
199
249
  injected_args = header.map { |column| s(:arg, column) }
200
- injected_args << s(:arg, :_line_number_)
250
+ injected_args << s(:arg, TestMethodDefTransformation::ROW_LINE_ARG)
201
251
  s(:args,
202
252
  s(:mlhs, *injected_args),
203
- s(:arg, :_test_index_),
253
+ s(:arg, TestMethodDefTransformation::ROW_INDEX_ARG),
204
254
  )
205
255
  end
206
256
  end
@@ -77,8 +77,7 @@ module RSpock
77
77
 
78
78
  def process_rspock(node)
79
79
  processed = process_all(node).compact
80
- children = [source_map_rescue_wrapper(s(:begin, *[EXTEND_RSPOCK_DECLARATIVE, *processed]))]
81
- node.updated(nil, children)
80
+ node.updated(nil, [EXTEND_RSPOCK_DECLARATIVE, *processed])
82
81
  end
83
82
 
84
83
  def on_block(node)
@@ -91,31 +90,6 @@ module RSpock
91
90
  strict: @strict
92
91
  ).run(node)
93
92
  end
94
-
95
- def source_map_rescue_wrapper(node)
96
- s(:kwbegin,
97
- s(:rescue,
98
- node,
99
- s(:resbody,
100
- s(:array,
101
- s(:const, nil, :StandardError)
102
- ),
103
- s(:lvasgn, :e),
104
- s(:begin,
105
- s(:send,
106
- s(:send,
107
- s(:const,
108
- s(:const,
109
- s(:cbase), :RSpock), :BacktraceFilter), :new), :filter_exception,
110
- s(:lvar, :e)
111
- ),
112
- s(:send, nil, :raise)
113
- )
114
- ),
115
- nil
116
- )
117
- )
118
- end
119
93
  end
120
94
  end
121
95
  end
@@ -1,3 +1,3 @@
1
1
  module RSpock
2
- VERSION = "2.5.0"
2
+ VERSION = "3.0.0"
3
3
  end
data/lib/rspock.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require 'rspock/version'
3
3
 
4
- require 'rspock/backtrace_filter'
5
4
  require 'rspock/declarative'
6
5
 
7
6
  require 'ast_transform'
data/rspock.gemspec CHANGED
@@ -19,7 +19,7 @@ Gem::Specification.new do |spec|
19
19
  spec.bindir = "exe"
20
20
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
21
  spec.require_paths = ["lib"]
22
- spec.required_ruby_version = '>= 3.2'
22
+ spec.required_ruby_version = '>= 3.3'
23
23
 
24
24
  # Development dependencies
25
25
  spec.add_development_dependency "bundler", ">= 2.1"
@@ -31,9 +31,8 @@ Gem::Specification.new do |spec|
31
31
  spec.add_development_dependency "simplecov", "~> 0.22"
32
32
 
33
33
  # Runtime dependencies
34
- spec.add_runtime_dependency "ast_transform", "~> 2.0"
34
+ # parser and unparser are used only through ast_transform, which owns their floors.
35
+ spec.add_runtime_dependency "ast_transform", "~> 3.0"
35
36
  spec.add_runtime_dependency "minitest", "~> 5.0"
36
37
  spec.add_runtime_dependency "mocha", ">= 1.0"
37
- spec.add_runtime_dependency "parser", ">= 3.0"
38
- spec.add_runtime_dependency "unparser", ">= 0.6"
39
38
  end
@@ -0,0 +1,198 @@
1
+ ---
2
+ name: rspock
3
+ description: >-
4
+ MUST be used when writing or modifying Minitest tests in any repo using
5
+ rspock (look for transform!(RSpock::AST::Transformation) in test files or
6
+ rspock in the Gemfile). RSpock rewrites test semantics via AST
7
+ transformation — code that looks like a no-op statement is an assertion,
8
+ and Minitest habits produce silently wrong tests.
9
+ ---
10
+
11
+ # RSpock: writing tests
12
+
13
+ RSpock is a Spock-inspired testing framework on top of Minitest. Tests are
14
+ valid Ruby syntax with **different semantics**, applied by AST
15
+ transformation at load time. Do not reason about these files as plain
16
+ Minitest.
17
+
18
+ ## The invariant that must never be violated
19
+
20
+ **Inside a `transform!(RSpock::AST::Transformation)` class, every bare
21
+ statement in a `Then`/`Expect` block IS an assertion. Outside one, it is
22
+ NOT — it evaluates and silently discards.**
23
+
24
+ Consequences:
25
+
26
+ - Inside a `transform!` class, write expression assertions — `a == b` in a
27
+ Then/Expect block. The transform compiles them to assertions with proper
28
+ failure messages; the Minitest assert API is not the dialect here.
29
+ - In a plain Minitest class, use the assert API (`assert_equal` and
30
+ friends). A bare comparison there evaluates and discards — a silently
31
+ green test.
32
+ - When editing a test file, first check for the `transform!` line at each
33
+ class definition; it decides which dialect that class speaks. Both
34
+ styles may legitimately coexist in one file (see `strict: false` below)
35
+ — match the dialect of the class you are in.
36
+
37
+ ## Boilerplate
38
+
39
+ ```ruby
40
+ require "test_helper"
41
+
42
+ transform!(RSpock::AST::Transformation)
43
+ class MyThingTest < Minitest::Test
44
+ test "descriptive name" do
45
+ # code blocks here
46
+ end
47
+ end
48
+ ```
49
+
50
+ The application must install the hook once (usually in the test helper):
51
+ `require "ast_transform"; ASTTransform.install`. That is the whole setup —
52
+ Rails apps need nothing extra (backtraces and debuggers are source-true by
53
+ construction; there is no backtrace cleaner to configure). Mixed files can
54
+ use `transform!(RSpock::AST::Transformation.new(strict: false))` to allow
55
+ plain Minitest tests alongside — this exists to ease gradual migration,
56
+ so treat mixed files as normal, not as something to unify.
57
+
58
+ The transform is an abstraction — trust it. If you ever need to see the
59
+ compiled Ruby (debugging only, never as routine verification), the
60
+ transformed files are written under `tmp/ast_transform/<relative path>`;
61
+ they are emitted line-aligned, so their line numbers match your source
62
+ exactly.
63
+
64
+ ## Code blocks and their order
65
+
66
+ `Given` (setup) → `When` (stimulus) → `Then` (response), or `Expect`
67
+ (stimulus+response in one), plus `Cleanup` (always runs; code defensively
68
+ with `&.`) and `Where` (data table, last in source but evaluated first).
69
+ Every block takes an optional description string. A `When` is always
70
+ followed by a `Then`. Use When+Then for side-effecting code, Expect for
71
+ pure functions.
72
+
73
+ ## Assertion forms (Then/Expect)
74
+
75
+ ```ruby
76
+ Then "the walk produced the right state"
77
+ actual == expected # binary operators: == != =~ !~ > < >= <=
78
+ list.include?(x) # bare boolean expression asserts
79
+ !cart.empty? # negation asserts
80
+ name = actual.first # assignments pass through (not assertions)
81
+ ```
82
+
83
+ LHS is actual, RHS is expected. Exception assertions live in Then, apply
84
+ to the preceding When, one per block:
85
+
86
+ ```ruby
87
+ Then "a parse error names the token"
88
+ e = raises JSON::ParserError # capture optional
89
+ e.message.include?("unexpected token")
90
+ ```
91
+
92
+ `raises` is not supported in Expect blocks.
93
+
94
+ ## Where tables (data-driven)
95
+
96
+ ```ruby
97
+ test "adding #{a} and #{b} gives #{c}" do
98
+ Expect
99
+ a + b == c
100
+
101
+ Where
102
+ a | b | c
103
+ -1 | 1 | 0
104
+ 0 | 0 | 0
105
+ 1 | 2 | 3
106
+ end
107
+ ```
108
+
109
+ Header names become local variables and interpolate into the test name.
110
+ The table is evaluated in class scope — it cannot see instance methods or
111
+ test-local variables. Order rows like a truth table; rightmost column is
112
+ the expected result.
113
+
114
+ To generate an exhaustive table instead of writing it by hand:
115
+
116
+ ```
117
+ rake rspock:truth_table -- a=-1,0,1 b=-1,0,1 expected_result="'?'"
118
+ ```
119
+
120
+ It emits the formatted cross-product (fill the `'?'` column manually).
121
+ Escape commas inside a value with `\,` (e.g. `b="gen(1\, 2)","gen(3\, 4)"`).
122
+ Non-Rails projects must load the gem's Rakefile once to get the task —
123
+ see the README's installation section.
124
+
125
+ ## Interaction mocking (Then only)
126
+
127
+ ```ruby
128
+ Then
129
+ 1 * subscriber.receive("hello") # exactly one call
130
+ 0 * mailer.deliver # must never be called
131
+ (1..3) * poller.tick # between one and three
132
+ (1.._) * poller.tick # at least once
133
+ (_..3) * poller.tick # at most three times
134
+ _ * cache.fetch("key") >> cached # any count, stubbed return
135
+ 1 * repo.find(42) >> raises(RecordNotFound) # stubbed exception
136
+ 1 * ui.frame("Build", &my_block) # block-identity check
137
+ ```
138
+
139
+ Declared in Then but installed before When runs — declare naturally,
140
+ RSpock handles ordering. Compiles to Mocha. Inline blocks (`{ }` /
141
+ `do...end`) are not allowed in interactions — use a named proc with `&`.
142
+ Mocks never yield blocks by design: needing that signals the unit under
143
+ test is doing too much — restructure so the mock boundary sits between
144
+ responsibilities.
145
+
146
+ ## Debugging failures
147
+
148
+ - Backtraces AND debugger display are source-true: transformed code is
149
+ emitted with every statement on its original source line, so line
150
+ numbers point at the file you wrote with no mapping layer. Trust them;
151
+ don't second-guess against `tmp/ast_transform/`.
152
+ - `break file:line` binds on user statements; interactive debuggers show
153
+ your real source. One documented oddity: interaction setups execute
154
+ before the When body, so stepping through a test with interactions
155
+ jumps from the interaction lines back up to the When line once.
156
+ - To isolate a Where row: the generated test name embeds the row's index
157
+ and source line (e.g. `... 1 line 15`). For a newly failing row, copy
158
+ the rerun command printed with the failure and add a plain
159
+ `binding.pry`. For a chosen row, run `-n /line_15/` with the line from
160
+ the editor gutter, or break conditionally on the column locals
161
+ themselves (`binding.pry if input == "not json"`). A source-line
162
+ breakpoint on a data row cannot isolate its run — the table evaluates
163
+ once, in class scope; name-selection is the mechanism.
164
+
165
+ ## Pitfalls (wrong → right)
166
+
167
+ ```ruby
168
+ # WRONG: Minitest API inside an RSpock class
169
+ assert_equal 3, add(1, 2)
170
+ # RIGHT
171
+ Expect
172
+ add(1, 2) == 3
173
+ ```
174
+
175
+ ```ruby
176
+ # WRONG: bare comparison in a class without transform! — silently green
177
+ class FooTest < Minitest::Test
178
+ test("x") { compute == 42 }
179
+ end
180
+ # RIGHT: add transform!(RSpock::AST::Transformation) above the class,
181
+ # or use assert_equal in plain Minitest
182
+ ```
183
+
184
+ ```ruby
185
+ # WRONG: Where table using an instance method for column data
186
+ Where
187
+ input | expected
188
+ helper_val | 1 # NameError: class scope
189
+ # RIGHT: use class methods or literals in Where rows
190
+ ```
191
+
192
+ ```ruby
193
+ # WRONG: expecting a mocked method to yield
194
+ 1 * ui.with_spinner("work") { drain(io) }
195
+ # RIGHT: restructure — mock boundary between responsibilities
196
+ success = drain(io) # test with a real StringIO
197
+ 1 * ui.ok("work") # simple expectation, no block
198
+ ```