cmdx-rspec 1.3.0 → 2.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +279 -156
  4. data/lib/cmdx/rspec/helpers.rb +257 -259
  5. data/lib/cmdx/rspec/matchers/be_complete.rb +20 -0
  6. data/lib/cmdx/rspec/matchers/be_deprecated.rb +14 -52
  7. data/lib/cmdx/rspec/matchers/be_interrupted.rb +20 -0
  8. data/lib/cmdx/rspec/matchers/be_ko.rb +19 -0
  9. data/lib/cmdx/rspec/matchers/be_ok.rb +19 -0
  10. data/lib/cmdx/rspec/matchers/be_successful.rb +8 -20
  11. data/lib/cmdx/rspec/matchers/have_been_retried.rb +41 -0
  12. data/lib/cmdx/rspec/matchers/have_been_rolled_back.rb +23 -0
  13. data/lib/cmdx/rspec/matchers/have_callback.rb +46 -0
  14. data/lib/cmdx/rspec/matchers/have_chain_root.rb +30 -0
  15. data/lib/cmdx/rspec/matchers/have_chain_size.rb +29 -0
  16. data/lib/cmdx/rspec/matchers/have_duration.rb +30 -0
  17. data/lib/cmdx/rspec/matchers/have_empty_context.rb +4 -16
  18. data/lib/cmdx/rspec/matchers/have_empty_metadata.rb +3 -9
  19. data/lib/cmdx/rspec/matchers/have_errors_on.rb +50 -0
  20. data/lib/cmdx/rspec/matchers/have_failed.rb +7 -24
  21. data/lib/cmdx/rspec/matchers/have_input.rb +41 -0
  22. data/lib/cmdx/rspec/matchers/have_matching_context.rb +5 -20
  23. data/lib/cmdx/rspec/matchers/have_matching_metadata.rb +5 -17
  24. data/lib/cmdx/rspec/matchers/have_middleware.rb +29 -0
  25. data/lib/cmdx/rspec/matchers/have_no_errors.rb +30 -0
  26. data/lib/cmdx/rspec/matchers/have_output.rb +46 -0
  27. data/lib/cmdx/rspec/matchers/have_pipeline_tasks.rb +27 -0
  28. data/lib/cmdx/rspec/matchers/have_retry_on.rb +36 -0
  29. data/lib/cmdx/rspec/matchers/have_skipped.rb +7 -24
  30. data/lib/cmdx/rspec/matchers/have_tag.rb +30 -0
  31. data/lib/cmdx/rspec/matchers/raise_cmdx_fault.rb +74 -0
  32. data/lib/cmdx/rspec/version.rb +2 -1
  33. data/lib/cmdx/rspec.rb +22 -3
  34. metadata +24 -15
  35. data/.cursor/prompts/docs.md +0 -12
  36. data/.cursor/prompts/llms.md +0 -20
  37. data/.cursor/prompts/rspec.md +0 -24
  38. data/.cursor/prompts/yardoc.md +0 -14
  39. data/.cursor/rules/cursor-instructions.mdc +0 -62
  40. data/.rspec +0 -4
  41. data/.rubocop.yml +0 -64
  42. data/src/cmdx-dark-logo.png +0 -0
  43. data/src/cmdx-light-logo.png +0 -0
  44. data/src/cmdx-logo.svg +0 -1
@@ -2,225 +2,223 @@
2
2
 
3
3
  module CMDx
4
4
  module RSpec
5
- # Helper methods for setting up RSpec stubs and expectations on CMDx command execution.
5
+ # RSpec helpers for stubbing and asserting Task execution. Each helper
6
+ # builds a frozen {CMDx::Result} carrying the requested {CMDx::Signal}
7
+ # and wires it into a fresh {CMDx::Chain} so callers see realistic
8
+ # execution output without invoking the Task's `work`.
9
+ #
10
+ # Mix into example groups via `config.include CMDx::RSpec::Helpers`.
6
11
  module Helpers
