stepper_motor 0.1.16 → 0.1.18

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: 4b94d7f405351309bd1fbda83b352031795cbee87aad100d412d847e3d03543c
4
- data.tar.gz: 59ecde85caacac0c7e65f6a0c527676bac4700b84fa39ce55443e81592023738
3
+ metadata.gz: 2b4b58accbed9841696bb2d0296335863b71b26b9a33a457028cc4c834ec1fec
4
+ data.tar.gz: c0f6e6fda44e9d8c7808743e425a059e2a8c1196fc4fcd1fcb6240521d8b5bbf
5
5
  SHA512:
6
- metadata.gz: 5305f1f98eb8b8c630e45309280fae524ec458d15a75362e647a36e22fd29ca0fe1a7ecbbba186ed60f33b64b7b6b875e66878280e21a6a3196473652b82f098
7
- data.tar.gz: 2e6db70cf8ad6b844c4d7a38eab6f087f785b1a112f446844e25d6d943bffd3f3b71da6bd7baa69c7249a7978a5538d2572235b3f90475b01af8be0d07074cfd
6
+ metadata.gz: 2b3aa12d9c21cf9f5e8246bce6fa8d88bfaeea046ad434ada641c7c0e1ce62cba173584fd4c836c6e70d98468f1ebc70ce93ea0246fb40ed3992ea45a97328f2
7
+ data.tar.gz: ae3fb23fc0f81f3a92e63abf6a6b444a801421c388ed3ef3a7e6ff23562a74bca218d3b257cb7a6dfa896e383d4e093bd990f67209f4ca41936598db91e680a9
data/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.1.18] - 2025-06-20
6
+
7
+ - Add `cancel_if` at Journey class level for blanket journey cancellation conditions. This makes it very easy to abort journeys across multiple steps.
8
+
9
+ ## [0.1.17] - 2025-06-20
10
+
11
+ - Mandate Ruby 3.1+ because we are using 3.x syntax and there are shenanigans with sqlite3 gem version and locking. Compatibility with 2.7 can be assured but is too much hassle at the moment.
12
+ If you need this compatibility, feel free to implement it or contact me for a quote.
13
+
5
14
  ## [0.1.16] - 2025-06-20
6
15
 
7
16
  - Add `skip!` flow control method to skip the current (or next) step and move on to the subsequent step, or finish the journey.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StepperMotor
4
+ # A wrapper for conditional logic that can be evaluated against an object.
5
+ # This class encapsulates different types of conditions (booleans, symbols, callables, arrays)
6
+ # and provides a unified interface for checking if a condition is satisfied by a given object.
7
+ # It handles negation and ensures proper context when evaluating conditions.
8
+ class Conditional
9
+ def initialize(condition, negate: false)
10
+ @condition = condition
11
+ @negate = negate
12
+ validate_condition
13
+ end
14
+
15
+ def satisfied_by?(object)
16
+ result = case @condition
17
+ when Array
18
+ @condition.all? { |c| Conditional.new(c).satisfied_by?(object) }
19
+ when Symbol
20
+ !!object.send(@condition)
21
+ when Conditional
22
+ @condition.satisfied_by?(object)
23
+ else
24
+ if @condition.respond_to?(:call)
25
+ !!object.instance_exec(&@condition)
26
+ else
27
+ !!@condition
28
+ end
29
+ end
30
+
31
+ @negate ? !result : result
32
+ end
33
+
34
+ private
35
+
36
+ def validate_condition
37
+ unless [true, false, nil].include?(@condition) || @condition.is_a?(Symbol) || @condition.is_a?(Array) || @condition.is_a?(Conditional) || @condition.respond_to?(:call)
38
+ raise ArgumentError, "condition must be a boolean, nil, Symbol, Array, Conditional or a callable object, but was a #{@condition.inspect}"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -47,6 +47,9 @@ module StepperMotor
47
47
  # @return [Array<StepperMotor::Step>] the step definitions defined so far
48
48
  class_attribute :step_definitions, default: []
49
49
 
50
+ # @return [Array<StepperMotor::Conditional>] the cancel_if conditions defined for this journey class
51
+ class_attribute :cancel_if_conditions, default: []
52
+
50
53
  belongs_to :hero, polymorphic: true, optional: true
51
54
 
52
55
  STATES = %w[ready paused performing canceled finished]
@@ -92,19 +95,8 @@ module StepperMotor
92
95
  raise StepConfigurationError, "Either skip_if: or if: can be specified, but not both"
93
96
  end
94
97
  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
98
+ # Convert if: to skip_if:
99
+ additional_step_definition_options[:skip_if] = StepperMotor::Conditional.new(additional_step_definition_options.delete(:if), negate: true)
108
100
  end
109
101
 
110
102
  wait = if wait && after
@@ -166,6 +158,41 @@ module StepperMotor
166
158
  self.class.lookup_step_definition(by_step_name)
167
159
  end
168
160
 
