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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c62f05b7bc2c994345697f0d403fc6faeaf491c3efa883bfdfe9c3dc7595d52a
4
- data.tar.gz: 6fbf7c140444e906ef178af631023022994707c4cf282d954e681c5f6fe07bc1
3
+ metadata.gz: 4b94d7f405351309bd1fbda83b352031795cbee87aad100d412d847e3d03543c
4
+ data.tar.gz: 59ecde85caacac0c7e65f6a0c527676bac4700b84fa39ce55443e81592023738
5
5
  SHA512:
6
- metadata.gz: f95cd7b0eebf80306a14d04f5ce3cd229e0b4e5ee4aa7fc5f12d902ee027b2d97294ecce7bc0a15e56e8490ea9f4724becc6f817dc6bcc9f10667767292c6b85
7
- data.tar.gz: e44253c904ab4cf471216352cdc0ec85790532ecb66d9d401d24a9ee85673c42af6d2aa4301be67824344bd0f1864fc34121713db6499f841765a3e6a3ea602b
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 only be performed if the condition returns a truthy value.
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, on_exception: :pause!, if: true, **additional_step_definition_options, &blk)
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 to be
104
- executed as a step, pass the name of the method as the name of the step.
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, on_exception:, if: binding.local_variable_get(:if), **additional_step_definition_options, &blk).tap do |step_definition|
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
@@ -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
- # @param if[TrueClass,FalseClass,NilClass,Symbol,Proc] condition to check before performing the step. If a boolean is provided,
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:, wait: 0, if: true, &step_block)
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
- @if_condition = binding.local_variable_get(:if) # Done this way because `if` is a reserved keyword
39
+ @skip_if_condition = skip_if
38
40
 
39
- # Validate the if condition
40
- if ![true, false, nil].include?(@if_condition) && !@if_condition.is_a?(Symbol) && !@if_condition.respond_to?(:call)
41
- raise ArgumentError, "if: condition must be a boolean, nil, Symbol or a callable object, but was a #{@if_condition.inspect}"
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 performed based on the if condition
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 performed, false otherwise
49
- def should_perform?(journey)
50
- case @if_condition
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
- !!@if_condition
54
+ !!@skip_if_condition
53
55
  when Symbol
54
- journey.send(@if_condition) # Allow private methods
56
+ journey.send(@skip_if_condition) # Allow private methods
55
57
  else
56
- journey.instance_exec(&@if_condition)
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 `if` condition be false
69
- if !should_perform?(journey)
70
- journey.logger.info { "skipping as if: condition was falsey or returned false" }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StepperMotor
4
- VERSION = "0.1.15"
4
+ VERSION = "0.1.16"
5
5
  end
data/manual/MANUAL.md CHANGED
@@ -427,9 +427,9 @@ class Erasure < StepperMotor::Journey
427
427
  end
428
428
  ```
429
429
 
430
- ### Conditional steps with `if:`
430
+ ### Conditional steps with `skip_if:`
431
431
 
432
- You can make steps conditional by using the `if:` parameter. This allows you to skip steps based on runtime conditions. The `if:` parameter accepts:
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, if: :should_send_welcome? do
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, if: :is_premium_user? do
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, if: -> { hero.email.present? } do
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, if: Rails.application.config.new_feature_enabled do
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, if: ENV["PERFORM_LEGACY_STEP"] do # This step will never execute
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
- Inside a step, you currently can use the following flow control methods:
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 from inside a step. Unlike Rails `render` or `redirect` that require an explicit
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
- You can't call those methods outside of the context of a performing step, and an exception is going to be raised if you do.
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
 
@@ -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.15", T.untyped)
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_ `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.
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
- if: T.any(TrueClass, FalseClass, NilClass, Symbol, Proc),
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:, wait: 0, if: true, &step_block); end
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 performed based on the if condition
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 performed, false otherwise
54
+ # _@return_ — true if the step should be skipped, false otherwise
55
55
  sig { params(journey: StepperMotor::Journey).returns(T::Boolean) }
56
- def should_perform?(journey); end
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_ `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 only be performed if the condition returns a truthy value.
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, on_exception: :pause!, if: true, **additional_step_definition_options, &blk); end
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
  #
@@ -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_ `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.
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
- ?if: (TrueClass | FalseClass | NilClass | Symbol | Proc)
41
+ ?skip_if: (TrueClass | FalseClass | NilClass | Symbol | Proc)
42
42
  ) -> void
43
43
 
44
- # Checks if the step should be performed based on the if condition
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 performed, false otherwise
49
- def should_perform?: (StepperMotor::Journey journey) -> bool
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_ `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 only be performed if the condition returns a truthy value.
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 if: with symbol condition that returns true" do
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, if: :should_run do
13
+ step :one, skip_if: :should_skip do
14
14
  SideEffects.touch!("step executed")
15
15
  end
16
16
 
17
- def should_run
18
- true
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 if: with symbol condition that returns false" do
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, if: :should_run do
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 should_run
40
- false
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 if: with block condition that returns true" do
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, if: -> { hero.present? } do
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 if: with block condition that returns false" do
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, if: -> { hero.nil? } do
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 if: with block condition that accesses journey instance variables" do
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, if: -> { @condition_met } do
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 = false
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 if: with block condition that can be changed during journey execution" do
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, if: -> { @condition_met } do
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 = true
111
+ @condition_met = false
112
112
  end
113
113
 
114
- step :three, if: -> { @condition_met } do
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 = false
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 if: condition is false and continues to next step" do
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, if: :false_condition do
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 false_condition
146
- false
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 if: condition is false and finishes journey if no more steps" do
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, if: :false_condition do
159
+ step :one, skip_if: :true_condition do
160
160
  SideEffects.touch!("step executed")
161
161
  end
162
162
 
163
- def false_condition
164
- false
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 if: with literal true" do
173
+ test "supports skip_if: with literal false (performs step)" do
174
174
  journey_class = create_journey_subclass do
175
- step :one, if: true do
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 if: with literal false" do
187
+ test "supports skip_if: with literal true (skips step)" do
188
188
  journey_class = create_journey_subclass do
189
- step :one, if: false do
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 if: with literal false and finishes journey if no more steps" do
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, if: false do
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 true when if: is not specified" do
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 if condition" do
230
+ test "treats nil as false in skip_if condition (performs step)" do
231
231
  journey_class = create_journey_subclass do
232
- step :one, if: nil do
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 "treats nil as false and finishes journey if no more steps" do
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: nil do
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 "raises ArgumentError when if: condition is neither symbol nor callable" do
260
- assert_raises(ArgumentError) do
261
- create_journey_subclass do
262
- step :one, if: "not a symbol or callable" do
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 "passes if: parameter to step definition" do
270
- step_def = StepperMotor::Step.new(name: "a_step", seq: 1, on_exception: :reattempt!)
271
- assert_if_parameter = ->(**options) {
272
- assert options.key?(:if)
273
- assert_equal :test_condition, options[:if]
274
- # Return the original definition
275
- step_def
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
- StepperMotor::Step.stub :new, assert_if_parameter do
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 :test_step, if: :test_condition do
349
+ step :one, skip_if: :condition1, if: :condition2 do
281
350
  # noop
282
351
  end
283
352
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stepper_motor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.15
4
+ version: 0.1.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov