stepper_motor 0.1.15 → 0.1.16
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 +4 -4
- data/CHANGELOG.md +7 -0
- data/lib/stepper_motor/journey/flow_control.rb +49 -0
- data/lib/stepper_motor/journey.rb +45 -5
- data/lib/stepper_motor/step.rb +19 -17
- data/lib/stepper_motor/version.rb +1 -1
- data/manual/MANUAL.md +23 -12
- data/rbi/stepper_motor.rbi +40 -12
- data/sig/stepper_motor.rbs +36 -10
- data/test/stepper_motor/journey/exception_handling_test.rb +57 -0
- data/test/stepper_motor/journey/flow_control_test.rb +164 -0
- data/test/stepper_motor/journey/if_condition_test.rb +125 -56
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4b94d7f405351309bd1fbda83b352031795cbee87aad100d412d847e3d03543c
|
4
|
+
data.tar.gz: 59ecde85caacac0c7e65f6a0c527676bac4700b84fa39ce55443e81592023738
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5305f1f98eb8b8c630e45309280fae524ec458d15a75362e647a36e22fd29ca0fe1a7ecbbba186ed60f33b64b7b6b875e66878280e21a6a3196473652b82f098
|
7
|
+
data.tar.gz: 2e6db70cf8ad6b844c4d7a38eab6f087f785b1a112f446844e25d6d943bffd3f3b71da6bd7baa69c7249a7978a5538d2572235b3f90475b01af8be0d07074cfd
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,12 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
+
## [Unreleased]
|
4
|
+
|
5
|
+
## [0.1.16] - 2025-06-20
|
6
|
+
|
7
|
+
- Add `skip!` flow control method to skip the current (or next) step and move on to the subsequent step, or finish the journey.
|
8
|
+
- Rename `if:` parameter to `skip_if:` for better clarity. The `if:` parameter is still supported for brevity.
|
9
|
+
|
3
10
|
## [0.1.15] - 2025-06-20
|
4
11
|
|
5
12
|
- Add `if:` condition allowing steps to be skipped. The `if:` can be a boolean, a callable or a symbol for a method name. The method should be on the Journey.
|
@@ -27,6 +27,55 @@ module StepperMotor::Journey::FlowControl
|
|
27
27
|
throw :abort_step if @current_step_definition
|
28
28
|
end
|
29
29
|
|
30
|
+
# Is used to skip the current step and proceed to the next step in the journey. This is useful when you want to
|
31
|
+
# conditionally skip a step based on some business logic without canceling the entire journey. For example,
|
32
|
+
# you might want to skip a reminder email step if the user has already taken the required action.
|
33
|
+
#
|
34
|
+
# If there are more steps after the current step, `skip!` will schedule the next step to be performed.
|
35
|
+
# If the current step is the last step in the journey, `skip!` will finish the journey.
|
36
|
+
#
|
37
|
+
# `skip!` may be called within a step or outside of a step for journeys in the `ready` state.
|
38
|
+
# When called outside of a step, it will skip the next scheduled step and proceed to the following step.
|
39
|
+
#
|
40
|
+
# @return void
|
41
|
+
def skip!
|
42
|
+
if @current_step_definition
|
43
|
+
# Called within a step - set flag to skip current step
|
44
|
+
@skip_current_step = true
|
45
|
+
throw :abort_step if @current_step_definition
|
46
|
+
else
|
47
|
+
# Called outside of a step - skip next scheduled step
|
48
|
+
with_lock do
|
49
|
+
raise "skip! can only be used on journeys in the `ready` state, but was in #{state.inspect}" unless ready?
|
50
|
+
|
51
|
+
current_step_name = next_step_name
|
52
|
+
current_step_definition = lookup_step_definition(current_step_name)
|
53
|
+
|
54
|
+
unless current_step_definition
|
55
|
+
logger.warn { "no step definition found for #{current_step_name} - finishing journey" }
|
56
|
+
finished!
|
57
|
+
update!(previous_step_name: current_step_name, next_step_name: nil)
|
58
|
+
return
|
59
|
+
end
|
60
|
+
|
61
|
+
current_step_seq = current_step_definition.seq
|
62
|
+
next_step_definition = step_definitions[current_step_seq + 1]
|
63
|
+
|
64
|
+
if next_step_definition
|
65
|
+
# There are more steps after this one - schedule the next step
|
66
|
+
logger.info { "skipping scheduled step #{current_step_name}, will continue to #{next_step_definition.name}" }
|
67
|
+
set_next_step_and_enqueue(next_step_definition)
|
68
|
+
ready!
|
69
|
+
else
|
70
|
+
# This is the last step - finish the journey
|
71
|
+
logger.info { "skipping scheduled step #{current_step_name}, finishing journey" }
|
72
|
+
finished!
|
73
|
+
update!(previous_step_name: current_step_name, next_step_name: nil)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
30
79
|
# Is used to pause a Journey at any point. The "paused" state is similar to the "ready" state, except that "perform_next_step!" on the
|
31
80
|
# journey will do nothing - even if it is scheduled to be performed. Pausing a Journey can be useful in the following situations:
|
32
81
|
#
|
@@ -78,12 +78,35 @@ module StepperMotor
|
|
78
78
|
# attribute will be set to the current time. The `after` value gets converted into the `wait` value and passed to the step definition.
|
79
79
|
# Mutually exclusive with `wait:`
|
80
80
|
# @param on_exception[Symbol] See {StepperMotor::Step#on_exception}
|
81
|
+
# @param skip_if[TrueClass,FalseClass,Symbol,Proc] condition to check before performing the step. If a symbol is provided,
|
82
|
+
# it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context.
|
83
|
+
# The step will be skipped if the condition returns a truthy value.
|
81
84
|
# @param if[TrueClass,FalseClass,Symbol,Proc] condition to check before performing the step. If a symbol is provided,
|
82
85
|
# it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context.
|
83
|
-
# The step will
|
86
|
+
# The step will be performed if the condition returns a truthy value. and skipped otherwise. Inverse of `skip_if`.
|
84
87
|
# @param additional_step_definition_options[Hash] Any remaining options get passed to `StepperMotor::Step.new` as keyword arguments.
|
85
88
|
# @return [StepperMotor::Step] the step definition that has been created
|
86
|
-
def self.step(name = nil, wait: nil, after: nil,
|
89
|
+
def self.step(name = nil, wait: nil, after: nil, **additional_step_definition_options, &blk)
|
90
|
+
# Handle the if: alias for backward compatibility
|
91
|
+
if additional_step_definition_options.key?(:if) && additional_step_definition_options.key?(:skip_if)
|
92
|
+
raise StepConfigurationError, "Either skip_if: or if: can be specified, but not both"
|
93
|
+
end
|
94
|
+
if additional_step_definition_options.key?(:if)
|
95
|
+
if_condition = additional_step_definition_options.delete(:if)
|
96
|
+
# Convert if: to skip_if: by negating either the actual value or the return value of the callable
|
97
|
+
# if: truthy means perform, skip_if: truthy means "skip"
|
98
|
+
additional_step_definition_options[:skip_if] = case if_condition
|
99
|
+
when true, false, nil
|
100
|
+
!if_condition
|
101
|
+
when Symbol
|
102
|
+
# For symbols, we need to create a proc that negates the result
|
103
|
+
-> { !send(if_condition) }
|
104
|
+
else
|
105
|
+
# For callables, we need to create a proc that negates the result
|
106
|
+
-> { !instance_exec(&if_condition) }
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
87
110
|
wait = if wait && after
|
88
111
|
raise StepConfigurationError, "Either wait: or after: can be specified, but not both"
|
89
112
|
elsif !wait && !after
|
@@ -100,8 +123,8 @@ module StepperMotor
|
|
100
123
|
raise StepConfigurationError, <<~MSG
|
101
124
|
Step #{step_definitions.length + 1} of #{self} has no explicit name,
|
102
125
|
and no block with step definition has been provided. Without a name the step
|
103
|
-
must be defined with a block to execute. If you want an instance method
|
104
|
-
|
126
|
+
must be defined with a block to execute. If you want an instance method of
|
127
|
+
this Journey to be used as the step, pass the name of the method as the name of the step.
|
105
128
|
MSG
|
106
129
|
end
|
107
130
|
|
@@ -112,7 +135,7 @@ module StepperMotor
|
|
112
135
|
raise StepConfigurationError, "Step named #{name.inspect} already defined" if known_step_names.include?(name)
|
113
136
|
|
114
137
|
# Create the step definition
|
115
|
-
StepperMotor::Step.new(name: name, wait: wait, seq: step_definitions.length,
|
138
|
+
StepperMotor::Step.new(name: name, wait: wait, seq: step_definitions.length, **additional_step_definition_options, &blk).tap do |step_definition|
|
116
139
|
# As per Rails docs: you need to be aware when using class_attribute with mutable structures
|
117
140
|
# as Array or Hash. In such cases, you don't want to do changes in place. Instead use setters.
|
118
141
|
# See https://apidock.com/rails/v7.1.3.2/Class/class_attribute
|
@@ -232,6 +255,22 @@ module StepperMotor
|
|
232
255
|
logger.info { "will reattempt #{current_step_name} in #{@reattempt_after} seconds" }
|
233
256
|
set_next_step_and_enqueue(@current_step_definition, wait: @reattempt_after)
|
234
257
|
ready!
|
258
|
+
elsif @skip_current_step
|
259
|
+
# The step asked to be skipped
|
260
|
+
current_step_seq = @current_step_definition.seq
|
261
|
+
next_step_definition = step_definitions[current_step_seq + 1]
|
262
|
+
|
263
|
+
if next_step_definition
|
264
|
+
# There are more steps after this one - schedule the next step
|
265
|
+
logger.info { "skipping current step #{current_step_name}, will continue to #{next_step_definition.name}" }
|
266
|
+
set_next_step_and_enqueue(next_step_definition)
|
267
|
+
ready!
|
268
|
+
else
|
269
|
+
# This is the last step - finish the journey
|
270
|
+
logger.info { "skipping current step #{current_step_name}, finishing journey" }
|
271
|
+
finished!
|
272
|
+
update!(previous_step_name: current_step_name, next_step_name: nil)
|
273
|
+
end
|
235
274
|
elsif finished?
|
236
275
|
logger.info { "was marked finished inside the step" }
|
237
276
|
update!(previous_step_name: current_step_name, next_step_name: nil)
|
@@ -250,6 +289,7 @@ module StepperMotor
|
|
250
289
|
# and not via background jobs (which reload the model). This should actually be solved
|
251
290
|
# using some object that contains the state of the action later, but for now - the dirty approach is fine.
|
252
291
|
@reattempt_after = nil
|
292
|
+
@skip_current_step = nil
|
253
293
|
@current_step_definition = nil
|
254
294
|
# Re-raise the exception, now that we have persisted the Journey according to the recovery policy
|
255
295
|
if ex_rescued_at_perform
|
data/lib/stepper_motor/step.rb
CHANGED
@@ -24,36 +24,38 @@ class StepperMotor::Step
|
|
24
24
|
# The possible values are:
|
25
25
|
# * `:cancel!` - cancels the Journey and re-raises the exception. The Journey will be persisted before re-raising.
|
26
26
|
# * `:reattempt!` - reattempts the Journey and re-raises the exception. The Journey will be persisted before re-raising.
|
27
|
-
#
|
27
|
+
# * `:pause!` - pauses the Journey and re-raises the exception. The Journey will be persisted before re-raising.
|
28
|
+
# * `:skip!` - skips the current step and proceeds to the next step, or finishes the journey if it's the last step.
|
29
|
+
# @param skip_if[TrueClass,FalseClass,NilClass,Symbol,Proc] condition to check before performing the step. If a boolean is provided,
|
28
30
|
# it will be used directly. If nil is provided, it will be treated as false. If a symbol is provided,
|
29
31
|
# it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context.
|
30
32
|
# The step will only be performed if the condition returns a truthy value.
|
31
|
-
def initialize(name:, seq:, on_exception
|
33
|
+
def initialize(name:, seq:, on_exception: :pause!, wait: 0, skip_if: false, &step_block)
|
32
34
|
@step_block = step_block
|
33
35
|
@name = name.to_s
|
34
36
|
@wait = wait
|
35
37
|
@seq = seq
|
36
38
|
@on_exception = on_exception # TODO: Validate?
|
37
|
-
@
|
39
|
+
@skip_if_condition = skip_if
|
38
40
|
|
39
|
-
# Validate the
|
40
|
-
if ![true, false, nil].include?(@
|
41
|
-
raise ArgumentError, "
|
41
|
+
# Validate the skip_if condition
|
42
|
+
if ![true, false, nil].include?(@skip_if_condition) && !@skip_if_condition.is_a?(Symbol) && !@skip_if_condition.respond_to?(:call)
|
43
|
+
raise ArgumentError, "skip_if: condition must be a boolean, nil, Symbol or a callable object, but was a #{@skip_if_condition.inspect}"
|
42
44
|
end
|
43
45
|
end
|
44
46
|
|
45
|
-
# Checks if the step should be
|
47
|
+
# Checks if the step should be skipped based on the skip_if condition
|
46
48
|
#
|
47
49
|
# @param journey[StepperMotor::Journey] the journey to check the condition for
|
48
|
-
# @return [Boolean] true if the step should be
|
49
|
-
def
|
50
|
-
case @
|
50
|
+
# @return [Boolean] true if the step should be skipped, false otherwise
|
51
|
+
def should_skip?(journey)
|
52
|
+
case @skip_if_condition
|
51
53
|
when true, false, nil
|
52
|
-
!!@
|
54
|
+
!!@skip_if_condition
|
53
55
|
when Symbol
|
54
|
-
journey.send(@
|
56
|
+
journey.send(@skip_if_condition) # Allow private methods
|
55
57
|
else
|
56
|
-
journey.instance_exec(&@
|
58
|
+
journey.instance_exec(&@skip_if_condition)
|
57
59
|
end
|
58
60
|
end
|
59
61
|
|
@@ -65,9 +67,9 @@ class StepperMotor::Step
|
|
65
67
|
# journey will be called
|
66
68
|
# @return void
|
67
69
|
def perform_in_context_of(journey)
|
68
|
-
# Return early should the `
|
69
|
-
if
|
70
|
-
journey.logger.info { "skipping as
|
70
|
+
# Return early should the `skip_if` condition be truthy
|
71
|
+
if should_skip?(journey)
|
72
|
+
journey.logger.info { "skipping as skip_if: condition was truthy" }
|
71
73
|
return
|
72
74
|
end
|
73
75
|
|
@@ -99,7 +101,7 @@ class StepperMotor::Step
|
|
99
101
|
# Act according to the set policy. The basic 2 for the moment are :reattempt! and :cancel!,
|
100
102
|
# and can be applied by just calling the methods on the passed journey
|
101
103
|
case @on_exception
|
102
|
-
when :reattempt!, :cancel!, :pause!
|
104
|
+
when :reattempt!, :cancel!, :pause!, :skip!
|
103
105
|
catch(:abort_step) { journey.public_send(@on_exception) }
|
104
106
|
else
|
105
107
|
# Leave the journey hanging in the "performing" state
|
data/manual/MANUAL.md
CHANGED
@@ -427,9 +427,9 @@ class Erasure < StepperMotor::Journey
|
|
427
427
|
end
|
428
428
|
```
|
429
429
|
|
430
|
-
### Conditional steps with `
|
430
|
+
### Conditional steps with `skip_if:`
|
431
431
|
|
432
|
-
You can make steps conditional by using the `
|
432
|
+
You can make steps conditional by using the `skip_if:` parameter. This allows you to skip steps based on runtime conditions. The `skip_if:` parameter accepts:
|
433
433
|
|
434
434
|
* A symbol (method name) that returns a boolean.
|
435
435
|
* A callable (lambda or proc) that returns a boolean. It will be `instance_exec`d in the context of the Journey.
|
@@ -437,15 +437,17 @@ You can make steps conditional by using the `if:` parameter. This allows you to
|
|
437
437
|
|
438
438
|
When a step's condition evaluates to `false`, the step is skipped and the journey continues to the next step. If there are no more steps, the journey finishes.
|
439
439
|
|
440
|
+
> **Note:** The `if:` parameter is also supported as an alias for `skip_if:` for backward compatibility, but `skip_if:` is the preferred parameter name.
|
441
|
+
|
440
442
|
#### Using method names as conditions
|
441
443
|
|
442
444
|
```ruby
|
443
445
|
class UserOnboardingJourney < StepperMotor::Journey
|
444
|
-
step :send_welcome_email,
|
446
|
+
step :send_welcome_email, skip_if: :should_send_welcome? do
|
445
447
|
WelcomeMailer.welcome(hero).deliver_later
|
446
448
|
end
|
447
449
|
|
448
|
-
step :send_premium_offer,
|
450
|
+
step :send_premium_offer, skip_if: :is_premium_user? do
|
449
451
|
PremiumOfferMailer.exclusive_offer(hero).deliver_later
|
450
452
|
end
|
451
453
|
|
@@ -471,7 +473,7 @@ You can use lambdas or procs for more dynamic conditions. They will be `instance
|
|
471
473
|
|
472
474
|
```ruby
|
473
475
|
class OrderProcessingJourney < StepperMotor::Journey
|
474
|
-
step :send_confirmation,
|
476
|
+
step :send_confirmation, skip_if: -> { hero.email.present? } do
|
475
477
|
OrderConfirmationMailer.confirm(hero).deliver_later
|
476
478
|
end
|
477
479
|
|
@@ -487,11 +489,11 @@ You can use literal boolean values to conditionally include or exclude steps:
|
|
487
489
|
|
488
490
|
```ruby
|
489
491
|
class FeatureFlagJourney < StepperMotor::Journey
|
490
|
-
step :new_feature_step,
|
492
|
+
step :new_feature_step, skip_if: Rails.application.config.new_feature_enabled do
|
491
493
|
NewFeatureService.process(hero)
|
492
494
|
end
|
493
495
|
|
494
|
-
step :legacy_step,
|
496
|
+
step :legacy_step, skip_if: ENV["PERFORM_LEGACY_STEP"] do # This step will never execute
|
495
497
|
LegacyService.process(hero)
|
496
498
|
end
|
497
499
|
|
@@ -548,23 +550,32 @@ stateDiagram-v2
|
|
548
550
|
|
549
551
|
## Flow control within steps
|
550
552
|
|
551
|
-
|
553
|
+
You currently can use the following flow control methods, both when a step is performing and on a Journey fetched from the database:
|
552
554
|
|
553
555
|
* `cancel!` - cancel the Journey immediately. It will be persisted and moved into the `canceled` state.
|
554
556
|
* `reattempt!` - reattempt the Journey immediately, triggering it asynchronously. It will be persisted
|
555
557
|
and returned into the `ready` state. You can specify the `wait:` interval, which may deviate from
|
556
|
-
the wait time defined for the current step
|
558
|
+
the wait time defined for the current step. `reattepmt!` cannot be used outside of steps!
|
557
559
|
* `pause!` - pause the Journey either within a step or outside of one. This moves the Journey into the `paused` state.
|
558
560
|
In that state, the journey is still considered unique-per-hero (you won't be able to create an identical Journey)
|
559
561
|
but it will not be picked up by the scheduled step jobs. Should it get picked up, the step will not be performed.
|
560
562
|
You have to explicitly `resume!` the Journey to make it `ready` - once you do, a new job will be scheduled to
|
561
563
|
perform the step.
|
564
|
+
* `skip!` - skip either the step currently being performed or the step scheduled to be taken next, and proceed to the next
|
565
|
+
step in the journey. This is useful when you want to conditionally skip a step based on some business logic without
|
566
|
+
canceling the entire journey. For example, you might want to skip a reminder email step if the user has already taken the required action.
|
567
|
+
|
568
|
+
If there are more steps after the current step, `skip!` will schedule the next step to be performed.
|
569
|
+
If the current step is the last step in the journey, `skip!` will finish the journey.
|
562
570
|
|
563
571
|
> [!IMPORTANT]
|
564
|
-
> Flow control methods use `throw` when they are called
|
565
|
-
> `return`, the code following a `reattempt!` or `cancel!` within the same scope will not be executed, so those methods may only be called once within a particular scope.
|
572
|
+
> Flow control methods use `throw` when they are called during step execution. Unlike Rails `render` or `redirect` that require an explicit
|
573
|
+
> `return`, the code following a `reattempt!` or `cancel!` within the same scope will not be executed inside steps, so those methods may only be called once within a particular scope.
|
566
574
|
|
567
|
-
|
575
|
+
Most of those methods do the right thing both inside steps and outside step execution. The only exception is `reattempt!`.
|
576
|
+
|
577
|
+
> [!IMPORTANT]
|
578
|
+
> `reattempt!` only works inside of steps.
|
568
579
|
|
569
580
|
## Transactional semantics within steps
|
570
581
|
|
data/rbi/stepper_motor.rbi
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
# StepperMotor is a module for building multi-step flows where steps are sequential and only
|
3
3
|
# ever progress forward. The building block of StepperMotor is StepperMotor::Journey
|
4
4
|
module StepperMotor
|
5
|
-
VERSION = T.let("0.1.
|
5
|
+
VERSION = T.let("0.1.16", T.untyped)
|
6
6
|
PerformStepJobV2 = T.let(StepperMotor::PerformStepJob, T.untyped)
|
7
7
|
RecoverStuckJourneysJobV1 = T.let(StepperMotor::RecoverStuckJourneysJob, T.untyped)
|
8
8
|
|
@@ -32,28 +32,28 @@ module StepperMotor
|
|
32
32
|
#
|
33
33
|
# _@param_ `wait` — the amount of time to wait before entering the step
|
34
34
|
#
|
35
|
-
# _@param_ `on_exception` — the action to take if an exception occurs when performing the step. The possible values are: * `:cancel!` - cancels the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:reattempt!` - reattempts the Journey and re-raises the exception. The Journey will be persisted before re-raising.
|
35
|
+
# _@param_ `on_exception` — the action to take if an exception occurs when performing the step. The possible values are: * `:cancel!` - cancels the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:reattempt!` - reattempts the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:pause!` - pauses the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:skip!` - skips the current step and proceeds to the next step, or finishes the journey if it's the last step.
|
36
36
|
#
|
37
|
-
# _@param_ `
|
37
|
+
# _@param_ `skip_if` — condition to check before performing the step. If a boolean is provided, it will be used directly. If nil is provided, it will be treated as false. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will only be performed if the condition returns a truthy value.
|
38
38
|
sig do
|
39
39
|
params(
|
40
40
|
name: T.any(String, Symbol),
|
41
41
|
seq: T.untyped,
|
42
42
|
on_exception: Symbol,
|
43
43
|
wait: T.any(Numeric, ActiveSupport::Duration),
|
44
|
-
|
44
|
+
skip_if: T.any(TrueClass, FalseClass, NilClass, Symbol, Proc),
|
45
45
|
step_block: T.untyped
|
46
46
|
).void
|
47
47
|
end
|
48
|
-
def initialize(name:, seq:, on_exception
|
48
|
+
def initialize(name:, seq:, on_exception: :pause!, wait: 0, skip_if: false, &step_block); end
|
49
49
|
|
50
|
-
# Checks if the step should be
|
50
|
+
# Checks if the step should be skipped based on the skip_if condition
|
51
51
|
#
|
52
52
|
# _@param_ `journey` — the journey to check the condition for
|
53
53
|
#
|
54
|
-
# _@return_ — true if the step should be
|
54
|
+
# _@return_ — true if the step should be skipped, false otherwise
|
55
55
|
sig { params(journey: StepperMotor::Journey).returns(T::Boolean) }
|
56
|
-
def
|
56
|
+
def should_skip?(journey); end
|
57
57
|
|
58
58
|
# Performs the step on the passed Journey, wrapping the step with the required context.
|
59
59
|
#
|
@@ -139,7 +139,9 @@ module StepperMotor
|
|
139
139
|
#
|
140
140
|
# _@param_ `on_exception` — See {StepperMotor::Step#on_exception}
|
141
141
|
#
|
142
|
-
# _@param_ `
|
142
|
+
# _@param_ `skip_if` — condition to check before performing the step. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will be skipped if the condition returns a truthy value.
|
143
|
+
#
|
144
|
+
# _@param_ `if` — condition to check before performing the step. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will be performed if the condition returns a truthy value. and skipped otherwise. Inverse of `skip_if`.
|
143
145
|
#
|
144
146
|
# _@param_ `additional_step_definition_options` — Any remaining options get passed to `StepperMotor::Step.new` as keyword arguments.
|
145
147
|
#
|
@@ -149,13 +151,11 @@ module StepperMotor
|
|
149
151
|
name: T.nilable(String),
|
150
152
|
wait: T.nilable(T.any(Float, T.untyped, ActiveSupport::Duration)),
|
151
153
|
after: T.nilable(T.any(Float, T.untyped, ActiveSupport::Duration)),
|
152
|
-
on_exception: Symbol,
|
153
|
-
if: T.any(TrueClass, FalseClass, Symbol, Proc),
|
154
154
|
additional_step_definition_options: T::Hash[T.untyped, T.untyped],
|
155
155
|
blk: T.untyped
|
156
156
|
).returns(StepperMotor::Step)
|
157
157
|
end
|
158
|
-
def self.step(name = nil, wait: nil, after: nil,
|
158
|
+
def self.step(name = nil, wait: nil, after: nil, **additional_step_definition_options, &blk); end
|
159
159
|
|
160
160
|
# sord warn - "StepperMotor::Step?" does not appear to be a type
|
161
161
|
# Returns the `Step` object for a named step. This is used when performing a step, but can also
|
@@ -248,6 +248,20 @@ module StepperMotor
|
|
248
248
|
sig { params(wait: T.untyped).returns(T.untyped) }
|
249
249
|
def reattempt!(wait: nil); end
|
250
250
|
|
251
|
+
# Is used to skip the current step and proceed to the next step in the journey. This is useful when you want to
|
252
|
+
# conditionally skip a step based on some business logic without canceling the entire journey. For example,
|
253
|
+
# you might want to skip a reminder email step if the user has already taken the required action.
|
254
|
+
#
|
255
|
+
# If there are more steps after the current step, `skip!` will schedule the next step to be performed.
|
256
|
+
# If the current step is the last step in the journey, `skip!` will finish the journey.
|
257
|
+
#
|
258
|
+
# `skip!` may be called within a step or outside of a step for journeys in the `ready` state.
|
259
|
+
# When called outside of a step, it will skip the next scheduled step and proceed to the following step.
|
260
|
+
#
|
261
|
+
# _@return_ — void
|
262
|
+
sig { returns(T.untyped) }
|
263
|
+
def skip!; end
|
264
|
+
|
251
265
|
# Is used to pause a Journey at any point. The "paused" state is similar to the "ready" state, except that "perform_next_step!" on the
|
252
266
|
# journey will do nothing - even if it is scheduled to be performed. Pausing a Journey can be useful in the following situations:
|
253
267
|
#
|
@@ -299,6 +313,20 @@ module StepperMotor
|
|
299
313
|
sig { params(wait: T.untyped).returns(T.untyped) }
|
300
314
|
def reattempt!(wait: nil); end
|
301
315
|
|
316
|
+
# Is used to skip the current step and proceed to the next step in the journey. This is useful when you want to
|
317
|
+
# conditionally skip a step based on some business logic without canceling the entire journey. For example,
|
318
|
+
# you might want to skip a reminder email step if the user has already taken the required action.
|
319
|
+
#
|
320
|
+
# If there are more steps after the current step, `skip!` will schedule the next step to be performed.
|
321
|
+
# If the current step is the last step in the journey, `skip!` will finish the journey.
|
322
|
+
#
|
323
|
+
# `skip!` may be called within a step or outside of a step for journeys in the `ready` state.
|
324
|
+
# When called outside of a step, it will skip the next scheduled step and proceed to the following step.
|
325
|
+
#
|
326
|
+
# _@return_ — void
|
327
|
+
sig { returns(T.untyped) }
|
328
|
+
def skip!; end
|
329
|
+
|
302
330
|
# Is used to pause a Journey at any point. The "paused" state is similar to the "ready" state, except that "perform_next_step!" on the
|
303
331
|
# journey will do nothing - even if it is scheduled to be performed. Pausing a Journey can be useful in the following situations:
|
304
332
|
#
|
data/sig/stepper_motor.rbs
CHANGED
@@ -30,23 +30,23 @@ module StepperMotor
|
|
30
30
|
#
|
31
31
|
# _@param_ `wait` — the amount of time to wait before entering the step
|
32
32
|
#
|
33
|
-
# _@param_ `on_exception` — the action to take if an exception occurs when performing the step. The possible values are: * `:cancel!` - cancels the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:reattempt!` - reattempts the Journey and re-raises the exception. The Journey will be persisted before re-raising.
|
33
|
+
# _@param_ `on_exception` — the action to take if an exception occurs when performing the step. The possible values are: * `:cancel!` - cancels the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:reattempt!` - reattempts the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:pause!` - pauses the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:skip!` - skips the current step and proceeds to the next step, or finishes the journey if it's the last step.
|
34
34
|
#
|
35
|
-
# _@param_ `
|
35
|
+
# _@param_ `skip_if` — condition to check before performing the step. If a boolean is provided, it will be used directly. If nil is provided, it will be treated as false. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will only be performed if the condition returns a truthy value.
|
36
36
|
def initialize: (
|
37
37
|
name: (String | Symbol),
|
38
38
|
seq: untyped,
|
39
|
-
on_exception: Symbol,
|
39
|
+
?on_exception: Symbol,
|
40
40
|
?wait: (Numeric | ActiveSupport::Duration),
|
41
|
-
?
|
41
|
+
?skip_if: (TrueClass | FalseClass | NilClass | Symbol | Proc)
|
42
42
|
) -> void
|
43
43
|
|
44
|
-
# Checks if the step should be
|
44
|
+
# Checks if the step should be skipped based on the skip_if condition
|
45
45
|
#
|
46
46
|
# _@param_ `journey` — the journey to check the condition for
|
47
47
|
#
|
48
|
-
# _@return_ — true if the step should be
|
49
|
-
def
|
48
|
+
# _@return_ — true if the step should be skipped, false otherwise
|
49
|
+
def should_skip?: (StepperMotor::Journey journey) -> bool
|
50
50
|
|
51
51
|
# Performs the step on the passed Journey, wrapping the step with the required context.
|
52
52
|
#
|
@@ -127,7 +127,9 @@ module StepperMotor
|
|
127
127
|
#
|
128
128
|
# _@param_ `on_exception` — See {StepperMotor::Step#on_exception}
|
129
129
|
#
|
130
|
-
# _@param_ `
|
130
|
+
# _@param_ `skip_if` — condition to check before performing the step. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will be skipped if the condition returns a truthy value.
|
131
|
+
#
|
132
|
+
# _@param_ `if` — condition to check before performing the step. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will be performed if the condition returns a truthy value. and skipped otherwise. Inverse of `skip_if`.
|
131
133
|
#
|
132
134
|
# _@param_ `additional_step_definition_options` — Any remaining options get passed to `StepperMotor::Step.new` as keyword arguments.
|
133
135
|
#
|
@@ -136,8 +138,6 @@ module StepperMotor
|
|
136
138
|
?String? name,
|
137
139
|
?wait: (Float | untyped | ActiveSupport::Duration)?,
|
138
140
|
?after: (Float | untyped | ActiveSupport::Duration)?,
|
139
|
-
?on_exception: Symbol,
|
140
|
-
?if: (TrueClass | FalseClass | Symbol | Proc),
|
141
141
|
**::Hash[untyped, untyped] additional_step_definition_options
|
142
142
|
) -> StepperMotor::Step
|
143
143
|
|
@@ -218,6 +218,19 @@ module StepperMotor
|
|
218
218
|
# _@return_ — void
|
219
219
|
def reattempt!: (?wait: untyped) -> untyped
|
220
220
|
|
221
|
+
# Is used to skip the current step and proceed to the next step in the journey. This is useful when you want to
|
222
|
+
# conditionally skip a step based on some business logic without canceling the entire journey. For example,
|
223
|
+
# you might want to skip a reminder email step if the user has already taken the required action.
|
224
|
+
#
|
225
|
+
# If there are more steps after the current step, `skip!` will schedule the next step to be performed.
|
226
|
+
# If the current step is the last step in the journey, `skip!` will finish the journey.
|
227
|
+
#
|
228
|
+
# `skip!` may be called within a step or outside of a step for journeys in the `ready` state.
|
229
|
+
# When called outside of a step, it will skip the next scheduled step and proceed to the following step.
|
230
|
+
#
|
231
|
+
# _@return_ — void
|
232
|
+
def skip!: () -> untyped
|
233
|
+
|
221
234
|
# Is used to pause a Journey at any point. The "paused" state is similar to the "ready" state, except that "perform_next_step!" on the
|
222
235
|
# journey will do nothing - even if it is scheduled to be performed. Pausing a Journey can be useful in the following situations:
|
223
236
|
#
|
@@ -264,6 +277,19 @@ module StepperMotor
|
|
264
277
|
# _@return_ — void
|
265
278
|
def reattempt!: (?wait: untyped) -> untyped
|
266
279
|
|
280
|
+
# Is used to skip the current step and proceed to the next step in the journey. This is useful when you want to
|
281
|
+
# conditionally skip a step based on some business logic without canceling the entire journey. For example,
|
282
|
+
# you might want to skip a reminder email step if the user has already taken the required action.
|
283
|
+
#
|
284
|
+
# If there are more steps after the current step, `skip!` will schedule the next step to be performed.
|
285
|
+
# If the current step is the last step in the journey, `skip!` will finish the journey.
|
286
|
+
#
|
287
|
+
# `skip!` may be called within a step or outside of a step for journeys in the `ready` state.
|
288
|
+
# When called outside of a step, it will skip the next scheduled step and proceed to the following step.
|
289
|
+
#
|
290
|
+
# _@return_ — void
|
291
|
+
def skip!: () -> untyped
|
292
|
+
|
267
293
|
# Is used to pause a Journey at any point. The "paused" state is similar to the "ready" state, except that "perform_next_step!" on the
|
268
294
|
# journey will do nothing - even if it is scheduled to be performed. Pausing a Journey can be useful in the following situations:
|
269
295
|
#
|
@@ -3,6 +3,8 @@
|
|
3
3
|
require "test_helper"
|
4
4
|
|
5
5
|
class ExceptionHandlingTest < ActiveSupport::TestCase
|
6
|
+
include SideEffects::TestHelper
|
7
|
+
|
6
8
|
# See below.
|
7
9
|
self.use_transactional_tests = false
|
8
10
|
|
@@ -46,6 +48,61 @@ class ExceptionHandlingTest < ActiveSupport::TestCase
|
|
46
48
|
assert faulty_journey.canceled?
|
47
49
|
end
|
48
50
|
|
51
|
+
test "with :skip!, skips the failing step and continues to next step" do
|
52
|
+
faulty_journey_class = create_journey_subclass do
|
53
|
+
step on_exception: :skip! do
|
54
|
+
raise CustomEx, "Something went wrong"
|
55
|
+
end
|
56
|
+
|
57
|
+
step do
|
58
|
+
SideEffects.touch! "second step"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
faulty_journey = faulty_journey_class.create!
|
63
|
+
assert faulty_journey.ready?
|
64
|
+
|
65
|
+
assert_raises(CustomEx) { faulty_journey.perform_next_step! }
|
66
|
+
|
67
|
+
assert faulty_journey.persisted?
|
68
|
+
refute faulty_journey.changed?
|
69
|
+
assert faulty_journey.ready?
|
70
|
+
|
71
|
+
# The second step should now be scheduled
|
72
|
+
assert_produced_side_effects("second step") do
|
73
|
+
faulty_journey.perform_next_step!
|
74
|
+
end
|
75
|
+
assert faulty_journey.finished?
|
76
|
+
end
|
77
|
+
|
78
|
+
test "with :skip! on last step, skips the failing step and finishes the journey" do
|
79
|
+
faulty_journey_class = create_journey_subclass do
|
80
|
+
step do
|
81
|
+
SideEffects.touch! "first step"
|
82
|
+
end
|
83
|
+
|
84
|
+
step on_exception: :skip! do
|
85
|
+
raise CustomEx, "Something went wrong"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
faulty_journey = faulty_journey_class.create!
|
90
|
+
assert faulty_journey.ready?
|
91
|
+
|
92
|
+
# Perform first step
|
93
|
+
assert_produced_side_effects("first step") do
|
94
|
+
faulty_journey.perform_next_step!
|
95
|
+
end
|
96
|
+
assert faulty_journey.ready?
|
97
|
+
|
98
|
+
# The second step should be skipped due to exception
|
99
|
+
assert_raises(CustomEx) { faulty_journey.perform_next_step! }
|
100
|
+
|
101
|
+
assert faulty_journey.persisted?
|
102
|
+
refute faulty_journey.changed?
|
103
|
+
assert faulty_journey.finished?
|
104
|
+
end
|
105
|
+
|
49
106
|
test "pauses the journey by default at the failig step" do
|
50
107
|
faulty_journey_class = create_journey_subclass do
|
51
108
|
step do
|
@@ -75,4 +75,168 @@ class FlowControlTest < ActiveSupport::TestCase
|
|
75
75
|
|
76
76
|
assert_no_side_effects { journey.perform_next_step! }
|
77
77
|
end
|
78
|
+
|
79
|
+
test "can skip a step and continue to next step" do
|
80
|
+
skipping_journey = create_journey_subclass do
|
81
|
+
step do
|
82
|
+
SideEffects.touch! "before skipping"
|
83
|
+
skip!
|
84
|
+
SideEffects.touch! "after skipping"
|
85
|
+
end
|
86
|
+
|
87
|
+
step do
|
88
|
+
SideEffects.touch! "second step"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
journey = skipping_journey.create!
|
93
|
+
assert journey.ready?
|
94
|
+
|
95
|
+
# First step should be skipped
|
96
|
+
assert_produced_side_effects("before skipping") do
|
97
|
+
assert_did_not_produce_side_effects("after skipping") do
|
98
|
+
journey.perform_next_step!
|
99
|
+
end
|
100
|
+
end
|
101
|
+
assert journey.ready?
|
102
|
+
|
103
|
+
# Second step should be performed
|
104
|
+
assert_produced_side_effects("second step") do
|
105
|
+
journey.perform_next_step!
|
106
|
+
end
|
107
|
+
assert journey.finished?
|
108
|
+
end
|
109
|
+
|
110
|
+
test "can skip the last step and finish the journey" do
|
111
|
+
skipping_journey = create_journey_subclass do
|
112
|
+
step do
|
113
|
+
SideEffects.touch! "first step"
|
114
|
+
end
|
115
|
+
|
116
|
+
step do
|
117
|
+
SideEffects.touch! "before skipping last"
|
118
|
+
skip!
|
119
|
+
SideEffects.touch! "after skipping last"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
journey = skipping_journey.create!
|
124
|
+
assert journey.ready?
|
125
|
+
|
126
|
+
# First step should be performed normally
|
127
|
+
assert_produced_side_effects("first step") do
|
128
|
+
journey.perform_next_step!
|
129
|
+
end
|
130
|
+
assert journey.ready?
|
131
|
+
|
132
|
+
# Last step should be skipped and journey should finish
|
133
|
+
assert_produced_side_effects("before skipping last") do
|
134
|
+
assert_did_not_produce_side_effects("after skipping last") do
|
135
|
+
journey.perform_next_step!
|
136
|
+
end
|
137
|
+
end
|
138
|
+
assert journey.finished?
|
139
|
+
end
|
140
|
+
|
141
|
+
test "skip! can be called outside of a step for ready journeys" do
|
142
|
+
skipping_journey = create_journey_subclass do
|
143
|
+
step do
|
144
|
+
SideEffects.touch! "first step"
|
145
|
+
end
|
146
|
+
|
147
|
+
step do
|
148
|
+
SideEffects.touch! "second step"
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
journey = skipping_journey.create!
|
153
|
+
assert journey.ready?
|
154
|
+
|
155
|
+
# Skip the first step from outside
|
156
|
+
journey.skip!
|
157
|
+
assert journey.ready?
|
158
|
+
|
159
|
+
# The second step should now be scheduled
|
160
|
+
assert_produced_side_effects("second step") do
|
161
|
+
journey.perform_next_step!
|
162
|
+
end
|
163
|
+
assert journey.finished?
|
164
|
+
end
|
165
|
+
|
166
|
+
test "skip! outside of step raises error for non-ready journeys" do
|
167
|
+
skipping_journey = create_journey_subclass do
|
168
|
+
step do
|
169
|
+
SideEffects.touch! "step completed"
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
journey = skipping_journey.create!
|
174
|
+
journey.pause!
|
175
|
+
assert journey.paused?
|
176
|
+
|
177
|
+
assert_raises(RuntimeError, "skip! can only be used on journeys in the `ready` state") do
|
178
|
+
journey.skip!
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
test "skip! outside of step can finish journey when skipping last step" do
|
183
|
+
skipping_journey = create_journey_subclass do
|
184
|
+
step do
|
185
|
+
SideEffects.touch! "first step"
|
186
|
+
end
|
187
|
+
|
188
|
+
step do
|
189
|
+
SideEffects.touch! "last step"
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
journey = skipping_journey.create!
|
194
|
+
assert journey.ready?
|
195
|
+
|
196
|
+
# Perform first step
|
197
|
+
assert_produced_side_effects("first step") do
|
198
|
+
journey.perform_next_step!
|
199
|
+
end
|
200
|
+
assert journey.ready?
|
201
|
+
|
202
|
+
# Skip the last step from outside
|
203
|
+
journey.skip!
|
204
|
+
assert journey.finished?
|
205
|
+
end
|
206
|
+
|
207
|
+
test "skip! outside of step handles missing step definitions gracefully" do
|
208
|
+
skipping_journey = create_journey_subclass do
|
209
|
+
step do
|
210
|
+
SideEffects.touch! "step completed"
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
journey = skipping_journey.create!
|
215
|
+
assert journey.ready?
|
216
|
+
|
217
|
+
# Manually set a non-existent next step
|
218
|
+
journey.update!(next_step_name: "non_existent_step")
|
219
|
+
|
220
|
+
# Skip should handle this gracefully and finish the journey
|
221
|
+
journey.skip!
|
222
|
+
assert journey.finished?
|
223
|
+
end
|
224
|
+
|
225
|
+
test "skip! aborts the current step execution" do
|
226
|
+
skipping_journey = create_journey_subclass do
|
227
|
+
step do
|
228
|
+
SideEffects.touch! "before skip"
|
229
|
+
skip!
|
230
|
+
SideEffects.touch! "after skip"
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
journey = skipping_journey.create!
|
235
|
+
|
236
|
+
assert_produced_side_effects("before skip") do
|
237
|
+
assert_did_not_produce_side_effects("after skip") do
|
238
|
+
journey.perform_next_step!
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
78
242
|
end
|
@@ -8,14 +8,14 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
8
8
|
include SideEffects::TestHelper
|
9
9
|
include StepperMotor::TestHelper
|
10
10
|
|
11
|
-
test "supports
|
11
|
+
test "supports skip_if: with symbol condition that returns false (performs step)" do
|
12
12
|
journey_class = create_journey_subclass do
|
13
|
-
step :one,
|
13
|
+
step :one, skip_if: :should_skip do
|
14
14
|
SideEffects.touch!("step executed")
|
15
15
|
end
|
16
16
|
|
17
|
-
def
|
18
|
-
|
17
|
+
def should_skip
|
18
|
+
false
|
19
19
|
end
|
20
20
|
end
|
21
21
|
|
@@ -26,9 +26,9 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
26
26
|
assert journey.finished?
|
27
27
|
end
|
28
28
|
|
29
|
-
test "supports
|
29
|
+
test "supports skip_if: with symbol condition that returns true (skips step)" do
|
30
30
|
journey_class = create_journey_subclass do
|
31
|
-
step :one,
|
31
|
+
step :one, skip_if: :should_skip do
|
32
32
|
SideEffects.touch!("step executed")
|
33
33
|
end
|
34
34
|
|
@@ -36,8 +36,8 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
36
36
|
SideEffects.touch!("second step executed")
|
37
37
|
end
|
38
38
|
|
39
|
-
def
|
40
|
-
|
39
|
+
def should_skip
|
40
|
+
true
|
41
41
|
end
|
42
42
|
end
|
43
43
|
|
@@ -47,9 +47,9 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
47
47
|
refute SideEffects.produced?("step executed")
|
48
48
|
end
|
49
49
|
|
50
|
-
test "supports
|
50
|
+
test "supports skip_if: with block condition that returns false (performs step)" do
|
51
51
|
journey_class = create_journey_subclass do
|
52
|
-
step :one,
|
52
|
+
step :one, skip_if: -> { hero.nil? } do
|
53
53
|
SideEffects.touch!("step executed")
|
54
54
|
end
|
55
55
|
end
|
@@ -61,9 +61,9 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
61
61
|
assert journey.finished?
|
62
62
|
end
|
63
63
|
|
64
|
-
test "supports
|
64
|
+
test "supports skip_if: with block condition that returns true (skips step)" do
|
65
65
|
journey_class = create_journey_subclass do
|
66
|
-
step :one,
|
66
|
+
step :one, skip_if: -> { hero.present? } do
|
67
67
|
SideEffects.touch!("step executed")
|
68
68
|
end
|
69
69
|
|
@@ -78,9 +78,9 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
78
78
|
refute SideEffects.produced?("step executed")
|
79
79
|
end
|
80
80
|
|
81
|
-
test "supports
|
81
|
+
test "supports skip_if: with block condition that accesses journey instance variables" do
|
82
82
|
journey_class = create_journey_subclass do
|
83
|
-
step :one,
|
83
|
+
step :one, skip_if: -> { @condition_met } do
|
84
84
|
SideEffects.touch!("step executed")
|
85
85
|
end
|
86
86
|
|
@@ -90,7 +90,7 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
90
90
|
|
91
91
|
def initialize(*args)
|
92
92
|
super
|
93
|
-
@condition_met =
|
93
|
+
@condition_met = true
|
94
94
|
end
|
95
95
|
end
|
96
96
|
|
@@ -100,24 +100,24 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
100
100
|
refute SideEffects.produced?("step executed")
|
101
101
|
end
|
102
102
|
|
103
|
-
test "supports
|
103
|
+
test "supports skip_if: with block condition that can be changed during journey execution" do
|
104
104
|
journey_class = create_journey_subclass do
|
105
|
-
step :one,
|
105
|
+
step :one, skip_if: -> { @condition_met } do
|
106
106
|
SideEffects.touch!("first step executed")
|
107
107
|
end
|
108
108
|
|
109
109
|
step :two do
|
110
110
|
SideEffects.touch!("second step executed")
|
111
|
-
@condition_met =
|
111
|
+
@condition_met = false
|
112
112
|
end
|
113
113
|
|
114
|
-
step :three,
|
114
|
+
step :three, skip_if: -> { @condition_met } do
|
115
115
|
SideEffects.touch!("third step executed")
|
116
116
|
end
|
117
117
|
|
118
118
|
def initialize(*args)
|
119
119
|
super
|
120
|
-
@condition_met =
|
120
|
+
@condition_met = true
|
121
121
|
end
|
122
122
|
end
|
123
123
|
|
@@ -128,9 +128,9 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
128
128
|
refute SideEffects.produced?("first step executed")
|
129
129
|
end
|
130
130
|
|
131
|
-
test "skips step when
|
131
|
+
test "skips step when skip_if: condition is true and continues to next step" do
|
132
132
|
journey_class = create_journey_subclass do
|
133
|
-
step :one,
|
133
|
+
step :one, skip_if: :true_condition do
|
134
134
|
SideEffects.touch!("first step executed")
|
135
135
|
end
|
136
136
|
|
@@ -142,8 +142,8 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
142
142
|
SideEffects.touch!("third step executed")
|
143
143
|
end
|
144
144
|
|
145
|
-
def
|
146
|
-
|
145
|
+
def true_condition
|
146
|
+
true
|
147
147
|
end
|
148
148
|
end
|
149
149
|
|
@@ -154,14 +154,14 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
154
154
|
refute SideEffects.produced?("first step executed")
|
155
155
|
end
|
156
156
|
|
157
|
-
test "skips step when
|
157
|
+
test "skips step when skip_if: condition is true and finishes journey if no more steps" do
|
158
158
|
journey_class = create_journey_subclass do
|
159
|
-
step :one,
|
159
|
+
step :one, skip_if: :true_condition do
|
160
160
|
SideEffects.touch!("step executed")
|
161
161
|
end
|
162
162
|
|
163
|
-
def
|
164
|
-
|
163
|
+
def true_condition
|
164
|
+
true
|
165
165
|
end
|
166
166
|
end
|
167
167
|
|
@@ -170,9 +170,9 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
170
170
|
refute SideEffects.produced?("step executed")
|
171
171
|
end
|
172
172
|
|
173
|
-
test "supports
|
173
|
+
test "supports skip_if: with literal false (performs step)" do
|
174
174
|
journey_class = create_journey_subclass do
|
175
|
-
step :one,
|
175
|
+
step :one, skip_if: false do
|
176
176
|
SideEffects.touch!("step executed")
|
177
177
|
end
|
178
178
|
end
|
@@ -184,9 +184,9 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
184
184
|
assert journey.finished?
|
185
185
|
end
|
186
186
|
|
187
|
-
test "supports
|
187
|
+
test "supports skip_if: with literal true (skips step)" do
|
188
188
|
journey_class = create_journey_subclass do
|
189
|
-
step :one,
|
189
|
+
step :one, skip_if: true do
|
190
190
|
SideEffects.touch!("step executed")
|
191
191
|
end
|
192
192
|
|
@@ -201,9 +201,9 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
201
201
|
refute SideEffects.produced?("step executed")
|
202
202
|
end
|
203
203
|
|
204
|
-
test "supports
|
204
|
+
test "supports skip_if: with literal true and finishes journey if no more steps" do
|
205
205
|
journey_class = create_journey_subclass do
|
206
|
-
step :one,
|
206
|
+
step :one, skip_if: true do
|
207
207
|
SideEffects.touch!("step executed")
|
208
208
|
end
|
209
209
|
end
|
@@ -213,7 +213,7 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
213
213
|
refute SideEffects.produced?("step executed")
|
214
214
|
end
|
215
215
|
|
216
|
-
test "defaults to
|
216
|
+
test "defaults to false when skip_if: is not specified" do
|
217
217
|
journey_class = create_journey_subclass do
|
218
218
|
step :one do
|
219
219
|
SideEffects.touch!("step executed")
|
@@ -227,9 +227,9 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
227
227
|
assert journey.finished?
|
228
228
|
end
|
229
229
|
|
230
|
-
test "treats nil as false in
|
230
|
+
test "treats nil as false in skip_if condition (performs step)" do
|
231
231
|
journey_class = create_journey_subclass do
|
232
|
-
step :one,
|
232
|
+
step :one, skip_if: nil do
|
233
233
|
SideEffects.touch!("step executed")
|
234
234
|
end
|
235
235
|
|
@@ -240,44 +240,113 @@ class IfConditionTest < ActiveSupport::TestCase
|
|
240
240
|
|
241
241
|
journey = journey_class.create!
|
242
242
|
speedrun_journey(journey)
|
243
|
+
assert SideEffects.produced?("step executed")
|
243
244
|
assert SideEffects.produced?("second step executed")
|
244
|
-
refute SideEffects.produced?("step executed")
|
245
245
|
end
|
246
246
|
|
247
|
-
test "
|
247
|
+
test "raises ArgumentError when skip_if: condition is neither symbol nor callable" do
|
248
|
+
assert_raises(ArgumentError) do
|
249
|
+
create_journey_subclass do
|
250
|
+
step :one, skip_if: "not a symbol or callable" do
|
251
|
+
# noop
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
test "passes skip_if: parameter to step definition" do
|
258
|
+
step_def = StepperMotor::Step.new(name: "a_step", seq: 1, on_exception: :reattempt!)
|
259
|
+
assert_skip_if_parameter = ->(**options) {
|
260
|
+
assert options.key?(:skip_if)
|
261
|
+
assert_equal :test_condition, options[:skip_if]
|
262
|
+
# Return the original definition
|
263
|
+
step_def
|
264
|
+
}
|
265
|
+
|
266
|
+
StepperMotor::Step.stub :new, assert_skip_if_parameter do
|
267
|
+
create_journey_subclass do
|
268
|
+
step :test_step, skip_if: :test_condition do
|
269
|
+
# noop
|
270
|
+
end
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
# Backward compatibility tests for if: parameter
|
276
|
+
test "supports if: with symbol condition that returns true (performs step, backward compatibility)" do
|
248
277
|
journey_class = create_journey_subclass do
|
249
|
-
step :one, if:
|
278
|
+
step :one, if: :should_run do
|
250
279
|
SideEffects.touch!("step executed")
|
251
280
|
end
|
281
|
+
|
282
|
+
def should_run
|
283
|
+
true
|
284
|
+
end
|
285
|
+
end
|
286
|
+
|
287
|
+
journey = journey_class.create!
|
288
|
+
assert_produced_side_effects("step executed") do
|
289
|
+
journey.perform_next_step!
|
290
|
+
end
|
291
|
+
assert journey.finished?
|
292
|
+
end
|
293
|
+
|
294
|
+
test "supports if: with symbol condition that returns false (skips step, backward compatibility)" do
|
295
|
+
journey_class = create_journey_subclass do
|
296
|
+
step :one, if: :should_run do
|
297
|
+
SideEffects.touch!("step executed")
|
298
|
+
end
|
299
|
+
|
300
|
+
step :two do
|
301
|
+
SideEffects.touch!("second step executed")
|
302
|
+
end
|
303
|
+
|
304
|
+
def should_run
|
305
|
+
false
|
306
|
+
end
|
252
307
|
end
|
253
308
|
|
254
309
|
journey = journey_class.create!
|
255
310
|
speedrun_journey(journey)
|
311
|
+
assert SideEffects.produced?("second step executed")
|
256
312
|
refute SideEffects.produced?("step executed")
|
257
313
|
end
|
258
314
|
|
259
|
-
test "
|
260
|
-
|
261
|
-
|
262
|
-
step
|
263
|
-
# noop
|
264
|
-
end
|
315
|
+
test "supports if: with literal true (performs step, backward compatibility)" do
|
316
|
+
journey_class = create_journey_subclass do
|
317
|
+
step :one, if: true do
|
318
|
+
SideEffects.touch!("step executed")
|
265
319
|
end
|
266
320
|
end
|
321
|
+
|
322
|
+
journey = journey_class.create!
|
323
|
+
assert_produced_side_effects("step executed") do
|
324
|
+
journey.perform_next_step!
|
325
|
+
end
|
326
|
+
assert journey.finished?
|
267
327
|
end
|
268
328
|
|
269
|
-
test "
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
329
|
+
test "supports if: with literal false (skips step, backward compatibility)" do
|
330
|
+
journey_class = create_journey_subclass do
|
331
|
+
step :one, if: false do
|
332
|
+
SideEffects.touch!("step executed")
|
333
|
+
end
|
334
|
+
|
335
|
+
step :two do
|
336
|
+
SideEffects.touch!("second step executed")
|
337
|
+
end
|
338
|
+
end
|
339
|
+
|
340
|
+
journey = journey_class.create!
|
341
|
+
speedrun_journey(journey)
|
342
|
+
assert SideEffects.produced?("second step executed")
|
343
|
+
refute SideEffects.produced?("step executed")
|
344
|
+
end
|
277
345
|
|
278
|
-
|
346
|
+
test "raises error when both skip_if: and if: are specified" do
|
347
|
+
assert_raises(StepperMotor::StepConfigurationError) do
|
279
348
|
create_journey_subclass do
|
280
|
-
step :
|
349
|
+
step :one, skip_if: :condition1, if: :condition2 do
|
281
350
|
# noop
|
282
351
|
end
|
283
352
|
end
|