stepper_motor 0.1.7 → 0.1.8

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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +12 -0
  3. data/.github/workflows/ci.yml +51 -0
  4. data/CHANGELOG.md +77 -2
  5. data/Gemfile +11 -0
  6. data/README.md +13 -374
  7. data/Rakefile +21 -3
  8. data/bin/test +5 -0
  9. data/lib/generators/install_generator.rb +6 -1
  10. data/lib/generators/stepper_motor_migration_003.rb.erb +6 -0
  11. data/lib/generators/stepper_motor_migration_004.rb.erb +26 -0
  12. data/lib/stepper_motor/forward_scheduler.rb +8 -4
  13. data/lib/stepper_motor/journey/flow_control.rb +58 -0
  14. data/lib/stepper_motor/journey/recovery.rb +34 -0
  15. data/lib/stepper_motor/journey.rb +85 -84
  16. data/lib/stepper_motor/perform_step_job_v2.rb +2 -2
  17. data/lib/stepper_motor/recover_stuck_journeys_job_v1.rb +3 -1
  18. data/lib/stepper_motor/step.rb +70 -5
  19. data/lib/stepper_motor/version.rb +1 -1
  20. data/lib/stepper_motor.rb +0 -1
  21. data/lib/tasks/stepper_motor_tasks.rake +8 -0
  22. data/manual/MANUAL.md +538 -0
  23. data/rbi/stepper_motor.rbi +459 -0
  24. data/sig/stepper_motor.rbs +406 -3
  25. data/stepper_motor.gemspec +49 -0
  26. data/test/dummy/Rakefile +8 -0
  27. data/test/dummy/app/assets/stylesheets/application.css +1 -0
  28. data/test/dummy/app/controllers/application_controller.rb +6 -0
  29. data/test/dummy/app/helpers/application_helper.rb +4 -0
  30. data/test/dummy/app/jobs/application_job.rb +9 -0
  31. data/test/dummy/app/mailers/application_mailer.rb +6 -0
  32. data/test/dummy/app/models/application_record.rb +5 -0
  33. data/test/dummy/app/views/layouts/application.html.erb +27 -0
  34. data/test/dummy/app/views/layouts/mailer.html.erb +13 -0
  35. data/test/dummy/app/views/layouts/mailer.text.erb +1 -0
  36. data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
  37. data/test/dummy/app/views/pwa/service-worker.js +26 -0
  38. data/test/dummy/bin/dev +2 -0
  39. data/test/dummy/bin/rails +4 -0
  40. data/test/dummy/bin/rake +4 -0
  41. data/test/dummy/bin/setup +34 -0
  42. data/test/dummy/config/application.rb +28 -0
  43. data/test/dummy/config/boot.rb +7 -0
  44. data/test/dummy/config/cable.yml +10 -0
  45. data/test/dummy/config/database.yml +32 -0
  46. data/test/dummy/config/environment.rb +7 -0
  47. data/test/dummy/config/environments/development.rb +71 -0
  48. data/test/dummy/config/environments/production.rb +91 -0
  49. data/test/dummy/config/environments/test.rb +55 -0
  50. data/test/dummy/config/initializers/content_security_policy.rb +27 -0
  51. data/test/dummy/config/initializers/filter_parameter_logging.rb +10 -0
  52. data/test/dummy/config/initializers/inflections.rb +18 -0
  53. data/test/dummy/config/initializers/stepper_motor.rb +3 -0
  54. data/test/dummy/config/locales/en.yml +31 -0
  55. data/test/dummy/config/puma.rb +40 -0
  56. data/test/dummy/config/routes.rb +16 -0
  57. data/test/dummy/config/storage.yml +34 -0
  58. data/test/dummy/config.ru +8 -0
  59. data/test/dummy/db/migrate/20250520094921_stepper_motor_migration_001.rb +38 -0
  60. data/test/dummy/db/migrate/20250520094922_stepper_motor_migration_002.rb +8 -0
  61. data/test/dummy/db/migrate/20250522212312_stepper_motor_migration_003.rb +7 -0
  62. data/test/dummy/db/migrate/20250525110812_stepper_motor_migration_004.rb +28 -0
  63. data/test/dummy/db/schema.rb +37 -0
  64. data/test/dummy/public/400.html +114 -0
  65. data/test/dummy/public/404.html +114 -0
  66. data/test/dummy/public/406-unsupported-browser.html +114 -0
  67. data/test/dummy/public/422.html +114 -0
  68. data/test/dummy/public/500.html +114 -0
  69. data/test/dummy/public/icon.png +0 -0
  70. data/test/dummy/public/icon.svg +3 -0
  71. data/test/side_effects_helper.rb +67 -0
  72. data/test/stepper_motor/cyclic_scheduler_test.rb +77 -0
  73. data/{spec/stepper_motor/forward_scheduler_spec.rb → test/stepper_motor/forward_scheduler_test.rb} +9 -10
  74. data/test/stepper_motor/journey/exception_handling_test.rb +89 -0
  75. data/test/stepper_motor/journey/flow_control_test.rb +78 -0
  76. data/test/stepper_motor/journey/idempotency_test.rb +65 -0
  77. data/test/stepper_motor/journey/step_definition_test.rb +187 -0
  78. data/test/stepper_motor/journey/uniqueness_test.rb +48 -0
  79. data/test/stepper_motor/journey_test.rb +352 -0
  80. data/{spec/stepper_motor/recover_stuck_journeys_job_spec.rb → test/stepper_motor/recover_stuck_journeys_job_test.rb} +14 -14
  81. data/{spec/stepper_motor/recovery_spec.rb → test/stepper_motor/recovery_test.rb} +27 -27
  82. data/test/stepper_motor/test_helper_test.rb +44 -0
  83. data/test/stepper_motor_test.rb +9 -0
  84. data/test/test_helper.rb +46 -0
  85. metadata +120 -24
  86. data/.rspec +0 -3
  87. data/.ruby-version +0 -1
  88. data/.standard.yml +0 -4
  89. data/.yardopts +0 -1
  90. data/spec/helpers/side_effects.rb +0 -85
  91. data/spec/spec_helper.rb +0 -90
  92. data/spec/stepper_motor/cyclic_scheduler_spec.rb +0 -68
  93. data/spec/stepper_motor/generator_spec.rb +0 -16
  94. data/spec/stepper_motor/journey_spec.rb +0 -401
  95. data/spec/stepper_motor/test_helper_spec.rb +0 -48
  96. data/spec/stepper_motor_spec.rb +0 -7
