stepper_motor 0.1.18 → 0.1.19

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: 2b4b58accbed9841696bb2d0296335863b71b26b9a33a457028cc4c834ec1fec
4
- data.tar.gz: c0f6e6fda44e9d8c7808743e425a059e2a8c1196fc4fcd1fcb6240521d8b5bbf
3
+ metadata.gz: b27839f0ee5621c5da6e694e2ee3e790f9f4b96383d05bf5ebbfc0a7ddf99928
4
+ data.tar.gz: 60bf0af4da25cf6f6c7ef2d835bcbd717ec662215da2bf14aea50da6949dc316
5
5
  SHA512:
6
- metadata.gz: 2b3aa12d9c21cf9f5e8246bce6fa8d88bfaeea046ad434ada641c7c0e1ce62cba173584fd4c836c6e70d98468f1ebc70ce93ea0246fb40ed3992ea45a97328f2
7
- data.tar.gz: ae3fb23fc0f81f3a92e63abf6a6b444a801421c388ed3ef3a7e6ff23562a74bca218d3b257cb7a6dfa896e383d4e093bd990f67209f4ca41936598db91e680a9
6
+ metadata.gz: 275d2ba0cc4ff6710f3d87189853f9836a767bfea6e56ac6a635135f7aa8e8043038f3cff1b28fd3b548ea06dd65392b37adb4f6d5aa4a310e7e7d95f53cb444
7
+ data.tar.gz: dd3f67919bf6daca240b6eb32d4c83ca720fc8ae9f2b7e9c571a7913e2e640840c7f1dc4c497f5114f14694b0a413ccdba758330c418844a3adc77b5312289ce
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.1.19] - 2025-06-25
6
+
7
+ - With `speedrun_journey`, allow configuring the maximum number of steps - for testing journeys
8
+ that iterate or reattempt a lot.
9
+ - With `speedrun_journey`, use time travel by default
10
+ - Allow steps to be placed relative to other steps using `before_step:` and `after_step:` keyword arguments
11
+ - Use just-in-time index lookup instead of `Step#seq`
12
+
5
13
  ## [0.1.18] - 2025-06-20
6
14
 
7
15
  - Add `cancel_if` at Journey class level for blanket journey cancellation conditions. This makes it very easy to abort journeys across multiple steps.
@@ -58,8 +58,7 @@ module StepperMotor::Journey::FlowControl
58
58
  return
59
59
  end
60
60
 
61
- current_step_seq = current_step_definition.seq
62
- next_step_definition = step_definitions[current_step_seq + 1]
61
+ next_step_definition = self.class.step_definitions_following(current_step_definition).first
63
62
 
64
63
  if next_step_definition
65
64
  # There are more steps after this one - schedule the next step
@@ -79,7 +79,13 @@ module StepperMotor
79
79
  # as opposed to when the previous step has completed. When the journey gets scheduled, the triggering job is going to
80
80
  # be delayed by this amount of time _minus the `wait` values of the preceding steps, and the `next_step_to_be_performed_at`
81
81
  # attribute will be set to the current time. The `after` value gets converted into the `wait` value and passed to the step definition.
82
- # Mutually exclusive with `wait:`
82
+ # Mutually exclusive with `wait:`.
83
+ # @param before_step[String,Symbol,nil] the name of the step before which this step should be inserted.
84
+ # This allows you to control the order of steps by inserting a step before a specific existing step.
85
+ # The step name can be provided as a string or symbol. Mutually exclusive with `after_step:`.
86
+ # @param after_step[String,Symbol,nil] the name of the step after which this step should be inserted.
87
+ # This allows you to control the order of steps by inserting a step after a specific existing step.
88
+ # The step name can be provided as a string or symbol. Mutually exclusive with `before_step:`.
83
89
  # @param on_exception[Symbol] See {StepperMotor::Step#on_exception}
84
90
  # @param skip_if[TrueClass,FalseClass,Symbol,Proc] condition to check before performing the step. If a symbol is provided,
85
91
  # it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context.
@@ -89,7 +95,7 @@ module StepperMotor
89
95
  # The step will be performed if the condition returns a truthy value. and skipped otherwise. Inverse of `skip_if`.
90
96
  # @param additional_step_definition_options[Hash] Any remaining options get passed to `StepperMotor::Step.new` as keyword arguments.
91
97
  # @return [StepperMotor::Step] the step definition that has been created
92
- def self.step(name = nil, wait: nil, after: nil, **additional_step_definition_options, &blk)
98
+ def self.step(name = nil, wait: nil, after: nil, before_step: nil, after_step: nil, **additional_step_definition_options, &blk)
93
99
  # Handle the if: alias for backward compatibility
94
100
  if additional_step_definition_options.key?(:if) && additional_step_definition_options.key?(:skip_if)
95
101
  raise StepConfigurationError, "Either skip_if: or if: can be specified, but not both"
@@ -99,11 +105,31 @@ module StepperMotor
99
105
  additional_step_definition_options[:skip_if] = StepperMotor::Conditional.new(additional_step_definition_options.delete(:if), negate: true)
100
106
  end
101
107
 
102
- wait = if wait && after
108
+ # Validate before_step and after_step parameters
109
+ if before_step && after_step
110
+ raise StepConfigurationError, "Either before_step: or after_step: can be specified, but not both"
111
+ end
112
+
113
+ # Validate that referenced steps exist
114
+ if before_step
115
+ referenced_step = lookup_step_definition(before_step)
116
+ unless referenced_step
117
+ raise StepConfigurationError, "Step named #{before_step.inspect} not found for before_step: parameter"
118
+ end
119
+ end
120
+
121
+ if after_step
122
+ referenced_step = lookup_step_definition(after_step)
123
+ unless referenced_step
124
+ raise StepConfigurationError, "Step named #{after_step.inspect} not found for after_step: parameter"
125
+ end
126
+ end
127
+
128
+ wait = if wait && after&.respond_to?(:to_f)
103
129
  raise StepConfigurationError, "Either wait: or after: can be specified, but not both"