161
+ # Defines a condition that will cause the journey to cancel if satisfied.
162
+ # This works like Rails' `etag` - it's class-inheritable and appendable.
163
+ # Multiple `cancel_if` calls can be made to a Journey definition.
164
+ # All conditions are evaluated after setting the state to `performing`.
165
+ # If any condition is satisfied, the journey will cancel.
166
+ #
167
+ # @param condition_arg [TrueClass, FalseClass, Symbol, Proc, Array, Conditional] the condition to check
168
+ # @param condition_blk [Proc] a block that will be evaluated as a condition
169
+ # @return [void]
170
+ def self.cancel_if(condition_arg = :__no_argument_given__, &condition_blk)
171
+ # Check if neither argument nor block is provided
172
+ if condition_arg == :__no_argument_given__ && !condition_blk
173
+ raise ArgumentError, "cancel_if requires either a condition argument or a block"
174
+ end
175
+
176
+ # Check if both argument and block are provided
177
+ if condition_arg != :__no_argument_given__ && condition_blk
178
+ raise ArgumentError, "cancel_if accepts either a condition argument or a block, but not both"
179
+ end
180
+
181
+ # Select the condition: positional argument takes precedence if not sentinel
182
+ condition = if condition_arg != :__no_argument_given__
183
+ condition_arg
184
+ else
185
+ condition_blk
186
+ end
187
+
188
+ conditional = StepperMotor::Conditional.new(condition)
189
+
190
+ # As per Rails docs: you need to be aware when using class_attribute with mutable structures
191
+ # as Array or Hash. In such cases, you don't want to do changes in place. Instead use setters.
192
+ # See https://apidock.com/rails/v7.1.3.2/Class/class_attribute
193
+ self.cancel_if_conditions = cancel_if_conditions + [conditional]
194
+ end
195
+
169
196
  # Performs the next step in the journey. Will check whether any other process has performed the step already
170
197
  # and whether the record is unchanged, and will then lock it and set the state to 'performimg'.
171
198
  #
@@ -188,6 +215,14 @@ module StepperMotor
188
215
  performing!
189
216
  after_locking_for_step(next_step_name)
190
217
  end
218
+
219
+ # Check cancel_if conditions after setting state to performing
220
+ if cancel_if_conditions.any? { |conditional| conditional.satisfied_by?(self) }
221
+ logger.info { "cancel_if condition satisfied, canceling journey" }
222
+ cancel!
223
+ return
224
+ end
225
+
191
226
  current_step_name = next_step_name
192
227
 
193
228
  if current_step_name
@@ -36,12 +36,7 @@ class StepperMotor::Step
36
36
  @wait = wait
37
37
  @seq = seq
38
38
  @on_exception = on_exception # TODO: Validate?
39
- @skip_if_condition = skip_if
40
-
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}"
44
- end
39
+ @skip_if_condition = StepperMotor::Conditional.new(skip_if)
45
40
  end
46
41
 
47
42
  # Checks if the step should be skipped based on the skip_if condition
@@ -49,14 +44,7 @@ class StepperMotor::Step
49
44
  # @param journey[StepperMotor::Journey] the journey to check the condition for
50
45
  # @return [Boolean] true if the step should be skipped, false otherwise
51
46
  def should_skip?(journey)
52
- case @skip_if_condition
53
- when true, false, nil
54
- !!@skip_if_condition
55
- when Symbol
56
- journey.send(@skip_if_condition) # Allow private methods
57
- else
58
- journey.instance_exec(&@skip_if_condition)
59
- end
47
+ @skip_if_condition.satisfied_by?(journey)
60
48
  end
61
49
 
62
50
  # Performs the step on the passed Journey, wrapping the step with the required context.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StepperMotor
4
- VERSION = "0.1.16"
4
+ VERSION = "0.1.18"
5
5
  end
data/lib/stepper_motor.rb CHANGED
@@ -13,6 +13,7 @@ module StepperMotor
13
13
 
14
14
  autoload :Journey, File.dirname(__FILE__) + "/stepper_motor/journey.rb"
15
15
  autoload :Step, File.dirname(__FILE__) + "/stepper_motor/step.rb"
16
+ autoload :Conditional, File.dirname(__FILE__) + "/stepper_motor/conditional.rb"
16
17
 
17
18
  autoload :BaseJob, File.dirname(__FILE__) + "/stepper_motor/base_job.rb"
18
19
  autoload :PerformStepJob, File.dirname(__FILE__) + "/stepper_motor/perform_step_job.rb"
data/manual/MANUAL.md CHANGED
@@ -515,6 +515,123 @@ It is possible to store instance variables on the `Journey` instance, but they d
515
515
  > This means that the volatile state such as instance variables is not going to be available between steps. Always assume that
516
516
  > the `Journey` you are inside of does not have any instance variables set by previous steps and has just been freshly loaded from the database.
517
517
 