7
12
 
8
- # Sets up a stub that allows a command to receive :execute and return a successful result.
13
+ # Stubs `command.execute` to return a frozen successful Result.
9
14
  #
10
- # @param command [Class] The command class to stub execution on
11
- # @param metadata [Hash] Optional metadata to pass to the result
12
- # @param context [Hash] Optional keyword arguments to pass to the command
13
- #
14
- # @return [CMDx::Result] The successful result object
15
- #
16
- # @example Stubbing successful execution with context
17
- # stub_task_success(MyCommand, user_id: 123, role: "admin")
18
- #
19
- # result = MyCommand.execute(user_id: 123, role: "admin")
20
- # expect(result).to be_successful
21
- #
22
- # @example Stubbing successful execution without context
23
- # stub_task_success(MyCommand)
24
- #
25
- # result = MyCommand.execute
26
- # expect(result).to be_successful
15
+ # @param command [Class] the Task class to stub
16
+ # @param metadata [Hash] payload exposed via `result.metadata`
17
+ # @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
18
+ # @return [CMDx::Result] the frozen Result installed on the stub
19
+ # @example
20
+ # stub_task_success(SomeTask, metadata: { id: 1 })
27
21
  def stub_task_success(command, metadata: {}, **context)
28
- task = command.new(context)
29
- result = task.result
30
-
31
- result.metadata.merge!(metadata)
32
- result.executing!
33
- result.executed!
34
-
35
- allow(command).to receive(:execute).and_return(result)
36
-
37
- result
22
+ build_stub(command, :execute, CMDx::Signal.success(nil, metadata:), context)
38
23
  end
39
24
 
40
- # Sets up a stub that allows a command to receive :execute! and return a successful result.
41
- #
42
- # @param command [Class] The command class to stub execution on
43
- # @param metadata [Hash] Optional metadata to pass to the result
44
- # @param context [Hash] Optional keyword arguments to pass to the command
45
- #
46
- # @return [CMDx::Result] The successful result object
47
- #
48
- # @example Stubbing successful execution with context
49
- # stub_task_success!(MyCommand, user_id: 123, role: "admin")
50
- #
51
- # result = MyCommand.execute!(user_id: 123, role: "admin")
52
- # expect(result).to be_successful
53
- #
54
- # @example Stubbing successful execution without context
55
- # stub_task_success!(MyCommand)
25
+ # Stubs `command.execute!` (bang variant) to return a frozen successful Result.
56
26
  #
57
- # result = MyCommand.execute!
58
- # expect(result).to be_successful
27
+ # @param command [Class] the Task class to stub
28
+ # @param metadata [Hash] payload exposed via `result.metadata`
29
+ # @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
30
+ # @return [CMDx::Result] the frozen Result installed on the stub
59
31
  def stub_task_success!(command, metadata: {}, **context)
60
- task = command.new(context)
61
- result = task.result
62
-
63
- result.metadata.merge!(metadata)
64
- result.executing!
65
- result.executed!
66
-
67
- allow(command).to receive(:execute!).and_return(result)
68
-
69
- result
32
+ build_stub(command, :execute!, CMDx::Signal.success(nil, metadata:), context, strict: true)
70
33
  end
71
34
 
72
- # Sets up a stub that allows a command to receive :execute and return a skipped result.
73
- #
74
- # @param command [Class] The command class to stub execution on
75
- # @param reason [String, nil] Optional reason for skipping
76
- # @param cause [CMDx::Fault, nil] Optional cause for skipping
77
- # @param metadata [Hash] Optional metadata to pass to the result
78
- # @param context [Hash] Optional keyword arguments to pass to the command
79
- #
80
- # @return [CMDx::Result] The skipped result object
35
+ # Stubs `command.execute` to return a frozen skipped Result.
81
36
  #