@@ -1,401 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../spec_helper"
4
-
5
- # rubocop:disable Lint/ConstantDefinitionInBlock
6
- RSpec.describe "StepperMotor::Journey" do
7
- include ActiveJob::TestHelper
8
-
9
- it "allows an empty journey to be defined and performed to completion" do
10
- pointless_class = create_journey_subclass
11
- journey = pointless_class.create!
12
- journey.perform_next_step!
13
- expect(journey).to be_finished
14
- end
15
-
16
- it "allows a journey consisting of one step to be defined and performed to completion" do
17
- single_step_class = create_journey_subclass do
18
- step :do_thing do
19
- SideEffects.touch!("do_thing")
20
- end
21
- end
22
-
23
- journey = single_step_class.create!
24
- expect(journey.next_step_to_be_performed_at).not_to be_nil
25
- journey.perform_next_step!
26
- expect(journey).to be_finished
27
- expect(SideEffects).to be_produced("do_thing")
28
- end
29
-
30
- it "allows a journey consisting of multiple named steps to be defined and performed to completion" do
31
- multi_step_journey_class = create_journey_subclass do
32
- [:step1, :step2, :step3].each do |step_name|
33
- step step_name do
34
- SideEffects.touch!("from_#{step_name}")
35
- end
36
- end
37
- end
38
-
39
- journey = multi_step_journey_class.create!
40
- expect(journey.next_step_name).to eq("step1")
41
-
42
- journey.perform_next_step!
43
- expect(journey.next_step_name).to eq("step2")
44
- expect(journey.previous_step_name).to eq("step1")
45
-
46
- journey.perform_next_step!
47
- expect(journey.next_step_name).to eq("step3")
48
- expect(journey.previous_step_name).to eq("step2")
49
-
50
- journey.perform_next_step!
51
- expect(journey).to be_finished
52
- expect(journey.next_step_name).to be_nil
53
- expect(journey.previous_step_name).to eq("step3")
54
-
55
- expect(SideEffects).to be_produced("from_step1")
56
- expect(SideEffects).to be_produced("from_step2")
57
- expect(SideEffects).to be_produced("from_step3")
58
- end
59
-
60
- it "allows a journey consisting of multiple anonymous steps to be defined and performed to completion" do
61
- anonymous_steps_class = create_journey_subclass do
62
- 3.times do |n|
63
- step do
64
- SideEffects.touch!("sidefx_#{n}")
65
- end
66
- end
67
- end
68
-
69
- journey = anonymous_steps_class.create!
70
- expect(journey.next_step_name).to eq("step_1")
71
-
72
- journey.perform_next_step!
73
- expect(journey.next_step_name).to eq("step_2")
74
- expect(journey.previous_step_name).to eq("step_1")
75
-
76
- journey.perform_next_step!
77
- expect(journey.next_step_name).to eq("step_3")
78
- expect(journey.previous_step_name).to eq("step_2")
79
-
80
- journey.perform_next_step!
81
- expect(journey).to be_finished
82
- expect(journey.next_step_name).to be_nil
83
- expect(journey.previous_step_name).to eq("step_3")
84
-
85
- expect(SideEffects).to be_produced("sidefx_0")
86
- expect(SideEffects).to be_produced("sidefx_1")
87
- expect(SideEffects).to be_produced("sidefx_2")
88
- end
89
-
90
- it "allows an arbitrary ActiveRecord to be attached as the hero" do
91
- carried_journey_class = create_journey_subclass
92
- carrier_journey_class = create_journey_subclass do
93
- step :only do
94
- raise "Incorrect" unless hero.instance_of?(carried_journey_class)
95
- end
96
- end
97
-
98
- hero = carried_journey_class.create!
99
- journey = carrier_journey_class.create!(hero: hero)
100
- expect {
101
- journey.perform_next_step!
102
- }.not_to raise_error
103
- end
104
-
105
- it "allows a journey where steps are delayed in time using wait:" do
106
- timely_journey_class = create_journey_subclass do
107
- step wait: 10.hours do
108
- SideEffects.touch! "after_10_hours.txt"
109
- end
110
-
111
- step wait: 5.minutes do
112
- SideEffects.touch! "after_5_minutes.txt"
113
- end
114
-
115
- step do
116
- SideEffects.touch! "final_nowait.txt"
117
- end
118
- end
119
-
120
- freeze_time
121
- timely_journey_class.create!
122
-
123
- expect {
124
- perform_enqueued_jobs
125
- }.to not_have_produced_any_side_effects
126
-
127
- travel 10.hours
128
- expect {
129
- perform_enqueued_jobs
130
- }.to have_produced_side_effects_named("after_10_hours.txt")
131
-
132
- travel 4.minutes
133
- expect {
134
- perform_enqueued_jobs
135
- }.to not_have_produced_any_side_effects
136
-
137
- travel 1.minutes
138
- expect {
139
- perform_enqueued_jobs
140
- }.to have_produced_side_effects_named("after_5_minutes.txt")
141
-
142
- expect {
143
- perform_enqueued_jobs
144
- }.to have_produced_side_effects_named("final_nowait.txt")
145
- end
146
-
147
- it "allows a journey where steps are delayed in time using after:" do
148
- journey_class = create_journey_subclass do
149
- step after: 10.hours do
150
- SideEffects.touch! "step1"
151
- end
152
-
153
- step after: 605.minutes do
154
- SideEffects.touch! "step2"
155
- end
156
-
157
- step do
158
- SideEffects.touch! "step3"
159
- end
160
- end
161
-
162
- timely_journey = journey_class.create!
163
- freeze_time
164
-
165
- # Note that the "perform_enqueued_jobs" helper method performs the job even if
166
- # its "scheduled_at" lies in the future. Presumably this is done so that testing is
167
- # easier to do, but we check the time the journey was set to perform the next step at
168
- # - and therefore a job which runs too early will produce another job that replaces it.
169
- expect { perform_enqueued_jobs }.to not_have_produced_any_side_effects
170
-
171
- travel_to(timely_journey.next_step_to_be_performed_at + 1.second)
172
- expect { perform_enqueued_jobs }.to have_produced_side_effects_named("step1")
173
-
174
- travel(4.minutes)
175
- expect { perform_enqueued_jobs }.to not_have_produced_any_side_effects
176
-
177
- travel(1.minutes + 1.second)
178
- expect { perform_enqueued_jobs }.to have_produced_side_effects_named("step2")
179
- expect { perform_enqueued_jobs }.to have_produced_side_effects_named("step3")
180
- expect(enqueued_jobs).to be_empty # Journey ended
181
- end
182
-
183
- it "tracks steps entered and completed using counters" do
184
- failing = create_journey_subclass do
185
- step do
186
- raise "oops"
187
- end
188
- end
189
-
190
- not_failing = create_journey_subclass do
191
- step do
192
- true # no-op
193
- end
194
- end
195
-
196
- failing_journey = failing.create!
197
- expect { failing_journey.perform_next_step! }.to raise_error(/oops/)
198
- expect(failing_journey.steps_entered).to eq(1)
199
- expect(failing_journey.steps_completed).to eq(0)
200
-
201
- failing_journey.ready!
202
- expect { failing_journey.perform_next_step! }.to raise_error(/oops/)
203
- expect(failing_journey.steps_entered).to eq(2)
204
- expect(failing_journey.steps_completed).to eq(0)
205
-
206
- non_failing_journey = not_failing.create!
207
- non_failing_journey.perform_next_step!
208
- expect(non_failing_journey.steps_entered).to eq(1)
209
- expect(non_failing_journey.steps_completed).to eq(1)
210
- end
211
-
212
- it "does not allow invalid values for after: and wait:" do
213
- expect {
214
- create_journey_subclass do
215
- step after: 10.hours do
216
- # pass
217
- end
218
-
219
- step after: 5.hours do
220
- # pass
221
- end
222
- end
223
- }.to raise_error(ArgumentError)
224
-
225
- expect {
226
- create_journey_subclass do
227
- step wait: -5.hours do
228
- # pass
229
- end
230
- end
231
- }.to raise_error(ArgumentError)
232
-
233
- expect {
234
- create_journey_subclass do
235
- step after: 5.hours, wait: 2.seconds do
236
- # pass
237
- end
238
- end
239
- }.to raise_error(ArgumentError)
240
- end
241
-
242
- it "allows a step to reattempt itself" do
243
- deferring = create_journey_subclass do
244
- step do
245
- reattempt! wait: 5.minutes
246
- raise "Should never be reached"
247
- end
248
- end
249
-
250
- journey = deferring.create!
251
- perform_enqueued_jobs
252
-
253
- journey.reload
254
- expect(journey.previous_step_name).to eq("step_1")
255
- expect(journey.next_step_name).to eq("step_1")
256
- expect(journey.next_step_to_be_performed_at).to be_within(1.second).of(Time.current + 5.minutes)
257
-
258
- travel 5.minutes + 1.second
259
- perform_enqueued_jobs
260
-
261
- journey.reload
262
- expect(journey.previous_step_name).to eq("step_1")
263
- expect(journey.next_step_name).to eq("step_1")
264
- expect(journey.next_step_to_be_performed_at).to be_within(1.second).of(Time.current + 5.minutes)
265
- end
266
-
267
- 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
268
- interrupting = create_journey_subclass do
269
- step :step1 do
270
- SideEffects.touch!("step1_before_cancel")
271
- cancel!
272
- SideEffects.touch!("step1_after_cancel")
273
- end
274
-
275
- step :step2 do
276
- raise "Should never be reached"
277
- end
278
- end
279
-
280
- journey = interrupting.create!
281
- expect(journey.next_step_name).to eq("step1")
282
-
283
- perform_enqueued_jobs
284
- expect(SideEffects).to be_produced("step1_before_cancel")
285
- expect(SideEffects).not_to be_produced("step1_after_cancel")
286
- assert_canceled_or_finished(journey)
287
- end
288
-
289
- it "forbids multiple similar journeys for the same hero at the same time unless allow_multiple is set" do
290
- actor_class = create_journey_subclass
291
- hero = actor_class.create!
292
-
293
- exclusive_journey_class = create_journey_subclass do
294
- step do
295
- raise "The step should never be entered as we are not testing the step itself here"
296
- end
297
- end
298
-
299
- expect {
300
- 2.times { exclusive_journey_class.create! }
301
- }.not_to raise_error
302
-
303
- expect {
304
- 2.times { exclusive_journey_class.create!(hero: hero) }
305
- }.to raise_error(ActiveRecord::RecordNotUnique)
306
-
307
- expect {
308
- 2.times { exclusive_journey_class.create!(hero: hero, allow_multiple: true) }
309
- }.not_to raise_error
310
- end
311
-
312
- it "forbids multiple steps with the same name within a journey" do
313
- expect {
314
- create_journey_subclass do
315
- step :foo do
316
- true
317
- end
318
-
319
- step "foo" do
320
- true
321
- end
322
- end
323
- }.to raise_error(ArgumentError)
324
- end
325
-
326
- it "finishes the journey after perform_next_step" do
327
- rapid = create_journey_subclass do
328
- step :one do
329
- true # no-op
330
- end
331
- step :two do
332
- true # no-op
333
- end
334
- end
335
-
336
- journey = rapid.create!
337
- expect(journey).to be_ready
338
- journey.perform_next_step!
339
- expect(journey).to be_ready
340
- journey.perform_next_step!
341
- expect(journey).to be_finished
342
- end
343
-
344
- it "does not enter next step on a finished journey" do
345
- near_instant = create_journey_subclass do
346
- step :one do
347
- finished!
348
- end
349
-
350
- step :two do
351
- raise "Should never be reache"
352
- end
353
- end
354
-
355
- journey = near_instant.create!
356
- expect(journey).to be_ready
357
- journey.perform_next_step!
358
- expect(journey).to be_finished
359
-
360
- expect { journey.perform_next_step! }.not_to raise_error
361
- end
362
-
363
- it "raises an exception if a step changes the journey but does not save it" do
364
- mutating = create_journey_subclass do
365
- step :one do
366
- self.state = "canceled"
367
- end
368
- end
369
-
370
- journey = mutating.create!
371
- expect {
372
- journey.perform_next_step!
373
- }.to raise_error(StepperMotor::JourneyNotPersisted)
374
- end
375
-
376
- it "resets the instance variables after performing a step" do
377
- self_resetting = create_journey_subclass do
378
- step :one do
379
- raise unless @current_step_definition
380
- end
381
-
382
- step :two do
383
- @reattempt_after = 2.minutes
384
- end
385
- end
386
-
387
- journey = self_resetting.create!
388
- expect { journey.perform_next_step! }.not_to raise_error
389
- expect(journey.instance_variable_get(:@current_step_definition)).to be_nil
390
-
391
- expect { journey.perform_next_step! }.not_to raise_error
392
- expect(journey.instance_variable_get(:@current_step_definition)).to be_nil
393
- expect(journey.instance_variable_get(:@reattempt_after)).to be_nil
394
- end
395
-
396
- def assert_canceled_or_finished(model)
397
- model.reload
398
- expect(model.state).to be_in(["canceled", "finished"])
399
- end
400
- end
401
- # rubocop:enable Lint/ConstantDefinitionInBlock
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../spec_helper"
4
-
5
- RSpec.describe "StepperMotor::TestHelper" do
6
- include SideEffects::SpecHelper
7
- include StepperMotor::TestHelper
8
-
9
- before do
10
- establish_test_connection
11
- run_generator
12
- run_migrations
13
- end
14
-
15
- def speedy_journey_class
16
- create_journey_subclass do
17
- step :step_1, wait: 40.minutes do
18
- SideEffects.touch!("step_1")
19
- end
20
-
21
- step :step_2, wait: 2.days do
22
- SideEffects.touch!("step_2")
23
- end
24
-
25
- step do
26
- SideEffects.touch!("step_3")
27
- end
28
- end
29
- end
30
-
31
- it "speedruns the journey despite waits being configured" do
32
- journey = speedy_journey_class.create!
33
- expect(journey).to be_ready
34
-
35
- expect {
36
- speedrun_journey(journey)
37
- }.to have_produced_side_effects_named("step_1", "step_2", "step_3")
38
- end
39
-
40
- it "is able to perform a single step forcibly" do
41
- journey = speedy_journey_class.create!
42
- expect(journey).to be_ready
43
-
44
- expect {
45
- immediately_perform_single_step(journey, :step_2)
46
- }.to have_produced_side_effects_named("step_2")
47
- end
48
- end
@@ -1,7 +0,0 @@
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