wheneverd 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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +26 -0
  3. data/.rubocop.yml +41 -0
  4. data/.yardopts +8 -0
  5. data/AGENTS.md +42 -0
  6. data/CHANGELOG.md +28 -0
  7. data/FEATURE_SUMMARY.md +38 -0
  8. data/Gemfile +16 -0
  9. data/Gemfile.lock +129 -0
  10. data/LICENSE +21 -0
  11. data/README.md +204 -0
  12. data/Rakefile +196 -0
  13. data/bin/console +8 -0
  14. data/bin/setup +5 -0
  15. data/exe/wheneverd +9 -0
  16. data/lib/wheneverd/cli/activate.rb +19 -0
  17. data/lib/wheneverd/cli/current.rb +22 -0
  18. data/lib/wheneverd/cli/deactivate.rb +19 -0
  19. data/lib/wheneverd/cli/delete.rb +20 -0
  20. data/lib/wheneverd/cli/help.rb +18 -0
  21. data/lib/wheneverd/cli/init.rb +78 -0
  22. data/lib/wheneverd/cli/reload.rb +40 -0
  23. data/lib/wheneverd/cli/show.rb +23 -0
  24. data/lib/wheneverd/cli/write.rb +32 -0
  25. data/lib/wheneverd/cli.rb +87 -0
  26. data/lib/wheneverd/core_ext/numeric_duration.rb +56 -0
  27. data/lib/wheneverd/dsl/at_normalizer.rb +48 -0
  28. data/lib/wheneverd/dsl/calendar_symbol_period_list.rb +42 -0
  29. data/lib/wheneverd/dsl/context.rb +72 -0
  30. data/lib/wheneverd/dsl/errors.rb +29 -0
  31. data/lib/wheneverd/dsl/loader.rb +49 -0
  32. data/lib/wheneverd/dsl/period_parser.rb +135 -0
  33. data/lib/wheneverd/duration.rb +27 -0
  34. data/lib/wheneverd/entry.rb +31 -0
  35. data/lib/wheneverd/errors.rb +9 -0
  36. data/lib/wheneverd/interval.rb +37 -0
  37. data/lib/wheneverd/job/command.rb +29 -0
  38. data/lib/wheneverd/schedule.rb +25 -0
  39. data/lib/wheneverd/systemd/calendar_spec.rb +109 -0
  40. data/lib/wheneverd/systemd/cron_parser.rb +352 -0
  41. data/lib/wheneverd/systemd/errors.rb +23 -0
  42. data/lib/wheneverd/systemd/renderer.rb +153 -0
  43. data/lib/wheneverd/systemd/systemctl.rb +38 -0
  44. data/lib/wheneverd/systemd/time_parser.rb +75 -0
  45. data/lib/wheneverd/systemd/unit_deleter.rb +64 -0
  46. data/lib/wheneverd/systemd/unit_lister.rb +59 -0
  47. data/lib/wheneverd/systemd/unit_namer.rb +69 -0
  48. data/lib/wheneverd/systemd/unit_writer.rb +132 -0
  49. data/lib/wheneverd/trigger/boot.rb +26 -0
  50. data/lib/wheneverd/trigger/calendar.rb +26 -0
  51. data/lib/wheneverd/trigger/interval.rb +30 -0
  52. data/lib/wheneverd/version.rb +6 -0
  53. data/lib/wheneverd.rb +41 -0
  54. data/test/cli_activate_test.rb +110 -0
  55. data/test/cli_current_test.rb +94 -0
  56. data/test/cli_deactivate_test.rb +111 -0
  57. data/test/cli_end_to_end_test.rb +98 -0
  58. data/test/cli_reload_test.rb +132 -0
  59. data/test/cli_systemctl_integration_test.rb +76 -0
  60. data/test/cli_systemd_analyze_test.rb +64 -0
  61. data/test/cli_test.rb +332 -0
  62. data/test/domain_model_test.rb +108 -0
  63. data/test/dsl_calendar_symbol_period_list_test.rb +53 -0
  64. data/test/dsl_loader_test.rb +384 -0
  65. data/test/support/cli_subprocess_test_helpers.rb +38 -0
  66. data/test/support/cli_test_helpers.rb +114 -0
  67. data/test/systemd_calendar_spec_test.rb +45 -0
  68. data/test/systemd_cron_parser_test.rb +114 -0
  69. data/test/systemd_renderer_errors_test.rb +85 -0
  70. data/test/systemd_renderer_test.rb +161 -0
  71. data/test/systemd_systemctl_test.rb +46 -0
  72. data/test/systemd_time_parser_test.rb +25 -0
  73. data/test/systemd_unit_deleter_test.rb +83 -0
  74. data/test/systemd_unit_writer_prune_test.rb +85 -0
  75. data/test/systemd_unit_writer_test.rb +71 -0
  76. data/test/test_helper.rb +34 -0
  77. data/test/wheneverd_test.rb +9 -0
  78. data/wheneverd.gemspec +35 -0
  79. metadata +136 -0