82
- # @example Stubbing skipped execution with context
83
- # stub_task_skip(MyCommand, foo: "bar")
84
- #
85
- # result = MyCommand.execute(foo: "bar")
86
- # expect(result).to have_skipped
87
- #
88
- # @example Stubbing skipped execution with reason
89
- # stub_task_skip(MyCommand, reason: "Skipped for testing", foo: "bar")
90
- #
91
- # result = MyCommand.execute(foo: "bar")
92
- # expect(result).to have_skipped(reason: "Skipped for testing")
37
+ # @param command [Class] the Task class to stub
38
+ # @param reason [String, nil] human-readable skip reason
39
+ # @param cause [Exception, nil] originating cause attached to the signal
40
+ # @param metadata [Hash] payload exposed via `result.metadata`
41
+ # @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
42
+ # @return [CMDx::Result] the frozen Result installed on the stub
93
43
  def stub_task_skip(command, reason: nil, cause: nil, metadata: {}, **context)
94
- task = command.new(context)
95
- result = task.result
96
- cause ||= CMDx::SkipFault.new(result)
97
-
98
- result.executing!
99
- result.skip!(reason, halt: false, cause:, **metadata)
100
-
101
- allow(command).to receive(:execute).and_return(result)
102
-
103
- result
44
+ build_stub(command, :execute, CMDx::Signal.skipped(reason, metadata:, cause:), context)
104
45
  end
105
46
 
106
- # Sets up a stub that allows a command to receive :execute! and return a skipped result.
107
- #
108
- # @param command [Class] The command class to stub execution on
109
- # @param reason [String, nil] Optional reason for skipping
110
- # @param cause [CMDx::Fault, nil] Optional cause for skipping
111
- # @param metadata [Hash] Optional metadata to pass to the result
112
- # @param context [Hash] Optional keyword arguments to pass to the command
113
- #
114
- # @return [CMDx::Result] The skipped result object
115
- #
116
- # @example Stubbing skipped execution with context
117
- # stub_task_skip!(MyCommand, foo: "bar")
47
+ # Stubs `command.execute!` (bang variant) to return a frozen skipped Result.
118
48
  #
119
- # result = MyCommand.execute!(foo: "bar")
120
- # expect(result).to have_skipped
49
+ # @param command [Class] the Task class to stub
50
+ # @param reason [String, nil] human-readable skip reason
51
+ # @param cause [Exception, nil] originating cause attached to the signal
52
+ # @param metadata [Hash] payload exposed via `result.metadata`
53
+ # @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
54
+ # @return [CMDx::Result] the frozen Result installed on the stub
55
+ def stub_task_skip!(command, reason: nil, cause: nil, metadata: {}, **context)
56
+ build_stub(command, :execute!, CMDx::Signal.skipped(reason, metadata:, cause:), context, strict: true)
57
+ end
58
+
59
+ # Stubs `command.execute` to return a frozen failed Result.
121
60
  #
122
- # @example Stubbing skipped execution with reason
123
- # stub_task_skip!(MyCommand, reason: "Skipped for testing", foo: "bar")
61
+ # @param command [Class] the Task class to stub
62
+ # @param reason [String, nil] human-readable failure reason
63
+ # @param cause [Exception, nil] originating cause attached to the signal
64
+ # @param metadata [Hash] payload exposed via `result.metadata`
65
+ # @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
66
+ # @return [CMDx::Result] the frozen Result installed on the stub
67
+ def stub_task_fail(command, reason: nil, cause: nil, metadata: {}, **context)
68
+ build_stub(command, :execute, CMDx::Signal.failed(reason, metadata:, cause:), context)
69
+ end
70
+
71
+ # Stubs `command.execute!` (bang variant) to return a frozen failed Result.
124
72
  #