518
+ ### Blanket step conditions with `cancel_if`
519
+
520
+ In addition to conditional steps that can be skipped, you can define blanket conditions that apply to all steps in a journey using `cancel_if`. These conditions are evaluated after the journey's state is set to `performing` but before any step execution begins. If any `cancel_if` condition is satisfied, the entire journey is canceled immediately.
521
+
522
+ `cancel_if` works like Rails' `etag` - it's class-inheritable and appendable. You can call `cancel_if` multiple times in a journey definition, and all conditions will be evaluated.
523
+
524
+ #### Using blocks with `cancel_if`
525
+
526
+ The most common way to use `cancel_if` is with a block that gets evaluated in the context of the journey:
527
+
528
+ ```ruby
529
+ class UserOnboardingJourney < StepperMotor::Journey
530
+ cancel_if { hero.deactivated? }
531
+ cancel_if { hero.account_closed? }
532
+
533
+ step :send_welcome_email do
534
+ WelcomeMailer.welcome(hero).deliver_later
535
+ end
536
+
537
+ step :send_premium_offer, wait: 2.days do
538
+ PremiumOfferMailer.exclusive_offer(hero).deliver_later
539
+ end
540
+
541
+ step :complete_onboarding, wait: 7.days do
542
+ hero.update!(onboarding_completed_at: Time.current)
543
+ end
544
+ end
545
+ ```
546
+
547
+ In this example, if the user becomes deactivated or closes their account at any point during the onboarding journey, the entire journey will be canceled and no further steps will be executed.
548
+
549
+ #### Using positional arguments with `cancel_if`
550
+
551
+ You can also pass conditions directly as arguments to `cancel_if`:
552
+
553
+ ```ruby
554
+ class PaymentProcessingJourney < StepperMotor::Journey
555
+ cancel_if :payment_canceled?
556
+ cancel_if { hero.amount > 10000 } # Cancel if amount exceeds limit
557
+
558
+ step :validate_payment do
559
+ PaymentValidator.validate(hero)
560
+ end
561
+
562
+ step :process_payment do
563
+ PaymentProcessor.charge(hero)
564
+ end
565
+
566
+ step :send_confirmation do
567
+ PaymentConfirmationMailer.confirm(hero).deliver_later
568
+ end
569
+
570
+ private
571
+
572
+ def payment_canceled?
573
+ hero.canceled_at.present?
574
+ end
575
+ end
576
+ ```
577
+
578
+ #### Supported condition types
579
+
580
+ `cancel_if` accepts the same types of conditions as `skip_if`:
581
+
582
+ * **Symbols**: Method names that return a boolean
583
+ * **Blocks**: Callable blocks that return a boolean
584
+ * **Booleans**: Literal `true` or `false` values
585
+ * **Arrays**: Arrays of conditions (all must be true)
586
+ * **Procs/Lambdas**: Callable objects
587
+ * **Conditional objects**: `StepperMotor::Conditional` instances
588
+ * **Nil**: Treated as `false` (doesn't cancel)
589
+
590
+ #### Multiple `cancel_if` conditions
591
+
592
+ You can define multiple `cancel_if` conditions, and any one of them being satisfied will cancel the journey:
593
+
594
+ ```ruby
595
+ class EmailCampaignJourney < StepperMotor::Journey
596
+ cancel_if { hero.unsubscribed? }
597
+ cancel_if { hero.bounced? }
598
+ cancel_if { hero.complained? }
599
+ cancel_if { hero.deactivated? }
600
+
601
+ step :send_initial_email do
602
+ CampaignMailer.initial(hero).deliver_later
603
+ end
604
+
605
+ step :send_follow_up, wait: 3.days do
606
+ CampaignMailer.follow_up(hero).deliver_later
607
+ end
608
+
609
+ step :send_final_reminder, wait: 7.days do
610
+ CampaignMailer.final_reminder(hero).deliver_later
611
+ end
612
+ end
613
+ ```
614
+
615
+ #### Inheritance and appendability
616
+
617
+ `cancel_if` conditions are class-inheritable and appendable, just like Rails' `etag`:
618
+
619
+ ```ruby
620
+ class BaseJourney < StepperMotor::Journey
621
+ cancel_if { hero.deactivated? }
622
+ end
623
+
624
+ class PremiumUserJourney < BaseJourney
625
+ cancel_if { hero.subscription_expired? } # Adds to parent's conditions
626
+
627
+ step :send_premium_content do
628
+ PremiumContentMailer.send_content(hero).deliver_later
629
+ end
630
+ end
631
+ ```
632
+
633
+ In this example, `PremiumUserJourney` will cancel if either the user is deactivated (from the parent class) or if their subscription has expired (from the child class).
634
+
518
635
 
519
636
  ### Waiting for the start of the step
520
637
 
@@ -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.16", T.untyped)
5
+ VERSION = T.let("0.1.18", T.untyped)
6
6
  PerformStepJobV2 = T.let(StepperMotor::PerformStepJob, T.untyped)
7
7
  RecoverStuckJourneysJobV1 = T.let(StepperMotor::RecoverStuckJourneysJob, T.untyped)
8
8
 
@@ -124,6 +124,10 @@ module StepperMotor
124
124
  sig { returns(T.untyped) }
125
125
  def step_definitions; end
126
126
 
127
+ # _@return_ — the cancel_if conditions defined for this journey class
128
+ sig { returns(T::Array[StepperMotor::Conditional]) }
129
+ def cancel_if_conditions; end
130
+
127
131
  # sord duck - #to_f looks like a duck type, replacing with untyped
128
132
  # sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
129
133
  # sord duck - #to_f looks like a duck type, replacing with untyped
@@ -173,6 +177,18 @@ module StepperMotor
173
177
  sig { params(by_step_name: T.untyped).returns(T.untyped) }
174
178
  def lookup_step_definition(by_step_name); end
175
179
 
180
+ # Defines a condition that will cause the journey to cancel if satisfied.
181
+ # This works like Rails' `etag` - it's class-inheritable and appendable.
182
+ # Multiple `cancel_if` calls can be made to a Journey definition.
183
+ # All conditions are evaluated after setting the state to `performing`.
184
+ # If any condition is satisfied, the journey will cancel.
185
+ #
186
+ # _@param_ `condition_arg` — the condition to check
187
+ #
188
+ # _@param_ `condition_blk` — a block that will be evaluated as a condition
189
+ sig { params(condition_arg: T.any(TrueClass, FalseClass, Symbol, Proc, T::Array[T.untyped], Conditional), condition_blk: T.untyped).void }
190
+ def self.cancel_if(condition_arg = :__no_argument_given__, &condition_blk); end
191
+
176
192
  # Performs the next step in the journey. Will check whether any other process has performed the step already
177
193
  # and whether the record is unchanged, and will then lock it and set the state to 'performimg'.
178
194
  #
@@ -359,6 +375,25 @@ module StepperMotor
359
375
  class BaseJob < ActiveJob::Base
360
376
  end
361
377
 
378
+ # A wrapper for conditional logic that can be evaluated against an object.
379
+ # This class encapsulates different types of conditions (booleans, symbols, callables, arrays)
380
+ # and provides a unified interface for checking if a condition is satisfied by a given object.
381
+ # It handles negation and ensures proper context when evaluating conditions.
382
+ class Conditional
383
+ # sord omit - no YARD type given for "condition", using untyped
384
+ # sord omit - no YARD type given for "negate:", using untyped
385
+ sig { params(condition: T.untyped, negate: T.untyped).void }
386
+ def initialize(condition, negate: false); end
387
+
388
+ # sord omit - no YARD type given for "object", using untyped
389
+ sig { params(object: T.untyped).returns(T::Boolean) }
390
+ def satisfied_by?(object); end
391
+
392
+ # sord omit - no YARD return type given, using untyped
393
+ sig { returns(T.untyped) }
394
+ def validate_condition; end
395
+ end
396
+
362
397
  module TestHelper
363
398
  # Allows running a given Journey to completion, skipping across the waiting periods.
364
399
  # This is useful to evaluate all side effects of a Journey. The helper will ensure
@@ -112,6 +112,9 @@ module StepperMotor
112
112
  # _@see_ `Journey.step_definitions`
113
113
  def step_definitions: () -> untyped
114
114
 
115
+ # _@return_ — the cancel_if conditions defined for this journey class
116
+ def cancel_if_conditions: () -> ::Array[StepperMotor::Conditional]
117
+
115
118
  # sord duck - #to_f looks like a duck type, replacing with untyped
116
119
  # sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
117
120
  # sord duck - #to_f looks like a duck type, replacing with untyped
@@ -155,6 +158,17 @@ module StepperMotor
155
158
  # _@see_ `Journey.lookup_step_definition`
156
159
  def lookup_step_definition: (untyped by_step_name) -> untyped
157
160
 
161
+ # Defines a condition that will cause the journey to cancel if satisfied.
162
+ # This works like Rails' `etag` - it's class-inheritable and appendable.
163
+ # Multiple `cancel_if` calls can be made to a Journey definition.
164
+ # All conditions are evaluated after setting the state to `performing`.
165
+ # If any condition is satisfied, the journey will cancel.
166
+ #
167
+ # _@param_ `condition_arg` — the condition to check
168
+ #
169
+ # _@param_ `condition_blk` — a block that will be evaluated as a condition
170
+ def self.cancel_if: (?(TrueClass | FalseClass | Symbol | Proc | ::Array[untyped] | Conditional) condition_arg) -> void
171
+
158
172
  # Performs the next step in the journey. Will check whether any other process has performed the step already
159
173
  # and whether the record is unchanged, and will then lock it and set the state to 'performimg'.
160
174
  #
@@ -320,6 +334,22 @@ module StepperMotor
320
334
  class BaseJob < ActiveJob::Base
321
335
  end
322
336
 
337
+ # A wrapper for conditional logic that can be evaluated against an object.
338
+ # This class encapsulates different types of conditions (booleans, symbols, callables, arrays)
339
+ # and provides a unified interface for checking if a condition is satisfied by a given object.
340
+ # It handles negation and ensures proper context when evaluating conditions.
341
+ class Conditional
342
+ # sord omit - no YARD type given for "condition", using untyped
343
+ # sord omit - no YARD type given for "negate:", using untyped
344
+ def initialize: (untyped condition, ?negate: untyped) -> void
345
+
346
+ # sord omit - no YARD type given for "object", using untyped
347
+ def satisfied_by?: (untyped object) -> bool
348
+
349
+ # sord omit - no YARD return type given, using untyped
350
+ def validate_condition: () -> untyped
351
+ end
352
+
323
353
  module TestHelper
324
354
  # Allows running a given Journey to completion, skipping across the waiting periods.
325
355
  # This is useful to evaluate all side effects of a Journey. The helper will ensure
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
12
12
  spec.summary = "Effortless step workflows that embed nicely inside Rails"
13
13
  spec.description = "Step workflows for Rails/ActiveRecord"
14
14
  spec.homepage = "https://steppermotor.dev"
15
- spec.required_ruby_version = ">= 2.7.0"
15
+ spec.required_ruby_version = ">= 3.1.0"
16
16
 
17
17
  spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
18
 
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class CancelIfTest < ActiveSupport::TestCase
6
+ include ActiveJob::TestHelper
7
+ include SideEffects::TestHelper
8
+
9
+ test "cancel_if with block condition cancels journey when true" do
10
+ canceling_journey = create_journey_subclass do
11
+ cancel_if { true }
12
+
13
+ step do
14
+ SideEffects.touch! "should not run"
15
+ end
16
+ end
17
+
18
+ journey = canceling_journey.create!
19
+ assert journey.ready?
20
+
21
+ assert_no_side_effects { journey.perform_next_step! }
22
+ assert journey.canceled?
23
+ end
24
+
25
+ test "cancel_if with block condition does not cancel journey when false" do
26
+ canceling_journey = create_journey_subclass do
27
+ cancel_if { false }
28
+
29
+ step do
30
+ SideEffects.touch! "should run"
31
+ end
32
+ end
33
+
34
+ journey = canceling_journey.create!
35
+ assert journey.ready?
36
+
37
+ assert_produced_side_effects("should run") do
38
+ journey.perform_next_step!
39
+ end
40
+ assert journey.finished?
41
+ end
42
+
43
+ test "cancel_if with boolean argument cancels journey when true" do
44
+ canceling_journey = create_journey_subclass do
45
+ cancel_if true
46
+
47
+ step do
48
+ SideEffects.touch! "should not run"
49
+ end
50
+ end
51
+
52
+ journey = canceling_journey.create!
53
+ assert journey.ready?
54
+
55
+ assert_no_side_effects { journey.perform_next_step! }
56
+ assert journey.canceled?
57
+ end
58
+
59
+ test "cancel_if with boolean argument does not cancel journey when false" do
60
+ canceling_journey = create_journey_subclass do
61
+ cancel_if false
62
+
63
+ step do
64
+ SideEffects.touch! "should run"
65
+ end
66
+ end
67
+
68
+ journey = canceling_journey.create!
69
+ assert journey.ready?
70
+
71
+ assert_produced_side_effects("should run") do
72
+ journey.perform_next_step!
73
+ end
74
+ assert journey.finished?
75
+ end
76
+
77
+ test "cancel_if with symbol argument calls method on journey" do
78
+ canceling_journey = create_journey_subclass do
79
+ cancel_if :should_cancel?
80
+
81
+ step do
82
+ SideEffects.touch! "should not run"
83
+ end
84
+
85
+ def should_cancel?
86
+ true
87
+ end
88
+ end
89
+
90
+ journey = canceling_journey.create!
91
+ assert journey.ready?
92
+
93
+ assert_no_side_effects { journey.perform_next_step! }
94
+ assert journey.canceled?
95
+ end
96
+
97
+ test "cancel_if with symbol method call in block" do
98
+ canceling_journey = create_journey_subclass do
99
+ cancel_if { should_cancel? }
100
+
101
+ step do
102
+ SideEffects.touch! "should not run"
103
+ end
104
+
105
+ def should_cancel?
106
+ true
107
+ end
108
+ end
109
+
110
+ journey = canceling_journey.create!
111
+ assert journey.ready?
112
+
113
+ assert_no_side_effects { journey.perform_next_step! }
114
+ assert journey.canceled?
115
+ end
116
+
117
+ test "cancel_if with array argument cancels when all conditions are true" do
118
+ canceling_journey = create_journey_subclass do
119
+ cancel_if [true, true]
120
+
121
+ step do
122
+ SideEffects.touch! "should not run"
123
+ end
124
+ end
125
+
126
+ journey = canceling_journey.create!
127
+ assert journey.ready?
128
+
129
+ assert_no_side_effects { journey.perform_next_step! }
130
+ assert journey.canceled?
131
+ end
132
+
133
+ test "cancel_if with array argument does not cancel when any condition is false" do
134
+ canceling_journey = create_journey_subclass do
135
+ cancel_if [true, false]
136
+
137
+ step do
138
+ SideEffects.touch! "should run"
139
+ end
140
+ end
141
+
142
+ journey = canceling_journey.create!
143
+ assert journey.ready?
144
+
145
+ assert_produced_side_effects("should run") do
146
+ journey.perform_next_step!
147
+ end
148
+ assert journey.finished?
149
+ end
150
+
151
+ test "cancel_if with proc argument evaluates proc in journey context" do
152
+ proc_condition = -> { true }
153
+
154
+ canceling_journey = create_journey_subclass do
155
+ cancel_if proc_condition
156
+
157
+ step do
158
+ SideEffects.touch! "should not run"
159
+ end
160
+ end
161
+
162
+ journey = canceling_journey.create!
163
+ assert journey.ready?
164
+
165
+ assert_no_side_effects { journey.perform_next_step! }
166
+ assert journey.canceled?
167
+ end
168
+
169
+ test "cancel_if with conditional object works correctly" do
170
+ conditional = StepperMotor::Conditional.new(true)
171
+
172
+ canceling_journey = create_journey_subclass do
173
+ cancel_if conditional
174
+
175
+ step do
176
+ SideEffects.touch! "should not run"
177
+ end
178
+ end
179
+
180
+ journey = canceling_journey.create!
181
+ assert journey.ready?
182
+
183
+ assert_no_side_effects { journey.perform_next_step! }
184
+ assert journey.canceled?
185
+ end
186
+
187
+ test "cancel_if with nil argument does not cancel journey" do
188
+ canceling_journey = create_journey_subclass do
189
+ cancel_if nil
190
+
191
+ step do
192
+ SideEffects.touch! "should run"
193
+ end
194
+ end
195
+
196
+ journey = canceling_journey.create!
197
+ assert journey.ready?
198
+
199
+ assert_produced_side_effects("should run") do
200
+ journey.perform_next_step!
201
+ end
202
+ assert journey.finished?
203
+ end
204
+
205
+ test "multiple cancel_if calls are all evaluated" do
206
+ canceling_journey = create_journey_subclass do
207
+ cancel_if false
208
+ cancel_if true
209
+
210
+ step do
211
+ SideEffects.touch! "should not run"
212
+ end
213
+ end
214
+
215
+ journey = canceling_journey.create!
216
+ assert journey.ready?
217
+
218
+ assert_no_side_effects { journey.perform_next_step! }
219
+ assert journey.canceled?
220
+ end
221
+
222
+ test "cancel_if conditions are evaluated after setting state to performing" do
223
+ canceling_journey = create_journey_subclass do
224
+ cancel_if { performing? }
225
+
226
+ step do
227
+ SideEffects.touch! "should not run"
228
+ end
229
+ end
230
+
231
+ journey = canceling_journey.create!
232
+ assert journey.ready?
233
+
234
+ assert_no_side_effects { journey.perform_next_step! }
235
+ assert journey.canceled?
236
+ end
237
+
238
+ test "cancel_if with no arguments and no block raises error" do
239
+ assert_raises(ArgumentError, "cancel_if requires either a condition argument or a block") do
240
+ create_journey_subclass do
241
+ cancel_if
242
+ end
243
+ end
244
+ end
245
+
246
+ test "cancel_if with both argument and block raises error" do
247
+ assert_raises(ArgumentError, "cancel_if accepts either a condition argument or a block, but not both") do
248
+ create_journey_subclass do
249
+ cancel_if true do
250
+ false
251
+ end
252
+ end
253
+ end
254
+ end
255
+
256
+ test "cancel_if conditions are class-inheritable" do
257
+ parent_journey = create_journey_subclass do
258
+ cancel_if true
259
+ end
260
+
261
+ child_journey = create_journey_subclass(parent_journey) do
262
+ step do
263
+ SideEffects.touch! "should not run"
264
+ end
265
+ end
266
+
267
+ journey = child_journey.create!
268
+ assert journey.ready?
269
+
270
+ assert_no_side_effects { journey.perform_next_step! }
271
+ assert journey.canceled?
272
+ end
273
+
274
+ test "cancel_if conditions are appendable in subclasses" do
275
+ parent_journey = create_journey_subclass do
276
+ cancel_if false
277
+ end
278
+
279
+ child_journey = create_journey_subclass(parent_journey) do
280
+ cancel_if true
281
+
282
+ step do
283
+ SideEffects.touch! "should not run"
284
+ end
285
+ end
286
+
287
+ journey = child_journey.create!
288
+ assert journey.ready?
289
+
290
+ assert_no_side_effects { journey.perform_next_step! }
291
+ assert journey.canceled?
292
+ end
293
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class WrapConditionalTest < ActiveSupport::TestCase
6
+ # Validate the skip_if condition
7
+ # if ![true, false, nil].include?(@skip_if_condition) && !@skip_if_condition.is_a?(Symbol) && !@skip_if_condition.respond_to?(:call)
8
+ # raise ArgumentError, "skip_if: condition must be a boolean, nil, Symbol or a callable object, but was a #{@skip_if_condition.inspect}"
9
+ # end
10
+
11
+ test "wraps true without negate" do
12
+ conditional = StepperMotor::Conditional.new(true)
13
+ assert_equal true, conditional.satisfied_by?(nil)
14
+ assert_equal true, conditional.satisfied_by?("something")
15
+ end
16
+
17
+ test "wraps true with negate" do
18
+ conditional = StepperMotor::Conditional.new(true, negate: true)
19
+ assert_equal false, conditional.satisfied_by?(nil)
20
+ assert_equal false, conditional.satisfied_by?("something")
21
+ end
22
+
23
+ test "wraps false without negate" do
24
+ conditional = StepperMotor::Conditional.new(false)
25
+ assert_equal false, conditional.satisfied_by?(nil)
26
+ assert_equal false, conditional.satisfied_by?("something")
27
+ end
28
+
29
+ test "wraps false with negate" do
30
+ conditional = StepperMotor::Conditional.new(false, negate: true)
31
+ assert_equal true, conditional.satisfied_by?(nil)
32
+ assert_equal true, conditional.satisfied_by?("something")
33
+ end
34
+
35
+ test "wraps nil without negate" do
36
+ conditional = StepperMotor::Conditional.new(nil)
37
+ assert_equal false, conditional.satisfied_by?(nil)
38
+ assert_equal false, conditional.satisfied_by?("something")
39
+ end
40
+
41
+ test "wraps nil with negate" do
42
+ conditional = StepperMotor::Conditional.new(nil, negate: true)
43
+ assert_equal true, conditional.satisfied_by?(nil)
44
+ assert_equal true, conditional.satisfied_by?("something")
45
+ end
46
+
47
+ test "wraps multiple without negate" do
48
+ conditional = StepperMotor::Conditional.new([true, true])
49
+ assert_equal true, conditional.satisfied_by?(nil)
50
+
51
+ conditional = StepperMotor::Conditional.new([true, false])
52
+ assert_equal false, conditional.satisfied_by?(nil)
53
+
54
+ conditional = StepperMotor::Conditional.new([false, false])
55
+ assert_equal false, conditional.satisfied_by?(nil)
56
+ end
57
+
58
+ test "wraps multiple with proc" do
59
+ conditional = StepperMotor::Conditional.new([true, -> { true }])
60
+ assert_equal true, conditional.satisfied_by?(nil)
61
+
62
+ conditional = StepperMotor::Conditional.new([true, -> { false }])
63
+ assert_equal false, conditional.satisfied_by?(nil)
64
+ end
65
+
66
+ test "wraps multiple with negate" do
67
+ conditional = StepperMotor::Conditional.new([true, true], negate: true)
68
+ assert_equal false, conditional.satisfied_by?(nil)
69
+
70
+ conditional = StepperMotor::Conditional.new([true, false], negate: true)
71
+ assert_equal true, conditional.satisfied_by?(nil)
72
+
73
+ conditional = StepperMotor::Conditional.new([false, false], negate: true)
74
+ assert_equal true, conditional.satisfied_by?(nil)
75
+ end
76
+
77
+ test "wraps callable without negate" do
78
+ conditional = StepperMotor::Conditional.new(-> { :foo })
79
+ assert_equal true, conditional.satisfied_by?(nil)
80
+
81
+ conditional = StepperMotor::Conditional.new(-> {})
82
+ assert_equal false, conditional.satisfied_by?(nil)
83
+
84
+ conditional = StepperMotor::Conditional.new(-> { false })
85
+ assert_equal false, conditional.satisfied_by?(nil)
86
+
87
+ conditional = StepperMotor::Conditional.new(-> { true })
88
+ assert_equal true, conditional.satisfied_by?(nil)
89
+ end
90
+
91
+ test "wraps callable with negate" do
92
+ conditional = StepperMotor::Conditional.new(-> { :foo }, negate: true)
93
+ assert_equal false, conditional.satisfied_by?(nil)
94
+
95
+ conditional = StepperMotor::Conditional.new(-> {}, negate: true)
96
+ assert_equal true, conditional.satisfied_by?(nil)
97
+
98
+ conditional = StepperMotor::Conditional.new(-> { false }, negate: true)
99
+ assert_equal true, conditional.satisfied_by?(nil)
100
+
101
+ conditional = StepperMotor::Conditional.new(-> { true }, negate: true)
102
+ assert_equal false, conditional.satisfied_by?(nil)
103
+ end
104
+
105
+ class Doer
106
+ def should_do?
107
+ true
108
+ end
109
+ end
110
+
111
+ class NotDoer
112
+ def should_do?
113
+ false
114
+ end
115
+ end
116
+
117
+ test "wraps method without negate" do
118
+ doer = Doer.new
119
+
120
+ conditional = StepperMotor::Conditional.new(:should_do?)
121
+ assert_equal true, conditional.satisfied_by?(doer)
122
+
123
+ not_doer = NotDoer.new
124
+ assert_equal false, conditional.satisfied_by?(not_doer)
125
+ end
126
+
127
+ test "wraps method with negate" do
128
+ doer = Doer.new
129
+
130
+ conditional = StepperMotor::Conditional.new(:should_do?, negate: true)
131
+ assert_equal false, conditional.satisfied_by?(doer)
132
+
133
+ not_doer = NotDoer.new
134
+ assert_equal true, conditional.satisfied_by?(not_doer)
135
+ end
136
+
137
+ test "wraps conditional without negate" do
138
+ inner_conditional = StepperMotor::Conditional.new(true)
139
+ outer_conditional = StepperMotor::Conditional.new(inner_conditional)
140
+
141
+ assert_equal true, outer_conditional.satisfied_by?(nil)
142
+ assert_equal true, outer_conditional.satisfied_by?("something")
143
+ end
144
+
145
+ test "wraps conditional with negate" do
146
+ inner_conditional = StepperMotor::Conditional.new(true)
147
+ outer_conditional = StepperMotor::Conditional.new(inner_conditional, negate: true)
148
+
149
+ assert_equal false, outer_conditional.satisfied_by?(nil)
150
+ assert_equal false, outer_conditional.satisfied_by?("something")
151
+ end
152
+
153
+ test "wraps negated conditional without negate" do
154
+ inner_conditional = StepperMotor::Conditional.new(true, negate: true)
155
+ outer_conditional = StepperMotor::Conditional.new(inner_conditional)
156
+
157
+ assert_equal false, outer_conditional.satisfied_by?(nil)
158
+ assert_equal false, outer_conditional.satisfied_by?("something")
159
+ end
160
+
161
+ test "wraps negated conditional with negate" do
162
+ inner_conditional = StepperMotor::Conditional.new(true, negate: true)
163
+ outer_conditional = StepperMotor::Conditional.new(inner_conditional, negate: true)
164
+
165
+ assert_equal true, outer_conditional.satisfied_by?(nil)
166
+ assert_equal true, outer_conditional.satisfied_by?("something")
167
+ end
168
+
169
+ test "wraps conditional with method" do
170
+ doer = Doer.new
171
+ inner_conditional = StepperMotor::Conditional.new(:should_do?)
172
+ outer_conditional = StepperMotor::Conditional.new(inner_conditional)
173
+
174
+ assert_equal true, outer_conditional.satisfied_by?(doer)
175
+
176
+ not_doer = NotDoer.new
177
+ assert_equal false, outer_conditional.satisfied_by?(not_doer)
178
+ end
179
+
180
+ test "wraps conditional with method and negate" do
181
+ doer = Doer.new
182
+ inner_conditional = StepperMotor::Conditional.new(:should_do?)
183
+ outer_conditional = StepperMotor::Conditional.new(inner_conditional, negate: true)
184
+
185
+ assert_equal false, outer_conditional.satisfied_by?(doer)
186
+
187
+ not_doer = NotDoer.new
188
+ assert_equal true, outer_conditional.satisfied_by?(not_doer)
189
+ end
190
+ end
data/test/test_helper.rb CHANGED
@@ -32,11 +32,12 @@ module JourneyDefinitionHelper
32
32
  super
33
33
  end
34
34
 
35
- def create_journey_subclass(&blk)
35
+ def create_journey_subclass(parent_class = nil, &blk)
36
36
  # https://stackoverflow.com/questions/4113479/dynamic-class-definition-with-a-class-name
37
37
  random_component = @class_names_rng.hex(8)
38
38
  random_name = "JourneySubclass_#{random_component}"
39
- klass = Class.new(StepperMotor::Journey, &blk)
39
+ parent_class ||= StepperMotor::Journey
40
+ klass = Class.new(parent_class, &blk)
40
41
  Object.const_set(random_name, klass)
41
42
  @dynamic_class_names << random_name
42
43
  klass
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.16
4
+ version: 0.1.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
@@ -236,6 +236,7 @@ files:
236
236
  - lib/generators/stepper_motor_migration_005.rb.erb
237
237
  - lib/stepper_motor.rb
238
238
  - lib/stepper_motor/base_job.rb
239
+ - lib/stepper_motor/conditional.rb
239
240
  - lib/stepper_motor/cyclic_scheduler.rb
240
241
  - lib/stepper_motor/delete_completed_journeys_job.rb
241
242
  - lib/stepper_motor/forward_scheduler.rb
@@ -309,6 +310,7 @@ files:
309
310
  - test/stepper_motor/cyclic_scheduler_test.rb
310
311
  - test/stepper_motor/forward_scheduler_test.rb
311
312
  - test/stepper_motor/housekeeping_job_test.rb
313
+ - test/stepper_motor/journey/cancel_if_test.rb
312
314
  - test/stepper_motor/journey/exception_handling_test.rb
313
315
  - test/stepper_motor/journey/flow_control_test.rb
314
316
  - test/stepper_motor/journey/idempotency_test.rb
@@ -320,6 +322,7 @@ files:
320
322
  - test/stepper_motor/recover_stuck_journeys_job_test.rb
321
323
  - test/stepper_motor/recovery_test.rb
322
324
  - test/stepper_motor/test_helper_test.rb
325
+ - test/stepper_motor/wrap_conditional_test.rb
323
326
  - test/stepper_motor_test.rb
324
327
  - test/test_helper.rb
325
328
  homepage: https://steppermotor.dev
@@ -337,7 +340,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
337
340
  requirements:
338
341
  - - ">="
339
342
  - !ruby/object:Gem::Version
340
- version: 2.7.0
343
+ version: 3.1.0
341
344
  required_rubygems_version: !ruby/object:Gem::Requirement
342
345
  requirements:
343
346
  - - ">="