cmdx-rspec 2.0.0 → 2.1.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: 9d99809d55db6542a630f55457df90bacbf7598a9969a8a1e54cf27630362030
4
- data.tar.gz: 7fe0770bb36f09002b9b45cbf01751b0aab0efac7529be7c3c65d5a751399dbb
3
+ metadata.gz: c236290ec6d51edc6b2f65d8f69cb8ba7c516f74436f3f6f58947ce90ddcf545
4
+ data.tar.gz: fe7699ab785d5554a7317ed1963b16c975e57464c780b2c8e515ea3c198b45bc
5
5
  SHA512:
6
- metadata.gz: 3fa228ad4eec439aea347ed0c12746f66252e0712dfba58ddd246ffde63102dcedc5c2b2c5816441f9d5eec9a3261937ad19ea22c31b197c74b7e20f5daa8cdc
7
- data.tar.gz: 5818b4c8a34414a5834abb7e8665480d24b9c0e63ae1d5bebc2f1fa37c5156b9b377ec26cef78251a24c1ce2bd98b4d8f439031aa4ba8efb52a9b8888c903c91
6
+ metadata.gz: 441052016d2e78837cf7f541d67a6b240464441ee78f7fc45450e702e85a3c5e0b6f3a66667b1f852133728f777b23bc8d3c3ce59f6ebfe2981348dc54c8def2
7
+ data.tar.gz: 9d3970eeee44f807f0b507880ca569f0e4b4c676749a5b4604a7e93a4657075b81a6d5c0ef0e2a32f66191d12fa8dd420d119ecfb1f55ad55cdbd964f8bd8d53
data/.DS_Store ADDED
Binary file
data/CHANGELOG.md CHANGED
@@ -6,7 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [2.0.0] - Unreleased
9
+ ## [2.1.0] - 2026-10-14
10
+
11
+ ### Added
12
+ - Added `expected_task_execution`, `expected_task_execution!`, `expected_no_task_execution`, and `expected_no_task_execution!` helpers for asserting on `execute` / `execute!` via `have_received` after the action
13
+ - Added `build_result`, `build_successful_result`, `build_skipped_result`, `build_failed_result`, and `build_echoed_result` helpers for constructing real `CMDx::Result` objects without stubbing task execution
14
+ - Added `stub_task` and `stub_task!` alias of `stub_task_success` and `stub_task_success!`
15
+ - Task stub helpers — raise `ArgumentError` when `command` is not a `CMDx::Task` subclass
16
+
17
+ ### Changed
18
+ - README — document RuboCop `RSpec/NoExpectationExample` configuration for task execution mock helpers
19
+ - Task execution expectation helpers (`unstub_task`, `expect_task_execution`, `expected_task_execution`, and their `!` variants) — optional positional arguments are forwarded to RSpec's `.with` matcher alongside keyword context
20
+ - `build_echoed_result` and `stub_task_throw` — echo upstream validation now raises with message `upstream result must be a failed CMDx::Result`
21
+ - `stub_task_fail!` — stubbed `execute!` now raises `CMDx::Fault` (or re-raises `cause`) instead of returning a failed result, matching CMDx strict execution
22
+
23
+ ### Fixed
24
+ - Task stub helpers — stubbed results now use the supplied command class for `result.task`
25
+
26
+ ## [2.0.0] - 2026-05-05
10
27
 
11
28
  ### Added
12
29
  - Support for CMDx v2
data/README.md CHANGED
@@ -38,6 +38,14 @@ require "cmdx/rspec"
38
38
 