104
- elsif !wait && !after
130
+ elsif !wait && (!after || !after.respond_to?(:to_f))
105
131
  0
106
- elsif after
132
+ elsif after&.respond_to?(:to_f)
107
133
  accumulated = step_definitions.map(&:wait).sum
108
134
  after - accumulated
109
135
  else
@@ -127,12 +153,27 @@ module StepperMotor
127
153
  raise StepConfigurationError, "Step named #{name.inspect} already defined" if known_step_names.include?(name)
128
154
 
129
155
  # Create the step definition
130
- StepperMotor::Step.new(name: name, wait: wait, seq: step_definitions.length, **additional_step_definition_options, &blk).tap do |step_definition|
131
- # As per Rails docs: you need to be aware when using class_attribute with mutable structures
132
- # as Array or Hash. In such cases, you don't want to do changes in place. Instead use setters.
133
- # See https://apidock.com/rails/v7.1.3.2/Class/class_attribute
156
+ step_definition = StepperMotor::Step.new(name: name, wait: wait, **additional_step_definition_options, &blk)
157
+
158
+ # Determine insertion position based on before_step or after_step parameters
159
+ if before_step
160
+ target_step = lookup_step_definition(before_step)
161
+ target_index = step_definitions.index(target_step)
162
+ new_step_definitions = step_definitions.dup
163
+ new_step_definitions.insert(target_index, step_definition)
164
+ self.step_definitions = new_step_definitions
165
+ elsif after_step
166
+ target_step = lookup_step_definition(after_step)
167
+ target_index = step_definitions.index(target_step)
168
+ new_step_definitions = step_definitions.dup
169
+ new_step_definitions.insert(target_index + 1, step_definition)
170
+ self.step_definitions = new_step_definitions
171
+ else
172
+ # Default behavior: append to the end
134
173
  self.step_definitions = step_definitions + [step_definition]
135
174
  end
175
+
176
+ step_definition
136
177
  end
137
178
 
138
179
  # Returns the `Step` object for a named step. This is used when performing a step, but can also
@@ -144,6 +185,16 @@ module StepperMotor
144
185
  step_definitions.find { |d| d.name.to_s == by_step_name.to_s }
145
186
  end
146
187
 
188
+ # Returns all step definitions that follow the given step in the journey
189
+ #
190
+ # @param step_definition[StepperMotor::Step] the step to find the following steps for
191
+ # @return [Array<StepperMotor::Step>] the following steps, or empty array if this is the last step
192
+ def self.step_definitions_following(step_definition)
193
+ current_index = step_definitions.index(step_definition)
194
+ return [] unless current_index
195
+ step_definitions[(current_index + 1)..]
196
+ end
197
+
147
198
  # Alias for the class attribute, for brevity
148
199
  #
149
200
  # @see Journey.step_definitions
@@ -292,8 +343,7 @@ module StepperMotor
292
343
  ready!
293
344
  elsif @skip_current_step
294
345
  # The step asked to be skipped
295
- current_step_seq = @current_step_definition.seq
296
- next_step_definition = step_definitions[current_step_seq + 1]
346
+ next_step_definition = self.class.step_definitions_following(@current_step_definition).first
297
347
 
298
348
  if next_step_definition
299
349
  # There are more steps after this one - schedule the next step
@@ -309,7 +359,7 @@ module StepperMotor
309
359
  elsif finished?
310
360
  logger.info { "was marked finished inside the step" }
311
361
  update!(previous_step_name: current_step_name, next_step_name: nil)
312
- elsif (next_step_definition = step_definitions[@current_step_definition.seq + 1])
362
+ elsif (next_step_definition = self.class.step_definitions_following(@current_step_definition).first)
313
363
  logger.info { "will continue to #{next_step_definition.name}" }
314
364
  set_next_step_and_enqueue(next_step_definition)
315
365
  ready!
@@ -337,8 +387,7 @@ module StepperMotor
337
387
 
338
388
  # @return [ActiveSupport::Duration]
339
389
  def time_remaining_until_final_step
340
- current_step_seq = @current_step_definition&.seq || -1
341
- subsequent_steps = step_definitions.select { |definition| definition.seq > current_step_seq }
390
+ subsequent_steps = @current_step_definition ? self.class.step_definitions_following(@current_step_definition) : step_definitions
342
391
  seconds_remaining = subsequent_steps.map { |definition| definition.wait.to_f }.sum
343
392
  seconds_remaining.seconds # Convert to ActiveSupport::Duration
344
393
  end
@@ -13,9 +13,6 @@ class StepperMotor::Step
13
13
  # @return [Numeric,ActiveSupport::Duration] how long to wait before performing the step
14
14
  attr_reader :wait
15
15
 
16
- # @private
17
- attr_reader :seq
18
-
19
16
  # Creates a new step definition
20
17
  #
21
18
  # @param name[String,Symbol] the name of the Step
@@ -30,11 +27,10 @@ class StepperMotor::Step
30
27
  # it will be used directly. If nil is provided, it will be treated as false. If a symbol is provided,
31
28
  # it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context.
32
29
  # The step will only be performed if the condition returns a truthy value.
33
- def initialize(name:, seq:, on_exception: :pause!, wait: 0, skip_if: false, &step_block)
30
+ def initialize(name:, on_exception: :pause!, wait: 0, skip_if: false, &step_block)
34
31
  @step_block = step_block
35
32
  @name = name.to_s
36
33
  @wait = wait
37
- @seq = seq
38
34
  @on_exception = on_exception # TODO: Validate?
39
35
  @skip_if_condition = StepperMotor::Conditional.new(skip_if)
40
36
  end