125
- # result = MyCommand.execute!(foo: "bar")
126
- # expect(result).to have_skipped(reason: "Skipped for testing")
127
- def stub_task_skip!(command, reason: nil, cause: nil, metadata: {}, **context)
128
- task = command.new(context)
129
- result = task.result
130
- cause ||= CMDx::SkipFault.new(result)
73
+ # @param command [Class] the Task class to stub
74
+ # @param reason [String, nil] human-readable failure reason
75
+ # @param cause [Exception, nil] originating cause attached to the signal
76
+ # @param metadata [Hash] payload exposed via `result.metadata`
77
+ # @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
78
+ # @return [CMDx::Result] the frozen Result installed on the stub
79
+ def stub_task_fail!(command, reason: nil, cause: nil, metadata: {}, **context)
80
+ build_stub(command, :execute!, CMDx::Signal.failed(reason, metadata:, cause:), context, strict: true)
81
+ end
131
82
 
132
- result.executing!
133
- result.skip!(reason, halt: false, cause:, **metadata)
83
+ # Stubs `command.execute` to return a frozen failed Result whose
84
+ # `cause` is an instance of +exception+. Models the "rescued
85
+ # StandardError -> failed signal" path that Runtime takes when a
86
+ # task's `work` raises something other than a Fault.
87
+ #
88
+ # @param command [Class] the Task class to stub
89
+ # @param exception [Class<StandardError>, StandardError] the cause
90
+ # @param message [String, nil] message used when constructing the
91
+ # exception (when +exception+ is a Class) and to derive the reason
92
+ # @param metadata [Hash]
93
+ # @param context [Hash{Symbol => Object}]
94
+ # @return [CMDx::Result] the frozen Result installed on the stub
95
+ # @example
96
+ # stub_task_error(MyCommand, Net::OpenTimeout, "boom")
97
+ def stub_task_error(command, exception, message = nil, metadata: {}, **context)
98
+ ex = exception.is_a?(Class) ? exception.new(message || "stubbed") : exception
99
+ reason = "[#{ex.class}] #{ex.message}"
100
+ build_stub(command, :execute, CMDx::Signal.failed(reason, metadata:, cause: ex), context)
101
+ end
134
102
 
135
- allow(command).to receive(:execute!).and_return(result)
103
+ # Stubs `command.execute` to return a frozen failed Result that
104
+ # echoes +upstream_result+. Models the `throw!`-then-propagate path
105
+ # used by nested tasks/workflows.
106
+ #
107
+ # @param command [Class] the Task class to stub
108
+ # @param upstream_result [CMDx::Result] the originating failure
109
+ # @param metadata [Hash]
110
+ # @param context [Hash{Symbol => Object}]
111
+ # @return [CMDx::Result] the frozen Result installed on the stub
112
+ def stub_task_throw(command, upstream_result, metadata: {}, **context)
113
+ unless upstream_result.is_a?(CMDx::Result) && upstream_result.failed?
114
+ raise ArgumentError,
115
+ "upstream_result must be a failed CMDx::Result"
116
+ end
136
117
 
137
- result
118
+ build_stub(command, :execute, CMDx::Signal.echoed(upstream_result, metadata:), context)
138
119
  end
139
120
 
140
- # Sets up a stub that allows a command to receive :execute and return a failed result.
141
- #
142
- # @param command [Class] The command class to stub execution on
143
- # @param reason [String, nil] Optional reason for failure
144
- # @param cause [CMDx::Fault, nil] Optional cause for failure
145
- # @param metadata [Hash] Optional metadata to pass to the result
146
- # @param context [Hash] Optional keyword arguments to pass to the command
147
- #
148
- # @return [CMDx::Result] The failed result object
149
- #
150
- # @example Stubbing failed execution with context
151
- # stub_task_fail(MyCommand, foo: "bar")
152
- #
153
- # result = MyCommand.execute(foo: "bar")
154
- # expect(result).to have_failed
121
+ # Stubs `command.execute` to return a successful Result flagged as
122
+ # `deprecated?`. Useful when asserting deprecation surfaces without
123
+ # triggering the real `Deprecation` action.
155
124
  #
