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 +4 -4
- data/CHANGELOG.md +8 -0
- data/lib/stepper_motor/journey/flow_control.rb +1 -2
- data/lib/stepper_motor/journey.rb +63 -14
- data/lib/stepper_motor/step.rb +1 -5
- data/lib/stepper_motor/test_helper.rb +28 -3
- data/lib/stepper_motor/version.rb +1 -1
- data/manual/MANUAL.md +65 -13
- data/rbi/stepper_motor.rbi +24 -12
- data/sig/stepper_motor.rbs +19 -7
- data/test/stepper_motor/journey/if_condition_test.rb +1 -1
- data/test/stepper_motor/journey/step_definition_test.rb +1 -1
- data/test/stepper_motor/journey/step_ordering_test.rb +267 -0
- data/test/stepper_motor/test_helper_test.rb +59 -0
- metadata +7 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b27839f0ee5621c5da6e694e2ee3e790f9f4b96383d05bf5ebbfc0a7ddf99928
|
4
|
+
data.tar.gz: 60bf0af4da25cf6f6c7ef2d835bcbd717ec662215da2bf14aea50da6949dc316
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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
|
-
|
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,
|
131
|
-
|
132
|
-
|
133
|
-
|
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
|
-
|
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 =
|
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
|
-
|
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
|
data/lib/stepper_motor/step.rb
CHANGED
@@ -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:,
|
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 =
|
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
|
-
|
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
|
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.
|
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
|
-
|
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
|
-
|
378
|
-
|
379
|
-
|
385
|
+
class UserOnboardingJourney < StepperMotor::Journey
|
386
|
+
step :send_welcome_email do
|
387
|
+
WelcomeMailer.welcome(hero).deliver_later
|
388
|
+
end
|
380
389
|
|
381
|
-
step :
|
382
|
-
|
383
|
-
end
|
390
|
+
step :send_premium_offer, after: 2.days do
|
391
|
+
PremiumOfferMailer.exclusive_offer(hero).deliver_later
|
392
|
+
end
|
384
393
|
|
385
|
-
|
386
|
-
|
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
|
-
|
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:
|
data/rbi/stepper_motor.rbi
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
# StepperMotor is a module for building multi-step flows where steps are sequential and only
|
3
3
|
# ever progress forward. The building block of StepperMotor is StepperMotor::Journey
|
4
4
|
module StepperMotor
|
5
|
-
VERSION = T.let("0.1.
|
5
|
+
VERSION = T.let("0.1.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:,
|
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
|
#
|
data/sig/stepper_motor.rbs
CHANGED
@@ -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",
|
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",
|
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.
|
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-
|
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.
|
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: []
|