@@ -8,14 +8,39 @@ module StepperMotor::TestHelper
8
8
  # has neither canceled nor finished, an exception will be raised.
9
9
  #
10
10
  # @param journey[StepperMotor::Journey] the journey to speedrun
11
+ # @param time_travel[Boolean] whether to use ActiveSupport time travel (default: true)
12
+ # Note: When time_travel is true, this method will permanently travel time forward
13
+ # and will not reset it back to the original time when the method exits.
14
+ # @param maximum_steps[Symbol,Integer] how many steps can we take until we assume the
15
+ # journey has hung and fail the test. Default value is :reasonable, which is 10x the
16
+ # number of steps. :unlimited allows "a ton", but can make your test hang if your logic
17
+ # lets a step reattempt indefinitely
11
18
  # @return void
12
- def speedrun_journey(journey)
19
+ def speedrun_journey(journey, time_travel: true, maximum_steps: :reasonable)
13
20
  journey.save!
14
- n_steps = journey.step_definitions.length
21
+ n_steps = case maximum_steps
22
+ when :reasonable
23
+ journey.step_definitions.length * 10
24
+ when :unlimited
25
+ 0xFFFF
26
+ when Integer
27
+ maximum_steps
28
+ else
29
+ raise ArgumentError, "maximum_steps may be :reasonable, :unlimited or an Integer, was #{maximum_steps.inspect}"
30
+ end
31
+
15
32
  n_steps.times do
16
33
  journey.reload
17
34
  break if journey.canceled? || journey.finished?
18
- journey.update(next_step_to_be_performed_at: Time.current)
35
+
36
+ if time_travel
37
+ # Use time travel to move slightly ahead of the time when the next step should be performed
38
+ next_step_time = journey.next_step_to_be_performed_at
39
+ travel_to(next_step_time + 1.second)
40
+ else
41
+ # Update the journey's timestamp to bypass waiting periods
42
+ journey.update(next_step_to_be_performed_at: Time.current)
43
+ end
19
44
  journey.perform_next_step!
20
45
  end
21
46
  journey.reload
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StepperMotor
4
- VERSION = "0.1.18"
4
+ VERSION = "0.1.19"
5
5
  end
data/manual/MANUAL.md CHANGED
@@ -27,7 +27,9 @@ So, stepper_motor aims to give you "just enough of Temporal-like functionality"
27
27
 
28
28
  ## A brief introduction to stepper_motor
29
29
 