156
- # @example Stubbing failed execution with reason
157
- # stub_task_fail(MyCommand, reason: "Failed for testing", foo: "bar")
158
- #
159
- # result = MyCommand.execute(foo: "bar")
160
- # expect(result).to have_failed(reason: "Failed for testing")
161
- def stub_task_fail(command, reason: nil, cause: nil, metadata: {}, **context)
162
- task = command.new(context)
163
- result = task.result
164
- cause ||= CMDx::FailFault.new(result)
125
+ # @param command [Class] the Task class to stub
126
+ # @param metadata [Hash]
127
+ # @param context [Hash{Symbol => Object}]
128
+ # @return [CMDx::Result]
129
+ def stub_task_deprecated(command, metadata: {}, **context)
130
+ build_stub(command, :execute, CMDx::Signal.success(nil, metadata:), context, deprecated: true)
131
+ end
165
132
 
166
- result.executing!
167
- result.fail!(reason, halt: false, cause:, **metadata)
133
+ # Captures lines written to a temporary CMDx logger for the duration
134
+ # of the block. Restores the previous logger on exit.
135
+ #
136
+ # @yield runs the block with `CMDx.configuration.logger` swapped
137
+ # @return [Array<String>] captured log lines
138
+ # @example
139
+ # logs = capture_cmdx_logs { MyCommand.execute }
140
+ # expect(logs.join).to include("status=success")
141
+ def capture_cmdx_logs(&)
142
+ raise ArgumentError, "block required" unless block_given?
143
+
144
+ io = StringIO.new
145
+ previous = CMDx.configuration.logger
146
+ CMDx.configuration.logger = Logger.new(io, formatter: previous&.formatter || CMDx::LogFormatters::Line.new)
147
+ yield
148
+ io.string.lines.map(&:chomp)
149
+ ensure
150
+ CMDx.configuration.logger = previous if previous
151
+ end
168
152
 
169
- allow(command).to receive(:execute).and_return(result)
153
+ # Subscribes to telemetry events on +command+'s telemetry registry
154
+ # for the duration of the block. Captures every emitted event.
155
+ # Tasks subclassing +command+ also fire (telemetry is cloned at
156
+ # class definition; the registry array is shared by reference until
157
+ # +dup+).
158
+ #
159
+ # @param command [Class] the Task class whose telemetry to listen on
160
+ # @param events [Array<Symbol>] event names to subscribe to;
161
+ # defaults to all of {CMDx::Telemetry::EVENTS}
162
+ # @yield runs the block with subscribers attached
163
+ # @return [Array<CMDx::Telemetry::Event>] captured events in emission order
164
+ # @example
165
+ # events = subscribe_telemetry(MyCommand, :task_executed) { MyCommand.execute }
166
+ # expect(events.map(&:name)).to eq([:task_executed])
167
+ def subscribe_telemetry(command, *events, &)
168
+ raise ArgumentError, "block required" unless block_given?
169
+
170
+ events = CMDx::Telemetry::EVENTS if events.empty?
171
+ captured = []
172
+ telemetry = command.telemetry
173
+ listener = ->(event) { captured << event }
174
+
175
+ events.each { |e| telemetry.subscribe(e, listener) }
176
+ begin
177
+ yield
178
+ ensure
179
+ events.each { |e| telemetry.unsubscribe(e, listener) }
180
+ end
170
181
 
171
- result
182
+ captured
172
183
  end
173
184
 
