stepper_motor 0.1.0

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.
@@ -0,0 +1,426 @@
1
+ require_relative "../spec_helper"
2
+
3
+ # rubocop:disable Lint/ConstantDefinitionInBlock
4
+ RSpec.describe "StepperMotor::Journey" do
5
+ include ActiveJob::TestHelper
6
+
7
+ before :all do
8
+ establish_test_connection
9
+ run_generator
10
+ run_migrations
11
+ ActiveJob::Base.queue_adapter = :test
12
+ ActiveJob::Base.logger = Logger.new(nil)
13
+ end
14
+
15
+ after :all do
16
+ FileUtils.rm_rf(fake_app_root)
17
+ end
18
+
19
+ before :each do
20
+ Thread.current[:stepper_motor_side_effects] = {}
21
+ end
22
+
23
+ after :each do
24
+ # Remove all jobs that remain in the queue
25
+ ActiveJob::Base.queue_adapter.enqueued_jobs.clear
26
+ end
27
+
28
+ it "allows an empty journey to be defined and performed to completion" do
29
+ class PointlessJourney < StepperMotor::Journey
30
+ end
31
+
32
+ journey = PointlessJourney.create!
33
+ journey.perform_next_step!
34
+ expect(journey).to be_finished
35
+ end
36
+
37
+ it "allows a journey consisting of one step to be defined and performed to completion" do
38
+ class SingleStepJourney < StepperMotor::Journey
39
+ step :do_thing do
40
+ SideEffects.touch!("do_thing")
41
+ end
42
+ end
43
+
44
+ journey = SingleStepJourney.create!
45
+ expect(journey.next_step_to_be_performed_at).not_to be_nil
46
+ journey.perform_next_step!
47
+ expect(journey).to be_finished
48
+ expect(SideEffects).to be_produced("do_thing")
49
+ end
50
+
51
+ it "allows a journey consisting of multiple named steps to be defined and performed to completion" do
52
+ step_names = [:step1, :step2, :step3]
53
+
54
+ # Use Class.new so that step_names can be passed into the block
55
+ MultiStepJourney = Class.new(StepperMotor::Journey) do
56
+ step_names.each do |step_name|
57
+ step step_name do
58
+ SideEffects.touch!("from_#{step_name}")
59
+ end
60
+ end
61
+ end
62
+
63
+ journey = MultiStepJourney.create!
64
+ expect(journey.next_step_name).to eq("step1")
65
+
66
+ journey.perform_next_step!
67
+ expect(journey.next_step_name).to eq("step2")
68
+ expect(journey.previous_step_name).to eq("step1")
69
+
70
+ journey.perform_next_step!
71
+ expect(journey.next_step_name).to eq("step3")
72
+ expect(journey.previous_step_name).to eq("step2")
73
+
74
+ journey.perform_next_step!
75
+ expect(journey).to be_finished
76
+ expect(journey.next_step_name).to be_nil
77
+ expect(journey.previous_step_name).to eq("step3")
78
+
79
+ expect(SideEffects).to be_produced("from_step1")
80
+ expect(SideEffects).to be_produced("from_step2")
81
+ expect(SideEffects).to be_produced("from_step3")
82
+ end
83
+
84
+ it "allows a journey consisting of multiple anonymous steps to be defined and performed to completion" do
85
+ AnonymousStepsJourney = Class.new(StepperMotor::Journey) do
86
+ 3.times do |n|
87
+ step do
88
+ SideEffects.touch!("sidefx_#{n}")
89
+ end
90
+ end
91
+ end
92
+
93
+ journey = AnonymousStepsJourney.create!
94
+ expect(journey.next_step_name).to eq("step_1")
95
+
96
+ journey.perform_next_step!
97
+ expect(journey.next_step_name).to eq("step_2")
98
+ expect(journey.previous_step_name).to eq("step_1")
99
+
100
+ journey.perform_next_step!
101
+ expect(journey.next_step_name).to eq("step_3")
102
+ expect(journey.previous_step_name).to eq("step_2")
103
+
104
+ journey.perform_next_step!
105
+ expect(journey).to be_finished
106
+ expect(journey.next_step_name).to be_nil
107
+ expect(journey.previous_step_name).to eq("step_3")
108
+
109
+ expect(SideEffects).to be_produced("sidefx_0")
110
+ expect(SideEffects).to be_produced("sidefx_1")
111
+ expect(SideEffects).to be_produced("sidefx_2")
112
+ end
113
+
114
+ it "allows an arbitrary ActiveRecord to be attached as the hero" do
115
+ class SomeOtherJourney < StepperMotor::Journey
116
+ step do
117
+ # nothing, but we need to have a step so that the journey doesn't get destroyed immediately after creation
118
+ end
119
+ end
120
+
121
+ class CarryingJourney < StepperMotor::Journey
122
+ step :only do
123
+ raise "Incorrect" unless hero.instance_of?(SomeOtherJourney)
124
+ end
125
+ end
126
+
127
+ hero = SomeOtherJourney.create!
128
+ journey = CarryingJourney.create!(hero: hero)
129
+ expect {
130
+ journey.perform_next_step!
131
+ }.not_to raise_error
132
+ end
133
+
134
+ it "allows a journey where steps are delayed in time using wait:" do
135
+ class TimelyJourney < StepperMotor::Journey
136
+ step wait: 10.hours do
137
+ SideEffects.touch! "after_10_hours.txt"
138
+ end
139
+
140
+ step wait: 5.minutes do
141
+ SideEffects.touch! "after_5_minutes.txt"
142
+ end
143
+
144
+ step do
145
+ SideEffects.touch! "final_nowait.txt"
146
+ end
147
+ end
148
+
149
+ freeze_time
150
+ TimelyJourney.create!
151
+
152
+ expect {
153
+ perform_enqueued_jobs
154
+ }.to not_have_produced_any_side_effects
155
+
156
+ travel 10.hours
157
+ expect {
158
+ perform_enqueued_jobs
159
+ }.to have_produced_side_effects_named("after_10_hours.txt")
160
+
161
+ travel 4.minutes
162
+ expect {
163
+ perform_enqueued_jobs
164
+ }.to not_have_produced_any_side_effects
165
+
166
+ travel 1.minutes
167
+ expect {
168
+ perform_enqueued_jobs
169
+ }.to have_produced_side_effects_named("after_5_minutes.txt")
170
+
171
+ expect {
172
+ perform_enqueued_jobs
173
+ }.to have_produced_side_effects_named("final_nowait.txt")
174
+ end
175
+
176
+ it "allows a journey where steps are delayed in time using after:" do
177
+ class TimelyJourneyUsingAfter < StepperMotor::Journey
178
+ step after: 10.hours do
179
+ SideEffects.touch! "step1"
180
+ end
181
+
182
+ step after: 605.minutes do
183
+ SideEffects.touch! "step2"
184
+ end
185
+
186
+ step do
187
+ SideEffects.touch! "step3"
188
+ end
189
+ end
190
+
191
+ TimelyJourneyUsingAfter.create!
192
+ freeze_time
193
+ expect { perform_enqueued_jobs }.to not_have_produced_any_side_effects
194
+
195
+ travel 10.hours
196
+ perform_enqueued_jobs
197
+ expect { perform_enqueued_jobs }.to have_produced_side_effects_named("step1")
198
+
199
+ travel 4.minutes
200
+ expect { perform_enqueued_jobs }.to not_have_produced_any_side_effects
201
+
202
+ travel 1.minutes
203
+ expect { perform_enqueued_jobs }.to have_produced_side_effects_named("step2")
204
+ expect { perform_enqueued_jobs }.to have_produced_side_effects_named("step3")
205
+ end
206
+
207
+ it "tracks steps entered and completed using counters" do
208
+ class FailingJourney < StepperMotor::Journey
209
+ step do
210
+ raise "oops"
211
+ end
212
+ end
213
+
214
+ class NotFailingJourney < StepperMotor::Journey
215
+ step do
216
+ true # no-op
217
+ end
218
+ end
219
+
220
+ failing_journey = FailingJourney.create!
221
+ expect { failing_journey.perform_next_step! }.to raise_error(/oops/)
222
+ expect(failing_journey.steps_entered).to eq(1)
223
+ expect(failing_journey.steps_completed).to eq(0)
224
+
225
+ failing_journey.ready!
226
+ expect { failing_journey.perform_next_step! }.to raise_error(/oops/)
227
+ expect(failing_journey.steps_entered).to eq(2)
228
+ expect(failing_journey.steps_completed).to eq(0)
229
+
230
+ non_failing_journey = NotFailingJourney.create!
231
+ non_failing_journey.perform_next_step!
232
+ expect(non_failing_journey.steps_entered).to eq(1)
233
+ expect(non_failing_journey.steps_completed).to eq(1)
234
+ end
235
+
236
+ it "does not allow invalid values for after: and wait:" do
237
+ expect {
238
+ class MisconfiguredJourney1 < StepperMotor::Journey
239
+ step after: 10.hours do
240
+ # pass
241
+ end
242
+
243
+ step after: 5.hours do
244
+ # pass
245
+ end
246
+ end
247
+ }.to raise_error(ArgumentError)
248
+
249
+ expect {
250
+ class MisconfiguredJourney2 < StepperMotor::Journey
251
+ step wait: -5.hours do
252
+ # pass
253
+ end
254
+ end
255
+ }.to raise_error(ArgumentError)
256
+
257
+ expect {
258
+ class MisconfiguredJourney3 < StepperMotor::Journey
259
+ step after: 5.hours, wait: 2.seconds do
260
+ # pass
261
+ end
262
+ end
263
+ }.to raise_error(ArgumentError)
264
+ end
265
+
266
+ it "allows a step to reattempt itself" do
267
+ class DeferringJourney < StepperMotor::Journey
268
+ step do
269
+ reattempt! wait: 5.minutes
270
+ raise "Should never be reached"
271
+ end
272
+ end
273
+
274
+ journey = DeferringJourney.create!
275
+ perform_enqueued_jobs
276
+
277
+ journey.reload
278
+ expect(journey.previous_step_name).to eq("step_1")
279
+ expect(journey.next_step_name).to eq("step_1")
280
+ expect(journey.next_step_to_be_performed_at).to be_within(1.second).of(Time.current + 5.minutes)
281
+
282
+ travel 5.minutes + 1.second
283
+ perform_enqueued_jobs
284
+
285
+ journey.reload
286
+ expect(journey.previous_step_name).to eq("step_1")
287
+ expect(journey.next_step_name).to eq("step_1")
288
+ expect(journey.next_step_to_be_performed_at).to be_within(1.second).of(Time.current + 5.minutes)
289
+ end
290
+
291
+ it "allows a journey consisting of multiple steps where the first step bails out to be defined and performed to the point of cancellation" do
292
+ class InterruptedJourney < StepperMotor::Journey
293
+ step :step1 do
294
+ SideEffects.touch!("step1_before_cancel")
295
+ cancel!
296
+ SideEffects.touch!("step1_after_cancel")
297
+ end
298
+
299
+ step :step2 do
300
+ raise "Should never be reached"
301
+ end
302
+ end
303
+
304
+ journey = InterruptedJourney.create!
305
+ expect(journey.next_step_name).to eq("step1")
306
+
307
+ perform_enqueued_jobs
308
+ expect(SideEffects).to be_produced("step1_before_cancel")
309
+ expect(SideEffects).not_to be_produced("step1_after_cancel")
310
+ assert_canceled_or_finished(journey)
311
+ end
312
+
313
+ it "forbids multiple similar journeys for the same hero at the same time unless allow_multiple is set" do
314
+ class SomeActor < StepperMotor::Journey
315
+ end
316
+ hero = SomeActor.create!
317
+
318
+ class ExclusiveJourney < StepperMotor::Journey
319
+ step do
320
+ raise "The step should never be entered as we are not testing the step itself here"
321
+ end
322
+ end
323
+
324
+ expect {
325
+ 2.times { ExclusiveJourney.create! }
326
+ }.not_to raise_error
327
+
328
+ expect {
329
+ 2.times { ExclusiveJourney.create!(hero: hero) }
330
+ }.to raise_error(ActiveRecord::RecordNotUnique)
331
+
332
+ expect {
333
+ 2.times { ExclusiveJourney.create!(hero: hero, allow_multiple: true) }
334
+ }.not_to raise_error
335
+ end
336
+
337
+ it "forbids multiple steps with the same name within a journey" do
338
+ expect {
339
+ class RepeatedStepsJourney < StepperMotor::Journey
340
+ step :foo do
341
+ true
342
+ end
343
+
344
+ step "foo" do
345
+ true
346
+ end
347
+ end
348
+ }.to raise_error(ArgumentError)
349
+ end
350
+
351
+ it "finishes the journey after perform_next_step" do
352
+ class RapidlyFinishingJourney < StepperMotor::Journey
353
+ step :one do
354
+ true # no-op
355
+ end
356
+ step :two do
357
+ true # no-op
358
+ end
359
+ end
360
+
361
+ journey = RapidlyFinishingJourney.create!
362
+ expect(journey).to be_ready
363
+ journey.perform_next_step!
364
+ expect(journey).to be_ready
365
+ journey.perform_next_step!
366
+ expect(journey).to be_finished
367
+ end
368
+
369
+ it "does not enter next step on a finished journey" do
370
+ class NearInstantJourney < StepperMotor::Journey
371
+ step :one do
372
+ finished!
373
+ end
374
+
375
+ step :two do
376
+ raise "Should never be reache"
377
+ end
378
+ end
379
+
380
+ journey = NearInstantJourney.create!
381
+ expect(journey).to be_ready
382
+ journey.perform_next_step!
383
+ expect(journey).to be_finished
384
+
385
+ expect { journey.perform_next_step! }.not_to raise_error
386
+ end
387
+
388
+ it "raises an exception if a step changes the journey but does not save it" do
389
+ class MutatingJourney < StepperMotor::Journey
390
+ step :one do
391
+ self.state = "canceled"
392
+ end
393
+ end
394
+
395
+ journey = MutatingJourney.create!
396
+ expect {
397
+ journey.perform_next_step!
398
+ }.to raise_error(StepperMotor::JourneyNotPersisted)
399
+ end
400
+
401
+ it "resets the instance variables after performing a step" do
402
+ class SelfResettingJourney < StepperMotor::Journey
403
+ step :one do
404
+ raise unless @current_step_definition
405
+ end
406
+
407
+ step :two do
408
+ @reattempt_after = 2.minutes
409
+ end
410
+ end
411
+
412
+ journey = SelfResettingJourney.create!
413
+ expect { journey.perform_next_step! }.not_to raise_error
414
+ expect(journey.instance_variable_get(:@current_step_definition)).to be_nil
415
+
416
+ expect { journey.perform_next_step! }.not_to raise_error
417
+ expect(journey.instance_variable_get(:@current_step_definition)).to be_nil
418
+ expect(journey.instance_variable_get(:@reattempt_after)).to be_nil
419
+ end
420
+
421
+ def assert_canceled_or_finished(model)
422
+ model.reload
423
+ expect(model.state).to be_in(["canceled", "finished"])
424
+ end
425
+ end
426
+ # rubocop:enable Lint/ConstantDefinitionInBlock
@@ -0,0 +1,44 @@
1
+ require_relative "../spec_helper"
2
+
3
+ RSpec.describe "StepperMotor::TestHelper" do
4
+ include SideEffects::SpecHelper
5
+ include StepperMotor::TestHelper
6
+
7
+ before do
8
+ establish_test_connection
9
+ run_generator
10
+ run_migrations
11
+ end
12
+
13
+ class SpeedyJourney < StepperMotor::Journey
14
+ step :step_1, wait: 40.minutes do
15
+ SideEffects.touch!("step_1")
16
+ end
17
+
18
+ step :step_2, wait: 2.days do
19
+ SideEffects.touch!("step_2")
20
+ end
21
+
22
+ step do
23
+ SideEffects.touch!("step_3")
24
+ end
25
+ end
26
+
27
+ it "speedruns the journey despite waits being configured" do
28
+ journey = SpeedyJourney.create!
29
+ expect(journey).to be_ready
30
+
31
+ expect {
32
+ speedrun_journey(journey)
33
+ }.to have_produced_side_effects_named("step_1", "step_2", "step_3")
34
+ end
35
+
36
+ it "is able to perform a single step forcibly" do
37
+ journey = SpeedyJourney.create!
38
+ expect(journey).to be_ready
39
+
40
+ expect {
41
+ immediately_perform_single_step(journey, :step_2)
42
+ }.to have_produced_side_effects_named("step_2")
43
+ end
44
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe StepperMotor do
4
+ it "has a version number" do
5
+ expect(StepperMotor::VERSION).not_to be nil
6
+ end
7
+ end
metadata ADDED
@@ -0,0 +1,203 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stepper_motor
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Julik Tarkhanov
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-01-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activejob
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: railties
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: globalid
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sqlite3
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.4'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.4'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '13.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '13.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: standard
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - '='
116
+ - !ruby/object:Gem::Version
117
+ version: 1.28.5
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - '='
123
+ - !ruby/object:Gem::Version
124
+ version: 1.28.5
125
+ - !ruby/object:Gem::Dependency
126
+ name: yard
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ description: Step workflows for Rails/ActiveRecord
140
+ email:
141
+ - me@julik.nl
142
+ executables: []
143
+ extensions: []
144
+ extra_rdoc_files: []
145
+ files:
146
+ - ".rspec"
147
+ - ".ruby-version"
148
+ - ".standard.yml"
149
+ - ".yardopts"
150
+ - CHANGELOG.md
151
+ - LICENSE.md
152
+ - README.md
153
+ - Rakefile
154
+ - bin/console
155
+ - bin/setup
156
+ - lib/generators/install_generator.rb
157
+ - lib/generators/stepper_motor_migration_001.rb.erb
158
+ - lib/stepper_motor.rb
159
+ - lib/stepper_motor/cyclic_scheduler.rb
160
+ - lib/stepper_motor/forward_scheduler.rb
161
+ - lib/stepper_motor/journey.rb
162
+ - lib/stepper_motor/perform_step_job.rb
163
+ - lib/stepper_motor/railtie.rb
164
+ - lib/stepper_motor/reap_hung_journeys_job.rb
165
+ - lib/stepper_motor/step.rb
166
+ - lib/stepper_motor/test_helper.rb
167
+ - lib/stepper_motor/version.rb
168
+ - sig/stepper_motor.rbs
169
+ - spec/helpers/side_effects.rb
170
+ - spec/spec_helper.rb
171
+ - spec/stepper_motor/cyclic_scheduler_spec.rb
172
+ - spec/stepper_motor/generator_spec.rb
173
+ - spec/stepper_motor/journey_spec.rb
174
+ - spec/stepper_motor/test_helper_spec.rb
175
+ - spec/stepper_motor_spec.rb
176
+ homepage: https://github.com/stepper_motor/stepper_motor
177
+ licenses:
178
+ - LGPL
179
+ metadata:
180
+ allowed_push_host: https://rubygems.org
181
+ homepage_uri: https://github.com/stepper_motor/stepper_motor
182
+ source_code_uri: https://github.com/stepper_motor/stepper_motor
183
+ changelog_uri: https://github.com/stepper_motor/stepper_motor/CHANGELOG.md
184
+ post_install_message:
185
+ rdoc_options: []
186
+ require_paths:
187
+ - lib
188
+ required_ruby_version: !ruby/object:Gem::Requirement
189
+ requirements:
190
+ - - ">="
191
+ - !ruby/object:Gem::Version
192
+ version: 2.7.0
193
+ required_rubygems_version: !ruby/object:Gem::Requirement
194
+ requirements:
195
+ - - ">="
196
+ - !ruby/object:Gem::Version
197
+ version: '0'
198
+ requirements: []
199
+ rubygems_version: 3.1.6
200
+ signing_key:
201
+ specification_version: 4
202
+ summary: Effortless step workflows that embed nicely inside Rails
203
+ test_files: []