30
- stepper_motor is built around the concept of a `Journey`. A `Journey` [is a sequence of steps happening to a `hero`](https://en.wikipedia.org/wiki/Hero%27s_journey) - once launched, the journey will run until it either finishes or cancels. A `Journey` is just an `ActiveRecord` model, with all the persistence methods you already know and use.
30
+ stepper_motor is built around the concept of a `Journey`. A `Journey` [is a sequence of steps happening to a `hero`](https://en.wikipedia.org/wiki/Hero%27s_journey) - once launched, the journey will run until it either finishes or cancels. But also - a `Journey` is an `ActiveRecord` model, with all the persistence methods you already know and use. Every `Journey` has a speficic identity - its primary key, just like any other database row, and carries state information.
31
+
32
+ A `Journey` changes states during asynchronous invocations, and can lay dormant for months until the time comes to take the next step.
31
33
 
32
34
  Steps are defined inside the Journey subclasses as blocks, and they run in the context of that subclass' instance. The following constraints apply:
33
35
 
@@ -40,7 +42,7 @@ The `step` blocks get executed in the context of the `Journey` model instance. T
40
42
 
41
43
  The steps are performed asynchronously, via ActiveJob. When a Journey is created, it gets scheduled for its initial step. The job then gets picked up by the ActiveJob queue worker (whichever you are using) and triggers the step on the `Journey`. If the journey decides to continue to the next step, it schedules another ActiveJob for itself with the step name and other details necessary.
42
44
 
43
- No state is carried inside the job.
45
+ No state is carried inside the job - both the state transition management and the steps are handled by the `Journey` itself.
44
46
 
45
47
  ## Installation
46
48
 
@@ -70,7 +72,7 @@ class SignupJourney < StepperMotor::Journey
70
72
  end
71
73
  end
72
74
 
73
- class SignupController
75
+ class SignupController < ApplicationController
74
76
  def create
75
77
  # ...your other business actions
76
78
  SignupJourney.create!(hero: current_user)
@@ -371,23 +373,54 @@ step :two do
371
373
  end
372
374
  ```
373
375
 
374
- You have a `Journey` which is about to start step `one`. When the step gets performed, stepper_motor will do a lookup to find _the next step in order of definition._ In this case the step will be step `two`, so the name of that step will be saved with the `Journey`. Imagine you then edit the code to add an extra step between those:
376
+ Your existing `Journey` is already primed to perform step `two`. However, a `Journey` which is about to perform step `one` will now set `one_bis` as the next step to perform. This allows limited reordering and editing of `Journey` definitions after they have already begun.
377
+
378
+ ### Step ordering with `before_step:` and `after_step:`
379
+
380
+ You can control the order of steps by inserting them before or after specific existing steps using the `before_step:` and `after_step:` parameters. This is useful when you want to add steps in the middle of a sequence without manually reordering all the steps.
381
+
382
+ The step names can be provided as strings or symbols, and only one of `before_step:` or `after_step:` can be specified:
375
383
 
376
384
  ```ruby
377
- step :one do
378
- # perform some action
379
- end
385
+ class UserOnboardingJourney < StepperMotor::Journey
386
+ step :send_welcome_email do
387
+ WelcomeMailer.welcome(hero).deliver_later
388
+ end
380
389
 
381
- step :one_bis do
382
- # some compliance action
383
- end
390
+ step :send_premium_offer, after: 2.days do
391
+ PremiumOfferMailer.exclusive_offer(hero).deliver_later
392
+ end
384
393
 
385
- step :two do
386
- # perform some other action
394
+ # Insert a compliance check before the premium offer
395
+ step :compliance_check, before_step: "send_premium_offer" do
396
+ ComplianceService.verify_eligibility(hero)
397
+ end
398
+
399
+ # Insert a reminder after the welcome email
400
+ step :send_reminder, after_step: "send_welcome_email", wait: 1.day do
401
+ ReminderMailer.follow_up(hero).deliver_later
402
+ end
403
+
404
+ step :complete_onboarding, after: 7.days do
405
+ hero.update!(onboarding_completed_at: Time.current)
406
+ end
387
407
  end
388
408
  ```
409
+ This approach is particularly useful when extending journey classes through inheritance, as you can insert steps relative to steps defined in the parent class.
410
+ Should you ever get confused and find yourself in need to retrace your steps, call `UserOnboardingJourney.step_definitions.map(&:name)`:
389
411
 
390
- Your existing `Journey` is already primed to perform step `two`. However, a `Journey` which is about to perform step `one` will now set `one_bis` as the next step to perform. This allows limited reordering and editing of `Journey` definitions after they have already begun.
412
+ ```ruby
413
+ UserOnboardingJourney.step_definitions.map(&:name)
414
+ # => ["send_welcome_email", "send_reminder", "compliance_check", "send_premium_offer", "complete_onboarding"]
415
+ ```
416
+
417
+ This will show you the exact order in which steps will be executed. In this case:
418
+
419
+ 1. `send_welcome_email`
420
+ 2. `send_reminder` (inserted after `send_welcome_email`)
421
+ 3. `compliance_check` (inserted before `send_premium_offer`)
422
+ 4. `send_premium_offer`
423
+ 5. `complete_onboarding`
391
424
 
392
425
  ### Using instance methods as steps
393
426
 
@@ -648,6 +681,25 @@ end
648
681
 
649
682
  The `wait:` parameter defines the amount of time computed **from the moment the Journey gets created or the previous step is completed.**
650
683
 
684
+ ## Execution model: assume that everything is async
685
+
686
+ stepper_motor is built on the assumption that steps should be able to execute asynchronously. This is a **very important** design trait which has a number of implications:
687
+
688
+ * You will almost never have the same `self` for a Journey between multiple steps
689
+ * Any instance variables you set inside a step will get discarded and will not be available in a subsequent step
690
+ * Any step may want to reattempt itself at an arbitrary point in the future. Any stack variables or local state will be discarded.
691
+
692
+ This is deliberate and intentional. At the moment, stepper_motor does not have any built-in primitives for executing multiple steps inline. The `speedrun_journey`
693
+ helper used in tests is going to `reload` your Journey between step entries, to allow for any state changes to be blown away - but it is not going to reset any instance variables.
694
+
695
+ A good way to think about it is: every step execution happens:
696
+
697
+ * On a different machine
698
+ * In a different thread
699
+ * In a different database transaction
700
+ * In the context of a different Journey instance
701
+ * Potentially - after a long time has passed since the previous attempt or previous step
702
+
651
703
  ## Journey states
652
704
 
653
705
  The Journeys are managed using a state machine, which stepper_motor completely coordinates for you. The states are as follows:
@@ -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.18", T.untyped)
5
+ VERSION = T.let("0.1.19", T.untyped)
6
6
  PerformStepJobV2 = T.let(StepperMotor::PerformStepJob, T.untyped)
7
7
  RecoverStuckJourneysJobV1 = T.let(StepperMotor::RecoverStuckJourneysJob, T.untyped)
8
8
 
@@ -24,7 +24,6 @@ module StepperMotor
24
24
  # array of the Journey subclass. When the step gets performed, the block passed to the
25
25
  # constructor will be instance_exec'd with the Journey model being the context
26
26
  class Step
27
- # sord omit - no YARD type given for "seq:", using untyped
28
27
  # sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
29
28
  # Creates a new step definition
30
29
  #
@@ -38,14 +37,13 @@ module StepperMotor
38
37
  sig do
39
38
  params(
40
39
  name: T.any(String, Symbol),
41
- seq: T.untyped,
42
40
  on_exception: Symbol,
43
41
  wait: T.any(Numeric, ActiveSupport::Duration),
44
42
  skip_if: T.any(TrueClass, FalseClass, NilClass, Symbol, Proc),
45
43
  step_block: T.untyped
46
44
  ).void
47
45
  end
48
- def initialize(name:, seq:, on_exception: :pause!, wait: 0, skip_if: false, &step_block); end
46
+ def initialize(name:, on_exception: :pause!, wait: 0, skip_if: false, &step_block); end
49
47
 
50
48
  # Checks if the step should be skipped based on the skip_if condition
51
49
  #
@@ -72,10 +70,6 @@ module StepperMotor
72
70
  sig { returns(T.any(Numeric, ActiveSupport::Duration)) }
73
71
  attr_reader :wait
74
72
 
75
- # sord omit - no YARD type given for :seq, using untyped
76
- sig { returns(T.untyped) }
77
- attr_reader :seq
78
-
79
73
  class MissingDefinition < NoMethodError
80
74
  end
81
75
  end
@@ -139,7 +133,11 @@ module StepperMotor
139
133
  #
140
134
  # _@param_ `wait` — the amount of time this step should wait before getting performed. When the journey gets scheduled, the triggering job is going to be delayed by this amount of time, and the `next_step_to_be_performed_at` attribute will be set to the current time plus the wait duration. Mutually exclusive with `after:`
141
135
  #
142
- # _@param_ `after` — the amount of time this step should wait before getting performed including all the previous waits. This allows you to set the wait time based on the time after the journey started, as opposed to when the previous step has completed. When the journey gets scheduled, the triggering job is going to be delayed by this amount of time _minus the `wait` values of the preceding steps, and the `next_step_to_be_performed_at` attribute will be set to the current time. The `after` value gets converted into the `wait` value and passed to the step definition. Mutually exclusive with `wait:`
136
+ # _@param_ `after` — the amount of time this step should wait before getting performed including all the previous waits. This allows you to set the wait time based on the time after the journey started, as opposed to when the previous step has completed. When the journey gets scheduled, the triggering job is going to be delayed by this amount of time _minus the `wait` values of the preceding steps, and the `next_step_to_be_performed_at` attribute will be set to the current time. The `after` value gets converted into the `wait` value and passed to the step definition. Mutually exclusive with `wait:`.
137
+ #
138
+ # _@param_ `before_step` — the name of the step before which this step should be inserted. This allows you to control the order of steps by inserting a step before a specific existing step. The step name can be provided as a string or symbol. Mutually exclusive with `after_step:`.
139
+ #
140
+ # _@param_ `after_step` — the name of the step after which this step should be inserted. This allows you to control the order of steps by inserting a step after a specific existing step. The step name can be provided as a string or symbol. Mutually exclusive with `before_step:`.
143
141
  #
144
142
  # _@param_ `on_exception` — See {StepperMotor::Step#on_exception}
145
143
  #
@@ -155,11 +153,13 @@ module StepperMotor
155
153
  name: T.nilable(String),
156
154
  wait: T.nilable(T.any(Float, T.untyped, ActiveSupport::Duration)),
157
155
  after: T.nilable(T.any(Float, T.untyped, ActiveSupport::Duration)),
156
+ before_step: T.nilable(T.any(String, Symbol)),
157
+ after_step: T.nilable(T.any(String, Symbol)),
158
158
  additional_step_definition_options: T::Hash[T.untyped, T.untyped],
159
159
  blk: T.untyped
160
160
  ).returns(StepperMotor::Step)
161
161
  end
162
- def self.step(name = nil, wait: nil, after: nil, **additional_step_definition_options, &blk); end
162
+ def self.step(name = nil, wait: nil, after: nil, before_step: nil, after_step: nil, **additional_step_definition_options, &blk); end
163
163
 
164
164
  # sord warn - "StepperMotor::Step?" does not appear to be a type
165
165
  # Returns the `Step` object for a named step. This is used when performing a step, but can also
@@ -169,6 +169,14 @@ module StepperMotor
169
169
  sig { params(by_step_name: T.any(Symbol, String)).returns(SORD_ERROR_StepperMotorStep) }
170
170
  def self.lookup_step_definition(by_step_name); end
171
171
 
172
+ # Returns all step definitions that follow the given step in the journey
173
+ #
174
+ # _@param_ `step_definition` — the step to find the following steps for
175
+ #
176
+ # _@return_ — the following steps, or empty array if this is the last step
177
+ sig { params(step_definition: StepperMotor::Step).returns(T::Array[StepperMotor::Step]) }
178
+ def self.step_definitions_following(step_definition); end
179
+
172
180
  # sord omit - no YARD type given for "by_step_name", using untyped
173
181
  # sord omit - no YARD return type given, using untyped
174
182
  # Alias for the class method, for brevity
@@ -403,9 +411,13 @@ module StepperMotor
403
411
  #
404
412
  # _@param_ `journey` — the journey to speedrun
405
413
  #
414
+ # _@param_ `time_travel` — whether to use ActiveSupport time travel (default: true) Note: When time_travel is true, this method will permanently travel time forward and will not reset it back to the original time when the method exits.
415
+ #
416
+ # _@param_ `maximum_steps` — how many steps can we take until we assume the journey has hung and fail the test. Default value is :reasonable, which is 10x the number of steps. :unlimited allows "a ton", but can make your test hang if your logic lets a step reattempt indefinitely
417
+ #
406
418
  # _@return_ — void
407
- sig { params(journey: StepperMotor::Journey).returns(T.untyped) }
408
- def speedrun_journey(journey); end
419
+ sig { params(journey: StepperMotor::Journey, time_travel: T::Boolean, maximum_steps: T.any(Symbol, Integer)).returns(T.untyped) }
420
+ def speedrun_journey(journey, time_travel: true, maximum_steps: :reasonable); end
409
421
 
410
422
  # Performs the named step of the journey without waiting for the time to perform the step.
411
423
  #
@@ -22,7 +22,6 @@ module StepperMotor
22
22
  # array of the Journey subclass. When the step gets performed, the block passed to the
23
23
  # constructor will be instance_exec'd with the Journey model being the context
24
24
  class Step
25
- # sord omit - no YARD type given for "seq:", using untyped
26
25
  # sord warn - ActiveSupport::Duration wasn't able to be resolved to a constant in this project
27
26
  # Creates a new step definition
28
27
  #
@@ -35,7 +34,6 @@ module StepperMotor
35
34
  # _@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
35
  def initialize: (
37
36
  name: (String | Symbol),
38
- seq: untyped,
39
37
  ?on_exception: Symbol,
40
38
  ?wait: (Numeric | ActiveSupport::Duration),
41
39
  ?skip_if: (TrueClass | FalseClass | NilClass | Symbol | Proc)
@@ -62,9 +60,6 @@ module StepperMotor
62
60
  # _@return_ — how long to wait before performing the step
63
61
  attr_reader wait: (Numeric | ActiveSupport::Duration)
64
62
 
65
- # sord omit - no YARD type given for :seq, using untyped
66
- attr_reader seq: untyped
67
-
68
63
  class MissingDefinition < NoMethodError
69
64
  end
70
65
  end
@@ -126,7 +121,11 @@ module StepperMotor
126
121
  #
127
122
  # _@param_ `wait` — the amount of time this step should wait before getting performed. When the journey gets scheduled, the triggering job is going to be delayed by this amount of time, and the `next_step_to_be_performed_at` attribute will be set to the current time plus the wait duration. Mutually exclusive with `after:`
128
123
  #
129
- # _@param_ `after` — the amount of time this step should wait before getting performed including all the previous waits. This allows you to set the wait time based on the time after the journey started, as opposed to when the previous step has completed. When the journey gets scheduled, the triggering job is going to be delayed by this amount of time _minus the `wait` values of the preceding steps, and the `next_step_to_be_performed_at` attribute will be set to the current time. The `after` value gets converted into the `wait` value and passed to the step definition. Mutually exclusive with `wait:`
124
+ # _@param_ `after` — the amount of time this step should wait before getting performed including all the previous waits. This allows you to set the wait time based on the time after the journey started, as opposed to when the previous step has completed. When the journey gets scheduled, the triggering job is going to be delayed by this amount of time _minus the `wait` values of the preceding steps, and the `next_step_to_be_performed_at` attribute will be set to the current time. The `after` value gets converted into the `wait` value and passed to the step definition. Mutually exclusive with `wait:`.
125
+ #
126
+ # _@param_ `before_step` — the name of the step before which this step should be inserted. This allows you to control the order of steps by inserting a step before a specific existing step. The step name can be provided as a string or symbol. Mutually exclusive with `after_step:`.
127
+ #
128
+ # _@param_ `after_step` — the name of the step after which this step should be inserted. This allows you to control the order of steps by inserting a step after a specific existing step. The step name can be provided as a string or symbol. Mutually exclusive with `before_step:`.
130
129
  #
131
130
  # _@param_ `on_exception` — See {StepperMotor::Step#on_exception}
132
131
  #
@@ -141,6 +140,8 @@ module StepperMotor
141
140
  ?String? name,
142
141
  ?wait: (Float | untyped | ActiveSupport::Duration)?,
143
142
  ?after: (Float | untyped | ActiveSupport::Duration)?,
143
+ ?before_step: (String | Symbol)?,
144
+ ?after_step: (String | Symbol)?,
144
145
  **::Hash[untyped, untyped] additional_step_definition_options
145
146
  ) -> StepperMotor::Step
146
147
 
@@ -151,6 +152,13 @@ module StepperMotor
151
152
  # _@param_ `by_step_name` — the name of the step to find
152
153
  def self.lookup_step_definition: ((Symbol | String) by_step_name) -> SORD_ERROR_StepperMotorStep
153
154
 
155
+ # Returns all step definitions that follow the given step in the journey
156
+ #
157
+ # _@param_ `step_definition` — the step to find the following steps for
158
+ #
159
+ # _@return_ — the following steps, or empty array if this is the last step
160
+ def self.step_definitions_following: (StepperMotor::Step step_definition) -> ::Array[StepperMotor::Step]
161
+
154
162
  # sord omit - no YARD type given for "by_step_name", using untyped
155
163
  # sord omit - no YARD return type given, using untyped
156
164
  # Alias for the class method, for brevity
@@ -359,8 +367,12 @@ module StepperMotor
359
367
  #
360
368
  # _@param_ `journey` — the journey to speedrun
361
369
  #
370
+ # _@param_ `time_travel` — whether to use ActiveSupport time travel (default: true) Note: When time_travel is true, this method will permanently travel time forward and will not reset it back to the original time when the method exits.
371
+ #
372
+ # _@param_ `maximum_steps` — how many steps can we take until we assume the journey has hung and fail the test. Default value is :reasonable, which is 10x the number of steps. :unlimited allows "a ton", but can make your test hang if your logic lets a step reattempt indefinitely
373
+ #
362
374
  # _@return_ — void
363
- def speedrun_journey: (StepperMotor::Journey journey) -> untyped
375
+ def speedrun_journey: (StepperMotor::Journey journey, ?time_travel: bool, ?maximum_steps: (Symbol | Integer)) -> untyped
364
376
 
365
377
  # Performs the named step of the journey without waiting for the time to perform the step.
366
378
  #
@@ -255,7 +255,7 @@ class IfConditionTest < ActiveSupport::TestCase
255
255
  end
256
256
 
257
257
  test "passes skip_if: parameter to step definition" do
258
- step_def = StepperMotor::Step.new(name: "a_step", seq: 1, on_exception: :reattempt!)
258
+ step_def = StepperMotor::Step.new(name: "a_step", on_exception: :reattempt!)
259
259
  assert_skip_if_parameter = ->(**options) {
260
260
  assert options.key?(:skip_if)
261
261
  assert_equal :test_condition, options[:skip_if]
@@ -17,7 +17,7 @@ class StepDefinitionTest < ActiveSupport::TestCase
17
17
  end
18
18
 
19
19
  test "passes any additional options to the step definition" do
20
- step_def = StepperMotor::Step.new(name: "a_step", seq: 1, on_exception: :reattempt!)
20
+ step_def = StepperMotor::Step.new(name: "a_step", on_exception: :reattempt!)
21
21
  assert_extra_arguments = ->(**options) {
22
22
  assert options.key?(:extra)
23
23
  # Return the original definition
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class StepOrderingTest < ActiveSupport::TestCase
6
+ include ActiveJob::TestHelper
7
+ include SideEffects::TestHelper
8
+ include StepperMotor::TestHelper
9
+
10
+ test "allows inserting step before another step using string" do
11
+ journey_class = create_journey_subclass do
12
+ step :first do
13
+ # noop
14
+ end
15
+ step :third do
16
+ # noop
17
+ end
18
+ step :second, before_step: "first" do
19
+ # noop
20
+ end
21
+ end
22
+
23
+ assert_equal ["second", "first", "third"], journey_class.step_definitions.map(&:name)
24
+ end
25
+
26
+ test "allows inserting step before another step using symbol" do
27
+ journey_class = create_journey_subclass do
28
+ step :first do
29
+ # noop
30
+ end
31
+ step :third do
32
+ # noop
33
+ end
34
+ step :second, before_step: :first do
35
+ # noop
36
+ end
37
+ end
38
+
39
+ assert_equal ["second", "first", "third"], journey_class.step_definitions.map(&:name)
40
+ end
41
+
42
+ test "allows inserting step after another step using string" do
43
+ journey_class = create_journey_subclass do
44
+ step :first do
45
+ # noop
46
+ end
47
+ step :third do
48
+ # noop
49
+ end
50
+ step :second, after_step: "first" do
51
+ # noop
52
+ end
53
+ end
54
+
55
+ assert_equal ["first", "second", "third"], journey_class.step_definitions.map(&:name)
56
+ end
57
+
58
+ test "allows inserting step after another step using symbol" do
59
+ journey_class = create_journey_subclass do
60
+ step :first do
61
+ # noop
62
+ end
63
+ step :third do
64
+ # noop
65
+ end
66
+ step :second, after_step: :first do
67
+ # noop
68
+ end
69
+ end
70
+
71
+ assert_equal ["first", "second", "third"], journey_class.step_definitions.map(&:name)
72
+ end
73
+
74
+ test "allows inserting step at the beginning using before_step" do
75
+ journey_class = create_journey_subclass do
76
+ step :second do
77
+ # noop
78
+ end
79
+ step :third do
80
+ # noop
81
+ end
82
+ step :first, before_step: "second" do
83
+ # noop
84
+ end
85
+ end
86
+
87
+ assert_equal ["first", "second", "third"], journey_class.step_definitions.map(&:name)
88
+ end
89
+
90
+ test "allows inserting step at the end using after_step" do
91
+ journey_class = create_journey_subclass do
92
+ step :first do
93
+ # noop
94
+ end
95
+ step :second do
96
+ # noop
97
+ end
98
+ step :third, after_step: "second" do
99
+ # noop
100
+ end
101
+ end
102
+
103
+ assert_equal ["first", "second", "third"], journey_class.step_definitions.map(&:name)
104
+ end
105
+
106
+ test "allows complex step ordering with multiple insertions" do
107
+ journey_class = create_journey_subclass do
108
+ step :step_1 do
109
+ # noop
110
+ end
111
+ step :step_4 do
112
+ # noop
113
+ end
114
+ step :step_2, after_step: "step_1" do
115
+ # noop
116
+ end
117
+ step :step_3, before_step: "step_4" do
118
+ # noop
119
+ end
120
+ end
121
+
122
+ assert_equal ["step_1", "step_2", "step_3", "step_4"], journey_class.step_definitions.map(&:name)
123
+ end
124
+
125
+ test "raises error when both before_step and after_step are specified" do
126
+ assert_raises(StepperMotor::StepConfigurationError, "Either before_step: or after_step: can be specified, but not both") do
127
+ create_journey_subclass do
128
+ step :first do
129
+ # noop
130
+ end
131
+ step :second, before_step: "first", after_step: "first" do
132
+ # noop
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ test "raises error when before_step references non-existent step" do
139
+ assert_raises(StepperMotor::StepConfigurationError, "Step named \"nonexistent\" not found for before_step: parameter") do
140
+ create_journey_subclass do
141
+ step :first, before_step: "nonexistent" do
142
+ # noop
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ test "raises error when after_step references non-existent step" do
149
+ assert_raises(StepperMotor::StepConfigurationError, "Step named \"nonexistent\" not found for after_step: parameter") do
150
+ create_journey_subclass do
151
+ step :first, after_step: "nonexistent" do
152
+ # noop
153
+ end
154
+ end
155
+ end
156
+ end
157
+
158
+ test "maintains existing after: timing functionality" do
159
+ journey_class = create_journey_subclass do
160
+ step :first, after: 5.minutes do
161
+ # noop
162
+ end
163
+ step :second, after: 10.minutes do
164
+ # noop
165
+ end
166
+ end
167
+
168
+ assert_equal ["first", "second"], journey_class.step_definitions.map(&:name)
169
+ assert_equal 5.minutes, journey_class.step_definitions[0].wait
170
+ assert_equal 5.minutes, journey_class.step_definitions[1].wait
171
+ end
172
+
173
+ test "allows mixing step ordering with timing" do
174
+ journey_class = create_journey_subclass do
175
+ step :first, wait: 1.minute do
176
+ # noop
177
+ end
178
+ step :third, after_step: "first" do
179
+ # noop
180
+ end
181
+ step :second, before_step: "third", wait: 2.minutes do
182
+ # noop
183
+ end
184
+ end
185
+
186
+ assert_equal ["first", "second", "third"], journey_class.step_definitions.map(&:name)
187
+ assert_equal 1.minute, journey_class.step_definitions[0].wait
188
+ assert_equal 2.minutes, journey_class.step_definitions[1].wait
189
+ assert_equal 0, journey_class.step_definitions[2].wait
190
+ end
191
+
192
+ test "allows inserting step with method name" do
193
+ journey_class = create_journey_subclass do
194
+ step :first do
195
+ # noop
196
+ end
197
+ step :third do
198
+ # noop
199
+ end
200
+ step :second, after_step: "first"
201
+
202
+ def second
203
+ # noop
204
+ end
205
+ end
206
+
207
+ assert_equal ["first", "second", "third"], journey_class.step_definitions.map(&:name)
208
+ end
209
+
210
+ test "allows inserting step with automatic name generation" do
211
+ journey_class = create_journey_subclass do
212
+ step :first do
213
+ # noop
214
+ end
215
+ step :third do
216
+ # noop
217
+ end
218
+ step before_step: "third" do
219
+ # noop
220
+ end
221
+ end
222
+
223
+ assert_equal ["first", "step_3", "third"], journey_class.step_definitions.map(&:name)
224
+ end
225
+
226
+ test "allows inserting step with additional options" do
227
+ journey_class = create_journey_subclass do
228
+ step :first do
229
+ # noop
230
+ end
231
+ step :third do
232
+ # noop
233
+ end
234
+ step :second, after_step: "first", on_exception: :skip! do
235
+ # noop
236
+ end
237
+ end
238
+
239
+ assert_equal ["first", "second", "third"], journey_class.step_definitions.map(&:name)
240
+ assert_equal :skip!, journey_class.step_definitions[1].instance_variable_get(:@on_exception)
241
+ end
242
+
243
+ test "allows inserting steps before or after steps defined in superclass" do
244
+ parent_class = create_journey_subclass do
245
+ step :parent_first do
246
+ # noop
247
+ end
248
+ step :parent_last do
249
+ # noop
250
+ end
251
+ end
252
+
253
+ child_class = create_journey_subclass(parent_class) do
254
+ step :child_before, before_step: "parent_first" do
255
+ # noop
256
+ end
257
+ step :child_after, after_step: "parent_last" do
258
+ # noop
259
+ end
260
+ step :child_middle, after_step: "parent_first" do
261
+ # noop
262
+ end
263
+ end
264
+
265
+ assert_equal ["child_before", "parent_first", "child_middle", "parent_last", "child_after"], child_class.step_definitions.map(&:name)
266
+ end
267
+ end
@@ -22,6 +22,16 @@ class TestHelperTest < ActiveSupport::TestCase
22
22
  end
23
23
  end
24
24
 
25
+ def infinite_journey_class
26
+ create_journey_subclass do
27
+ step :step_1 do
28
+ SideEffects.touch!("step_1")
29
+ # This step never finishes, causing infinite loop
30
+ reattempt!
31
+ end
32
+ end
33
+ end
34
+
25
35
  test "speedruns the journey despite waits being configured" do
26
36
  journey = speedy_journey_class.create!
27
37
  assert journey.ready?
@@ -33,6 +43,36 @@ class TestHelperTest < ActiveSupport::TestCase
33
43
  assert SideEffects.produced?("step_3")
34
44
  end
35
45
 
46
+ test "speedruns the journey with time travel by default" do
47
+ journey = speedy_journey_class.create!
48
+ assert journey.ready?
49
+
50
+ original_time = Time.current
51
+ SideEffects.clear!
52
+ speedrun_journey(journey)
53
+ assert SideEffects.produced?("step_1")
54
+ assert SideEffects.produced?("step_2")
55
+ assert SideEffects.produced?("step_3")
56
+
57
+ # Calculate expected time difference: 40 minutes + 2 days + 1 second buffer per step
58
+ expected_time_difference = 40.minutes + 2.days + 3.seconds
59
+
60
+ # Verify that time has traveled forward by approximately the expected amount
61
+ # (allowing for small execution time differences)
62
+ assert_in_delta expected_time_difference, Time.current - original_time, 1.second
63
+ end
64
+
65
+ test "speedruns the journey without time travel when specified" do
66
+ journey = speedy_journey_class.create!
67
+ assert journey.ready?
68
+
69
+ SideEffects.clear!
70
+ speedrun_journey(journey, time_travel: false)
71
+ assert SideEffects.produced?("step_1")
72
+ assert SideEffects.produced?("step_2")
73
+ assert SideEffects.produced?("step_3")
74
+ end
75
+
36
76
  test "is able to perform a single step forcibly" do
37
77
  journey = speedy_journey_class.create!
38
78
  assert journey.ready?
@@ -41,4 +81,23 @@ class TestHelperTest < ActiveSupport::TestCase
41
81
  immediately_perform_single_step(journey, :step_2)
42
82
  assert SideEffects.produced?("step_2")
43
83
  end
84
+
85
+ test "fails when maximum_steps limit is exceeded" do
86
+ journey = infinite_journey_class.create!
87
+ assert journey.ready?
88
+
89
+ SideEffects.clear!
90
+
91
+ # This should raise an exception because the journey will try to perform more than 2 steps
92
+ # but the infinite loop in step_1 will never finish
93
+ error = assert_raises(RuntimeError) do
94
+ speedrun_journey(journey, maximum_steps: 2)
95
+ end
96
+
97
+ # Verify the error message indicates the journey didn't finish after the maximum steps
98
+ assert_match(/did not finish or cancel after performing 2 steps/, error.message)
99
+
100
+ # Verify that the step was executed (at least once)
101
+ assert SideEffects.produced?("step_1")
102
+ end
44
103
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stepper_motor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.18
4
+ version: 0.1.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 2025-06-20 00:00:00.000000000 Z
11
+ date: 2025-06-25 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: activerecord
@@ -316,6 +317,7 @@ files:
316
317
  - test/stepper_motor/journey/idempotency_test.rb
317
318
  - test/stepper_motor/journey/if_condition_test.rb
318
319
  - test/stepper_motor/journey/step_definition_test.rb
320
+ - test/stepper_motor/journey/step_ordering_test.rb
319
321
  - test/stepper_motor/journey/uniqueness_test.rb
320
322
  - test/stepper_motor/journey_test.rb
321
323
  - test/stepper_motor/perform_step_job_test.rb
@@ -333,6 +335,7 @@ metadata:
333
335
  homepage_uri: https://steppermotor.dev
334
336
  source_code_uri: https://github.com/stepper-motor/stepper_motor
335
337
  changelog_uri: https://github.com/stepper-motor/stepper_motor/blob/main/CHANGELOG.md
338
+ post_install_message:
336
339
  rdoc_options: []
337
340
  require_paths:
338
341
  - lib
@@ -347,7 +350,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
347
350
  - !ruby/object:Gem::Version
348
351
  version: '0'
349
352
  requirements: []
350
- rubygems_version: 3.6.6
353
+ rubygems_version: 3.4.10
354
+ signing_key:
351
355
  specification_version: 4
352
356
  summary: Effortless step workflows that embed nicely inside Rails
353
357
  test_files: []