174
- # Sets up a stub that allows a command to receive :execute! and return a failed result.
175
- #
176
- # @param command [Class] The command class to stub execution on
177
- # @param reason [String, nil] Optional reason for failure
178
- # @param cause [CMDx::Fault, nil] Optional cause for failure
179
- # @param metadata [Hash] Optional metadata to pass to the result
180
- # @param context [Hash] Optional keyword arguments to pass to the command
181
- #
182
- # @return [CMDx::Result] The failed result object
183
- #
184
- # @example Stubbing failed execution with context
185
- # stub_task_fail!(MyCommand, foo: "bar")
186
- #
187
- # result = MyCommand.execute!(foo: "bar")
188
- # expect(result).to have_failed
189
- #
190
- # @example Stubbing failed execution with reason
191
- # stub_task_fail!(MyCommand, reason: "Failed for testing", foo: "bar")
192
- #
193
- # result = MyCommand.execute!(foo: "bar")
194
- # expect(result).to have_failed(reason: "Failed for testing")
195
- def stub_task_fail!(command, reason: nil, cause: nil, metadata: {}, **context)
196
- task = command.new(context)
197
- result = task.result
198
- cause ||= CMDx::FailFault.new(result)
199
-
200
- result.executing!
201
- result.fail!(reason, halt: false, cause:, **metadata)
185
+ # Captures the {CMDx::Chain} produced by +command+'s execution
186
+ # within the block. Subscribes to +command+'s `:task_executed`
187
+ # telemetry to grab the chain reference before Runtime teardown
188
+ # clears it.
189
+ #
190
+ # @param command [Class] the Task class whose chain to capture
191
+ # @yield the block to execute
192
+ # @return [CMDx::Chain, nil] the chain (frozen by Runtime), or nil
193
+ # when +command+ didn't run as a root during the block
194
+ # @example
195
+ # chain = with_cmdx_chain(MyWorkflow) { MyWorkflow.execute }
196
+ # expect(chain.size).to be > 1
197
+ def with_cmdx_chain(command)
198
+ raise ArgumentError, "block required" unless block_given?
199
+
200
+ captured = nil
201
+ telemetry = command.telemetry
202
+ listener = lambda do |event|
203
+ captured ||= event.payload[:result].chain if event.root
204
+ end
202
205
 
203
- allow(command).to receive(:execute!).and_return(result)
206
+ telemetry.subscribe(:task_executed, listener)
207
+ begin
208
+ yield
209
+ ensure
210
+ telemetry.unsubscribe(:task_executed, listener)
211
+ end
204
212
 
205
- result
213
+ captured
206
214
  end
207
215
 
208
- # Unstubs a command's :execute method.
209
- #
210
- # @param command [Class] The command class to unstub execution on
211
- # @param context [Hash] Optional keyword arguments to match against
216
+ # Restores `command.execute` to its original implementation.
217
+ # When `context` is supplied, only the matching argument signature is unstubbed.
212
218
  #
219
+ # @param command [Class] the Task class to unstub
220
+ # @param context [Hash{Symbol => Object}] argument signature whose stub to release
213
221
  # @return [void]
214
- #
215
- # @example Unstubbing execute with context
216
- # unstub_task(MyCommand, foo: "bar")
217
- #
218
- # MyCommand.execute(foo: "bar")
219
- #
220
- # @example Unstubbing execute without context
221
- # unstub_task(MyCommand)
222
- #
223
- # MyCommand.execute
224
222
  def unstub_task(command, **context)
225
223
  if context.empty?
226
224
  allow(command).to receive(:execute).and_call_original
@@ -229,22 +227,12 @@ module CMDx
229
227
  end
230
228
  end
231
229
 
232
- # Unstubs a command's :execute! method.
233
- #
234
- # @param command [Class] The command class to unstub execution on
235
- # @param context [Hash] Optional keyword arguments to match against
230
+ # Restores `command.execute!` to its original implementation.
231
+ # When `context` is supplied, only the matching argument signature is unstubbed.
236
232
  #
233
+ # @param command [Class] the Task class to unstub
234
+ # @param context [Hash{Symbol => Object}] argument signature whose stub to release
237
235
  # @return [void]
238
- #
239
- # @example Unstubbing execute!
240
- # unstub_task!(MyCommand, foo: "bar")
241
- #
242
- # MyCommand.execute!(foo: "bar")
243
- #
244
- # @example Unstubbing execute! without context
245
- # unstub_task!(MyCommand)
246
- #
247
- # MyCommand.execute!
248
236
  def unstub_task!(command, **context)
249
237
  if context.empty?