@@ -0,0 +1,384 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+
7
+ module DSLLoaderTestHelpers
8
+ def setup
9
+ super
10
+ @tmpdir = Dir.mktmpdir("wheneverd-")
11
+ end
12
+
13
+ def teardown
14
+ FileUtils.rm_rf(@tmpdir) if @tmpdir
15
+ super
16
+ end
17
+
18
+ private
19
+
20
+ def load_schedule(source)
21
+ path = write_schedule(source)
22
+ Wheneverd::DSL::Loader.load_file(path)
23
+ end
24
+
25
+ def write_schedule(source)
26
+ path = File.join(@tmpdir, "schedule.rb")
27
+ File.write(path, source)
28
+ path
29
+ end
30
+ end
31
+
32
+ class DSLLoaderIntervalAndDurationTest < Minitest::Test
33
+ include DSLLoaderTestHelpers
34
+
35
+ def test_loads_interval_string_every_block
36
+ schedule = load_schedule(<<~RUBY)
37
+ every "5m" do
38
+ command "echo hello"
39
+ end
40
+ RUBY
41
+
42
+ entry = schedule.entries.fetch(0)
43
+ assert_instance_of Wheneverd::Trigger::Interval, entry.trigger
44
+ assert_equal 300, entry.trigger.seconds
45
+ assert_equal ["echo hello"], entry.jobs.map(&:command)
46
+ end
47
+
48
+ def test_loads_duration_with_at_as_calendar
49
+ schedule = load_schedule(<<~RUBY)
50
+ every 1.day, at: "4:30 am" do
51
+ command "echo four_thirty"
52
+ end
53
+ RUBY
54
+
55
+ entry = schedule.entries.fetch(0)
56
+ assert_instance_of Wheneverd::Trigger::Calendar, entry.trigger
57
+ assert_equal ["day@4:30 am"], entry.trigger.on_calendar
58
+ assert_equal ["echo four_thirty"], entry.jobs.map(&:command)
59
+ end
60
+
61
+ def test_loads_duration_without_at_as_interval
62
+ schedule = load_schedule(<<~RUBY)
63
+ every 1.day do
64
+ command "echo daily"
65
+ end
66
+ RUBY
67
+
68
+ entry = schedule.entries.fetch(0)
69
+ assert_instance_of Wheneverd::Trigger::Interval, entry.trigger
70
+ assert_equal 86_400, entry.trigger.seconds
71
+ end
72
+
73
+ def test_loads_duration_with_at_array_as_calendar
74
+ schedule = load_schedule(<<~RUBY)
75
+ every 1.day, at: ["4:30 am", "6:00 pm"] do
76
+ command "echo twice_daily"
77
+ end
78
+ RUBY
79
+
80
+ entry = schedule.entries.fetch(0)
81
+ assert_instance_of Wheneverd::Trigger::Calendar, entry.trigger
82
+ assert_equal ["day@4:30 am", "day@6:00 pm"], entry.trigger.on_calendar
83
+ end
84
+
85
+ def test_loads_cron_string_as_calendar_trigger
86
+ schedule = load_schedule(<<~RUBY)
87
+ every "0 0 27-31 * *" do
88
+ command "echo raw_cron"
89
+ end
90
+ RUBY
91
+
92
+ entry = schedule.entries.fetch(0)
93
+ assert_instance_of Wheneverd::Trigger::Calendar, entry.trigger
94
+ assert_equal ["cron:0 0 27-31 * *"], entry.trigger.on_calendar
95
+ end
96
+ end
97
+
98
+ class DSLLoaderSymbolPeriodsTest < Minitest::Test
99
+ include DSLLoaderTestHelpers
100
+
101
+ def test_loads_symbol_shortcut_hour
102
+ schedule = load_schedule(<<~RUBY)
103
+ every :hour do
104
+ command "echo hourly"
105
+ end
106
+ RUBY
107
+
108
+ entry = schedule.entries.fetch(0)
109
+ assert_instance_of Wheneverd::Trigger::Calendar, entry.trigger
110
+ assert_equal ["hour"], entry.trigger.on_calendar
111
+ end
112
+
113
+ def test_reboot_symbol_is_rejected
114
+ schedule_path = write_schedule(<<~RUBY)
115
+ every :reboot do
116
+ command "echo reboot"
117
+ end
118
+ RUBY
119
+
120
+ error = assert_raises(Wheneverd::DSL::InvalidPeriodError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
121
+ assert_includes error.message, schedule_path
122
+ assert_includes error.message, ":reboot"
123
+ assert_includes error.message, "not supported"
124
+ end
125
+
126
+ def test_loads_day_selector_symbol_with_at
127
+ schedule = load_schedule(<<~RUBY)
128
+ every :sunday, at: "12pm" do
129
+ command "echo weekly"
130
+ end
131
+ RUBY
132
+
133
+ entry = schedule.entries.fetch(0)
134
+ assert_instance_of Wheneverd::Trigger::Calendar, entry.trigger
135
+ assert_equal ["sunday@12pm"], entry.trigger.on_calendar
136
+ end
137
+
138
+ def test_loads_weekday_symbol
139
+ schedule = load_schedule(<<~RUBY)
140
+ every :weekday do
141
+ command "echo weekday"
142
+ end
143
+ RUBY
144
+
145
+ entry = schedule.entries.fetch(0)
146
+ assert_equal ["weekday"], entry.trigger.on_calendar
147
+ end
148
+
149
+ def test_loads_weekend_symbol
150
+ schedule = load_schedule(<<~RUBY)
151
+ every :weekend do
152
+ command "echo weekend"
153
+ end
154
+ RUBY
155
+
156
+ entry = schedule.entries.fetch(0)
157
+ assert_equal ["weekend"], entry.trigger.on_calendar
158
+ end
159
+
160
+ def test_loads_multiple_day_symbols_as_calendar_trigger
161
+ schedule = load_schedule(<<~RUBY)
162
+ every :tuesday, :wednesday, at: "12pm" do
163
+ command "echo midweek"
164
+ end
165
+ RUBY
166
+
167
+ entry = schedule.entries.fetch(0)
168
+ assert_instance_of Wheneverd::Trigger::Calendar, entry.trigger
169
+ assert_equal ["tuesday@12pm", "wednesday@12pm"], entry.trigger.on_calendar
170
+ end
171
+
172
+ def test_loads_period_symbol_array_as_calendar_trigger
173
+ schedule = load_schedule(<<~RUBY)
174
+ every %i[tuesday wednesday], at: "12pm" do
175
+ command "echo midweek"
176
+ end
177
+ RUBY
178
+
179
+ entry = schedule.entries.fetch(0)
180
+ assert_instance_of Wheneverd::Trigger::Calendar, entry.trigger
181
+ assert_equal ["tuesday@12pm", "wednesday@12pm"], entry.trigger.on_calendar
182
+ end
183
+
184
+ def test_loads_multiple_day_symbols_with_at_array_as_calendar_trigger
185
+ entry = load_schedule(<<~RUBY).entries.fetch(0)
186
+ every :tuesday, :wednesday, at: ["4:30 am", "6:00 pm"] do
187
+ command "echo twice_midweek"
188
+ end
189
+ RUBY
190
+
191
+ assert_instance_of Wheneverd::Trigger::Calendar, entry.trigger
192
+ assert_equal(
193
+ ["tuesday@4:30 am", "tuesday@6:00 pm", "wednesday@4:30 am", "wednesday@6:00 pm"],
194
+ entry.trigger.on_calendar
195
+ )
196
+ end
197
+ end
198
+
199
+ class DSLLoaderPeriodErrorsTest < Minitest::Test
200
+ include DSLLoaderTestHelpers
201
+
202
+ def test_invalid_interval_raises_error_with_path
203
+ schedule_path = write_schedule(<<~RUBY)
204
+ every "0m" do
205
+ command "echo no"
206
+ end
207
+ RUBY
208
+
209
+ error = assert_raises(Wheneverd::DSL::InvalidPeriodError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
210
+ assert_includes error.message, schedule_path
211
+ assert_includes error.message, "Interval must be positive"
212
+ end
213
+
214
+ def test_interval_string_does_not_accept_at
215
+ schedule_path = write_schedule(<<~RUBY)
216
+ every "5m", at: "12pm" do
217
+ command "echo nope"
218
+ end
219
+ RUBY
220
+
221
+ error = assert_raises(Wheneverd::DSL::InvalidPeriodError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
222
+ assert_includes error.message, schedule_path
223
+ assert_includes error.message, "interval periods"
224
+ end
225
+
226
+ def test_cron_string_does_not_accept_at
227
+ schedule_path = write_schedule(<<~RUBY)
228
+ every "0 0 27-31 * *", at: "12pm" do
229
+ command "echo nope"
230
+ end
231
+ RUBY
232
+
233
+ error = assert_raises(Wheneverd::DSL::InvalidPeriodError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
234
+ assert_includes error.message, schedule_path
235
+ assert_includes error.message, "cron periods"
236
+ end
237
+
238
+ def test_duration_at_is_rejected_for_non_daily_durations
239
+ schedule_path = write_schedule(<<~RUBY)
240
+ every 2.days, at: "4:30 am" do
241
+ command "echo nope"
242
+ end
243
+ RUBY
244
+
245
+ error = assert_raises(Wheneverd::DSL::InvalidPeriodError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
246
+ assert_includes error.message, schedule_path
247
+ assert_includes error.message, "at: is only supported"
248
+ end
249
+
250
+ def test_every_requires_a_block
251
+ schedule_path = write_schedule(<<~RUBY)
252
+ every "5m"
253
+ RUBY
254
+
255
+ error = assert_raises(Wheneverd::DSL::InvalidPeriodError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
256
+ assert_includes error.message, schedule_path
257
+ assert_includes error.message, "requires a block"
258
+ end
259
+
260
+ def test_every_rejects_unknown_period_type
261
+ schedule_path = write_schedule(<<~RUBY)
262
+ every Object.new do
263
+ command "echo nope"
264
+ end
265
+ RUBY
266
+
267
+ error = assert_raises(Wheneverd::DSL::InvalidPeriodError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
268
+ assert_includes error.message, schedule_path
269
+ assert_includes error.message, "Unsupported period type"
270
+ end
271
+
272
+ def test_every_rejects_unrecognized_string_period
273
+ schedule_path = write_schedule(<<~RUBY)
274
+ every "not a schedule" do
275
+ command "echo nope"
276
+ end
277
+ RUBY
278
+
279
+ error = assert_raises(Wheneverd::DSL::InvalidPeriodError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
280
+ assert_includes error.message, schedule_path
281
+ assert_includes error.message, "Unrecognized period"
282
+ end
283
+
284
+ def test_unknown_symbol_period_raises_error
285
+ schedule_path = write_schedule(<<~RUBY)
286
+ every :fortnight do
287
+ command "echo nope"
288
+ end
289
+ RUBY
290
+
291
+ error = assert_raises(Wheneverd::DSL::InvalidPeriodError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
292
+ assert_includes error.message, schedule_path
293
+ assert_includes error.message, "Unknown period symbol"
294
+ end
295
+
296
+ def test_reboot_does_not_accept_at
297
+ schedule_path = write_schedule(<<~RUBY)
298
+ every :reboot, at: "12pm" do
299
+ command "echo nope"
300
+ end
301
+ RUBY
302
+
303
+ error = assert_raises(Wheneverd::DSL::InvalidPeriodError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
304
+ assert_includes error.message, schedule_path
305
+ assert_includes error.message, ":reboot"
306
+ assert_includes error.message, "not supported"
307
+ end
308
+ end
309
+
310
+ class DSLLoaderAtValidationErrorsTest < Minitest::Test
311
+ include DSLLoaderTestHelpers
312
+
313
+ def test_invalid_at_type_raises_error_with_path
314
+ schedule_path = write_schedule(<<~RUBY)
315
+ every 1.day, at: 123 do
316
+ command "echo nope"
317
+ end
318
+ RUBY
319
+
320
+ error = assert_raises(Wheneverd::DSL::InvalidAtError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
321
+ assert_includes error.message, schedule_path
322
+ assert_includes error.message, "at:"
323
+ end
324
+
325
+ def test_invalid_at_array_rejects_non_string
326
+ schedule_path = write_schedule(<<~RUBY)
327
+ every 1.day, at: ["4:30 am", 123] do
328
+ command "echo nope"
329
+ end
330
+ RUBY
331
+
332
+ error = assert_raises(Wheneverd::DSL::InvalidAtError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
333
+ assert_includes error.message, schedule_path
334
+ assert_includes error.message, "Array of Strings"
335
+ end
336
+
337
+ def test_invalid_at_array_rejects_empty
338
+ schedule_path = write_schedule(<<~RUBY)
339
+ every 1.day, at: [] do
340
+ command "echo nope"
341
+ end
342
+ RUBY
343
+
344
+ error = assert_raises(Wheneverd::DSL::InvalidAtError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
345
+ assert_includes error.message, schedule_path
346
+ assert_includes error.message, "must not be empty"
347
+ end
348
+ end
349
+
350
+ class DSLLoaderCommandAndEvalErrorsTest < Minitest::Test
351
+ include DSLLoaderTestHelpers
352
+
353
+ def test_invalid_command_raises_error_with_path
354
+ schedule_path = write_schedule(<<~RUBY)
355
+ every "5m" do
356
+ command ""
357
+ end
358
+ RUBY
359
+
360
+ error = assert_raises(Wheneverd::DSL::LoadError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
361
+ assert_includes error.message, schedule_path
362
+ assert_includes error.message, "Command must not be empty"
363
+ end
364
+
365
+ def test_command_outside_every_is_rejected
366
+ schedule_path = write_schedule(<<~RUBY)
367
+ command "echo nope"
368
+ RUBY
369
+
370
+ error = assert_raises(Wheneverd::DSL::LoadError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
371
+ assert_includes error.message, schedule_path
372
+ assert_includes error.message, "inside every()"
373
+ end
374
+
375
+ def test_unknown_method_in_schedule_is_wrapped_as_load_error
376
+ schedule_path = write_schedule(<<~RUBY)
377
+ totally_unknown_dsl_method "nope"
378
+ RUBY
379
+
380
+ error = assert_raises(Wheneverd::DSL::LoadError) { Wheneverd::DSL::Loader.load_file(schedule_path) }
381
+ assert_includes error.message, schedule_path
382
+ assert_includes error.message, "totally_unknown_dsl_method"
383
+ end
384
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "rbconfig"
5
+ require "tmpdir"
6
+
7
+ module CLISubprocessTestHelpers
8
+ def with_temp_project_dir
9
+ Dir.mktmpdir("wheneverd-e2e-") do |tmp|
10
+ project_dir = File.join(tmp, "myapp")
11
+ FileUtils.mkdir_p(project_dir)
12
+ yield project_dir
13
+ end
14
+ end
15
+
16
+ def exe_path
17
+ File.expand_path("../../exe/wheneverd", __dir__)
18
+ end
19
+
20
+ def run_exe(args, chdir:, env: {})
21
+ stdout, stderr, status = Open3.capture3(env, RbConfig.ruby, exe_path, *args, chdir: chdir)
22
+ [status.exitstatus, stdout, stderr]
23
+ end
24
+
25
+ def write_schedule(project_dir, contents)
26
+ path = File.join(project_dir, "config", "schedule.rb")
27
+ FileUtils.mkdir_p(File.dirname(path))
28
+ File.write(path, contents)
29
+ end
30
+
31
+ def find_executable(name)
32
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
33
+ candidate = File.join(dir, name)
34
+ return candidate if File.file?(candidate) && File.executable?(candidate)
35
+ end
36
+ nil
37
+ end
38
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+ require "wheneverd/cli"
6
+
7
+ module CLITestHelpers
8
+ SYSTEMCTL_USER_PREFIX = ["systemctl", "--user", "--no-pager"].freeze
9
+
10
+ def run_cli(args)
11
+ status = nil
12
+ out, err = capture_io { status = Wheneverd::CLI.run("wheneverd", args) }
13
+ [status, out, err]
14
+ end
15
+
16
+ def run_activate_with_capture3_stub(identifier: "demo", **kwargs)
17
+ run_cli_with_capture3_stub(["activate", "--identifier", identifier], **kwargs)
18
+ end
19
+
20
+ def run_deactivate_with_capture3_stub(identifier: "demo", **kwargs)
21
+ run_cli_with_capture3_stub(["deactivate", "--identifier", identifier], **kwargs)
22
+ end
23
+
24
+ def run_reload_with_capture3_stub(unit_dir:, identifier: "demo", **kwargs)
25
+ run_cli_with_capture3_stub(
26
+ ["reload", "--identifier", identifier, "--unit-dir", unit_dir],
27
+ **kwargs
28
+ )
29
+ end
30
+
31
+ def write_empty_schedule(path = File.join("config", "schedule.rb"))
32
+ FileUtils.mkdir_p(File.dirname(path))
33
+ File.write(path, "# frozen_string_literal: true\n")
34
+ end
35
+
36
+ def with_capture3_stub(exitstatus: 0, stdout: "", stderr: "")
37
+ calls = []
38
+ Thread.current[:open3_capture3_stub] = {
39
+ calls: calls,
40
+ stdout: stdout,
41
+ stderr: stderr,
42
+ exitstatus: exitstatus
43
+ }
44
+ yield calls
45
+ ensure
46
+ Thread.current[:open3_capture3_stub] = nil
47
+ end
48
+
49
+ def run_cli_with_capture3_stub(args, exitstatus: 0, stdout: "", stderr: "")
50
+ status = out = err = nil
51
+ calls = nil
52
+ with_capture3_stub(exitstatus: exitstatus, stdout: stdout, stderr: stderr) do |stub_calls|
53
+ calls = stub_calls
54
+ status, out, err = run_cli(args)
55
+ end
56
+ [status, out, err, calls]
57
+ end
58
+
59
+ def assert_cli_success(status, err)
60
+ assert_equal 0, status
61
+ assert_equal "", err
62
+ end
63
+
64
+ def assert_systemctl_call(calls, index, expected_args)
65
+ args, kwargs = calls.fetch(index)
66
+ assert_equal expected_args, args
67
+ assert_equal({}, kwargs)
68
+ end
69
+
70
+ def assert_systemctl_call_starts_with(calls, index, prefix, includes: nil)
71
+ args, kwargs = calls.fetch(index)
72
+ assert_equal prefix, args.take(prefix.length)
73
+ assert_equal({}, kwargs)
74
+ return if includes.nil?
75
+
76
+ Array(includes).each { |expected| assert_includes args, expected }
77
+ end
78
+
79
+ def with_project_dir
80
+ Dir.mktmpdir("wheneverd-project-") do |tmp|
81
+ project_dir = File.join(tmp, "myapp")
82
+ FileUtils.mkdir_p(project_dir)
83
+ Dir.chdir(project_dir) { yield project_dir }
84
+ end
85
+ end
86
+
87
+ def with_inited_project_dir
88
+ with_project_dir do |project_dir|
89
+ assert_equal 0, run_cli(["init"]).first
90
+ yield project_dir
91
+ end
92
+ end
93
+
94
+ def expected_units(identifier: "demo", schedule_path: File.join("config", "schedule.rb"))
95
+ schedule = Wheneverd::DSL::Loader.load_file(File.expand_path(schedule_path))
96
+ Wheneverd::Systemd::Renderer.render(schedule, identifier: identifier)
97
+ end
98
+
99
+ def expected_timer_basenames(identifier: "demo",
100
+ schedule_path: File.join("config", "schedule.rb"))
101
+ expected_units(identifier: identifier, schedule_path: schedule_path)
102
+ .select { |unit| unit.kind == :timer }
103
+ .map(&:path_basename)
104
+ .uniq
105
+ end
106
+
107
+ def expected_service_basenames(identifier: "demo",
108
+ schedule_path: File.join("config", "schedule.rb"))
109
+ expected_units(identifier: identifier, schedule_path: schedule_path)
110
+ .select { |unit| unit.kind == :service }
111
+ .map(&:path_basename)
112
+ .uniq
113
+ end
114
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class SystemdCalendarSpecTest < Minitest::Test
6
+ def test_translates_base_calendar_specs
7
+ assert_equal "hourly", Wheneverd::Systemd::CalendarSpec.to_on_calendar("hour")
8
+ assert_equal "daily", Wheneverd::Systemd::CalendarSpec.to_on_calendar("day")
9
+ assert_equal "monthly", Wheneverd::Systemd::CalendarSpec.to_on_calendar("month")
10
+ assert_equal "yearly", Wheneverd::Systemd::CalendarSpec.to_on_calendar("year")
11
+ end
12
+
13
+ def test_translates_weekday_weekend_and_days_without_at_to_midnight
14
+ assert_equal "Mon..Fri *-*-* 00:00:00", Wheneverd::Systemd::CalendarSpec.to_on_calendar("weekday")
15
+ assert_equal "Sat,Sun *-*-* 00:00:00", Wheneverd::Systemd::CalendarSpec.to_on_calendar("weekend")
16
+ assert_equal "Mon *-*-* 00:00:00", Wheneverd::Systemd::CalendarSpec.to_on_calendar("monday")
17
+ end
18
+
19
+ def test_translates_calendar_specs_with_at
20
+ assert_equal "*-*-* 04:30:00", Wheneverd::Systemd::CalendarSpec.to_on_calendar("day@4:30 am")
21
+ assert_equal "Mon..Fri *-*-* 00:15:00", Wheneverd::Systemd::CalendarSpec.to_on_calendar("weekday@00:15")
22
+ assert_equal "Sat,Sun *-*-* 18:00:00",
23
+ Wheneverd::Systemd::CalendarSpec.to_on_calendar("weekend@6:00 pm")
24
+ end
25
+
26
+ def test_rejects_invalid_calendar_specs
27
+ assert_raises(Wheneverd::Systemd::InvalidCalendarSpecError) do
28
+ Wheneverd::Systemd::CalendarSpec.to_on_calendar("")
29
+ end
30
+
31
+ assert_raises(Wheneverd::Systemd::InvalidCalendarSpecError) do
32
+ Wheneverd::Systemd::CalendarSpec.to_on_calendar("not-a-period")
33
+ end
34
+
35
+ assert_raises(Wheneverd::Systemd::InvalidCalendarSpecError) do
36
+ Wheneverd::Systemd::CalendarSpec.to_on_calendar("hour@12pm")
37
+ end
38
+ end
39
+
40
+ def test_rejects_calendar_specs_that_expand_to_multiple_on_calendar_values
41
+ assert_raises(Wheneverd::Systemd::InvalidCalendarSpecError) do
42
+ Wheneverd::Systemd::CalendarSpec.to_on_calendar("cron:0 0 1 * Mon")
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class SystemdCronParserTest < Minitest::Test
6
+ def test_translates_simple_cron_to_single_value
7
+ assert_values("0 0 * * *", ["*-*-* 00:00:00"])
8
+ assert_values("59 23 5 * *", ["*-*-5 23:59:00"])
9
+ assert_values("0 0 27-31 * *", ["*-*-27..31 00:00:00"])
10
+ end
11
+
12
+ def test_translates_months_and_steps
13
+ assert_values("0 0 * 1 *", ["*-1-* 00:00:00"])
14
+ assert_values("0 0 * Jan *", ["*-1-* 00:00:00"])
15
+ assert_values("*/15 9-17 * * *", ["*-*-* 09..17:00/15:00"])
16
+ end
17
+
18
+ def test_translates_lists_and_steps
19
+ assert_values("0,30 8,16 * * *", ["*-*-* 08,16:00,30:00"])
20
+ assert_values("0 0 */2 * *", ["*-*-1/2 00:00:00"])
21
+ assert_values("0 0 * */2 *", ["*-1/2-* 00:00:00"])
22
+ end
23
+
24
+ def test_translates_day_of_week_variants
25
+ assert_values("0 0 * * Mon", ["Mon *-*-* 00:00:00"])
26
+ assert_values("0 0 * * 1-5", ["Mon..Fri *-*-* 00:00:00"])
27
+ assert_values("0 0 * * */2", ["Tue,Thu,Sat..Sun *-*-* 00:00:00"])
28
+ assert_values("0 0 * * Fri-Mon", ["Mon,Fri..Sun *-*-* 00:00:00"])
29
+ end
30
+
31
+ def test_dom_and_dow_or_semantics_expands_to_multiple_values
32
+ assert_values("0 0 1 * Mon", ["Mon *-*-* 00:00:00", "*-*-1 00:00:00"])
33
+ end
34
+
35
+ def test_to_on_calendar_rejects_multiple_values
36
+ assert_raises(Wheneverd::Systemd::UnsupportedCronError) do
37
+ Wheneverd::Systemd::CronParser.to_on_calendar("0 0 1 * Mon")
38
+ end
39
+ end
40
+
41
+ def test_private_helpers_reject_empty_numeric_field
42
+ assert_private_unsupported(
43
+ :parse_mapped_numeric_expression,
44
+ "",
45
+ 0..59,
46
+ field: "minute",
47
+ input: "x",
48
+ names: {}
49
+ )
50
+ end
51
+
52
+ def test_private_helpers_reject_invalid_numeric_tokens
53
+ common = { field: "minute", input: "x", names: {} }
54
+ assert_private_unsupported(:parse_mapped_value, " ", 0..59, **common)
55
+ assert_private_unsupported(:parse_positive_int, "x", field: "minute", input: "x", label: "step")
56
+ end
57
+
58
+ def test_private_helpers_reject_empty_dow_and_invalid_tokens
59
+ assert_private_unsupported(:parse_dow_set, "", input: "x")
60
+ assert_private_unsupported(:apply_dow_part, Array.new(7, false), "", input: "x")
61
+ assert_private_unsupported(:parse_dow_value, "", input: "x")
62
+ assert_private_unsupported(:parse_dow_value, "8", input: "x")
63
+ end
64
+
65
+ def test_rejects_unsupported_cron_patterns
66
+ assert_unsupported(
67
+ "0 0",
68
+ "x 0 * * *",
69
+ "60 0 * * *",
70
+ "0 0 0 * *",
71
+ "0 0 10 13 *"
72
+ )
73
+ end
74
+
75
+ def test_rejects_unsupported_cron_tokens
76
+ assert_unsupported(
77
+ "0 0 31-27 * *",
78
+ "0 0 * * Funday",
79
+ "0 0 */0 * *",
80
+ "0 0 ? * *"
81
+ )
82
+ end
83
+
84
+ def test_rejects_empty_tokens_and_invalid_steps
85
+ assert_unsupported(
86
+ "0,,15 0 * * *",
87
+ "*/x 0 * * *",
88
+ "0 0 * * Mon,,Tue",
89
+ "0 0 * * Mon-",
90
+ "0 0 * * 8"
91
+ )
92
+ end
93
+
94
+ private
95
+
96
+ def assert_values(cron, expected)
97
+ assert_equal expected, Wheneverd::Systemd::CronParser.to_on_calendar_values(cron)
98
+ end
99
+
100
+ def assert_private_unsupported(method, *args, **kwargs)
101
+ parser = Wheneverd::Systemd::CronParser
102
+ assert_raises(Wheneverd::Systemd::UnsupportedCronError) do
103
+ parser.send(method, *args, **kwargs)
104
+ end
105
+ end
106
+
107
+ def assert_unsupported(*crons)
108
+ crons.each do |cron|
109
+ assert_raises(Wheneverd::Systemd::UnsupportedCronError) do
110
+ Wheneverd::Systemd::CronParser.to_on_calendar_values(cron)
111
+ end
112
+ end
113
+ end
114
+ end