39
39
  This loads every matcher under `RSpec::Matchers` and exposes the helpers under `CMDx::RSpec::Helpers`. See [Helpers](#helpers) for how to mix the helpers into your example groups.
40
40
 
41
+ ### Relationship to the CMDx testing guide
42
+
43
+ The [CMDx testing docs](https://drexed.github.io/cmdx/testing/) use RSpec’s built-in predicate matchers on `CMDx::Result` — for example `be_success` calls `success?`, `be_failed` calls `failed?`, and `be_skipped` calls `skipped?`.
44
+
45
+ This gem adds matchers that pin both **state** and **status** via `result.to_h` (`be_successful`, `have_skipped`, `have_failed`), plus declaration matchers, chain helpers, and stub/build utilities. You can use both styles in the same suite.
46
+
47
+ For `execute!`, CMDx only raises when the outcome is **failed** (a `CMDx::Fault`, or the original exception when `result.cause` is a non-fault). Skips and successes return a result even in strict mode.
48
+
41
49
  ## Matchers
42
50
 
43
51
  All result-oriented matchers raise `ArgumentError` when given a subject that isn't a `CMDx::Result`. Class-oriented matchers accept either the Task class or an instance.
@@ -46,7 +54,7 @@ All result-oriented matchers raise `ArgumentError` when given a subject that isn
46
54
 
47
55
  #### `be_successful`
48
56
 
49
- Asserts a `CMDx::Result` completed with `state: complete` and `status: success`. Extra keyword args are forwarded to a `result.to_h` inclusion check, so any field can be constrained inline.
57
+ Asserts a `CMDx::Result` completed with `state: "complete"` and `status: "success"`. Extra keyword args are forwarded to a `result.to_h` inclusion check, so any field can be constrained inline.
50
58
 
51
59
  ```ruby
52
60
  expect(SomeTask.execute).to be_successful
@@ -55,19 +63,16 @@ expect(SomeTask.execute).to be_successful(metadata: { id: 1 })
55
63
 
56
64
  #### `have_skipped`
57
65
 
58
- Asserts a result was skipped (`state: interrupted`, `status: skipped`). Extra keyword args constrain other `result.to_h` fields.
66
+ Asserts a result was skipped (`state: "interrupted"`, `status: "skipped"`). Extra keyword args constrain other `result.to_h` fields.
59
67
 
60
68
  ```ruby
61
69
  expect(result).to have_skipped
62
- expect(result).to have_skipped(
63
- reason: "out of stock",
64
- cause: be_a(CMDx::SkipFault)
65
- )
70
+ expect(result).to have_skipped(reason: "out of stock", metadata: { code: "NA" })
66
71
  ```
67
72
 
68
73
  #### `have_failed`
69
74
 
70
- Asserts a result failed (`state: interrupted`, `status: failed`). Extra keyword args constrain other `result.to_h` fields.
75
+ Asserts a result failed (`state: "interrupted"`, `status: "failed"`). Extra keyword args constrain other `result.to_h` fields.
71
76
 
72
77
  ```ruby
73
78
  expect(result).to have_failed
@@ -79,7 +84,7 @@ expect(result).to have_failed(
79
84
 
80
85
  #### `be_ok` / `be_ko`
81
86
 
82
- `be_ok` passes when the result is success or skipped (anything but failed). `be_ko` is its inverse.
87
+ `be_ok` delegates to `Result#ok?` (success or skipped). `be_ko` delegates to `Result#ko?` (skipped or failed). Skipped results satisfy **both** matchers.
83
88
 
84
89
  ```ruby
85
90
  expect(result).to be_ok
@@ -88,7 +93,7 @@ expect(result).to be_ko
88
93
 
89
94
  #### `be_complete` / `be_interrupted`
90
95
 
91
- State-only assertions. `be_complete` passes when `state == :complete`; `be_interrupted` when `state == :interrupted`.
96
+ State-only assertions. `be_complete` passes when `state == "complete"`; `be_interrupted` when `state == "interrupted"`.
92
97
 
93
98
  ```ruby
94
99
  expect(result).to be_complete
@@ -180,6 +185,8 @@ expect { SomeTask.execute! }
180
185
 
181
186
  `with_reason` accepts a string (equality) or a Regexp; `with_cause` accepts a class (matches via `is_a?`) or a value (matched with `values_match?`).
182
187
 
188
+ In application code you can also use `CMDx::Fault.for?`, `Fault.reason?`, and `Fault.matches?` in `rescue` clauses; this matcher is the RSpec block equivalent for `execute!`.
189
+
183
190
  ### Chains
184
191
 
185
192
  #### `have_chain_root`
@@ -198,6 +205,8 @@ Passes when the chain's size matches `expected`. Accepts a `CMDx::Chain` or `CMD
198
205
  expect(result).to have_chain_size(3)
199
206
  ```
200
207
 
208
+ For failure propagation (`origin`, `caused_failure`, `threw_failure`), assert on the result directly — for example `expect(result.caused_failure.task).to eq(ValidateCard)` — as described in the [Result chain analysis](https://drexed.github.io/cmdx/outcomes/result/) docs.
209
+
201
210
  ### Task class declarations
202
211
 
203
212
  These matchers introspect a Task class's configuration. They accept either the class or an instance.
@@ -220,7 +229,8 @@ Asserts the class declares the given input/output. Keyword args are matched (par
220
229
 
221
230
  ```ruby
222
231
  expect(SomeTask).to have_input(:user_id)
223
- expect(SomeTask).to have_input(:user_id, type: :integer, required: true)
232
+ expect(SomeTask).to have_input(:user_id, required: true)
233
+ expect(SomeTask).to have_input(:user_id, options: hash_including(coerce: :integer))
224
234
  expect(SomeTask).to have_output(:total, required: true)
225
235
  ```
226
236
 
@@ -290,6 +300,30 @@ describe MyFeature do
290
300
  end
291
301
  ```
292
302
 
303
+ ### Result builders
304
+
305
+ Build a real `CMDx::Result` without stubbing `execute` — useful when a collaborator must return a result, when seeding a chain, or anywhere you need a result object in isolation. Extra keyword args (besides the documented ones) are forwarded to `task.new` as context data, same as the stubs below.
306
+
307
+ ```ruby
308
+ result = build_successful_result(snapshot:)
309
+ allow(FetchSnapshot).to receive(:execute).and_return(result)
310
+
311
+ build_skipped_result(reason: "not applicable")
312
+ build_failed_result(reason: "boom", cause: StandardError.new("kaput"))
313
+ build_echoed_result(upstream_failed_result)
314
+
315
+ build_result(:success, snapshot:, metadata: { id: 1 })
316
+ build_result(:failed, reason: "nope", strict: true, task: SomeTask)
317
+ build_result(:echo, upstream_result: failed_result)
318
+ ```
319
+
320
+ Options:
321
+
322
+ ```ruby
323
+ build_successful_result(task: SomeTask, chain: existing_chain, context: CMDx::Context.new(foo: 1))
324
+ build_result(:success, deprecated: true, retries: 2, rolled_back: true)
325
+ ```
326
+
293
327
  ### Stubs
294
328
 
295
329
  Each stub builds a frozen `CMDx::Result` carrying the requested signal and wires it into a fresh `CMDx::Chain`, so callers see realistic execution shape without invoking the task's `work`. Any extra keyword args (besides the documented ones) are forwarded to `command.new` as context overrides.
@@ -298,18 +332,18 @@ Each stub builds a frozen `CMDx::Result` carrying the requested signal and wires
298
332
 
299
333
  ```ruby
300
334
  # Non-bang variants stub `SomeTask.execute`
301
- stub_task_success(SomeTask)
335
+ stub_task_success(SomeTask) # alias: stub_task
302
336
  stub_task_skip(SomeTask)
303
337
  stub_task_fail(SomeTask)
304
338
 
305
339
  # Bang variants stub `SomeTask.execute!`
306
- stub_task_success!(SomeTask)
307
- stub_task_skip!(SomeTask)
308
- stub_task_fail!(SomeTask)
340
+ stub_task_success!(SomeTask) # alias: stub_task! — returns result
341
+ stub_task_skip!(SomeTask) # returns result (skips do not raise under execute!)
342
+ stub_task_fail!(SomeTask) # raises CMDx::Fault, or re-raises cause when set
309
343
 
310
344
  # Stub a specific argument signature
311
- stub_task_success(SomeTask, some: "value") # SomeTask.execute(some: "value")
312
- stub_task_skip!(SomeTask, some: "value") # SomeTask.execute!(some: "value")
345
+ stub_task_success(SomeTask, some: "value") # SomeTask.execute(some: "value")
346
+ stub_task_skip!(SomeTask, some: "value") # SomeTask.execute!(some: "value")
313
347
  ```
314
348
 
315
349
  Common options:
@@ -343,8 +377,12 @@ stub_task_deprecated(SomeTask)
343
377
  ```ruby
344
378
  stub_workflow_tasks(MyWorkflow) do |task|
345
379
  case task
346
- when TaskC then stub_task_skip(task)
347
- else stub_task_success(task)
380
+ when TaskB
381
+ stub_task_fail(task)
382
+ when TaskC
383
+ stub_task_skip(task)
384
+ else
385
+ stub_task_success(task)
348
386
  end
349
387
  end
350
388
 
@@ -373,8 +411,37 @@ expect_task_execution(SomeTask, some: "value")
373
411
  expect_no_task_execution(SomeTask)
374
412
  expect_no_task_execution!(SomeTask)
375
413
  expect_no_task_execution(SomeTask, some: "value")
414
+
415
+ # after the fact; requires a spy
416
+ expected_task_execution(SomeTask)
417
+ expected_task_execution!(SomeTask)
418
+ expected_task_execution(SomeTask, some: "value")
419
+
420
+ expected_no_task_execution(SomeTask)
421
+ expected_no_task_execution!(SomeTask)
422
+ expected_no_task_execution(SomeTask, some: "value")
423
+ ```
424
+
425
+ ### RuboCop: `RSpec/NoExpectationExample`
426
+
427
+ The [RSpec/NoExpectationExample](https://docs.rubocop.org/rubocop-rspec/latest/cops_rspec.html#rspecnoexpectationexample) cop flags examples that do not call an RSpec expectation (for example `expect`, `is_expected`, or `should`). That is intentional for examples whose only assertion is a message expectation registered by [`expect_task_execution`](#mocks), [`expect_no_task_execution`](#mocks), or the bang variants — those helpers call `expect(command).to receive(...)` internally, but the example body often contains no top-level `expect`.
428
+
429
+ RuboCop already treats method names matching `^expect_` and `^assert_` as expectations via the cop’s default `AllowedPatterns`. Add `^expected_` if you use the post-action [`expected_*`](#mocks) helpers (`expected_task_execution`, `expected_no_task_execution`, and their `!` variants), which assert with `have_received` instead of `receive`.
430
+
431
+ Merge the following into `.rubocop.yml` (adjust if your file already configures this cop):
432
+
433
+ ```yaml
434
+ RSpec/NoExpectationExample:
435
+ AllowedPatterns:
436
+ - "^expect_"
437
+ - "^expected_"
438
+ - "^assert_"
376
439
  ```
377
440
 
441
+ Examples that only stub or exercise behavior with an explicit matcher — for example `stub_task_success` followed by `expect(result).to be_successful` — do not need an allowed pattern. Workflow setup with `stub_workflow_tasks` is the same when you assert on outcomes afterward.
442
+
443
+ If you define your own expectation helpers, either name them with an `expect_` / `assert_` prefix or register them under `RSpec/Language/Expectations` in `.rubocop.yml` as described in the cop documentation.
444
+
378
445
  ### Diagnostics
379
446
 
380
447
  #### `capture_cmdx_logs`
@@ -10,6 +10,77 @@ module CMDx
10
10
  # Mix into example groups via `config.include CMDx::RSpec::Helpers`.
11
11
  module Helpers
12
12
 
13
+ # Anonymous task used by result builders when no +task+ is supplied.
14
+ STUB_TASK = Class.new(CMDx::Task) do
15
+ def self.name
16
+ "CMDx::RSpec::StubTask"
17
+ end
18
+ end
19
+ private_constant :STUB_TASK
20
+
21
+ # Builds a frozen {CMDx::Result} without stubbing task execution.
22
+ # Use when a collaborator must return a result, when seeding a chain,
23
+ # or anywhere a real result is needed outside `stub_task_*`.
24
+ #
25
+ # @param status [Symbol, CMDx::Signal] `:success`, `:skipped`, `:failed`,
26
+ # `:echo`, or a pre-built signal
27
+ # @param reason [String, nil] human-readable outcome reason
28
+ # @param cause [Exception, nil] originating exception on failure
29
+ # @param metadata [Hash] signal metadata payload
30
+ # @param upstream_result [CMDx::Result] required for `:echo`; must be failed
31
+ # @param task [Class] task class for `result.task` (defaults to an internal stub)
32
+ # @param chain [CMDx::Chain, nil] chain to join (a fresh one is created when omitted)
33
+ # @param context [Hash, CMDx::Context, nil] task context; other keyword args
34
+ # are forwarded to `task.new` when +context+ is omitted
35
+ # @param root [Boolean] whether this result is the chain root
36
+ # @param strict [Boolean] whether the result was produced via `execute!`
37
+ # @param deprecated [Boolean] whether the result is flagged deprecated
38
+ # @param retries [Integer] retry count beyond the first attempt
39
+ # @param duration [Float, nil] execution duration in milliseconds
40
+ # @param rolled_back [Boolean] whether rollback ran for this result
41
+ # @param tid [String, nil] execution identifier (defaults to a uuid_v7)
42
+ # @return [CMDx::Result]
43
+ # @raise [ArgumentError] for unknown statuses or invalid echo input
44
+ # @example
45
+ # result = build_result(:success, snapshot:)
46
+ # allow(FetchSnapshot).to receive(:execute).and_return(result)
47
+ def build_result(status, **options)
48
+ signal = resolve_result_signal(
49
+ status,
50
+ reason: options[:reason],
51
+ cause: options[:cause],
52
+ metadata: options.fetch(:metadata, {}),
53
+ upstream_result: options[:upstream_result]
54
+ )
55
+
56
+ assemble_result(signal, **options.except(:reason, :cause, :metadata, :upstream_result))
57
+ end
58
+
59
+ # @return [CMDx::Result] a successful result (`state: complete`, `status: success`)
60
+ # @see #build_result
61
+ def build_successful_result(metadata: {}, **context)
62
+ build_result(:success, metadata:, **context)
63
+ end
64
+
65
+ # @return [CMDx::Result] a skipped result (`state: interrupted`, `status: skipped`)
66
+ # @see #build_result
67
+ def build_skipped_result(reason: nil, cause: nil, metadata: {}, **context)
68
+ build_result(:skipped, reason:, cause:, metadata:, **context)
69
+ end
70
+
71
+ # @return [CMDx::Result] a failed result (`state: interrupted`, `status: failed`)
72
+ # @see #build_result
73
+ def build_failed_result(reason: nil, cause: nil, metadata: {}, **context)
74
+ build_result(:failed, reason:, cause:, metadata:, **context)
75
+ end
76
+
77
+ # @param upstream_result [CMDx::Result] the failed result being propagated
78
+ # @return [CMDx::Result] a failed result echoing +upstream_result+
79
+ # @see #build_result
80
+ def build_echoed_result(upstream_result, metadata: {}, **context)
81
+ build_result(:echo, upstream_result:, metadata:, **context)
82
+ end
83
+
13
84
  # Stubs `command.execute` to return a frozen successful Result.
14
85
  #
15
86
  # @param command [Class] the Task class to stub
@@ -21,6 +92,7 @@ module CMDx
21
92
  def stub_task_success(command, metadata: {}, **context)
22
93
  build_stub(command, :execute, CMDx::Signal.success(nil, metadata:), context)
23
94
  end
95
+ alias stub_task stub_task_success
24
96
 
25
97
  # Stubs `command.execute!` (bang variant) to return a frozen successful Result.
26
98
  #
@@ -31,6 +103,7 @@ module CMDx
31
103
  def stub_task_success!(command, metadata: {}, **context)
32
104
  build_stub(command, :execute!, CMDx::Signal.success(nil, metadata:), context, strict: true)
33
105
  end
106
+ alias stub_task! stub_task_success!
34
107
 
35
108
  # Stubs `command.execute` to return a frozen skipped Result.
36
109
  #
@@ -68,14 +141,16 @@ module CMDx
68
141
  build_stub(command, :execute, CMDx::Signal.failed(reason, metadata:, cause:), context)
69
142
  end
70
143
 
71
- # Stubs `command.execute!` (bang variant) to return a frozen failed Result.
144
+ # Stubs `command.execute!` so a call raises like real strict execution:
145
+ # {CMDx::Fault} for signal failures, or the original exception when
146
+ # +cause+ is set (mirrors {CMDx::Runtime#raise_signal!}).
72
147
  #
73
148
  # @param command [Class] the Task class to stub
74
149
  # @param reason [String, nil] human-readable failure reason
75
150
  # @param cause [Exception, nil] originating cause attached to the signal
76
151
  # @param metadata [Hash] payload exposed via `result.metadata`
77
152
  # @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
78
- # @return [CMDx::Result] the frozen Result installed on the stub
153
+ # @return [CMDx::Result] the frozen failed Result backing the stub (for inspection)
79
154
  def stub_task_fail!(command, reason: nil, cause: nil, metadata: {}, **context)
80
155
  build_stub(command, :execute!, CMDx::Signal.failed(reason, metadata:, cause:), context, strict: true)
81
156
  end
@@ -110,11 +185,7 @@ module CMDx
110
185
  # @param context [Hash{Symbol => Object}]
111
186
  # @return [CMDx::Result] the frozen Result installed on the stub
112
187
  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
117
-
188
+ validate_echo_upstream!(upstream_result)
118
189
  build_stub(command, :execute, CMDx::Signal.echoed(upstream_result, metadata:), context)
119
190
  end
120
191
 
@@ -142,8 +213,10 @@ module CMDx
142
213
  raise ArgumentError, "block required" unless block_given?
143
214
 
144
215
  io = StringIO.new
145
- previous = CMDx.configuration.logger
146
- CMDx.configuration.logger = Logger.new(io, formatter: previous&.formatter || CMDx::LogFormatters::Line.new)
216
+ previous = CMDx.configuration.logger
217
+ formatter = previous&.formatter || CMDx::LogFormatters::Line.new
218
+
219
+ CMDx.configuration.logger = Logger.new(io, formatter:)
147
220
  yield
148
221
  io.string.lines.map(&:chomp)
149
222
  ensure
@@ -172,8 +245,8 @@ module CMDx
172
245
  telemetry = command.telemetry
173
246
  listener = ->(event) { captured << event }
174
247
 
175
- events.each { |e| telemetry.subscribe(e, listener) }
176
248
  begin
249
+ events.each { |e| telemetry.subscribe(e, listener) }
177
250
  yield
178
251
  ensure
179
252
  events.each { |e| telemetry.unsubscribe(e, listener) }
@@ -199,12 +272,10 @@ module CMDx
199
272
 
200
273
  captured = nil
201
274
  telemetry = command.telemetry
202
- listener = lambda do |event|
203
- captured ||= event.payload[:result].chain if event.root
204
- end
275
+ listener = ->(event) { captured ||= event.payload[:result].chain if event.root }
205
276
 
206
- telemetry.subscribe(:task_executed, listener)
207
277
  begin
278
+ telemetry.subscribe(:task_executed, listener)
208
279
  yield
209
280
  ensure
210
281
  telemetry.unsubscribe(:task_executed, listener)
@@ -219,11 +290,11 @@ module CMDx
219
290
  # @param command [Class] the Task class to unstub
220
291
  # @param context [Hash{Symbol => Object}] argument signature whose stub to release
221
292
  # @return [void]
222
- def unstub_task(command, **context)
223
- if context.empty?
293
+ def unstub_task(command, *args, **context)
294
+ if args.empty? && context.empty?
224
295
  allow(command).to receive(:execute).and_call_original
225
296
  else
226
- allow(command).to receive(:execute).with(**context).and_call_original
297
+ allow(command).to receive(:execute).with(*args, **context).and_call_original
227
298
  end
228
299
  end
229
300
 
@@ -233,11 +304,11 @@ module CMDx
233
304
  # @param command [Class] the Task class to unstub
234
305
  # @param context [Hash{Symbol => Object}] argument signature whose stub to release
235
306
  # @return [void]
236
- def unstub_task!(command, **context)
237
- if context.empty?
307
+ def unstub_task!(command, *args, **context)
308
+ if args.empty? && context.empty?
238
309
  allow(command).to receive(:execute!).and_call_original
239
310
  else
240
- allow(command).to receive(:execute!).with(**context).and_call_original
311
+ allow(command).to receive(:execute!).with(*args, **context).and_call_original
241
312
  end
242
313
  end
243
314
 
@@ -247,11 +318,11 @@ module CMDx
247
318
  # @param command [Class] the Task class to expect
248
319
  # @param context [Hash{Symbol => Object}] argument signature to match
249
320
  # @return [RSpec::Mocks::MessageExpectation]
250
- def expect_task_execution(command, **context)
251
- if context.empty?
321
+ def expect_task_execution(command, *args, **context)
322
+ if args.empty? && context.empty?
252
323
  expect(command).to receive(:execute)
253
324
  else
254
- expect(command).to receive(:execute).with(**context)
325
+ expect(command).to receive(:execute).with(*args, **context)
255
326
  end
256
327
  end
257
328
 
@@ -261,11 +332,11 @@ module CMDx
261
332
  # @param command [Class] the Task class to expect
262
333
  # @param context [Hash{Symbol => Object}] argument signature to match
263
334
  # @return [RSpec::Mocks::MessageExpectation]
264
- def expect_task_execution!(command, **context)
265
- if context.empty?
335
+ def expect_task_execution!(command, *args, **context)
336
+ if args.empty? && context.empty?
266
337
  expect(command).to receive(:execute!)
267
338
  else
268
- expect(command).to receive(:execute!).with(**context)
339
+ expect(command).to receive(:execute!).with(*args, **context)
269
340
  end
270
341
  end
271
342
 
@@ -275,11 +346,11 @@ module CMDx
275
346
  # @param command [Class] the Task class to guard
276
347
  # @param context [Hash{Symbol => Object}] argument signature to forbid
277
348
  # @return [RSpec::Mocks::MessageExpectation]
278
- def expect_no_task_execution(command, **context)
279
- if context.empty?
349
+ def expect_no_task_execution(command, *args, **context)
350
+ if args.empty? && context.empty?
280
351
  expect(command).not_to receive(:execute)
281
352
  else
282
- expect(command).not_to receive(:execute).with(**context)
353
+ expect(command).not_to receive(:execute).with(*args, **context)
283
354
  end
284
355
  end
285
356
 
@@ -289,11 +360,72 @@ module CMDx
289
360
  # @param command [Class] the Task class to guard
290
361
  # @param context [Hash{Symbol => Object}] argument signature to forbid
291
362
  # @return [RSpec::Mocks::MessageExpectation]
292
- def expect_no_task_execution!(command, **context)
293
- if context.empty?
363
+ def expect_no_task_execution!(command, *args, **context)
364
+ if args.empty? && context.empty?
294
365
  expect(command).not_to receive(:execute!)
295
366
  else
296
- expect(command).not_to receive(:execute!).with(**context)
367
+ expect(command).not_to receive(:execute!).with(*args, **context)
368
+ end
369
+ end
370
+
371
+ # Asserts that `command.execute` was invoked (after the fact). Requires a
372
+ # prior spy, e.g. `allow(command).to receive(:execute).and_call_original`
373
+ # or a `stub_task_*` helper. When `context` is supplied, the assertion is
374
+ # constrained to that signature.
375
+ #
376
+ # @param command [Class] the Task class to assert against
377
+ # @param context [Hash{Symbol => Object}] argument signature to match
378
+ # @return [RSpec::Mocks::Matchers::HaveReceived]
379
+ def expected_task_execution(command, *args, **context)
380
+ if args.empty? && context.empty?
381
+ expect(command).to have_received(:execute)
382
+ else
383
+ expect(command).to have_received(:execute).with(*args, **context)
384
+ end
385
+ end
386
+
387
+ # Asserts that `command.execute!` was invoked (after the fact). Requires a
388
+ # prior spy on `execute!`. When `context` is supplied, the assertion is
389
+ # constrained to that signature.
390
+ #
391
+ # @param command [Class] the Task class to assert against
392
+ # @param context [Hash{Symbol => Object}] argument signature to match
393
+ # @return [RSpec::Mocks::Matchers::HaveReceived]
394
+ def expected_task_execution!(command, *args, **context)
395
+ if args.empty? && context.empty?
396
+ expect(command).to have_received(:execute!)
397
+ else
398
+ expect(command).to have_received(:execute!).with(*args, **context)
399
+ end
400
+ end
401
+
402
+ # Asserts that `command.execute` was not invoked (after the fact). Requires
403
+ # a prior spy on `execute`. When `context` is supplied, only the matching
404
+ # signature is checked.
405
+ #
406
+ # @param command [Class] the Task class to assert against
407
+ # @param context [Hash{Symbol => Object}] argument signature to forbid
408
+ # @return [RSpec::Mocks::Matchers::HaveReceived]
409
+ def expected_no_task_execution(command, *args, **context)
410
+ if args.empty? && context.empty?
411
+ expect(command).not_to have_received(:execute)
412
+ else
413
+ expect(command).not_to have_received(:execute).with(*args, **context)
414
+ end
415
+ end
416
+
417
+ # Asserts that `command.execute!` was not invoked (after the fact). Requires
418
+ # a prior spy on `execute!`. When `context` is supplied, only the matching
419
+ # signature is checked.
420
+ #
421
+ # @param command [Class] the Task class to assert against
422
+ # @param context [Hash{Symbol => Object}] argument signature to forbid
423
+ # @return [RSpec::Mocks::Matchers::HaveReceived]
424
+ def expected_no_task_execution!(command, *args, **context)
425
+ if args.empty? && context.empty?
426
+ expect(command).not_to have_received(:execute!)
427
+ else
428
+ expect(command).not_to have_received(:execute!).with(*args, **context)
297
429
  end
298
430
  end
299
431
 
@@ -318,9 +450,66 @@ module CMDx
318
450
 
319
451
  private
320
452
 
453
+ # @api private
454
+ def resolve_result_signal(status, reason:, cause:, metadata:, upstream_result:)
455
+ case status
456
+ when CMDx::Signal
457
+ status
458
+ when :success, :successful
459
+ CMDx::Signal.success(reason, metadata:, cause:)
460
+ when :skip, :skipped
461
+ CMDx::Signal.skipped(reason, metadata:, cause:)
462
+ when :fail, :failed, :failure
463
+ CMDx::Signal.failed(reason, metadata:, cause:)
464
+ when :echo, :echoed
465
+ validate_echo_upstream!(upstream_result)
466
+ CMDx::Signal.echoed(upstream_result, metadata:, cause:)
467
+ else
468
+ raise ArgumentError, "unknown result status #{status.inspect}"
469
+ end
470
+ end
471
+
472
+ # @api private
473
+ def validate_echo_upstream!(upstream_result)
474
+ return if upstream_result.is_a?(CMDx::Result) && upstream_result.failed?
475
+
476
+ raise ArgumentError, "upstream result must be a failed CMDx::Result"
477
+ end
478
+
479
+ # Constructs a frozen {CMDx::Result} from +signal+ and wires it into a
480
+ # {CMDx::Chain} so callers observe the same shape as a real execution.
481
+ #
482
+ # @api private
483
+ # @return [CMDx::Result]
484
+ LIFECYCLE_OPTIONS = %i[root strict deprecated retries duration rolled_back tid].freeze
485
+ private_constant :LIFECYCLE_OPTIONS
486
+
487
+ def assemble_result(signal, task: nil, chain: nil, context: nil, **options)
488
+ context_data = options.except(*LIFECYCLE_OPTIONS)
489
+ task_class = task || STUB_TASK
490
+ context_input = context || context_data
491
+ task_instance = task_class.new(context_input)
492
+ chain ||= CMDx::Chain.new
493
+ result = CMDx::Result.new(
494
+ chain,
495
+ task_instance,
496
+ signal,
497
+ root: options.fetch(:root, true),
498
+ strict: options.fetch(:strict, false),
499
+ deprecated: options.fetch(:deprecated, false),
500
+ retries: options.fetch(:retries, 0),
501
+ duration: options.fetch(:duration, nil),
502
+ rolled_back: options.fetch(:rolled_back, false),
503
+ tid: options.fetch(:tid, SecureRandom.uuid_v7)
504
+ )
505
+
506
+ chain.unshift(result)
507
+
508
+ result
509
+ end
510
+
321
511
  # 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.
512
+ # to return it.
324
513
  #
325
514
  # @api private
326
515
  # @param command [Class] the Task class being stubbed
@@ -328,25 +517,30 @@ module CMDx
328
517
  # @param signal [CMDx::Signal] outcome payload to wrap
329
518
  # @param context [Hash] context overrides for `command.new`
330
519
  # @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
- )
520
+ def build_stub(command, method, signal, context, **options)
521
+ raise ArgumentError, "command must be a Task class" unless command <= CMDx::Task
342
522
 
343
- chain.unshift(result)
523
+ result = assemble_result(signal, **options, task: command, context:)
344
524
 
345
- allow(command).to receive(method).and_return(result)
525
+ if options.fetch(:strict, false) && result.failed?
526
+ allow(command).to receive(method) { raise_strict_failure!(result) }
527
+ else
528
+ allow(command).to receive(method).and_return(result)
529
+ end
346
530
 
347
531
  result
348
532
  end
349
533
 
534
+ # @api private
535
+ # @param result [CMDx::Result] a failed strict result
536
+ # @raise [CMDx::Fault, StandardError]
537
+ def raise_strict_failure!(result)
538
+ cause = result.cause
539
+ raise(cause) if cause && !cause.is_a?(CMDx::Fault)
540
+
541
+ raise(CMDx::Fault, result.caused_failure, cause:)
542
+ end
543
+
350
544
  end
351
545
  end
352
546
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Matcher to verify that a result is "ko" (skipped or failed, anything but success).
3
+ # Matcher to verify that a result is "ko" (skipped or failed), via {CMDx::Result#ko?}.
4
4
  #
5
5
  # @example
6
6
  # expect(MyCommand.execute).to be_ko
@@ -4,7 +4,7 @@ module CMDx
4
4
  module RSpec
5
5
 
6
6
  # Gem version. Bumped on release; mirrored in the gemspec.
7
- VERSION = "2.0.0"
7
+ VERSION = "2.1.0"
8
8
 
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cmdx-rspec
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Juan Gomez
@@ -128,6 +128,7 @@ executables: []
128
128
  extensions: []
129
129
  extra_rdoc_files: []
130
130
  files:
131
+ - ".DS_Store"
131
132
  - CHANGELOG.md
132
133
  - CODE_OF_CONDUCT.md
133
134
  - LICENSE.txt
@@ -187,7 +188,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
187
188
  - !ruby/object:Gem::Version
188
189
  version: '0'
189
190
  requirements: []
190
- rubygems_version: 4.0.11
191
+ rubygems_version: 4.0.21
191
192
  specification_version: 4
192
193
  summary: Simple CMDx task testing via RSpec matchers.
193
194
  test_files: []