250
238
  allow(command).to receive(:execute!).and_call_original
@@ -253,22 +241,12 @@ module CMDx
253
241
  end
254
242
  end
255
243
 
256
- # Sets up an expectation that a command will receive :execute with the given context.
244
+ # Sets a positive message expectation that `command.execute` is invoked.
245
+ # When `context` is supplied, the expectation is constrained to that signature.
257
246
  #
258
- # @param command [Class] The command class to expect execution on
259
- # @param context [Hash] Optional keyword arguments to match against
260
- #
261
- # @return [RSpec::Mocks::MessageExpectation] The RSpec expectation object
262
- #
263
- # @example Expecting execution with context
264
- # expect_task_execution(MyCommand, user_id: 123, role: "admin")
265
- #
266
- # MyCommand.execute(user_id: 123, role: "admin")
267
- #
268
- # @example Expecting execution without context
269
- # expect_task_execution(MyCommand)
270
- #
271
- # MyCommand.execute
247
+ # @param command [Class] the Task class to expect
248
+ # @param context [Hash{Symbol => Object}] argument signature to match
249
+ # @return [RSpec::Mocks::MessageExpectation]
272
250
  def expect_task_execution(command, **context)
273
251
  if context.empty?
274
252
  expect(command).to receive(:execute)
@@ -277,22 +255,12 @@ module CMDx
277
255
  end
278
256
  end
279
257
 
280
- # Sets up an expectation that a command will receive :execute! with the given context.
281
- #
282
- # @param command [Class] The command class to expect execution on
283
- # @param context [Hash] Optional keyword arguments to match against
284
- #
285
- # @return [RSpec::Mocks::MessageExpectation] The RSpec expectation object
258
+ # Sets a positive message expectation that `command.execute!` is invoked.
259
+ # When `context` is supplied, the expectation is constrained to that signature.
286
260
  #
287
- # @example Expecting execution with context
288
- # expect_task_execution!(MyCommand, user_id: 123, role: "admin")
289
- #
290
- # MyCommand.execute!(user_id: 123, role: "admin")
291
- #
292
- # @example Expecting execution without context
293
- # expect_task_execution!(MyCommand)
294
- #
295
- # MyCommand.execute!
261
+ # @param command [Class] the Task class to expect
262
+ # @param context [Hash{Symbol => Object}] argument signature to match
263
+ # @return [RSpec::Mocks::MessageExpectation]
296
264
  def expect_task_execution!(command, **context)
297
265
  if context.empty?
298
266
  expect(command).to receive(:execute!)
@@ -301,22 +269,12 @@ module CMDx
301
269
  end
302
270
  end
303
271
 
304
- # Sets up an expectation that a command will not receive :execute.
305
- #
306
- # @param command [Class] The command class to expect execution on
307
- # @param context [Hash] Optional keyword arguments to match against
308
- #
309
- # @return [RSpec::Mocks::MessageExpectation] The RSpec expectation object
310
- #
311
- # @example Expecting no execution with context
312
- # expect_no_task_execution(MyCommand, foo: "bar")
313
- #
314
- # MyCommand.execute(foo: "bar")
272
+ # Sets a negative message expectation that `command.execute` is not invoked.
273
+ # When `context` is supplied, only the matching signature is forbidden.
315
274
  #
316
- # @example Expecting no execution with empty context
317
- # expect_no_task_execution(MyCommand)
318
- #
319
- # MyCommand.execute
275
+ # @param command [Class] the Task class to guard
276
+ # @param context [Hash{Symbol => Object}] argument signature to forbid
277
+ # @return [RSpec::Mocks::MessageExpectation]
320
278
  def expect_no_task_execution(command, **context)
321
279
  if context.empty?
322
280
  expect(command).not_to receive(:execute)
@@ -325,22 +283,12 @@ module CMDx
325
283
  end
326
284
  end
327
285
 
328
- # Sets up an expectation that a command will not receive :execute!.
329
- #
330
- # @param command [Class] The command class to expect execution on
331
- # @param context [Hash] Optional keyword arguments to match against
332
- #
333
- # @return [RSpec::Mocks::MessageExpectation] The RSpec expectation object
334
- #
335
- # @example Expecting no execution! with context
336
- # expect_no_task_execution!(MyCommand, foo: "bar")
286
+ # Sets a negative message expectation that `command.execute!` is not invoked.
287
+ # When `context` is supplied, only the matching signature is forbidden.
337
288
  #
338
- # MyCommand.execute!(foo: "bar")
339
- #
340
- # @example Expecting no execution! with empty context
341
- # expect_no_task_execution!(MyCommand)
342
- #
343
- # MyCommand.execute!
289
+ # @param command [Class] the Task class to guard
290
+ # @param context [Hash{Symbol => Object}] argument signature to forbid
291
+ # @return [RSpec::Mocks::MessageExpectation]
344
292
  def expect_no_task_execution!(command, **context)
345
293
  if context.empty?
346
294
  expect(command).not_to receive(:execute!)
@@ -349,6 +297,56 @@ module CMDx
349
297
  end
350
298
  end
351
299
 
300
+ # Yields each distinct Task class reachable from a Workflow's pipeline,
301
+ # in first-seen order, so callers can stub them in a single block.
302
+ #
303
+ # @param command [Class] a Workflow class (must include `CMDx::Workflow`)
304
+ # @yieldparam task [Class] a Task class referenced by the workflow pipeline
305
+ # @return [void]
306
+ # @raise [ArgumentError] when no block is given or `command` is not a Workflow
307
+ # @example
308
+ # stub_workflow_tasks(MyWorkflow) { |t| stub_task_success(t) }
309
+ def stub_workflow_tasks(command, &)
310
+ if !block_given?
311
+ raise ArgumentError, "block required"
312
+ elsif !command.include?(Workflow)
313
+ raise ArgumentError, "#{command.inspect} must be a workflow"
314
+ end
315
+
316
+ command.pipeline.flat_map(&:tasks).uniq.each(&)
317
+ end
318
+
319
+ private
320
+
321
+ # Constructs a frozen {CMDx::Result} from `signal` and stubs `command.method`
322
+ # to return it. The Result is unshifted onto a new {CMDx::Chain} so callers
323
+ # observe the same shape as a real execution.
324
+ #
325
+ # @api private
326
+ # @param command [Class] the Task class being stubbed
327
+ # @param method [Symbol] the message to stub (`:execute` or `:execute!`)
328
+ # @param signal [CMDx::Signal] outcome payload to wrap
329
+ # @param context [Hash] context overrides for `command.new`
330
+ # @return [CMDx::Result] the frozen Result installed on the stub
331
+ def build_stub(command, method, signal, context, **)
332
+ task = command.new(context)
333
+ chain = CMDx::Chain.new
334
+ result = CMDx::Result.new(
335
+ chain,
336
+ task,
337
+ signal,
338
+ root: true,
339
+ id: SecureRandom.uuid_v7,
340
+ **
341
+ )
342
+
343
+ chain.unshift(result)
344
+
345
+ allow(command).to receive(method).and_return(result)
346
+
347
+ result
348
+ end
349
+
352
350
  end
353
351
  end
354
352
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Matcher to verify that a result completed without interruption.
4
+ #
5
+ # @example
6
+ # expect(MyCommand.execute).to be_complete
7
+ RSpec::Matchers.define :be_complete do
8
+ description { "have completed" }
9
+
10
+ failure_message do |result|
11
+ "expected #{result.inspect} to have state #{CMDx::Signal::COMPLETE.inspect}, " \
12
+ "but was #{result.state.inspect}"
13
+ end
14
+
15
+ match do |result|
16
+ raise ArgumentError, "must be a CMDx::Result" unless result.is_a?(CMDx::Result)
17
+
18
+ result.state == CMDx::Signal::COMPLETE
19
+ end
20
+ end