ruby_spriter 0.6.7 → 0.7.0.1

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +3 -3
  3. data/CHANGELOG.md +1035 -405
  4. data/Gemfile +17 -17
  5. data/LICENSE +21 -21
  6. data/README.md +183 -902
  7. data/bin/ruby_spriter +20 -20
  8. data/lib/ruby_spriter/background_sampler.rb +140 -0
  9. data/lib/ruby_spriter/batch_processor.rb +268 -212
  10. data/lib/ruby_spriter/cell_cleanup_config.rb +21 -0
  11. data/lib/ruby_spriter/cell_cleanup_gimp_script.rb +123 -0
  12. data/lib/ruby_spriter/cell_cleanup_processor.rb +230 -0
  13. data/lib/ruby_spriter/cli.rb +676 -612
  14. data/lib/ruby_spriter/compression_manager.rb +101 -101
  15. data/lib/ruby_spriter/consolidator.rb +179 -179
  16. data/lib/ruby_spriter/dependency_checker.rb +224 -174
  17. data/lib/ruby_spriter/ghost_edge_cleaner.rb +164 -0
  18. data/lib/ruby_spriter/gimp_processor.rb +1188 -667
  19. data/lib/ruby_spriter/metadata_manager.rb +117 -116
  20. data/lib/ruby_spriter/platform.rb +137 -82
  21. data/lib/ruby_spriter/processor.rb +1230 -886
  22. data/lib/ruby_spriter/smoke_detector.rb +223 -0
  23. data/lib/ruby_spriter/threshold_stepper.rb +227 -0
  24. data/lib/ruby_spriter/utils/file_helper.rb +82 -82
  25. data/lib/ruby_spriter/utils/image_helper.rb +16 -0
  26. data/lib/ruby_spriter/utils/output_formatter.rb +65 -65
  27. data/lib/ruby_spriter/utils/path_helper.rb +59 -59
  28. data/lib/ruby_spriter/utils/spritesheet_splitter.rb +86 -86
  29. data/lib/ruby_spriter/version.rb +6 -7
  30. data/lib/ruby_spriter/video_processor.rb +357 -139
  31. data/lib/ruby_spriter.rb +38 -34
  32. data/ruby_spriter.gemspec +44 -42
  33. data/spec/fixtures/centered_sprite_with_inner_bg.png +0 -0
  34. data/spec/fixtures/centered_sprite_with_inner_bg_inner_removed.png +0 -0
  35. data/spec/fixtures/centered_sprite_with_inner_bg_threshold_stepped.png +0 -0
  36. data/spec/fixtures/complex_background_sprite.png +0 -0
  37. data/spec/fixtures/death - bloodborne rekconing.mp4 +0 -0
  38. data/spec/fixtures/death - bloodborne rekconing_spritesheet-nobg-global.png +0 -0
  39. data/spec/fixtures/death - bloodborne rekconing_spritesheet.png +0 -0
  40. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431-nobg-global.png +0 -0
  41. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431.png +0 -0
  42. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738-nobg-global.png +0 -0
  43. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738.png +0 -0
  44. data/spec/fixtures/has_inner_bg.png +0 -0
  45. data/spec/fixtures/has_small_inner_bg.png +0 -0
  46. data/spec/fixtures/smoke_effect_sprite.png +0 -0
  47. data/spec/fixtures/spritesheet_with_metadata.png +0 -0
  48. data/spec/fixtures/test_sprite.png +0 -0
  49. data/spec/fixtures/test_sprite_smoke_processed.png +0 -0
  50. data/spec/fixtures/test_video_spritesheet.png +0 -0
  51. data/spec/fixtures/transparent_bg_sprite.png +0 -0
  52. data/spec/fixtures/walk_north_sprite-sheet.png +0 -0
  53. data/spec/integration/no_fuzzy_mode_spec.rb +100 -0
  54. data/spec/ruby_spriter/batch_processor_spec.rb +509 -200
  55. data/spec/ruby_spriter/cli_spec.rb +2026 -1892
  56. data/spec/ruby_spriter/compression_manager_spec.rb +157 -157
  57. data/spec/ruby_spriter/consolidator_spec.rb +538 -538
  58. data/spec/ruby_spriter/gimp_processor_spec.rb +523 -425
  59. data/spec/ruby_spriter/platform_spec.rb +92 -82
  60. data/spec/ruby_spriter/processor_spec.rb +911 -735
  61. data/spec/ruby_spriter/utils/file_helper_spec.rb +150 -150
  62. data/spec/ruby_spriter/utils/path_helper_spec.rb +78 -78
  63. data/spec/ruby_spriter/utils/spritesheet_splitter_spec.rb +104 -104
  64. data/spec/ruby_spriter/video_processor_spec.rb +346 -29
  65. data/spec/spec_helper.rb +41 -41
  66. data/spec/tmp/cli_test_output.png +0 -0
  67. data/spec/tmp/cli_test_output_20251105_114647_395.png +0 -0
  68. data/spec/tmp/combined_test.png +0 -0
  69. data/spec/tmp/compat_test.png +0 -0
  70. data/spec/tmp/compat_test_20251030_174233_361.png +0 -0
  71. data/spec/tmp/final_all_features.png +0 -0
  72. data/spec/tmp/final_test_all_features.png +0 -0
  73. data/spec/tmp/full_pipeline_test.png +0 -0
  74. data/spec/tmp/inner_test.png +0 -0
  75. data/spec/tmp/integration_test.png +0 -0
  76. data/spec/tmp/validation_test.png +0 -0
  77. data/spec/unit/background_sampler_spec.rb +132 -0
  78. data/spec/unit/cell_cleanup_config_spec.rb +32 -0
  79. data/spec/unit/cell_cleanup_gimp_script_spec.rb +59 -0
  80. data/spec/unit/cell_cleanup_processor_spec.rb +261 -0
  81. data/spec/unit/ghost_edge_cleaner_spec.rb +223 -0
  82. data/spec/unit/gimp_processor_single_point_selection_spec.rb +81 -0
  83. data/spec/unit/smoke_detector_spec.rb +246 -0
  84. data/spec/unit/threshold_stepper_spec.rb +195 -0
  85. metadata +56 -10
@@ -1,1892 +1,2026 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
-
5
- RSpec.describe RubySpriter::CLI do
6
- describe 'Other Options' do
7
- describe '--keep-temp flag' do
8
- it 'sets keep_temp option to true' do
9
- # Mock the Processor to capture the options
10
- processor_double = instance_double(RubySpriter::Processor)
11
- allow(processor_double).to receive(:run)
12
-
13
- allow(RubySpriter::Processor).to receive(:new) do |options|
14
- expect(options[:keep_temp]).to eq(true)
15
- processor_double
16
- end
17
-
18
- # Parse with --keep-temp and a valid input to avoid validation errors
19
- described_class.start(['--video', 'test.mp4', '--keep-temp'])
20
- end
21
- end
22
-
23
- describe '--debug flag' do
24
- it 'sets both debug and keep_temp options to true' do
25
- processor_double = instance_double(RubySpriter::Processor)
26
- allow(processor_double).to receive(:run)
27
-
28
- allow(RubySpriter::Processor).to receive(:new) do |options|
29
- expect(options[:debug]).to eq(true)
30
- expect(options[:keep_temp]).to eq(true)
31
- processor_double
32
- end
33
-
34
- described_class.start(['--video', 'test.mp4', '--debug'])
35
- end
36
- end
37
-
38
- describe '--help flag' do
39
- it 'outputs help text and exits' do
40
- expect do
41
- expect { described_class.start(['--help']) }.to output(/Usage: ruby_spriter/).to_stdout
42
- end.to raise_error(SystemExit)
43
- end
44
-
45
- it 'displays Other Options section' do
46
- expect do
47
- expect { described_class.start(['--help']) }.to output(/Other Options:/).to_stdout
48
- end.to raise_error(SystemExit)
49
- end
50
-
51
- it 'lists --keep-temp in help output' do
52
- expect do
53
- expect { described_class.start(['--help']) }.to output(/--keep-temp/).to_stdout
54
- end.to raise_error(SystemExit)
55
- end
56
-
57
- it 'lists --debug in help output' do
58
- expect do
59
- expect { described_class.start(['--help']) }.to output(/--debug/).to_stdout
60
- end.to raise_error(SystemExit)
61
- end
62
-
63
- it 'lists --version in help output' do
64
- expect do
65
- expect { described_class.start(['--help']) }.to output(/--version/).to_stdout
66
- end.to raise_error(SystemExit)
67
- end
68
-
69
- it 'lists --check-dependencies in help output' do
70
- expect do
71
- expect { described_class.start(['--help']) }.to output(/--check-dependencies/).to_stdout
72
- end.to raise_error(SystemExit)
73
- end
74
-
75
- it 'supports short form -h' do
76
- expect do
77
- expect { described_class.start(['-h']) }.to output(/Usage: ruby_spriter/).to_stdout
78
- end.to raise_error(SystemExit)
79
- end
80
-
81
- it 'shows mode-specific help hints' do
82
- output = StringIO.new
83
- $stdout = output
84
-
85
- begin
86
- described_class.start(['--help'])
87
- rescue SystemExit
88
- # Expected
89
- ensure
90
- $stdout = STDOUT
91
- end
92
-
93
- expect(output.string).to include('Get mode-specific help:')
94
- end
95
- end
96
-
97
- describe '--version flag' do
98
- it 'outputs version information and exits' do
99
- expect do
100
- expect { described_class.start(['--version']) }
101
- .to output(/Ruby Spriter v#{RubySpriter::VERSION}/).to_stdout
102
- end.to raise_error(SystemExit)
103
- end
104
-
105
- it 'displays platform information' do
106
- expect do
107
- expect { described_class.start(['--version']) }
108
- .to output(/Platform:/).to_stdout
109
- end.to raise_error(SystemExit)
110
- end
111
-
112
- it 'displays date information' do
113
- expect do
114
- expect { described_class.start(['--version']) }
115
- .to output(/Date: #{RubySpriter::VERSION_DATE}/).to_stdout
116
- end.to raise_error(SystemExit)
117
- end
118
- end
119
-
120
- describe '--check-dependencies flag' do
121
- it 'sets check_dependencies option to true' do
122
- # Mock DependencyChecker to avoid actually checking dependencies
123
- checker_double = instance_double(RubySpriter::DependencyChecker)
124
- allow(checker_double).to receive(:print_report)
125
- allow(checker_double).to receive(:all_satisfied?).and_return(true)
126
- allow(RubySpriter::DependencyChecker).to receive(:new).and_return(checker_double)
127
-
128
- expect do
129
- described_class.start(['--check-dependencies'])
130
- end.to raise_error(SystemExit) { |error|
131
- expect(error.status).to eq(0)
132
- }
133
- end
134
-
135
- it 'exits with 0 when all dependencies are satisfied' do
136
- checker_double = instance_double(RubySpriter::DependencyChecker)
137
- allow(checker_double).to receive(:print_report)
138
- allow(checker_double).to receive(:all_satisfied?).and_return(true)
139
- allow(RubySpriter::DependencyChecker).to receive(:new).and_return(checker_double)
140
-
141
- expect do
142
- described_class.start(['--check-dependencies'])
143
- end.to raise_error(SystemExit) { |error|
144
- expect(error.status).to eq(0)
145
- }
146
- end
147
-
148
- it 'exits with 1 when dependencies are missing' do
149
- checker_double = instance_double(RubySpriter::DependencyChecker)
150
- allow(checker_double).to receive(:print_report)
151
- allow(checker_double).to receive(:all_satisfied?).and_return(false)
152
- allow(RubySpriter::DependencyChecker).to receive(:new).and_return(checker_double)
153
-
154
- expect do
155
- described_class.start(['--check-dependencies'])
156
- end.to raise_error(SystemExit) { |error|
157
- expect(error.status).to eq(1)
158
- }
159
- end
160
-
161
- it 'calls DependencyChecker with verbose: true' do
162
- checker_double = instance_double(RubySpriter::DependencyChecker)
163
- allow(checker_double).to receive(:print_report)
164
- allow(checker_double).to receive(:all_satisfied?).and_return(true)
165
-
166
- expect(RubySpriter::DependencyChecker).to receive(:new).with(verbose: true).and_return(checker_double)
167
-
168
- expect do
169
- described_class.start(['--check-dependencies'])
170
- end.to raise_error(SystemExit)
171
- end
172
- end
173
-
174
- describe '--overwrite flag' do
175
- it 'sets overwrite option to true' do
176
- processor_double = instance_double(RubySpriter::Processor)
177
- allow(processor_double).to receive(:run)
178
-
179
- allow(RubySpriter::Processor).to receive(:new) do |options|
180
- expect(options[:overwrite]).to eq(true)
181
- processor_double
182
- end
183
-
184
- described_class.start(['--video', 'test.mp4', '--overwrite'])
185
- end
186
-
187
- it 'defaults to false when not specified' do
188
- processor_double = instance_double(RubySpriter::Processor)
189
- allow(processor_double).to receive(:run)
190
-
191
- allow(RubySpriter::Processor).to receive(:new) do |options|
192
- expect(options[:overwrite]).to be_nil
193
- processor_double
194
- end
195
-
196
- described_class.start(['--video', 'test.mp4'])
197
- end
198
- end
199
- end
200
-
201
- describe '--image flag' do
202
- let(:fixture_with_meta) { File.join(__dir__, '..', 'fixtures', 'spritesheet_with_metadata.png') }
203
- let(:fixture_without_meta) { File.join(__dir__, '..', 'fixtures', 'image_without_metadata.png') }
204
-
205
- describe 'argument parsing' do
206
- it 'sets image option with --image flag' do
207
- processor_double = instance_double(RubySpriter::Processor)
208
- allow(processor_double).to receive(:run)
209
-
210
- allow(RubySpriter::Processor).to receive(:new) do |options|
211
- expect(options[:image]).to eq(fixture_with_meta)
212
- processor_double
213
- end
214
-
215
- described_class.start(['--image', fixture_with_meta])
216
- end
217
-
218
- it 'supports short form -i flag' do
219
- processor_double = instance_double(RubySpriter::Processor)
220
- allow(processor_double).to receive(:run)
221
-
222
- allow(RubySpriter::Processor).to receive(:new) do |options|
223
- expect(options[:image]).to eq(fixture_without_meta)
224
- processor_double
225
- end
226
-
227
- described_class.start(['-i', fixture_without_meta])
228
- end
229
-
230
- it 'accepts file path with spaces' do
231
- # Create a temp file with spaces in the name for this test
232
- temp_file = File.join(@test_dir, 'file with spaces.png')
233
- FileUtils.cp(fixture_with_meta, temp_file)
234
-
235
- processor_double = instance_double(RubySpriter::Processor)
236
- allow(processor_double).to receive(:run)
237
-
238
- allow(RubySpriter::Processor).to receive(:new) do |options|
239
- expect(options[:image]).to eq(temp_file)
240
- processor_double
241
- end
242
-
243
- described_class.start(['--image', temp_file])
244
- end
245
- end
246
-
247
- describe 'mutual exclusivity with other input modes' do
248
- it 'cannot be used with --video' do
249
- expect do
250
- described_class.start(['--video', 'test.mp4', '--image', fixture_with_meta])
251
- end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
252
- end
253
-
254
- it 'cannot be used with --consolidate' do
255
- expect do
256
- described_class.start(['--consolidate', 'a.png,b.png', '--image', fixture_with_meta])
257
- end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
258
- end
259
-
260
- it 'cannot be used with --verify' do
261
- expect do
262
- described_class.start(['--verify', fixture_with_meta, '--image', fixture_without_meta])
263
- end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
264
- end
265
-
266
- it 'can be used alone without error' do
267
- processor_double = instance_double(RubySpriter::Processor)
268
- allow(processor_double).to receive(:run)
269
- allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
270
-
271
- expect do
272
- described_class.start(['--image', fixture_with_meta])
273
- end.not_to raise_error
274
- end
275
- end
276
-
277
- describe 'file validation' do
278
- describe 'file existence' do
279
- it 'raises error for non-existent file' do
280
- expect do
281
- described_class.start(['--image', 'nonexistent.png'])
282
- end.to raise_error(RubySpriter::ValidationError, /File not found/)
283
- end
284
-
285
- it 'accepts existing PNG file with metadata' do
286
- processor_double = instance_double(RubySpriter::Processor)
287
- allow(processor_double).to receive(:run)
288
- allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
289
-
290
- expect(File.exist?(fixture_with_meta)).to be true
291
- expect do
292
- described_class.start(['--image', fixture_with_meta])
293
- end.not_to raise_error
294
- end
295
-
296
- it 'accepts existing PNG file without metadata' do
297
- processor_double = instance_double(RubySpriter::Processor)
298
- allow(processor_double).to receive(:run)
299
- allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
300
-
301
- expect(File.exist?(fixture_without_meta)).to be true
302
- expect do
303
- described_class.start(['--image', fixture_without_meta])
304
- end.not_to raise_error
305
- end
306
- end
307
-
308
- describe 'file extension validation' do
309
- it 'accepts .png extension' do
310
- processor_double = instance_double(RubySpriter::Processor)
311
- allow(processor_double).to receive(:run)
312
- allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
313
-
314
- expect(File.extname(fixture_with_meta)).to eq('.png')
315
- expect do
316
- described_class.start(['--image', fixture_with_meta])
317
- end.not_to raise_error
318
- end
319
-
320
- it 'accepts .PNG extension (case insensitive)' do
321
- # Create a temp file with uppercase extension
322
- temp_file = File.join(@test_dir, 'test.PNG')
323
- FileUtils.cp(fixture_with_meta, temp_file)
324
-
325
- processor_double = instance_double(RubySpriter::Processor)
326
- allow(processor_double).to receive(:run)
327
- allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
328
-
329
- expect do
330
- described_class.start(['--image', temp_file])
331
- end.not_to raise_error
332
- end
333
-
334
- it 'rejects .jpg extension' do
335
- # Create a fake .jpg file (doesn't need to be valid JPG for this test)
336
- temp_file = File.join(@test_dir, 'test.jpg')
337
- FileUtils.touch(temp_file)
338
-
339
- expect do
340
- described_class.start(['--image', temp_file])
341
- end.to raise_error(RubySpriter::ValidationError, /--image expects \.png file, got: \.jpg/)
342
- end
343
-
344
- it 'rejects .jpeg extension' do
345
- temp_file = File.join(@test_dir, 'test.jpeg')
346
- FileUtils.touch(temp_file)
347
-
348
- expect do
349
- described_class.start(['--image', temp_file])
350
- end.to raise_error(RubySpriter::ValidationError, /--image expects \.png file, got: \.jpeg/)
351
- end
352
-
353
- it 'rejects .gif extension' do
354
- temp_file = File.join(@test_dir, 'test.gif')
355
- FileUtils.touch(temp_file)
356
-
357
- expect do
358
- described_class.start(['--image', temp_file])
359
- end.to raise_error(RubySpriter::ValidationError, /--image expects \.png file, got: \.gif/)
360
- end
361
-
362
- it 'rejects .bmp extension' do
363
- temp_file = File.join(@test_dir, 'test.bmp')
364
- FileUtils.touch(temp_file)
365
-
366
- expect do
367
- described_class.start(['--image', temp_file])
368
- end.to raise_error(RubySpriter::ValidationError, /--image expects \.png file, got: \.bmp/)
369
- end
370
-
371
- it 'rejects file with no extension' do
372
- temp_file = File.join(@test_dir, 'testfile')
373
- FileUtils.touch(temp_file)
374
-
375
- expect do
376
- described_class.start(['--image', temp_file])
377
- end.to raise_error(RubySpriter::ValidationError, /--image expects \.png file/)
378
- end
379
- end
380
- end
381
-
382
- describe 'integration with processing options' do
383
- it 'works with --scale option' do
384
- processor_double = instance_double(RubySpriter::Processor)
385
- allow(processor_double).to receive(:run)
386
-
387
- allow(RubySpriter::Processor).to receive(:new) do |options|
388
- expect(options[:image]).to eq(fixture_with_meta)
389
- expect(options[:scale_percent]).to eq(50)
390
- processor_double
391
- end
392
-
393
- described_class.start(['--image', fixture_with_meta, '--scale', '50'])
394
- end
395
-
396
- it 'works with --remove-bg option' do
397
- processor_double = instance_double(RubySpriter::Processor)
398
- allow(processor_double).to receive(:run)
399
-
400
- allow(RubySpriter::Processor).to receive(:new) do |options|
401
- expect(options[:image]).to eq(fixture_with_meta)
402
- expect(options[:remove_bg]).to eq(true)
403
- processor_double
404
- end
405
-
406
- described_class.start(['--image', fixture_with_meta, '--remove-bg'])
407
- end
408
-
409
- it 'works with --sharpen option' do
410
- processor_double = instance_double(RubySpriter::Processor)
411
- allow(processor_double).to receive(:run)
412
-
413
- allow(RubySpriter::Processor).to receive(:new) do |options|
414
- expect(options[:image]).to eq(fixture_without_meta)
415
- expect(options[:sharpen]).to eq(true)
416
- processor_double
417
- end
418
-
419
- described_class.start(['--image', fixture_without_meta, '--sharpen'])
420
- end
421
-
422
- it 'works with --interpolation option' do
423
- processor_double = instance_double(RubySpriter::Processor)
424
- allow(processor_double).to receive(:run)
425
-
426
- allow(RubySpriter::Processor).to receive(:new) do |options|
427
- expect(options[:image]).to eq(fixture_with_meta)
428
- expect(options[:scale_interpolation]).to eq('nohalo')
429
- processor_double
430
- end
431
-
432
- described_class.start(['--image', fixture_with_meta, '--interpolation', 'nohalo'])
433
- end
434
-
435
- it 'works with multiple processing options combined' do
436
- processor_double = instance_double(RubySpriter::Processor)
437
- allow(processor_double).to receive(:run)
438
-
439
- allow(RubySpriter::Processor).to receive(:new) do |options|
440
- expect(options[:image]).to eq(fixture_without_meta)
441
- expect(options[:scale_percent]).to eq(50)
442
- expect(options[:remove_bg]).to eq(true)
443
- expect(options[:sharpen]).to eq(true)
444
- expect(options[:scale_interpolation]).to eq('lohalo')
445
- processor_double
446
- end
447
-
448
- described_class.start([
449
- '--image', fixture_without_meta,
450
- '--scale', '50',
451
- '--remove-bg',
452
- '--sharpen',
453
- '--interpolation', 'lohalo'
454
- ])
455
- end
456
-
457
- it 'works with --output option' do
458
- processor_double = instance_double(RubySpriter::Processor)
459
- allow(processor_double).to receive(:run)
460
-
461
- allow(RubySpriter::Processor).to receive(:new) do |options|
462
- expect(options[:image]).to eq(fixture_with_meta)
463
- expect(options[:output]).to eq('custom_output.png')
464
- processor_double
465
- end
466
-
467
- described_class.start(['--image', fixture_with_meta, '--output', 'custom_output.png'])
468
- end
469
-
470
- it 'works with --overwrite option' do
471
- processor_double = instance_double(RubySpriter::Processor)
472
- allow(processor_double).to receive(:run)
473
-
474
- allow(RubySpriter::Processor).to receive(:new) do |options|
475
- expect(options[:image]).to eq(fixture_with_meta)
476
- expect(options[:remove_bg]).to eq(true)
477
- expect(options[:overwrite]).to eq(true)
478
- processor_double
479
- end
480
-
481
- described_class.start(['--image', fixture_with_meta, '--remove-bg', '--overwrite'])
482
- end
483
-
484
- it 'works with --overwrite and --output options combined' do
485
- processor_double = instance_double(RubySpriter::Processor)
486
- allow(processor_double).to receive(:run)
487
-
488
- allow(RubySpriter::Processor).to receive(:new) do |options|
489
- expect(options[:image]).to eq(fixture_with_meta)
490
- expect(options[:scale_percent]).to eq(50)
491
- expect(options[:output]).to eq('custom.png')
492
- expect(options[:overwrite]).to eq(true)
493
- processor_double
494
- end
495
-
496
- described_class.start(['--image', fixture_with_meta, '--scale', '50', '--output', 'custom.png', '--overwrite'])
497
- end
498
- end
499
-
500
- describe 'output filename behavior with processing' do
501
- it 'generates unique filename by default when processing without --output' do
502
- # Mock all dependencies
503
- allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
504
- allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
505
-
506
- # Mock GimpProcessor to return a processed file
507
- gimp_double = instance_double(RubySpriter::GimpProcessor)
508
- allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_double)
509
- allow(gimp_double).to receive(:process).and_return('input-nobg-fuzzy_20251023_123456_789.png')
510
-
511
- processor = RubySpriter::Processor.new(
512
- image: fixture_with_meta,
513
- remove_bg: true,
514
- overwrite: false
515
- )
516
-
517
- allow(processor).to receive(:check_dependencies!)
518
- allow(processor).to receive(:setup_temp_directory)
519
- allow(processor).to receive(:cleanup)
520
- allow(processor).to receive(:gimp_path).and_return('/usr/bin/gimp')
521
-
522
- result = nil
523
- expect { result = processor.run }.to output(/SUCCESS/).to_stdout
524
-
525
- # Should return the uniquely-named file from GIMP processing
526
- expect(result[:output_file]).to match(/-nobg-fuzzy.*\.png$/)
527
- end
528
-
529
- it 'overwrites output file when --overwrite is specified' do
530
- # Mock all dependencies
531
- allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
532
- allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
533
-
534
- # Mock GimpProcessor - with overwrite:true, it should return same filename
535
- gimp_double = instance_double(RubySpriter::GimpProcessor)
536
- allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_double)
537
- allow(gimp_double).to receive(:process).and_return('input-scaled-50pct.png')
538
-
539
- processor = RubySpriter::Processor.new(
540
- image: fixture_with_meta,
541
- scale_percent: 50,
542
- overwrite: true
543
- )
544
-
545
- allow(processor).to receive(:check_dependencies!)
546
- allow(processor).to receive(:setup_temp_directory)
547
- allow(processor).to receive(:cleanup)
548
- allow(processor).to receive(:gimp_path).and_return('/usr/bin/gimp')
549
-
550
- result = nil
551
- expect { result = processor.run }.to output(/SUCCESS/).to_stdout
552
-
553
- # Should return the base filename (no timestamp)
554
- expect(result[:output_file]).to eq('input-scaled-50pct.png')
555
- end
556
-
557
- it 'generates unique output filename when --output is used without --overwrite' do
558
- # Mock all dependencies
559
- allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
560
- allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
561
-
562
- # Mock ensure_unique_output to verify it's called correctly
563
- allow(RubySpriter::Utils::FileHelper).to receive(:ensure_unique_output) do |path, overwrite:|
564
- expect(path).to eq('custom_output.png')
565
- expect(overwrite).to eq(false)
566
- 'custom_output_20251023_123456_789.png'
567
- end
568
-
569
- # Mock GimpProcessor
570
- gimp_double = instance_double(RubySpriter::GimpProcessor)
571
- allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_double)
572
- allow(gimp_double).to receive(:process).and_return('temp-processed.png')
573
-
574
- # Mock file operations
575
- allow(FileUtils).to receive(:cp)
576
-
577
- processor = RubySpriter::Processor.new(
578
- image: fixture_with_meta,
579
- remove_bg: true,
580
- output: 'custom_output.png',
581
- overwrite: false
582
- )
583
-
584
- allow(processor).to receive(:check_dependencies!)
585
- allow(processor).to receive(:setup_temp_directory)
586
- allow(processor).to receive(:cleanup)
587
- allow(processor).to receive(:gimp_path).and_return('/usr/bin/gimp')
588
-
589
- result = nil
590
- expect { result = processor.run }.to output(/SUCCESS/).to_stdout
591
-
592
- # Should return unique filename
593
- expect(result[:output_file]).to match(/custom_output_\d{8}_\d{6}_\d{3}\.png$/)
594
- end
595
-
596
- it 'uses exact output filename when --output and --overwrite are both specified' do
597
- # Mock all dependencies
598
- allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
599
- allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
600
-
601
- # Mock ensure_unique_output to verify it's called with overwrite:true
602
- allow(RubySpriter::Utils::FileHelper).to receive(:ensure_unique_output) do |path, overwrite:|
603
- expect(path).to eq('exact_output.png')
604
- expect(overwrite).to eq(true)
605
- 'exact_output.png'
606
- end
607
-
608
- # Mock GimpProcessor
609
- gimp_double = instance_double(RubySpriter::GimpProcessor)
610
- allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_double)
611
- allow(gimp_double).to receive(:process).and_return('temp-processed.png')
612
-
613
- # Mock file operations
614
- allow(FileUtils).to receive(:cp)
615
-
616
- processor = RubySpriter::Processor.new(
617
- image: fixture_with_meta,
618
- scale_percent: 50,
619
- output: 'exact_output.png',
620
- overwrite: true
621
- )
622
-
623
- allow(processor).to receive(:check_dependencies!)
624
- allow(processor).to receive(:setup_temp_directory)
625
- allow(processor).to receive(:cleanup)
626
- allow(processor).to receive(:gimp_path).and_return('/usr/bin/gimp')
627
-
628
- result = nil
629
- expect { result = processor.run }.to output(/SUCCESS/).to_stdout
630
-
631
- # Should return exact filename (no timestamp)
632
- expect(result[:output_file]).to eq('exact_output.png')
633
- end
634
-
635
- it 'generates unique filename when using --sharpen alone without --output' do
636
- # Mock all dependencies
637
- allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
638
- allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
639
-
640
- # Mock GimpProcessor to return a sharpened file
641
- gimp_double = instance_double(RubySpriter::GimpProcessor)
642
- allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_double)
643
- allow(gimp_double).to receive(:process).and_return('input-sharpened_20251023_123456_789.png')
644
-
645
- processor = RubySpriter::Processor.new(
646
- image: fixture_with_meta,
647
- sharpen: true,
648
- overwrite: false
649
- )
650
-
651
- allow(processor).to receive(:check_dependencies!)
652
- allow(processor).to receive(:setup_temp_directory)
653
- allow(processor).to receive(:cleanup)
654
-
655
- result = nil
656
- expect { result = processor.run }.to output(/SUCCESS/).to_stdout
657
-
658
- # Should return the uniquely-named sharpened file
659
- expect(result[:output_file]).to match(/-sharpened.*\.png$/)
660
- end
661
-
662
- it 'overwrites sharpened file when --sharpen with --overwrite' do
663
- # Mock all dependencies
664
- allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
665
- allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
666
-
667
- # Mock GimpProcessor - with overwrite:true, should return base filename
668
- gimp_double = instance_double(RubySpriter::GimpProcessor)
669
- allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_double)
670
- allow(gimp_double).to receive(:process).and_return('input-sharpened.png')
671
-
672
- processor = RubySpriter::Processor.new(
673
- image: fixture_with_meta,
674
- sharpen: true,
675
- overwrite: true
676
- )
677
-
678
- allow(processor).to receive(:check_dependencies!)
679
- allow(processor).to receive(:setup_temp_directory)
680
- allow(processor).to receive(:cleanup)
681
-
682
- result = nil
683
- expect { result = processor.run }.to output(/SUCCESS/).to_stdout
684
-
685
- # Should return the base filename (no timestamp)
686
- expect(result[:output_file]).to eq('input-sharpened.png')
687
- end
688
- end
689
- end
690
-
691
- describe '--video flag' do
692
- let(:fixture_video) { File.join(__dir__, '..', 'fixtures', 'test_video.mp4') }
693
-
694
- describe 'context-sensitive help' do
695
- it 'shows video mode help with --help' do
696
- output = StringIO.new
697
- $stdout = output
698
-
699
- begin
700
- described_class.start(['--video', '--help'])
701
- rescue SystemExit
702
- # Expected
703
- ensure
704
- $stdout = STDOUT
705
- end
706
-
707
- expect(output.string).to include('Video Mode')
708
- end
709
-
710
- it 'shows parent-child option hierarchy in video mode help' do
711
- output = StringIO.new
712
- $stdout = output
713
-
714
- begin
715
- described_class.start(['--video', '--help'])
716
- rescue SystemExit
717
- # Expected
718
- ensure
719
- $stdout = STDOUT
720
- end
721
-
722
- # Check for parent options
723
- expect(output.string).to include('-s, --scale PERCENT')
724
- expect(output.string).to include('-r, --remove-bg')
725
-
726
- # Check for child options with hierarchy marker
727
- expect(output.string).to include('└─ Interpolation:')
728
- expect(output.string).to include('└─ Sharpen radius')
729
- expect(output.string).to include('└─ Use fuzzy select')
730
- expect(output.string).to include('└─ Feather radius')
731
- expect(output.string).to include('└─ Grow selection')
732
-
733
- # Check that --order mentions BOTH requirement
734
- expect(output.string).to match(/order.*BOTH.*--scale.*AND.*--remove-bg/i)
735
- end
736
-
737
- it 'shows --sharpen as standalone option in video mode help' do
738
- output = StringIO.new
739
- $stdout = output
740
-
741
- begin
742
- described_class.start(['--video', '--help'])
743
- rescue SystemExit
744
- # Expected
745
- ensure
746
- $stdout = STDOUT
747
- end
748
-
749
- # --sharpen should be a standalone parent option (not indented under --scale)
750
- expect(output.string).to match(/^ --sharpen\s+Apply unsharp mask/)
751
-
752
- # --sharpen modifiers should be children under --sharpen
753
- expect(output.string).to include('└─ Sharpen radius')
754
- expect(output.string).to include('└─ Sharpen gain')
755
- expect(output.string).to include('└─ Sharpen threshold')
756
-
757
- # --interpolation should ONLY be under --scale (not under --sharpen)
758
- lines = output.string.lines
759
- sharpen_line_idx = lines.index { |l| l.include?('--sharpen') && l.include?('Apply unsharp mask') }
760
- scale_line_idx = lines.index { |l| l.include?('--scale PERCENT') }
761
- interpolation_line_idx = lines.index { |l| l.include?('└─ Interpolation') }
762
-
763
- # Interpolation should come after scale, not after sharpen
764
- expect(interpolation_line_idx).to be > scale_line_idx
765
- expect(interpolation_line_idx).to be < sharpen_line_idx
766
- end
767
-
768
- it 'shows image mode help with --help' do
769
- output = StringIO.new
770
- $stdout = output
771
-
772
- begin
773
- described_class.start(['--image', '--help'])
774
- rescue SystemExit
775
- # Expected
776
- ensure
777
- $stdout = STDOUT
778
- end
779
-
780
- expect(output.string).to include('Image Mode')
781
- end
782
-
783
- it 'shows consolidate mode help with --help' do
784
- output = StringIO.new
785
- $stdout = output
786
-
787
- begin
788
- described_class.start(['--consolidate', '--help'])
789
- rescue SystemExit
790
- # Expected
791
- ensure
792
- $stdout = STDOUT
793
- end
794
-
795
- expect(output.string).to include('Consolidate Mode')
796
- end
797
-
798
- it 'shows batch mode help with --help' do
799
- output = StringIO.new
800
- $stdout = output
801
-
802
- begin
803
- described_class.start(['--batch', '--help'])
804
- rescue SystemExit
805
- # Expected
806
- ensure
807
- $stdout = STDOUT
808
- end
809
-
810
- expect(output.string).to include('Batch Mode')
811
- end
812
-
813
- it 'shows split mode help with --help' do
814
- output = StringIO.new
815
- $stdout = output
816
-
817
- begin
818
- described_class.start(['--split', '--help'])
819
- rescue SystemExit
820
- # Expected
821
- ensure
822
- $stdout = STDOUT
823
- end
824
-
825
- expect(output.string).to include('Split Mode')
826
- end
827
- end
828
-
829
- describe 'argument parsing' do
830
- it 'sets video option with --video flag' do
831
- processor_double = instance_double(RubySpriter::Processor)
832
- allow(processor_double).to receive(:run)
833
-
834
- allow(RubySpriter::Processor).to receive(:new) do |options|
835
- expect(options[:video]).to eq(fixture_video)
836
- processor_double
837
- end
838
-
839
- described_class.start(['--video', fixture_video])
840
- end
841
-
842
- it 'supports short form -v flag' do
843
- processor_double = instance_double(RubySpriter::Processor)
844
- allow(processor_double).to receive(:run)
845
-
846
- allow(RubySpriter::Processor).to receive(:new) do |options|
847
- expect(options[:video]).to eq(fixture_video)
848
- processor_double
849
- end
850
-
851
- described_class.start(['-v', fixture_video])
852
- end
853
-
854
- it 'accepts file path with spaces' do
855
- # Create a temp file with spaces in the name for this test
856
- temp_file = File.join(@test_dir, 'video with spaces.mp4')
857
- FileUtils.cp(fixture_video, temp_file)
858
-
859
- processor_double = instance_double(RubySpriter::Processor)
860
- allow(processor_double).to receive(:run)
861
-
862
- allow(RubySpriter::Processor).to receive(:new) do |options|
863
- expect(options[:video]).to eq(temp_file)
864
- processor_double
865
- end
866
-
867
- described_class.start(['--video', temp_file])
868
- end
869
- end
870
-
871
- describe 'mutual exclusivity with other input modes' do
872
- it 'cannot be used with --image' do
873
- expect do
874
- described_class.start(['--video', fixture_video, '--image', 'test.png'])
875
- end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
876
- end
877
-
878
- it 'cannot be used with --consolidate' do
879
- expect do
880
- described_class.start(['--video', fixture_video, '--consolidate', 'a.png,b.png'])
881
- end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
882
- end
883
-
884
- it 'cannot be used with --verify' do
885
- expect do
886
- described_class.start(['--video', fixture_video, '--verify', 'test.png'])
887
- end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
888
- end
889
-
890
- it 'can be used alone without error' do
891
- processor_double = instance_double(RubySpriter::Processor)
892
- allow(processor_double).to receive(:run)
893
- allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
894
-
895
- expect do
896
- described_class.start(['--video', fixture_video])
897
- end.not_to raise_error
898
- end
899
- end
900
-
901
- describe 'file validation' do
902
- describe 'file existence' do
903
- it 'raises error for non-existent file' do
904
- expect do
905
- described_class.start(['--video', 'nonexistent.mp4'])
906
- end.to raise_error(RubySpriter::ValidationError, /File not found/)
907
- end
908
-
909
- it 'accepts existing MP4 file' do
910
- processor_double = instance_double(RubySpriter::Processor)
911
- allow(processor_double).to receive(:run)
912
- allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
913
-
914
- expect(File.exist?(fixture_video)).to be true
915
- expect do
916
- described_class.start(['--video', fixture_video])
917
- end.not_to raise_error
918
- end
919
- end
920
-
921
- describe 'file extension validation' do
922
- it 'accepts .mp4 extension' do
923
- processor_double = instance_double(RubySpriter::Processor)
924
- allow(processor_double).to receive(:run)
925
- allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
926
-
927
- expect(File.extname(fixture_video)).to eq('.mp4')
928
- expect do
929
- described_class.start(['--video', fixture_video])
930
- end.not_to raise_error
931
- end
932
-
933
- it 'accepts .MP4 extension (case insensitive)' do
934
- # Create a temp file with uppercase extension
935
- temp_file = File.join(@test_dir, 'test.MP4')
936
- FileUtils.cp(fixture_video, temp_file)
937
-
938
- processor_double = instance_double(RubySpriter::Processor)
939
- allow(processor_double).to receive(:run)
940
- allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
941
-
942
- expect do
943
- described_class.start(['--video', temp_file])
944
- end.not_to raise_error
945
- end
946
-
947
- it 'rejects .avi extension' do
948
- temp_file = File.join(@test_dir, 'test.avi')
949
- FileUtils.touch(temp_file)
950
-
951
- expect do
952
- described_class.start(['--video', temp_file])
953
- end.to raise_error(RubySpriter::ValidationError, /--video expects \.mp4 file, got: \.avi/)
954
- end
955
-
956
- it 'rejects .mov extension' do
957
- temp_file = File.join(@test_dir, 'test.mov')
958
- FileUtils.touch(temp_file)
959
-
960
- expect do
961
- described_class.start(['--video', temp_file])
962
- end.to raise_error(RubySpriter::ValidationError, /--video expects \.mp4 file, got: \.mov/)
963
- end
964
-
965
- it 'rejects .mkv extension' do
966
- temp_file = File.join(@test_dir, 'test.mkv')
967
- FileUtils.touch(temp_file)
968
-
969
- expect do
970
- described_class.start(['--video', temp_file])
971
- end.to raise_error(RubySpriter::ValidationError, /--video expects \.mp4 file, got: \.mkv/)
972
- end
973
-
974
- it 'rejects .wmv extension' do
975
- temp_file = File.join(@test_dir, 'test.wmv')
976
- FileUtils.touch(temp_file)
977
-
978
- expect do
979
- described_class.start(['--video', temp_file])
980
- end.to raise_error(RubySpriter::ValidationError, /--video expects \.mp4 file, got: \.wmv/)
981
- end
982
-
983
- it 'rejects file with no extension' do
984
- temp_file = File.join(@test_dir, 'videofile')
985
- FileUtils.touch(temp_file)
986
-
987
- expect do
988
- described_class.start(['--video', temp_file])
989
- end.to raise_error(RubySpriter::ValidationError, /--video expects \.mp4 file/)
990
- end
991
- end
992
- end
993
-
994
- describe 'integration with video-specific options' do
995
- it 'works with --frames option' do
996
- processor_double = instance_double(RubySpriter::Processor)
997
- allow(processor_double).to receive(:run)
998
-
999
- allow(RubySpriter::Processor).to receive(:new) do |options|
1000
- expect(options[:video]).to eq(fixture_video)
1001
- expect(options[:frame_count]).to eq(32)
1002
- processor_double
1003
- end
1004
-
1005
- described_class.start(['--video', fixture_video, '--frames', '32'])
1006
- end
1007
-
1008
- it 'works with --columns option' do
1009
- processor_double = instance_double(RubySpriter::Processor)
1010
- allow(processor_double).to receive(:run)
1011
-
1012
- allow(RubySpriter::Processor).to receive(:new) do |options|
1013
- expect(options[:video]).to eq(fixture_video)
1014
- expect(options[:columns]).to eq(8)
1015
- processor_double
1016
- end
1017
-
1018
- described_class.start(['--video', fixture_video, '--columns', '8'])
1019
- end
1020
-
1021
- it 'works with --width option' do
1022
- processor_double = instance_double(RubySpriter::Processor)
1023
- allow(processor_double).to receive(:run)
1024
-
1025
- allow(RubySpriter::Processor).to receive(:new) do |options|
1026
- expect(options[:video]).to eq(fixture_video)
1027
- expect(options[:max_width]).to eq(640)
1028
- processor_double
1029
- end
1030
-
1031
- described_class.start(['--video', fixture_video, '--width', '640'])
1032
- end
1033
-
1034
- it 'works with --background option' do
1035
- processor_double = instance_double(RubySpriter::Processor)
1036
- allow(processor_double).to receive(:run)
1037
-
1038
- allow(RubySpriter::Processor).to receive(:new) do |options|
1039
- expect(options[:video]).to eq(fixture_video)
1040
- expect(options[:bg_color]).to eq('white')
1041
- processor_double
1042
- end
1043
-
1044
- described_class.start(['--video', fixture_video, '--background', 'white'])
1045
- end
1046
-
1047
- it 'works with multiple video options combined' do
1048
- processor_double = instance_double(RubySpriter::Processor)
1049
- allow(processor_double).to receive(:run)
1050
-
1051
- allow(RubySpriter::Processor).to receive(:new) do |options|
1052
- expect(options[:video]).to eq(fixture_video)
1053
- expect(options[:frame_count]).to eq(64)
1054
- expect(options[:columns]).to eq(8)
1055
- expect(options[:max_width]).to eq(480)
1056
- expect(options[:bg_color]).to eq('white')
1057
- processor_double
1058
- end
1059
-
1060
- described_class.start([
1061
- '--video', fixture_video,
1062
- '--frames', '64',
1063
- '--columns', '8',
1064
- '--width', '480',
1065
- '--background', 'white'
1066
- ])
1067
- end
1068
- end
1069
-
1070
- describe 'integration with processing options' do
1071
- it 'works with --scale option' do
1072
- processor_double = instance_double(RubySpriter::Processor)
1073
- allow(processor_double).to receive(:run)
1074
-
1075
- allow(RubySpriter::Processor).to receive(:new) do |options|
1076
- expect(options[:video]).to eq(fixture_video)
1077
- expect(options[:scale_percent]).to eq(50)
1078
- processor_double
1079
- end
1080
-
1081
- described_class.start(['--video', fixture_video, '--scale', '50'])
1082
- end
1083
-
1084
- it 'works with --remove-bg option' do
1085
- processor_double = instance_double(RubySpriter::Processor)
1086
- allow(processor_double).to receive(:run)
1087
-
1088
- allow(RubySpriter::Processor).to receive(:new) do |options|
1089
- expect(options[:video]).to eq(fixture_video)
1090
- expect(options[:remove_bg]).to eq(true)
1091
- processor_double
1092
- end
1093
-
1094
- described_class.start(['--video', fixture_video, '--remove-bg'])
1095
- end
1096
-
1097
- it 'works with --sharpen option' do
1098
- processor_double = instance_double(RubySpriter::Processor)
1099
- allow(processor_double).to receive(:run)
1100
-
1101
- allow(RubySpriter::Processor).to receive(:new) do |options|
1102
- expect(options[:video]).to eq(fixture_video)
1103
- expect(options[:sharpen]).to eq(true)
1104
- processor_double
1105
- end
1106
-
1107
- described_class.start(['--video', fixture_video, '--sharpen'])
1108
- end
1109
-
1110
- it 'works with --interpolation option' do
1111
- processor_double = instance_double(RubySpriter::Processor)
1112
- allow(processor_double).to receive(:run)
1113
-
1114
- allow(RubySpriter::Processor).to receive(:new) do |options|
1115
- expect(options[:video]).to eq(fixture_video)
1116
- expect(options[:scale_interpolation]).to eq('lohalo')
1117
- processor_double
1118
- end
1119
-
1120
- described_class.start(['--video', fixture_video, '--interpolation', 'lohalo'])
1121
- end
1122
-
1123
- it 'works with all options combined' do
1124
- processor_double = instance_double(RubySpriter::Processor)
1125
- allow(processor_double).to receive(:run)
1126
-
1127
- allow(RubySpriter::Processor).to receive(:new) do |options|
1128
- expect(options[:video]).to eq(fixture_video)
1129
- expect(options[:frame_count]).to eq(32)
1130
- expect(options[:columns]).to eq(8)
1131
- expect(options[:scale_percent]).to eq(50)
1132
- expect(options[:remove_bg]).to eq(true)
1133
- expect(options[:sharpen]).to eq(true)
1134
- expect(options[:scale_interpolation]).to eq('nohalo')
1135
- processor_double
1136
- end
1137
-
1138
- described_class.start([
1139
- '--video', fixture_video,
1140
- '--frames', '32',
1141
- '--columns', '8',
1142
- '--scale', '50',
1143
- '--remove-bg',
1144
- '--sharpen',
1145
- '--interpolation', 'nohalo'
1146
- ])
1147
- end
1148
-
1149
- it 'works with --output option' do
1150
- processor_double = instance_double(RubySpriter::Processor)
1151
- allow(processor_double).to receive(:run)
1152
-
1153
- allow(RubySpriter::Processor).to receive(:new) do |options|
1154
- expect(options[:video]).to eq(fixture_video)
1155
- expect(options[:output]).to eq('custom_spritesheet.png')
1156
- processor_double
1157
- end
1158
-
1159
- described_class.start(['--video', fixture_video, '--output', 'custom_spritesheet.png'])
1160
- end
1161
-
1162
- it 'works with --save-frames option' do
1163
- processor_double = instance_double(RubySpriter::Processor)
1164
- allow(processor_double).to receive(:run)
1165
-
1166
- allow(RubySpriter::Processor).to receive(:new) do |options|
1167
- expect(options[:video]).to eq(fixture_video)
1168
- expect(options[:save_frames]).to eq(true)
1169
- processor_double
1170
- end
1171
-
1172
- described_class.start(['--video', fixture_video, '--save-frames'])
1173
- end
1174
- end
1175
-
1176
- describe 'preset configurations' do
1177
- it 'works with --preset thumbnail' do
1178
- processor_double = instance_double(RubySpriter::Processor)
1179
- allow(processor_double).to receive(:run)
1180
-
1181
- allow(RubySpriter::Processor).to receive(:new) do |options|
1182
- expect(options[:video]).to eq(fixture_video)
1183
- expect(options[:columns]).to eq(3)
1184
- expect(options[:frame_count]).to eq(9)
1185
- expect(options[:max_width]).to eq(240)
1186
- processor_double
1187
- end
1188
-
1189
- described_class.start(['--video', fixture_video, '--preset', 'thumbnail'])
1190
- end
1191
-
1192
- it 'works with --preset preview' do
1193
- processor_double = instance_double(RubySpriter::Processor)
1194
- allow(processor_double).to receive(:run)
1195
-
1196
- allow(RubySpriter::Processor).to receive(:new) do |options|
1197
- expect(options[:video]).to eq(fixture_video)
1198
- expect(options[:columns]).to eq(4)
1199
- expect(options[:frame_count]).to eq(16)
1200
- expect(options[:max_width]).to eq(400)
1201
- processor_double
1202
- end
1203
-
1204
- described_class.start(['--video', fixture_video, '--preset', 'preview'])
1205
- end
1206
-
1207
- it 'works with --preset detailed' do
1208
- processor_double = instance_double(RubySpriter::Processor)
1209
- allow(processor_double).to receive(:run)
1210
-
1211
- allow(RubySpriter::Processor).to receive(:new) do |options|
1212
- expect(options[:video]).to eq(fixture_video)
1213
- expect(options[:columns]).to eq(10)
1214
- expect(options[:frame_count]).to eq(50)
1215
- expect(options[:max_width]).to eq(320)
1216
- processor_double
1217
- end
1218
-
1219
- described_class.start(['--video', fixture_video, '--preset', 'detailed'])
1220
- end
1221
-
1222
- it 'works with --preset contact' do
1223
- processor_double = instance_double(RubySpriter::Processor)
1224
- allow(processor_double).to receive(:run)
1225
-
1226
- allow(RubySpriter::Processor).to receive(:new) do |options|
1227
- expect(options[:video]).to eq(fixture_video)
1228
- expect(options[:columns]).to eq(8)
1229
- expect(options[:frame_count]).to eq(64)
1230
- expect(options[:max_width]).to eq(160)
1231
- processor_double
1232
- end
1233
-
1234
- described_class.start(['--video', fixture_video, '--preset', 'contact'])
1235
- end
1236
- end
1237
- end
1238
-
1239
- describe '--consolidate flag' do
1240
- # Real spritesheets generated from test_video.mp4 using --video
1241
- # These demonstrate the actual workflow: --video creates spritesheets, --consolidate combines them
1242
- let(:spritesheet_4x2) { File.join(__dir__, '..', 'fixtures', 'spritesheet_4x2.png') } # 2 cols, 2 rows, 4 frames
1243
- let(:spritesheet_6x2) { File.join(__dir__, '..', 'fixtures', 'spritesheet_6x2.png') } # 2 cols, 3 rows, 6 frames
1244
- let(:spritesheet_4x4) { File.join(__dir__, '..', 'fixtures', 'spritesheet_4x4.png') } # 4 cols, 1 row, 4 frames (different columns)
1245
-
1246
- # Generic PNG fixtures for edge case testing
1247
- let(:fixture_with_meta) { File.join(__dir__, '..', 'fixtures', 'spritesheet_with_metadata.png') }
1248
- let(:fixture_without_meta) { File.join(__dir__, '..', 'fixtures', 'image_without_metadata.png') }
1249
-
1250
- describe 'argument parsing' do
1251
- it 'accepts comma-separated list of files' do
1252
- processor_double = instance_double(RubySpriter::Processor)
1253
- allow(processor_double).to receive(:run)
1254
-
1255
- allow(RubySpriter::Processor).to receive(:new) do |options|
1256
- expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2])
1257
- processor_double
1258
- end
1259
-
1260
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1261
- end
1262
-
1263
- it 'accepts three or more files' do
1264
- processor_double = instance_double(RubySpriter::Processor)
1265
- allow(processor_double).to receive(:run)
1266
-
1267
- allow(RubySpriter::Processor).to receive(:new) do |options|
1268
- expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2, spritesheet_4x4])
1269
- processor_double
1270
- end
1271
-
1272
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2},#{spritesheet_4x4}"])
1273
- end
1274
-
1275
- it 'accepts file paths with spaces' do
1276
- # Create temp files with spaces in names
1277
- temp_file1 = File.join(@test_dir, 'file with spaces 1.png')
1278
- temp_file2 = File.join(@test_dir, 'file with spaces 2.png')
1279
- FileUtils.cp(spritesheet_4x2, temp_file1)
1280
- FileUtils.cp(spritesheet_6x2, temp_file2)
1281
-
1282
- processor_double = instance_double(RubySpriter::Processor)
1283
- allow(processor_double).to receive(:run)
1284
-
1285
- allow(RubySpriter::Processor).to receive(:new) do |options|
1286
- expect(options[:consolidate]).to eq([temp_file1, temp_file2])
1287
- processor_double
1288
- end
1289
-
1290
- described_class.start(['--consolidate', "#{temp_file1},#{temp_file2}"])
1291
- end
1292
- end
1293
-
1294
- describe 'minimum file count validation' do
1295
- it 'requires at least 2 files' do
1296
- expect do
1297
- described_class.start(['--consolidate', spritesheet_4x2])
1298
- end.to raise_error(RubySpriter::ValidationError, /requires at least 2 files/)
1299
- end
1300
-
1301
- it 'accepts exactly 2 files' do
1302
- processor_double = instance_double(RubySpriter::Processor)
1303
- allow(processor_double).to receive(:run)
1304
- allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
1305
-
1306
- expect do
1307
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1308
- end.not_to raise_error
1309
- end
1310
-
1311
- it 'accepts more than 2 files' do
1312
- processor_double = instance_double(RubySpriter::Processor)
1313
- allow(processor_double).to receive(:run)
1314
- allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
1315
-
1316
- expect do
1317
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2},#{spritesheet_4x4}"])
1318
- end.not_to raise_error
1319
- end
1320
- end
1321
-
1322
- describe 'mutual exclusivity with other input modes' do
1323
- it 'cannot be used with --video' do
1324
- expect do
1325
- described_class.start(['--video', 'test.mp4', '--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1326
- end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
1327
- end
1328
-
1329
- it 'cannot be used with --image' do
1330
- expect do
1331
- described_class.start(['--image', spritesheet_4x2, '--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1332
- end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
1333
- end
1334
-
1335
- it 'cannot be used with --verify' do
1336
- expect do
1337
- described_class.start(['--verify', spritesheet_4x2, '--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1338
- end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
1339
- end
1340
-
1341
- it 'can be used alone without error' do
1342
- processor_double = instance_double(RubySpriter::Processor)
1343
- allow(processor_double).to receive(:run)
1344
- allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
1345
-
1346
- expect do
1347
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1348
- end.not_to raise_error
1349
- end
1350
- end
1351
-
1352
- describe 'file validation' do
1353
- describe 'file existence' do
1354
- it 'raises error if first file does not exist' do
1355
- expect do
1356
- described_class.start(['--consolidate', "nonexistent1.png,#{spritesheet_4x2}"])
1357
- end.to raise_error(RubySpriter::ValidationError, /File not found/)
1358
- end
1359
-
1360
- it 'raises error if second file does not exist' do
1361
- expect do
1362
- described_class.start(['--consolidate', "#{spritesheet_4x2},nonexistent2.png"])
1363
- end.to raise_error(RubySpriter::ValidationError, /File not found/)
1364
- end
1365
-
1366
- it 'raises error if any file in list does not exist' do
1367
- expect do
1368
- described_class.start(['--consolidate', "#{spritesheet_4x2},nonexistent.png,#{spritesheet_6x2}"])
1369
- end.to raise_error(RubySpriter::ValidationError, /File not found/)
1370
- end
1371
-
1372
- it 'accepts all existing spritesheet files' do
1373
- processor_double = instance_double(RubySpriter::Processor)
1374
- allow(processor_double).to receive(:run)
1375
- allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
1376
-
1377
- expect(File.exist?(spritesheet_4x2)).to be true
1378
- expect(File.exist?(spritesheet_6x2)).to be true
1379
-
1380
- expect do
1381
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1382
- end.not_to raise_error
1383
- end
1384
- end
1385
-
1386
- describe 'file extension validation' do
1387
- it 'accepts all .png spritesheet files' do
1388
- processor_double = instance_double(RubySpriter::Processor)
1389
- allow(processor_double).to receive(:run)
1390
- allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
1391
-
1392
- expect do
1393
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1394
- end.not_to raise_error
1395
- end
1396
-
1397
- it 'accepts .PNG extension (case insensitive)' do
1398
- temp_file1 = File.join(@test_dir, 'test1.PNG')
1399
- temp_file2 = File.join(@test_dir, 'test2.PNG')
1400
- FileUtils.cp(spritesheet_4x2, temp_file1)
1401
- FileUtils.cp(spritesheet_6x2, temp_file2)
1402
-
1403
- processor_double = instance_double(RubySpriter::Processor)
1404
- allow(processor_double).to receive(:run)
1405
- allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
1406
-
1407
- expect do
1408
- described_class.start(['--consolidate', "#{temp_file1},#{temp_file2}"])
1409
- end.not_to raise_error
1410
- end
1411
-
1412
- it 'rejects files with .jpg extension' do
1413
- temp_file = File.join(@test_dir, 'test.jpg')
1414
- FileUtils.touch(temp_file)
1415
-
1416
- expect do
1417
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{temp_file}"])
1418
- end.to raise_error(RubySpriter::ValidationError, /--consolidate expects \.png file, got: \.jpg/)
1419
- end
1420
-
1421
- it 'rejects files with .mp4 extension' do
1422
- temp_file = File.join(@test_dir, 'test.mp4')
1423
- FileUtils.touch(temp_file)
1424
-
1425
- expect do
1426
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{temp_file}"])
1427
- end.to raise_error(RubySpriter::ValidationError, /--consolidate expects \.png file, got: \.mp4/)
1428
- end
1429
-
1430
- it 'rejects files with no extension' do
1431
- temp_file = File.join(@test_dir, 'noextension')
1432
- FileUtils.touch(temp_file)
1433
-
1434
- expect do
1435
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{temp_file}"])
1436
- end.to raise_error(RubySpriter::ValidationError, /--consolidate expects \.png file/)
1437
- end
1438
-
1439
- it 'validates all files in the list' do
1440
- temp_file1 = File.join(@test_dir, 'test1.jpg')
1441
- temp_file2 = File.join(@test_dir, 'test2.gif')
1442
- FileUtils.touch(temp_file1)
1443
- FileUtils.touch(temp_file2)
1444
-
1445
- # Should fail on the first non-PNG file
1446
- expect do
1447
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{temp_file1},#{temp_file2}"])
1448
- end.to raise_error(RubySpriter::ValidationError, /--consolidate expects \.png file/)
1449
- end
1450
- end
1451
- end
1452
-
1453
- describe 'consolidation-specific options' do
1454
- it 'works with --validate-columns flag (default true)' do
1455
- processor_double = instance_double(RubySpriter::Processor)
1456
- allow(processor_double).to receive(:run)
1457
-
1458
- allow(RubySpriter::Processor).to receive(:new) do |options|
1459
- expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2])
1460
- expect(options[:validate_columns]).to eq(true)
1461
- processor_double
1462
- end
1463
-
1464
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}", '--validate-columns'])
1465
- end
1466
-
1467
- it 'works with --no-validate-columns flag' do
1468
- processor_double = instance_double(RubySpriter::Processor)
1469
- allow(processor_double).to receive(:run)
1470
-
1471
- allow(RubySpriter::Processor).to receive(:new) do |options|
1472
- expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2])
1473
- expect(options[:validate_columns]).to eq(false)
1474
- processor_double
1475
- end
1476
-
1477
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}", '--no-validate-columns'])
1478
- end
1479
- end
1480
-
1481
- describe 'integration with other options' do
1482
- it 'works with --output option' do
1483
- processor_double = instance_double(RubySpriter::Processor)
1484
- allow(processor_double).to receive(:run)
1485
-
1486
- allow(RubySpriter::Processor).to receive(:new) do |options|
1487
- expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2])
1488
- expect(options[:output]).to eq('consolidated_output.png')
1489
- processor_double
1490
- end
1491
-
1492
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}", '--output', 'consolidated_output.png'])
1493
- end
1494
-
1495
- it 'works with --debug option' do
1496
- processor_double = instance_double(RubySpriter::Processor)
1497
- allow(processor_double).to receive(:run)
1498
-
1499
- allow(RubySpriter::Processor).to receive(:new) do |options|
1500
- expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2])
1501
- expect(options[:debug]).to eq(true)
1502
- expect(options[:keep_temp]).to eq(true)
1503
- processor_double
1504
- end
1505
-
1506
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}", '--debug'])
1507
- end
1508
-
1509
- it 'works with multiple options combined' do
1510
- processor_double = instance_double(RubySpriter::Processor)
1511
- allow(processor_double).to receive(:run)
1512
-
1513
- allow(RubySpriter::Processor).to receive(:new) do |options|
1514
- expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2])
1515
- expect(options[:validate_columns]).to eq(false)
1516
- expect(options[:output]).to eq('combined.png')
1517
- expect(options[:debug]).to eq(true)
1518
- processor_double
1519
- end
1520
-
1521
- described_class.start([
1522
- '--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}",
1523
- '--no-validate-columns',
1524
- '--output', 'combined.png',
1525
- '--debug'
1526
- ])
1527
- end
1528
-
1529
- it 'works with --overwrite option' do
1530
- processor_double = instance_double(RubySpriter::Processor)
1531
- allow(processor_double).to receive(:run)
1532
-
1533
- allow(RubySpriter::Processor).to receive(:new) do |options|
1534
- expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2])
1535
- expect(options[:overwrite]).to eq(true)
1536
- processor_double
1537
- end
1538
-
1539
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}", '--overwrite'])
1540
- end
1541
- end
1542
-
1543
- describe 'default output filename behavior' do
1544
- it 'generates consolidated_spritesheet.png when no --output specified' do
1545
- # Mock all the dependencies
1546
- allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
1547
- allow(RubySpriter::Utils::FileHelper).to receive(:ensure_unique_output) do |path, overwrite:|
1548
- expect(path).to eq('consolidated_spritesheet.png')
1549
- expect(overwrite).to eq(false)
1550
- 'consolidated_spritesheet.png'
1551
- end
1552
-
1553
- consolidator_double = instance_double(RubySpriter::Consolidator)
1554
- allow(RubySpriter::Consolidator).to receive(:new).and_return(consolidator_double)
1555
- allow(consolidator_double).to receive(:consolidate).and_return({
1556
- output_file: 'consolidated_spritesheet.png',
1557
- columns: 2,
1558
- rows: 4,
1559
- frames: 8
1560
- })
1561
-
1562
- processor = RubySpriter::Processor.new(
1563
- consolidate_mode: true,
1564
- consolidate: [spritesheet_4x2, spritesheet_6x2],
1565
- overwrite: false
1566
- )
1567
-
1568
- allow(processor).to receive(:check_dependencies!)
1569
- allow(processor).to receive(:setup_temp_directory)
1570
- allow(processor).to receive(:cleanup)
1571
-
1572
- # Capture output to suppress console messages
1573
- expect { processor.run }.to output(/SUCCESS/).to_stdout
1574
- end
1575
-
1576
- it 'respects --overwrite flag with default filename' do
1577
- # Mock all the dependencies
1578
- allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
1579
- allow(RubySpriter::Utils::FileHelper).to receive(:ensure_unique_output) do |path, overwrite:|
1580
- expect(path).to eq('consolidated_spritesheet.png')
1581
- expect(overwrite).to eq(true)
1582
- 'consolidated_spritesheet.png'
1583
- end
1584
-
1585
- consolidator_double = instance_double(RubySpriter::Consolidator)
1586
- allow(RubySpriter::Consolidator).to receive(:new).and_return(consolidator_double)
1587
- allow(consolidator_double).to receive(:consolidate).and_return({
1588
- output_file: 'consolidated_spritesheet.png',
1589
- columns: 2,
1590
- rows: 4,
1591
- frames: 8
1592
- })
1593
-
1594
- processor = RubySpriter::Processor.new(
1595
- consolidate_mode: true,
1596
- consolidate: [spritesheet_4x2, spritesheet_6x2],
1597
- overwrite: true
1598
- )
1599
-
1600
- allow(processor).to receive(:check_dependencies!)
1601
- allow(processor).to receive(:setup_temp_directory)
1602
- allow(processor).to receive(:cleanup)
1603
-
1604
- # Capture output to suppress console messages
1605
- expect { processor.run }.to output(/SUCCESS/).to_stdout
1606
- end
1607
- end
1608
-
1609
- describe 'directory-based consolidation' do
1610
- let(:test_dir) { File.join(@test_dir, 'consolidate_dir') }
1611
-
1612
- before do
1613
- FileUtils.mkdir_p(test_dir)
1614
- # Copy fixture spritesheets to test directory
1615
- FileUtils.cp(spritesheet_4x2, File.join(test_dir, 'sprite1.png'))
1616
- FileUtils.cp(spritesheet_6x2, File.join(test_dir, 'sprite2.png'))
1617
- end
1618
-
1619
- it 'accepts --dir option with --consolidate' do
1620
- processor_double = instance_double(RubySpriter::Processor)
1621
- allow(processor_double).to receive(:run)
1622
-
1623
- allow(RubySpriter::Processor).to receive(:new) do |options|
1624
- expect(options[:consolidate]).to be_nil
1625
- expect(options[:dir]).to eq(test_dir)
1626
- processor_double
1627
- end
1628
-
1629
- described_class.start(['--consolidate', '--dir', test_dir])
1630
- end
1631
-
1632
- it 'validates directory exists' do
1633
- expect do
1634
- described_class.start(['--consolidate', '--dir', 'nonexistent_directory'])
1635
- end.to raise_error(RubySpriter::ValidationError, /Directory not found/)
1636
- end
1637
-
1638
- it 'cannot use --dir with comma-separated file list' do
1639
- expect do
1640
- described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}", '--dir', test_dir])
1641
- end.to raise_error(RubySpriter::ValidationError, /Cannot use --dir with comma-separated file list/)
1642
- end
1643
-
1644
- it 'works with --output option' do
1645
- processor_double = instance_double(RubySpriter::Processor)
1646
- allow(processor_double).to receive(:run)
1647
-
1648
- allow(RubySpriter::Processor).to receive(:new) do |options|
1649
- expect(options[:dir]).to eq(test_dir)
1650
- expect(options[:output]).to eq('custom_output.png')
1651
- processor_double
1652
- end
1653
-
1654
- described_class.start(['--consolidate', '--dir', test_dir, '--output', 'custom_output.png'])
1655
- end
1656
-
1657
- it 'works with --outputdir option' do
1658
- output_dir = File.join(@test_dir, 'output')
1659
- FileUtils.mkdir_p(output_dir)
1660
-
1661
- processor_double = instance_double(RubySpriter::Processor)
1662
- allow(processor_double).to receive(:run)
1663
-
1664
- allow(RubySpriter::Processor).to receive(:new) do |options|
1665
- expect(options[:dir]).to eq(test_dir)
1666
- expect(options[:outputdir]).to eq(output_dir)
1667
- processor_double
1668
- end
1669
-
1670
- described_class.start(['--consolidate', '--dir', test_dir, '--outputdir', output_dir])
1671
- end
1672
-
1673
- it 'works with --overwrite option' do
1674
- processor_double = instance_double(RubySpriter::Processor)
1675
- allow(processor_double).to receive(:run)
1676
-
1677
- allow(RubySpriter::Processor).to receive(:new) do |options|
1678
- expect(options[:dir]).to eq(test_dir)
1679
- expect(options[:overwrite]).to eq(true)
1680
- processor_double
1681
- end
1682
-
1683
- described_class.start(['--consolidate', '--dir', test_dir, '--overwrite'])
1684
- end
1685
-
1686
- it 'works with --max-compress option' do
1687
- processor_double = instance_double(RubySpriter::Processor)
1688
- allow(processor_double).to receive(:run)
1689
-
1690
- allow(RubySpriter::Processor).to receive(:new) do |options|
1691
- expect(options[:dir]).to eq(test_dir)
1692
- expect(options[:max_compress]).to eq(true)
1693
- processor_double
1694
- end
1695
-
1696
- described_class.start(['--consolidate', '--dir', test_dir, '--max-compress'])
1697
- end
1698
- end
1699
- end
1700
-
1701
- describe 'error handling' do
1702
- describe 'invalid option' do
1703
- it 'displays error message for invalid option' do
1704
- expect do
1705
- expect { described_class.start(['--invalid-option']) }
1706
- .to output(/Error:.*invalid/).to_stdout
1707
- end.to raise_error(SystemExit) { |error|
1708
- expect(error.status).to eq(1)
1709
- }
1710
- end
1711
-
1712
- it 'suggests using --help' do
1713
- expect do
1714
- expect { described_class.start(['--invalid-option']) }
1715
- .to output(/Use --help for usage information/).to_stdout
1716
- end.to raise_error(SystemExit)
1717
- end
1718
- end
1719
-
1720
- describe '--split option' do
1721
- it 'parses split option with R:C format' do
1722
- processor_double = instance_double(RubySpriter::Processor)
1723
- allow(processor_double).to receive(:run)
1724
-
1725
- allow(RubySpriter::Processor).to receive(:new) do |options|
1726
- expect(options[:split]).to eq('4:4')
1727
- processor_double
1728
- end
1729
-
1730
- described_class.start(['--image', 'test.png', '--split', '4:4'])
1731
- end
1732
- end
1733
-
1734
- describe '--override-md option' do
1735
- it 'sets override_md option to true' do
1736
- processor_double = instance_double(RubySpriter::Processor)
1737
- allow(processor_double).to receive(:run)
1738
-
1739
- allow(RubySpriter::Processor).to receive(:new) do |options|
1740
- expect(options[:override_md]).to eq(true)
1741
- processor_double
1742
- end
1743
-
1744
- described_class.start(['--image', 'test.png', '--split', '4:4', '--override-md'])
1745
- end
1746
- end
1747
-
1748
- describe '--extract option' do
1749
- it 'parses extract option with comma-separated frame numbers' do
1750
- processor_double = instance_double(RubySpriter::Processor)
1751
- allow(processor_double).to receive(:run)
1752
-
1753
- allow(RubySpriter::Processor).to receive(:new) do |options|
1754
- expect(options[:extract]).to eq('1,2,4,5,8')
1755
- processor_double
1756
- end
1757
-
1758
- described_class.start(['--image', 'test.png', '--extract', '1,2,4,5,8'])
1759
- end
1760
-
1761
- it 'allows duplicate frame numbers' do
1762
- processor_double = instance_double(RubySpriter::Processor)
1763
- allow(processor_double).to receive(:run)
1764
-
1765
- allow(RubySpriter::Processor).to receive(:new) do |options|
1766
- expect(options[:extract]).to eq('1,1,2,2,3,3')
1767
- processor_double
1768
- end
1769
-
1770
- described_class.start(['--image', 'test.png', '--extract', '1,1,2,2,3,3'])
1771
- end
1772
-
1773
- it 'cannot be used with --split' do
1774
- expect do
1775
- described_class.start(['--image', 'test.png', '--extract', '1,2,3', '--split', '4:4'])
1776
- end.to raise_error(RubySpriter::ValidationError, /--extract and --split are mutually exclusive/)
1777
- end
1778
- end
1779
-
1780
- describe '--columns option' do
1781
- it 'parses columns option for extraction grid' do
1782
- processor_double = instance_double(RubySpriter::Processor)
1783
- allow(processor_double).to receive(:run)
1784
-
1785
- allow(RubySpriter::Processor).to receive(:new) do |options|
1786
- expect(options[:columns]).to eq(3)
1787
- processor_double
1788
- end
1789
-
1790
- described_class.start(['--image', 'test.png', '--extract', '1,2,3', '--columns', '3'])
1791
- end
1792
-
1793
- it 'works without --extract (for future use)' do
1794
- processor_double = instance_double(RubySpriter::Processor)
1795
- allow(processor_double).to receive(:run)
1796
-
1797
- allow(RubySpriter::Processor).to receive(:new) do |options|
1798
- expect(options[:columns]).to eq(5)
1799
- processor_double
1800
- end
1801
-
1802
- described_class.start(['--image', 'test.png', '--columns', '5'])
1803
- end
1804
- end
1805
-
1806
- describe '--save-frames option' do
1807
- it 'sets save_frames option to true' do
1808
- processor_double = instance_double(RubySpriter::Processor)
1809
- allow(processor_double).to receive(:run)
1810
-
1811
- allow(RubySpriter::Processor).to receive(:new) do |options|
1812
- expect(options[:save_frames]).to eq(true)
1813
- processor_double
1814
- end
1815
-
1816
- described_class.start(['--image', 'test.png', '--extract', '1,2,3', '--save-frames'])
1817
- end
1818
-
1819
- it 'can be used without --extract' do
1820
- processor_double = instance_double(RubySpriter::Processor)
1821
- allow(processor_double).to receive(:run)
1822
-
1823
- allow(RubySpriter::Processor).to receive(:new) do |options|
1824
- expect(options[:save_frames]).to eq(true)
1825
- processor_double
1826
- end
1827
-
1828
- described_class.start(['--image', 'test.png', '--split', '4:4', '--save-frames'])
1829
- end
1830
- end
1831
-
1832
- describe '--add-meta option' do
1833
- it 'parses add-meta option with R:C format' do
1834
- processor_double = instance_double(RubySpriter::Processor)
1835
- allow(processor_double).to receive(:run)
1836
-
1837
- allow(RubySpriter::Processor).to receive(:new) do |options|
1838
- expect(options[:add_meta]).to eq('4:4')
1839
- processor_double
1840
- end
1841
-
1842
- described_class.start(['--image', 'test.png', '--add-meta', '4:4'])
1843
- end
1844
-
1845
- it 'cannot be combined with --scale' do
1846
- expect do
1847
- described_class.start(['--image', 'test.png', '--add-meta', '4:4', '--scale', '50'])
1848
- end.to raise_error(RubySpriter::ValidationError, /--add-meta cannot be combined with processing options/)
1849
- end
1850
-
1851
- it 'cannot be combined with --remove-bg' do
1852
- expect do
1853
- described_class.start(['--image', 'test.png', '--add-meta', '4:4', '--remove-bg'])
1854
- end.to raise_error(RubySpriter::ValidationError, /--add-meta cannot be combined with processing options/)
1855
- end
1856
-
1857
- it 'cannot be combined with --sharpen' do
1858
- expect do
1859
- described_class.start(['--image', 'test.png', '--add-meta', '4:4', '--sharpen'])
1860
- end.to raise_error(RubySpriter::ValidationError, /--add-meta cannot be combined with processing options/)
1861
- end
1862
- end
1863
-
1864
- describe '--overwrite-meta option' do
1865
- it 'sets overwrite_meta option to true' do
1866
- processor_double = instance_double(RubySpriter::Processor)
1867
- allow(processor_double).to receive(:run)
1868
-
1869
- allow(RubySpriter::Processor).to receive(:new) do |options|
1870
- expect(options[:overwrite_meta]).to eq(true)
1871
- processor_double
1872
- end
1873
-
1874
- described_class.start(['--image', 'test.png', '--add-meta', '4:4', '--overwrite-meta'])
1875
- end
1876
- end
1877
-
1878
- describe '--frames option for partial grids' do
1879
- it 'parses frames option with integer value' do
1880
- processor_double = instance_double(RubySpriter::Processor)
1881
- allow(processor_double).to receive(:run)
1882
-
1883
- allow(RubySpriter::Processor).to receive(:new) do |options|
1884
- expect(options[:frame_count]).to eq(14)
1885
- processor_double
1886
- end
1887
-
1888
- described_class.start(['--image', 'test.png', '--add-meta', '4:4', '--frames', '14'])
1889
- end
1890
- end
1891
- end
1892
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubySpriter::CLI do
6
+ describe 'Other Options' do
7
+ describe '--keep-temp flag' do
8
+ it 'sets keep_temp option to true' do
9
+ # Mock the Processor to capture the options
10
+ processor_double = instance_double(RubySpriter::Processor)
11
+ allow(processor_double).to receive(:run)
12
+
13
+ allow(RubySpriter::Processor).to receive(:new) do |options|
14
+ expect(options[:keep_temp]).to eq(true)
15
+ processor_double
16
+ end
17
+
18
+ # Parse with --keep-temp and a valid input to avoid validation errors
19
+ described_class.start(['--video', 'test.mp4', '--keep-temp'])
20
+ end
21
+ end
22
+
23
+ describe '--debug flag' do
24
+ it 'sets both debug and keep_temp options to true' do
25
+ processor_double = instance_double(RubySpriter::Processor)
26
+ allow(processor_double).to receive(:run)
27
+
28
+ allow(RubySpriter::Processor).to receive(:new) do |options|
29
+ expect(options[:debug]).to eq(true)
30
+ expect(options[:keep_temp]).to eq(true)
31
+ processor_double
32
+ end
33
+
34
+ described_class.start(['--video', 'test.mp4', '--debug'])
35
+ end
36
+ end
37
+
38
+ describe '--help flag' do
39
+ it 'outputs help text and exits' do
40
+ expect do
41
+ expect { described_class.start(['--help']) }.to output(/Usage: ruby_spriter/).to_stdout
42
+ end.to raise_error(SystemExit)
43
+ end
44
+
45
+ it 'displays Other Options section' do
46
+ expect do
47
+ expect { described_class.start(['--help']) }.to output(/Other Options:/).to_stdout
48
+ end.to raise_error(SystemExit)
49
+ end
50
+
51
+ it 'lists --keep-temp in help output' do
52
+ expect do
53
+ expect { described_class.start(['--help']) }.to output(/--keep-temp/).to_stdout
54
+ end.to raise_error(SystemExit)
55
+ end
56
+
57
+ it 'lists --debug in help output' do
58
+ expect do
59
+ expect { described_class.start(['--help']) }.to output(/--debug/).to_stdout
60
+ end.to raise_error(SystemExit)
61
+ end
62
+
63
+ it 'lists --version in help output' do
64
+ expect do
65
+ expect { described_class.start(['--help']) }.to output(/--version/).to_stdout
66
+ end.to raise_error(SystemExit)
67
+ end
68
+
69
+ it 'lists --check-dependencies in help output' do
70
+ expect do
71
+ expect { described_class.start(['--help']) }.to output(/--check-dependencies/).to_stdout
72
+ end.to raise_error(SystemExit)
73
+ end
74
+
75
+ it 'supports short form -h' do
76
+ expect do
77
+ expect { described_class.start(['-h']) }.to output(/Usage: ruby_spriter/).to_stdout
78
+ end.to raise_error(SystemExit)
79
+ end
80
+
81
+ it 'shows mode-specific help hints' do
82
+ output = StringIO.new
83
+ $stdout = output
84
+
85
+ begin
86
+ described_class.start(['--help'])
87
+ rescue SystemExit
88
+ # Expected
89
+ ensure
90
+ $stdout = STDOUT
91
+ end
92
+
93
+ expect(output.string).to include('Get mode-specific help:')
94
+ end
95
+ end
96
+
97
+ describe '--version flag' do
98
+ it 'outputs version information and exits' do
99
+ expect do
100
+ expect { described_class.start(['--version']) }
101
+ .to output(/Ruby Spriter v#{RubySpriter::VERSION}/).to_stdout
102
+ end.to raise_error(SystemExit)
103
+ end
104
+
105
+ it 'displays platform information' do
106
+ expect do
107
+ expect { described_class.start(['--version']) }
108
+ .to output(/Platform:/).to_stdout
109
+ end.to raise_error(SystemExit)
110
+ end
111
+
112
+ it 'displays date information' do
113
+ expect do
114
+ expect { described_class.start(['--version']) }
115
+ .to output(/Date: #{RubySpriter::VERSION_DATE}/).to_stdout
116
+ end.to raise_error(SystemExit)
117
+ end
118
+ end
119
+
120
+ describe '--check-dependencies flag' do
121
+ it 'sets check_dependencies option to true' do
122
+ # Mock DependencyChecker to avoid actually checking dependencies
123
+ checker_double = instance_double(RubySpriter::DependencyChecker)
124
+ allow(checker_double).to receive(:print_report)
125
+ allow(checker_double).to receive(:all_satisfied?).and_return(true)
126
+ allow(RubySpriter::DependencyChecker).to receive(:new).and_return(checker_double)
127
+
128
+ expect do
129
+ described_class.start(['--check-dependencies'])
130
+ end.to raise_error(SystemExit) { |error|
131
+ expect(error.status).to eq(0)
132
+ }
133
+ end
134
+
135
+ it 'exits with 0 when all dependencies are satisfied' do
136
+ checker_double = instance_double(RubySpriter::DependencyChecker)
137
+ allow(checker_double).to receive(:print_report)
138
+ allow(checker_double).to receive(:all_satisfied?).and_return(true)
139
+ allow(RubySpriter::DependencyChecker).to receive(:new).and_return(checker_double)
140
+
141
+ expect do
142
+ described_class.start(['--check-dependencies'])
143
+ end.to raise_error(SystemExit) { |error|
144
+ expect(error.status).to eq(0)
145
+ }
146
+ end
147
+
148
+ it 'exits with 1 when dependencies are missing' do
149
+ checker_double = instance_double(RubySpriter::DependencyChecker)
150
+ allow(checker_double).to receive(:print_report)
151
+ allow(checker_double).to receive(:all_satisfied?).and_return(false)
152
+ allow(RubySpriter::DependencyChecker).to receive(:new).and_return(checker_double)
153
+
154
+ expect do
155
+ described_class.start(['--check-dependencies'])
156
+ end.to raise_error(SystemExit) { |error|
157
+ expect(error.status).to eq(1)
158
+ }
159
+ end
160
+
161
+ it 'calls DependencyChecker with verbose: true' do
162
+ checker_double = instance_double(RubySpriter::DependencyChecker)
163
+ allow(checker_double).to receive(:print_report)
164
+ allow(checker_double).to receive(:all_satisfied?).and_return(true)
165
+
166
+ expect(RubySpriter::DependencyChecker).to receive(:new).with(verbose: true).and_return(checker_double)
167
+
168
+ expect do
169
+ described_class.start(['--check-dependencies'])
170
+ end.to raise_error(SystemExit)
171
+ end
172
+ end
173
+
174
+ describe '--overwrite flag' do
175
+ it 'sets overwrite option to true' do
176
+ processor_double = instance_double(RubySpriter::Processor)
177
+ allow(processor_double).to receive(:run)
178
+
179
+ allow(RubySpriter::Processor).to receive(:new) do |options|
180
+ expect(options[:overwrite]).to eq(true)
181
+ processor_double
182
+ end
183
+
184
+ described_class.start(['--video', 'test.mp4', '--overwrite'])
185
+ end
186
+
187
+ it 'defaults to false when not specified' do
188
+ processor_double = instance_double(RubySpriter::Processor)
189
+ allow(processor_double).to receive(:run)
190
+
191
+ allow(RubySpriter::Processor).to receive(:new) do |options|
192
+ expect(options[:overwrite]).to be_nil
193
+ processor_double
194
+ end
195
+
196
+ described_class.start(['--video', 'test.mp4'])
197
+ end
198
+ end
199
+ end
200
+
201
+ describe '--image flag' do
202
+ let(:fixture_with_meta) { File.join(__dir__, '..', 'fixtures', 'spritesheet_with_metadata.png') }
203
+ let(:fixture_without_meta) { File.join(__dir__, '..', 'fixtures', 'image_without_metadata.png') }
204
+
205
+ describe 'argument parsing' do
206
+ it 'sets image option with --image flag' do
207
+ processor_double = instance_double(RubySpriter::Processor)
208
+ allow(processor_double).to receive(:run)
209
+
210
+ allow(RubySpriter::Processor).to receive(:new) do |options|
211
+ expect(options[:image]).to eq(fixture_with_meta)
212
+ processor_double
213
+ end
214
+
215
+ described_class.start(['--image', fixture_with_meta])
216
+ end
217
+
218
+ it 'supports short form -i flag' do
219
+ processor_double = instance_double(RubySpriter::Processor)
220
+ allow(processor_double).to receive(:run)
221
+
222
+ allow(RubySpriter::Processor).to receive(:new) do |options|
223
+ expect(options[:image]).to eq(fixture_without_meta)
224
+ processor_double
225
+ end
226
+
227
+ described_class.start(['-i', fixture_without_meta])
228
+ end
229
+
230
+ it 'accepts file path with spaces' do
231
+ # Create a temp file with spaces in the name for this test
232
+ temp_file = File.join(@test_dir, 'file with spaces.png')
233
+ FileUtils.cp(fixture_with_meta, temp_file)
234
+
235
+ processor_double = instance_double(RubySpriter::Processor)
236
+ allow(processor_double).to receive(:run)
237
+
238
+ allow(RubySpriter::Processor).to receive(:new) do |options|
239
+ expect(options[:image]).to eq(temp_file)
240
+ processor_double
241
+ end
242
+
243
+ described_class.start(['--image', temp_file])
244
+ end
245
+ end
246
+
247
+ describe 'mutual exclusivity with other input modes' do
248
+ it 'cannot be used with --video' do
249
+ expect do
250
+ described_class.start(['--video', 'test.mp4', '--image', fixture_with_meta])
251
+ end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
252
+ end
253
+
254
+ it 'cannot be used with --consolidate' do
255
+ expect do
256
+ described_class.start(['--consolidate', 'a.png,b.png', '--image', fixture_with_meta])
257
+ end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
258
+ end
259
+
260
+ it 'cannot be used with --verify' do
261
+ expect do
262
+ described_class.start(['--verify', fixture_with_meta, '--image', fixture_without_meta])
263
+ end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
264
+ end
265
+
266
+ it 'can be used alone without error' do
267
+ processor_double = instance_double(RubySpriter::Processor)
268
+ allow(processor_double).to receive(:run)
269
+ allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
270
+
271
+ expect do
272
+ described_class.start(['--image', fixture_with_meta])
273
+ end.not_to raise_error
274
+ end
275
+ end
276
+
277
+ describe 'file validation' do
278
+ describe 'file existence' do
279
+ it 'raises error for non-existent file' do
280
+ expect do
281
+ described_class.start(['--image', 'nonexistent.png'])
282
+ end.to raise_error(RubySpriter::ValidationError, /File not found/)
283
+ end
284
+
285
+ it 'accepts existing PNG file with metadata' do
286
+ processor_double = instance_double(RubySpriter::Processor)
287
+ allow(processor_double).to receive(:run)
288
+ allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
289
+
290
+ expect(File.exist?(fixture_with_meta)).to be true
291
+ expect do
292
+ described_class.start(['--image', fixture_with_meta])
293
+ end.not_to raise_error
294
+ end
295
+
296
+ it 'accepts existing PNG file without metadata' do
297
+ processor_double = instance_double(RubySpriter::Processor)
298
+ allow(processor_double).to receive(:run)
299
+ allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
300
+
301
+ expect(File.exist?(fixture_without_meta)).to be true
302
+ expect do
303
+ described_class.start(['--image', fixture_without_meta])
304
+ end.not_to raise_error
305
+ end
306
+ end
307
+
308
+ describe 'file extension validation' do
309
+ it 'accepts .png extension' do
310
+ processor_double = instance_double(RubySpriter::Processor)
311
+ allow(processor_double).to receive(:run)
312
+ allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
313
+
314
+ expect(File.extname(fixture_with_meta)).to eq('.png')
315
+ expect do
316
+ described_class.start(['--image', fixture_with_meta])
317
+ end.not_to raise_error
318
+ end
319
+
320
+ it 'accepts .PNG extension (case insensitive)' do
321
+ # Create a temp file with uppercase extension
322
+ temp_file = File.join(@test_dir, 'test.PNG')
323
+ FileUtils.cp(fixture_with_meta, temp_file)
324
+
325
+ processor_double = instance_double(RubySpriter::Processor)
326
+ allow(processor_double).to receive(:run)
327
+ allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
328
+
329
+ expect do
330
+ described_class.start(['--image', temp_file])
331
+ end.not_to raise_error
332
+ end
333
+
334
+ it 'rejects .jpg extension' do
335
+ # Create a fake .jpg file (doesn't need to be valid JPG for this test)
336
+ temp_file = File.join(@test_dir, 'test.jpg')
337
+ FileUtils.touch(temp_file)
338
+
339
+ expect do
340
+ described_class.start(['--image', temp_file])
341
+ end.to raise_error(RubySpriter::ValidationError, /--image expects \.png file, got: \.jpg/)
342
+ end
343
+
344
+ it 'rejects .jpeg extension' do
345
+ temp_file = File.join(@test_dir, 'test.jpeg')
346
+ FileUtils.touch(temp_file)
347
+
348
+ expect do
349
+ described_class.start(['--image', temp_file])
350
+ end.to raise_error(RubySpriter::ValidationError, /--image expects \.png file, got: \.jpeg/)
351
+ end
352
+
353
+ it 'rejects .gif extension' do
354
+ temp_file = File.join(@test_dir, 'test.gif')
355
+ FileUtils.touch(temp_file)
356
+
357
+ expect do
358
+ described_class.start(['--image', temp_file])
359
+ end.to raise_error(RubySpriter::ValidationError, /--image expects \.png file, got: \.gif/)
360
+ end
361
+
362
+ it 'rejects .bmp extension' do
363
+ temp_file = File.join(@test_dir, 'test.bmp')
364
+ FileUtils.touch(temp_file)
365
+
366
+ expect do
367
+ described_class.start(['--image', temp_file])
368
+ end.to raise_error(RubySpriter::ValidationError, /--image expects \.png file, got: \.bmp/)
369
+ end
370
+
371
+ it 'rejects file with no extension' do
372
+ temp_file = File.join(@test_dir, 'testfile')
373
+ FileUtils.touch(temp_file)
374
+
375
+ expect do
376
+ described_class.start(['--image', temp_file])
377
+ end.to raise_error(RubySpriter::ValidationError, /--image expects \.png file/)
378
+ end
379
+ end
380
+ end
381
+
382
+ describe 'integration with processing options' do
383
+ it 'works with --scale option' do
384
+ processor_double = instance_double(RubySpriter::Processor)
385
+ allow(processor_double).to receive(:run)
386
+
387
+ allow(RubySpriter::Processor).to receive(:new) do |options|
388
+ expect(options[:image]).to eq(fixture_with_meta)
389
+ expect(options[:scale_percent]).to eq(50)
390
+ processor_double
391
+ end
392
+
393
+ described_class.start(['--image', fixture_with_meta, '--scale', '50'])
394
+ end
395
+
396
+ it 'works with --remove-bg option' do
397
+ processor_double = instance_double(RubySpriter::Processor)
398
+ allow(processor_double).to receive(:run)
399
+
400
+ allow(RubySpriter::Processor).to receive(:new) do |options|
401
+ expect(options[:image]).to eq(fixture_with_meta)
402
+ expect(options[:remove_bg]).to eq(true)
403
+ processor_double
404
+ end
405
+
406
+ described_class.start(['--image', fixture_with_meta, '--remove-bg'])
407
+ end
408
+
409
+ it 'works with --sharpen option' do
410
+ processor_double = instance_double(RubySpriter::Processor)
411
+ allow(processor_double).to receive(:run)
412
+
413
+ allow(RubySpriter::Processor).to receive(:new) do |options|
414
+ expect(options[:image]).to eq(fixture_without_meta)
415
+ expect(options[:sharpen]).to eq(true)
416
+ processor_double
417
+ end
418
+
419
+ described_class.start(['--image', fixture_without_meta, '--sharpen'])
420
+ end
421
+
422
+ it 'works with --interpolation option' do
423
+ processor_double = instance_double(RubySpriter::Processor)
424
+ allow(processor_double).to receive(:run)
425
+
426
+ allow(RubySpriter::Processor).to receive(:new) do |options|
427
+ expect(options[:image]).to eq(fixture_with_meta)
428
+ expect(options[:scale_interpolation]).to eq('nohalo')
429
+ processor_double
430
+ end
431
+
432
+ described_class.start(['--image', fixture_with_meta, '--interpolation', 'nohalo'])
433
+ end
434
+
435
+ it 'works with multiple processing options combined' do
436
+ processor_double = instance_double(RubySpriter::Processor)
437
+ allow(processor_double).to receive(:run)
438
+
439
+ allow(RubySpriter::Processor).to receive(:new) do |options|
440
+ expect(options[:image]).to eq(fixture_without_meta)
441
+ expect(options[:scale_percent]).to eq(50)
442
+ expect(options[:remove_bg]).to eq(true)
443
+ expect(options[:sharpen]).to eq(true)
444
+ expect(options[:scale_interpolation]).to eq('lohalo')
445
+ processor_double
446
+ end
447
+
448
+ described_class.start([
449
+ '--image', fixture_without_meta,
450
+ '--scale', '50',
451
+ '--remove-bg',
452
+ '--sharpen',
453
+ '--interpolation', 'lohalo'
454
+ ])
455
+ end
456
+
457
+ it 'works with --output option' do
458
+ processor_double = instance_double(RubySpriter::Processor)
459
+ allow(processor_double).to receive(:run)
460
+
461
+ allow(RubySpriter::Processor).to receive(:new) do |options|
462
+ expect(options[:image]).to eq(fixture_with_meta)
463
+ expect(options[:output]).to eq('custom_output.png')
464
+ processor_double
465
+ end
466
+
467
+ described_class.start(['--image', fixture_with_meta, '--output', 'custom_output.png'])
468
+ end
469
+
470
+ it 'works with --overwrite option' do
471
+ processor_double = instance_double(RubySpriter::Processor)
472
+ allow(processor_double).to receive(:run)
473
+
474
+ allow(RubySpriter::Processor).to receive(:new) do |options|
475
+ expect(options[:image]).to eq(fixture_with_meta)
476
+ expect(options[:remove_bg]).to eq(true)
477
+ expect(options[:overwrite]).to eq(true)
478
+ processor_double
479
+ end
480
+
481
+ described_class.start(['--image', fixture_with_meta, '--remove-bg', '--overwrite'])
482
+ end
483
+
484
+ it 'works with --overwrite and --output options combined' do
485
+ processor_double = instance_double(RubySpriter::Processor)
486
+ allow(processor_double).to receive(:run)
487
+
488
+ allow(RubySpriter::Processor).to receive(:new) do |options|
489
+ expect(options[:image]).to eq(fixture_with_meta)
490
+ expect(options[:scale_percent]).to eq(50)
491
+ expect(options[:output]).to eq('custom.png')
492
+ expect(options[:overwrite]).to eq(true)
493
+ processor_double
494
+ end
495
+
496
+ described_class.start(['--image', fixture_with_meta, '--scale', '50', '--output', 'custom.png', '--overwrite'])
497
+ end
498
+ end
499
+
500
+ describe 'output filename behavior with processing' do
501
+ it 'generates unique filename by default when processing without --output' do
502
+ # Mock all dependencies
503
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
504
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
505
+
506
+ # Mock GimpProcessor to return a processed file
507
+ gimp_double = instance_double(RubySpriter::GimpProcessor)
508
+ allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_double)
509
+ allow(gimp_double).to receive(:process).and_return('input-nobg-fuzzy_20251023_123456_789.png')
510
+
511
+ processor = RubySpriter::Processor.new(
512
+ image: fixture_with_meta,
513
+ remove_bg: true,
514
+ overwrite: false
515
+ )
516
+
517
+ allow(processor).to receive(:check_dependencies!)
518
+ allow(processor).to receive(:setup_temp_directory)
519
+ allow(processor).to receive(:cleanup)
520
+ allow(processor).to receive(:gimp_path).and_return('/usr/bin/gimp')
521
+
522
+ result = nil
523
+ expect { result = processor.run }.to output(/SUCCESS/).to_stdout
524
+
525
+ # Should return the uniquely-named file from GIMP processing
526
+ expect(result[:output_file]).to match(/-nobg-fuzzy.*\.png$/)
527
+ end
528
+
529
+ it 'overwrites output file when --overwrite is specified' do
530
+ # Mock all dependencies
531
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
532
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
533
+
534
+ # Mock GimpProcessor - with overwrite:true, it should return same filename
535
+ gimp_double = instance_double(RubySpriter::GimpProcessor)
536
+ allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_double)
537
+ allow(gimp_double).to receive(:process).and_return('input-scaled-50pct.png')
538
+
539
+ processor = RubySpriter::Processor.new(
540
+ image: fixture_with_meta,
541
+ scale_percent: 50,
542
+ overwrite: true
543
+ )
544
+
545
+ allow(processor).to receive(:check_dependencies!)
546
+ allow(processor).to receive(:setup_temp_directory)
547
+ allow(processor).to receive(:cleanup)
548
+ allow(processor).to receive(:gimp_path).and_return('/usr/bin/gimp')
549
+
550
+ result = nil
551
+ expect { result = processor.run }.to output(/SUCCESS/).to_stdout
552
+
553
+ # Should return the base filename (no timestamp)
554
+ expect(result[:output_file]).to eq('input-scaled-50pct.png')
555
+ end
556
+
557
+ it 'generates unique output filename when --output is used without --overwrite' do
558
+ # Mock all dependencies
559
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
560
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
561
+
562
+ # Mock ensure_unique_output to verify it's called correctly
563
+ allow(RubySpriter::Utils::FileHelper).to receive(:ensure_unique_output) do |path, overwrite:|
564
+ expect(path).to eq('custom_output.png')
565
+ expect(overwrite).to eq(false)
566
+ 'custom_output_20251023_123456_789.png'
567
+ end
568
+
569
+ # Mock GimpProcessor
570
+ gimp_double = instance_double(RubySpriter::GimpProcessor)
571
+ allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_double)
572
+ allow(gimp_double).to receive(:process).and_return('temp-processed.png')
573
+
574
+ # Mock file operations
575
+ allow(FileUtils).to receive(:cp)
576
+
577
+ processor = RubySpriter::Processor.new(
578
+ image: fixture_with_meta,
579
+ remove_bg: true,
580
+ output: 'custom_output.png',
581
+ overwrite: false
582
+ )
583
+
584
+ allow(processor).to receive(:check_dependencies!)
585
+ allow(processor).to receive(:setup_temp_directory)
586
+ allow(processor).to receive(:cleanup)
587
+ allow(processor).to receive(:gimp_path).and_return('/usr/bin/gimp')
588
+
589
+ result = nil
590
+ expect { result = processor.run }.to output(/SUCCESS/).to_stdout
591
+
592
+ # Should return unique filename
593
+ expect(result[:output_file]).to match(/custom_output_\d{8}_\d{6}_\d{3}\.png$/)
594
+ end
595
+
596
+ it 'uses exact output filename when --output and --overwrite are both specified' do
597
+ # Mock all dependencies
598
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
599
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
600
+
601
+ # Mock ensure_unique_output to verify it's called with overwrite:true
602
+ allow(RubySpriter::Utils::FileHelper).to receive(:ensure_unique_output) do |path, overwrite:|
603
+ expect(path).to eq('exact_output.png')
604
+ expect(overwrite).to eq(true)
605
+ 'exact_output.png'
606
+ end
607
+
608
+ # Mock GimpProcessor
609
+ gimp_double = instance_double(RubySpriter::GimpProcessor)
610
+ allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_double)
611
+ allow(gimp_double).to receive(:process).and_return('temp-processed.png')
612
+
613
+ # Mock file operations
614
+ allow(FileUtils).to receive(:cp)
615
+
616
+ processor = RubySpriter::Processor.new(
617
+ image: fixture_with_meta,
618
+ scale_percent: 50,
619
+ output: 'exact_output.png',
620
+ overwrite: true
621
+ )
622
+
623
+ allow(processor).to receive(:check_dependencies!)
624
+ allow(processor).to receive(:setup_temp_directory)
625
+ allow(processor).to receive(:cleanup)
626
+ allow(processor).to receive(:gimp_path).and_return('/usr/bin/gimp')
627
+
628
+ result = nil
629
+ expect { result = processor.run }.to output(/SUCCESS/).to_stdout
630
+
631
+ # Should return exact filename (no timestamp)
632
+ expect(result[:output_file]).to eq('exact_output.png')
633
+ end
634
+
635
+ it 'generates unique filename when using --sharpen alone without --output' do
636
+ # Mock all dependencies
637
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
638
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
639
+
640
+ # Mock GimpProcessor to return a sharpened file
641
+ gimp_double = instance_double(RubySpriter::GimpProcessor)
642
+ allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_double)
643
+ allow(gimp_double).to receive(:process).and_return('input-sharpened_20251023_123456_789.png')
644
+
645
+ processor = RubySpriter::Processor.new(
646
+ image: fixture_with_meta,
647
+ sharpen: true,
648
+ overwrite: false
649
+ )
650
+
651
+ allow(processor).to receive(:check_dependencies!)
652
+ allow(processor).to receive(:setup_temp_directory)
653
+ allow(processor).to receive(:cleanup)
654
+
655
+ result = nil
656
+ expect { result = processor.run }.to output(/SUCCESS/).to_stdout
657
+
658
+ # Should return the uniquely-named sharpened file
659
+ expect(result[:output_file]).to match(/-sharpened.*\.png$/)
660
+ end
661
+
662
+ it 'overwrites sharpened file when --sharpen with --overwrite' do
663
+ # Mock all dependencies
664
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
665
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
666
+
667
+ # Mock GimpProcessor - with overwrite:true, should return base filename
668
+ gimp_double = instance_double(RubySpriter::GimpProcessor)
669
+ allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_double)
670
+ allow(gimp_double).to receive(:process).and_return('input-sharpened.png')
671
+
672
+ processor = RubySpriter::Processor.new(
673
+ image: fixture_with_meta,
674
+ sharpen: true,
675
+ overwrite: true
676
+ )
677
+
678
+ allow(processor).to receive(:check_dependencies!)
679
+ allow(processor).to receive(:setup_temp_directory)
680
+ allow(processor).to receive(:cleanup)
681
+
682
+ result = nil
683
+ expect { result = processor.run }.to output(/SUCCESS/).to_stdout
684
+
685
+ # Should return the base filename (no timestamp)
686
+ expect(result[:output_file]).to eq('input-sharpened.png')
687
+ end
688
+ end
689
+ end
690
+
691
+ describe '--video flag' do
692
+ let(:fixture_video) { File.join(__dir__, '..', 'fixtures', 'test_video.mp4') }
693
+
694
+ describe 'context-sensitive help' do
695
+ it 'shows video mode help with --help' do
696
+ output = StringIO.new
697
+ $stdout = output
698
+
699
+ begin
700
+ described_class.start(['--video', '--help'])
701
+ rescue SystemExit
702
+ # Expected
703
+ ensure
704
+ $stdout = STDOUT
705
+ end
706
+
707
+ expect(output.string).to include('Video Mode')
708
+ end
709
+
710
+ it 'shows parent-child option hierarchy in video mode help' do
711
+ output = StringIO.new
712
+ $stdout = output
713
+
714
+ begin
715
+ described_class.start(['--video', '--help'])
716
+ rescue SystemExit
717
+ # Expected
718
+ ensure
719
+ $stdout = STDOUT
720
+ end
721
+
722
+ # Check for parent options
723
+ expect(output.string).to include('-s, --scale PERCENT')
724
+ expect(output.string).to include('-r, --remove-bg')
725
+
726
+ # Check for child options with hierarchy marker
727
+ expect(output.string).to include('└─ Interpolation:')
728
+ expect(output.string).to include('└─ Sharpen radius')
729
+ expect(output.string).to include('└─ Use fuzzy select')
730
+ expect(output.string).to include('└─ Feather radius')
731
+ expect(output.string).to include('└─ Grow selection')
732
+
733
+ # Check that --order mentions BOTH requirement
734
+ expect(output.string).to match(/order.*BOTH.*--scale.*AND.*--remove-bg/i)
735
+ end
736
+
737
+ it 'shows --sharpen as standalone option in video mode help' do
738
+ output = StringIO.new
739
+ $stdout = output
740
+
741
+ begin
742
+ described_class.start(['--video', '--help'])
743
+ rescue SystemExit
744
+ # Expected
745
+ ensure
746
+ $stdout = STDOUT
747
+ end
748
+
749
+ # --sharpen should be a standalone parent option (not indented under --scale)
750
+ expect(output.string).to match(/^ --sharpen\s+Apply unsharp mask/)
751
+
752
+ # --sharpen modifiers should be children under --sharpen
753
+ expect(output.string).to include('└─ Sharpen radius')
754
+ expect(output.string).to include('└─ Sharpen gain')
755
+ expect(output.string).to include('└─ Sharpen threshold')
756
+
757
+ # --interpolation should ONLY be under --scale (not under --sharpen)
758
+ lines = output.string.lines
759
+ sharpen_line_idx = lines.index { |l| l.include?('--sharpen') && l.include?('Apply unsharp mask') }
760
+ scale_line_idx = lines.index { |l| l.include?('--scale PERCENT') }
761
+ interpolation_line_idx = lines.index { |l| l.include?('└─ Interpolation') }
762
+
763
+ # Interpolation should come after scale, not after sharpen
764
+ expect(interpolation_line_idx).to be > scale_line_idx
765
+ expect(interpolation_line_idx).to be < sharpen_line_idx
766
+ end
767
+ it 'includes --by-frame flag in video mode help' do
768
+ output = StringIO.new
769
+ $stdout = output
770
+
771
+ begin
772
+ described_class.start(['--video', '--help'])
773
+ rescue SystemExit
774
+ # Expected
775
+ ensure
776
+ $stdout = STDOUT
777
+ end
778
+
779
+ expect(output.string).to include('--by-frame')
780
+ expect(output.string).to include('Remove background from each frame individually')
781
+ end
782
+
783
+ it 'shows image mode help with --help' do
784
+ output = StringIO.new
785
+ $stdout = output
786
+
787
+ begin
788
+ described_class.start(['--image', '--help'])
789
+ rescue SystemExit
790
+ # Expected
791
+ ensure
792
+ $stdout = STDOUT
793
+ end
794
+
795
+ expect(output.string).to include('Image Mode')
796
+ end
797
+
798
+ it 'shows consolidate mode help with --help' do
799
+ output = StringIO.new
800
+ $stdout = output
801
+
802
+ begin
803
+ described_class.start(['--consolidate', '--help'])
804
+ rescue SystemExit
805
+ # Expected
806
+ ensure
807
+ $stdout = STDOUT
808
+ end
809
+
810
+ expect(output.string).to include('Consolidate Mode')
811
+ end
812
+
813
+ it 'shows batch mode help with --help' do
814
+ output = StringIO.new
815
+ $stdout = output
816
+
817
+ begin
818
+ described_class.start(['--batch', '--help'])
819
+ rescue SystemExit
820
+ # Expected
821
+ ensure
822
+ $stdout = STDOUT
823
+ end
824
+
825
+ expect(output.string).to include('Batch Mode')
826
+ end
827
+ it 'includes --by-frame flag in batch mode help' do
828
+ output = StringIO.new
829
+ $stdout = output
830
+
831
+ begin
832
+ described_class.start(['--batch', '--help'])
833
+ rescue SystemExit
834
+ # Expected
835
+ ensure
836
+ $stdout = STDOUT
837
+ end
838
+
839
+ expect(output.string).to include('--by-frame')
840
+ expect(output.string).to include('Remove background from each frame individually')
841
+ end
842
+
843
+ it 'shows split mode help with --help' do
844
+ output = StringIO.new
845
+ $stdout = output
846
+
847
+ begin
848
+ described_class.start(['--split', '--help'])
849
+ rescue SystemExit
850
+ # Expected
851
+ ensure
852
+ $stdout = STDOUT
853
+ end
854
+
855
+ expect(output.string).to include('Split Mode')
856
+ end
857
+ end
858
+
859
+ describe 'argument parsing' do
860
+ it 'sets video option with --video flag' do
861
+ processor_double = instance_double(RubySpriter::Processor)
862
+ allow(processor_double).to receive(:run)
863
+
864
+ allow(RubySpriter::Processor).to receive(:new) do |options|
865
+ expect(options[:video]).to eq(fixture_video)
866
+ processor_double
867
+ end
868
+
869
+ described_class.start(['--video', fixture_video])
870
+ end
871
+
872
+ it 'supports short form -v flag' do
873
+ processor_double = instance_double(RubySpriter::Processor)
874
+ allow(processor_double).to receive(:run)
875
+
876
+ allow(RubySpriter::Processor).to receive(:new) do |options|
877
+ expect(options[:video]).to eq(fixture_video)
878
+ processor_double
879
+ end
880
+
881
+ described_class.start(['-v', fixture_video])
882
+ end
883
+
884
+ it 'accepts file path with spaces' do
885
+ # Create a temp file with spaces in the name for this test
886
+ temp_file = File.join(@test_dir, 'video with spaces.mp4')
887
+ FileUtils.cp(fixture_video, temp_file)
888
+
889
+ processor_double = instance_double(RubySpriter::Processor)
890
+ allow(processor_double).to receive(:run)
891
+
892
+ allow(RubySpriter::Processor).to receive(:new) do |options|
893
+ expect(options[:video]).to eq(temp_file)
894
+ processor_double
895
+ end
896
+
897
+ described_class.start(['--video', temp_file])
898
+ end
899
+ end
900
+
901
+ describe 'mutual exclusivity with other input modes' do
902
+ it 'cannot be used with --image' do
903
+ expect do
904
+ described_class.start(['--video', fixture_video, '--image', 'test.png'])
905
+ end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
906
+ end
907
+
908
+ it 'cannot be used with --consolidate' do
909
+ expect do
910
+ described_class.start(['--video', fixture_video, '--consolidate', 'a.png,b.png'])
911
+ end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
912
+ end
913
+
914
+ it 'cannot be used with --verify' do
915
+ expect do
916
+ described_class.start(['--video', fixture_video, '--verify', 'test.png'])
917
+ end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
918
+ end
919
+
920
+ it 'can be used alone without error' do
921
+ processor_double = instance_double(RubySpriter::Processor)
922
+ allow(processor_double).to receive(:run)
923
+ allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
924
+
925
+ expect do
926
+ described_class.start(['--video', fixture_video])
927
+ end.not_to raise_error
928
+ end
929
+ end
930
+
931
+ describe 'file validation' do
932
+ describe 'file existence' do
933
+ it 'raises error for non-existent file' do
934
+ expect do
935
+ described_class.start(['--video', 'nonexistent.mp4'])
936
+ end.to raise_error(RubySpriter::ValidationError, /File not found/)
937
+ end
938
+
939
+ it 'accepts existing MP4 file' do
940
+ processor_double = instance_double(RubySpriter::Processor)
941
+ allow(processor_double).to receive(:run)
942
+ allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
943
+
944
+ expect(File.exist?(fixture_video)).to be true
945
+ expect do
946
+ described_class.start(['--video', fixture_video])
947
+ end.not_to raise_error
948
+ end
949
+ end
950
+
951
+ describe 'file extension validation' do
952
+ it 'accepts .mp4 extension' do
953
+ processor_double = instance_double(RubySpriter::Processor)
954
+ allow(processor_double).to receive(:run)
955
+ allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
956
+
957
+ expect(File.extname(fixture_video)).to eq('.mp4')
958
+ expect do
959
+ described_class.start(['--video', fixture_video])
960
+ end.not_to raise_error
961
+ end
962
+
963
+ it 'accepts .MP4 extension (case insensitive)' do
964
+ # Create a temp file with uppercase extension
965
+ temp_file = File.join(@test_dir, 'test.MP4')
966
+ FileUtils.cp(fixture_video, temp_file)
967
+
968
+ processor_double = instance_double(RubySpriter::Processor)
969
+ allow(processor_double).to receive(:run)
970
+ allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
971
+
972
+ expect do
973
+ described_class.start(['--video', temp_file])
974
+ end.not_to raise_error
975
+ end
976
+
977
+ it 'rejects .avi extension' do
978
+ temp_file = File.join(@test_dir, 'test.avi')
979
+ FileUtils.touch(temp_file)
980
+
981
+ expect do
982
+ described_class.start(['--video', temp_file])
983
+ end.to raise_error(RubySpriter::ValidationError, /--video expects \.mp4 file, got: \.avi/)
984
+ end
985
+
986
+ it 'rejects .mov extension' do
987
+ temp_file = File.join(@test_dir, 'test.mov')
988
+ FileUtils.touch(temp_file)
989
+
990
+ expect do
991
+ described_class.start(['--video', temp_file])
992
+ end.to raise_error(RubySpriter::ValidationError, /--video expects \.mp4 file, got: \.mov/)
993
+ end
994
+
995
+ it 'rejects .mkv extension' do
996
+ temp_file = File.join(@test_dir, 'test.mkv')
997
+ FileUtils.touch(temp_file)
998
+
999
+ expect do
1000
+ described_class.start(['--video', temp_file])
1001
+ end.to raise_error(RubySpriter::ValidationError, /--video expects \.mp4 file, got: \.mkv/)
1002
+ end
1003
+
1004
+ it 'rejects .wmv extension' do
1005
+ temp_file = File.join(@test_dir, 'test.wmv')
1006
+ FileUtils.touch(temp_file)
1007
+
1008
+ expect do
1009
+ described_class.start(['--video', temp_file])
1010
+ end.to raise_error(RubySpriter::ValidationError, /--video expects \.mp4 file, got: \.wmv/)
1011
+ end
1012
+
1013
+ it 'rejects file with no extension' do
1014
+ temp_file = File.join(@test_dir, 'videofile')
1015
+ FileUtils.touch(temp_file)
1016
+
1017
+ expect do
1018
+ described_class.start(['--video', temp_file])
1019
+ end.to raise_error(RubySpriter::ValidationError, /--video expects \.mp4 file/)
1020
+ end
1021
+ end
1022
+ end
1023
+
1024
+ describe 'integration with video-specific options' do
1025
+ it 'works with --frames option' do
1026
+ processor_double = instance_double(RubySpriter::Processor)
1027
+ allow(processor_double).to receive(:run)
1028
+
1029
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1030
+ expect(options[:video]).to eq(fixture_video)
1031
+ expect(options[:frame_count]).to eq(32)
1032
+ processor_double
1033
+ end
1034
+
1035
+ described_class.start(['--video', fixture_video, '--frames', '32'])
1036
+ end
1037
+
1038
+ it 'works with --columns option' do
1039
+ processor_double = instance_double(RubySpriter::Processor)
1040
+ allow(processor_double).to receive(:run)
1041
+
1042
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1043
+ expect(options[:video]).to eq(fixture_video)
1044
+ expect(options[:columns]).to eq(8)
1045
+ processor_double
1046
+ end
1047
+
1048
+ described_class.start(['--video', fixture_video, '--columns', '8'])
1049
+ end
1050
+
1051
+ it 'works with --width option' do
1052
+ processor_double = instance_double(RubySpriter::Processor)
1053
+ allow(processor_double).to receive(:run)
1054
+
1055
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1056
+ expect(options[:video]).to eq(fixture_video)
1057
+ expect(options[:max_width]).to eq(640)
1058
+ processor_double
1059
+ end
1060
+
1061
+ described_class.start(['--video', fixture_video, '--width', '640'])
1062
+ end
1063
+
1064
+ it 'works with --background option' do
1065
+ processor_double = instance_double(RubySpriter::Processor)
1066
+ allow(processor_double).to receive(:run)
1067
+
1068
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1069
+ expect(options[:video]).to eq(fixture_video)
1070
+ expect(options[:bg_color]).to eq('white')
1071
+ processor_double
1072
+ end
1073
+
1074
+ described_class.start(['--video', fixture_video, '--background', 'white'])
1075
+ end
1076
+
1077
+ it 'works with multiple video options combined' do
1078
+ processor_double = instance_double(RubySpriter::Processor)
1079
+ allow(processor_double).to receive(:run)
1080
+
1081
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1082
+ expect(options[:video]).to eq(fixture_video)
1083
+ expect(options[:frame_count]).to eq(64)
1084
+ expect(options[:columns]).to eq(8)
1085
+ expect(options[:max_width]).to eq(480)
1086
+ expect(options[:bg_color]).to eq('white')
1087
+ processor_double
1088
+ end
1089
+
1090
+ described_class.start([
1091
+ '--video', fixture_video,
1092
+ '--frames', '64',
1093
+ '--columns', '8',
1094
+ '--width', '480',
1095
+ '--background', 'white'
1096
+ ])
1097
+ end
1098
+ end
1099
+
1100
+ describe 'integration with processing options' do
1101
+ it 'works with --scale option' do
1102
+ processor_double = instance_double(RubySpriter::Processor)
1103
+ allow(processor_double).to receive(:run)
1104
+
1105
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1106
+ expect(options[:video]).to eq(fixture_video)
1107
+ expect(options[:scale_percent]).to eq(50)
1108
+ processor_double
1109
+ end
1110
+
1111
+ described_class.start(['--video', fixture_video, '--scale', '50'])
1112
+ end
1113
+
1114
+ it 'works with --remove-bg option' do
1115
+ processor_double = instance_double(RubySpriter::Processor)
1116
+ allow(processor_double).to receive(:run)
1117
+
1118
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1119
+ expect(options[:video]).to eq(fixture_video)
1120
+ expect(options[:remove_bg]).to eq(true)
1121
+ processor_double
1122
+ end
1123
+
1124
+ described_class.start(['--video', fixture_video, '--remove-bg'])
1125
+ end
1126
+
1127
+ it 'works with --sharpen option' do
1128
+ processor_double = instance_double(RubySpriter::Processor)
1129
+ allow(processor_double).to receive(:run)
1130
+
1131
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1132
+ expect(options[:video]).to eq(fixture_video)
1133
+ expect(options[:sharpen]).to eq(true)
1134
+ processor_double
1135
+ end
1136
+
1137
+ described_class.start(['--video', fixture_video, '--sharpen'])
1138
+ end
1139
+
1140
+ it 'works with --interpolation option' do
1141
+ processor_double = instance_double(RubySpriter::Processor)
1142
+ allow(processor_double).to receive(:run)
1143
+
1144
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1145
+ expect(options[:video]).to eq(fixture_video)
1146
+ expect(options[:scale_interpolation]).to eq('lohalo')
1147
+ processor_double
1148
+ end
1149
+
1150
+ described_class.start(['--video', fixture_video, '--interpolation', 'lohalo'])
1151
+ end
1152
+
1153
+ it 'works with all options combined' do
1154
+ processor_double = instance_double(RubySpriter::Processor)
1155
+ allow(processor_double).to receive(:run)
1156
+
1157
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1158
+ expect(options[:video]).to eq(fixture_video)
1159
+ expect(options[:frame_count]).to eq(32)
1160
+ expect(options[:columns]).to eq(8)
1161
+ expect(options[:scale_percent]).to eq(50)
1162
+ expect(options[:remove_bg]).to eq(true)
1163
+ expect(options[:sharpen]).to eq(true)
1164
+ expect(options[:scale_interpolation]).to eq('nohalo')
1165
+ processor_double
1166
+ end
1167
+
1168
+ described_class.start([
1169
+ '--video', fixture_video,
1170
+ '--frames', '32',
1171
+ '--columns', '8',
1172
+ '--scale', '50',
1173
+ '--remove-bg',
1174
+ '--sharpen',
1175
+ '--interpolation', 'nohalo'
1176
+ ])
1177
+ end
1178
+
1179
+ it 'works with --output option' do
1180
+ processor_double = instance_double(RubySpriter::Processor)
1181
+ allow(processor_double).to receive(:run)
1182
+
1183
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1184
+ expect(options[:video]).to eq(fixture_video)
1185
+ expect(options[:output]).to eq('custom_spritesheet.png')
1186
+ processor_double
1187
+ end
1188
+
1189
+ described_class.start(['--video', fixture_video, '--output', 'custom_spritesheet.png'])
1190
+ end
1191
+
1192
+ it 'works with --save-frames option' do
1193
+ processor_double = instance_double(RubySpriter::Processor)
1194
+ allow(processor_double).to receive(:run)
1195
+
1196
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1197
+ expect(options[:video]).to eq(fixture_video)
1198
+ expect(options[:save_frames]).to eq(true)
1199
+ processor_double
1200
+ end
1201
+
1202
+ described_class.start(['--video', fixture_video, '--save-frames'])
1203
+ end
1204
+ end
1205
+
1206
+ describe 'preset configurations' do
1207
+ it 'works with --preset thumbnail' do
1208
+ processor_double = instance_double(RubySpriter::Processor)
1209
+ allow(processor_double).to receive(:run)
1210
+
1211
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1212
+ expect(options[:video]).to eq(fixture_video)
1213
+ expect(options[:columns]).to eq(3)
1214
+ expect(options[:frame_count]).to eq(9)
1215
+ expect(options[:max_width]).to eq(240)
1216
+ processor_double
1217
+ end
1218
+
1219
+ described_class.start(['--video', fixture_video, '--preset', 'thumbnail'])
1220
+ end
1221
+
1222
+ it 'works with --preset preview' do
1223
+ processor_double = instance_double(RubySpriter::Processor)
1224
+ allow(processor_double).to receive(:run)
1225
+
1226
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1227
+ expect(options[:video]).to eq(fixture_video)
1228
+ expect(options[:columns]).to eq(4)
1229
+ expect(options[:frame_count]).to eq(16)
1230
+ expect(options[:max_width]).to eq(400)
1231
+ processor_double
1232
+ end
1233
+
1234
+ described_class.start(['--video', fixture_video, '--preset', 'preview'])
1235
+ end
1236
+
1237
+ it 'works with --preset detailed' do
1238
+ processor_double = instance_double(RubySpriter::Processor)
1239
+ allow(processor_double).to receive(:run)
1240
+
1241
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1242
+ expect(options[:video]).to eq(fixture_video)
1243
+ expect(options[:columns]).to eq(10)
1244
+ expect(options[:frame_count]).to eq(50)
1245
+ expect(options[:max_width]).to eq(320)
1246
+ processor_double
1247
+ end
1248
+
1249
+ described_class.start(['--video', fixture_video, '--preset', 'detailed'])
1250
+ end
1251
+
1252
+ it 'works with --preset contact' do
1253
+ processor_double = instance_double(RubySpriter::Processor)
1254
+ allow(processor_double).to receive(:run)
1255
+
1256
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1257
+ expect(options[:video]).to eq(fixture_video)
1258
+ expect(options[:columns]).to eq(8)
1259
+ expect(options[:frame_count]).to eq(64)
1260
+ expect(options[:max_width]).to eq(160)
1261
+ processor_double
1262
+ end
1263
+
1264
+ described_class.start(['--video', fixture_video, '--preset', 'contact'])
1265
+ end
1266
+ end
1267
+ end
1268
+
1269
+ describe '--consolidate flag' do
1270
+ # Real spritesheets generated from test_video.mp4 using --video
1271
+ # These demonstrate the actual workflow: --video creates spritesheets, --consolidate combines them
1272
+ let(:spritesheet_4x2) { File.join(__dir__, '..', 'fixtures', 'spritesheet_4x2.png') } # 2 cols, 2 rows, 4 frames
1273
+ let(:spritesheet_6x2) { File.join(__dir__, '..', 'fixtures', 'spritesheet_6x2.png') } # 2 cols, 3 rows, 6 frames
1274
+ let(:spritesheet_4x4) { File.join(__dir__, '..', 'fixtures', 'spritesheet_4x4.png') } # 4 cols, 1 row, 4 frames (different columns)
1275
+
1276
+ # Generic PNG fixtures for edge case testing
1277
+ let(:fixture_with_meta) { File.join(__dir__, '..', 'fixtures', 'spritesheet_with_metadata.png') }
1278
+ let(:fixture_without_meta) { File.join(__dir__, '..', 'fixtures', 'image_without_metadata.png') }
1279
+
1280
+ describe 'argument parsing' do
1281
+ it 'accepts comma-separated list of files' do
1282
+ processor_double = instance_double(RubySpriter::Processor)
1283
+ allow(processor_double).to receive(:run)
1284
+
1285
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1286
+ expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2])
1287
+ processor_double
1288
+ end
1289
+
1290
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1291
+ end
1292
+
1293
+ it 'accepts three or more files' do
1294
+ processor_double = instance_double(RubySpriter::Processor)
1295
+ allow(processor_double).to receive(:run)
1296
+
1297
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1298
+ expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2, spritesheet_4x4])
1299
+ processor_double
1300
+ end
1301
+
1302
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2},#{spritesheet_4x4}"])
1303
+ end
1304
+
1305
+ it 'accepts file paths with spaces' do
1306
+ # Create temp files with spaces in names
1307
+ temp_file1 = File.join(@test_dir, 'file with spaces 1.png')
1308
+ temp_file2 = File.join(@test_dir, 'file with spaces 2.png')
1309
+ FileUtils.cp(spritesheet_4x2, temp_file1)
1310
+ FileUtils.cp(spritesheet_6x2, temp_file2)
1311
+
1312
+ processor_double = instance_double(RubySpriter::Processor)
1313
+ allow(processor_double).to receive(:run)
1314
+
1315
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1316
+ expect(options[:consolidate]).to eq([temp_file1, temp_file2])
1317
+ processor_double
1318
+ end
1319
+
1320
+ described_class.start(['--consolidate', "#{temp_file1},#{temp_file2}"])
1321
+ end
1322
+ end
1323
+
1324
+ describe 'minimum file count validation' do
1325
+ it 'requires at least 2 files' do
1326
+ expect do
1327
+ described_class.start(['--consolidate', spritesheet_4x2])
1328
+ end.to raise_error(RubySpriter::ValidationError, /requires at least 2 files/)
1329
+ end
1330
+
1331
+ it 'accepts exactly 2 files' do
1332
+ processor_double = instance_double(RubySpriter::Processor)
1333
+ allow(processor_double).to receive(:run)
1334
+ allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
1335
+
1336
+ expect do
1337
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1338
+ end.not_to raise_error
1339
+ end
1340
+
1341
+ it 'accepts more than 2 files' do
1342
+ processor_double = instance_double(RubySpriter::Processor)
1343
+ allow(processor_double).to receive(:run)
1344
+ allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
1345
+
1346
+ expect do
1347
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2},#{spritesheet_4x4}"])
1348
+ end.not_to raise_error
1349
+ end
1350
+ end
1351
+
1352
+ describe 'mutual exclusivity with other input modes' do
1353
+ it 'cannot be used with --video' do
1354
+ expect do
1355
+ described_class.start(['--video', 'test.mp4', '--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1356
+ end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
1357
+ end
1358
+
1359
+ it 'cannot be used with --image' do
1360
+ expect do
1361
+ described_class.start(['--image', spritesheet_4x2, '--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1362
+ end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
1363
+ end
1364
+
1365
+ it 'cannot be used with --verify' do
1366
+ expect do
1367
+ described_class.start(['--verify', spritesheet_4x2, '--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1368
+ end.to raise_error(RubySpriter::ValidationError, /Cannot use multiple input modes/)
1369
+ end
1370
+
1371
+ it 'can be used alone without error' do
1372
+ processor_double = instance_double(RubySpriter::Processor)
1373
+ allow(processor_double).to receive(:run)
1374
+ allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
1375
+
1376
+ expect do
1377
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1378
+ end.not_to raise_error
1379
+ end
1380
+ end
1381
+
1382
+ describe 'file validation' do
1383
+ describe 'file existence' do
1384
+ it 'raises error if first file does not exist' do
1385
+ expect do
1386
+ described_class.start(['--consolidate', "nonexistent1.png,#{spritesheet_4x2}"])
1387
+ end.to raise_error(RubySpriter::ValidationError, /File not found/)
1388
+ end
1389
+
1390
+ it 'raises error if second file does not exist' do
1391
+ expect do
1392
+ described_class.start(['--consolidate', "#{spritesheet_4x2},nonexistent2.png"])
1393
+ end.to raise_error(RubySpriter::ValidationError, /File not found/)
1394
+ end
1395
+
1396
+ it 'raises error if any file in list does not exist' do
1397
+ expect do
1398
+ described_class.start(['--consolidate', "#{spritesheet_4x2},nonexistent.png,#{spritesheet_6x2}"])
1399
+ end.to raise_error(RubySpriter::ValidationError, /File not found/)
1400
+ end
1401
+
1402
+ it 'accepts all existing spritesheet files' do
1403
+ processor_double = instance_double(RubySpriter::Processor)
1404
+ allow(processor_double).to receive(:run)
1405
+ allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
1406
+
1407
+ expect(File.exist?(spritesheet_4x2)).to be true
1408
+ expect(File.exist?(spritesheet_6x2)).to be true
1409
+
1410
+ expect do
1411
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1412
+ end.not_to raise_error
1413
+ end
1414
+ end
1415
+
1416
+ describe 'file extension validation' do
1417
+ it 'accepts all .png spritesheet files' do
1418
+ processor_double = instance_double(RubySpriter::Processor)
1419
+ allow(processor_double).to receive(:run)
1420
+ allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
1421
+
1422
+ expect do
1423
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}"])
1424
+ end.not_to raise_error
1425
+ end
1426
+
1427
+ it 'accepts .PNG extension (case insensitive)' do
1428
+ temp_file1 = File.join(@test_dir, 'test1.PNG')
1429
+ temp_file2 = File.join(@test_dir, 'test2.PNG')
1430
+ FileUtils.cp(spritesheet_4x2, temp_file1)
1431
+ FileUtils.cp(spritesheet_6x2, temp_file2)
1432
+
1433
+ processor_double = instance_double(RubySpriter::Processor)
1434
+ allow(processor_double).to receive(:run)
1435
+ allow(RubySpriter::Processor).to receive(:new).and_return(processor_double)
1436
+
1437
+ expect do
1438
+ described_class.start(['--consolidate', "#{temp_file1},#{temp_file2}"])
1439
+ end.not_to raise_error
1440
+ end
1441
+
1442
+ it 'rejects files with .jpg extension' do
1443
+ temp_file = File.join(@test_dir, 'test.jpg')
1444
+ FileUtils.touch(temp_file)
1445
+
1446
+ expect do
1447
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{temp_file}"])
1448
+ end.to raise_error(RubySpriter::ValidationError, /--consolidate expects \.png file, got: \.jpg/)
1449
+ end
1450
+
1451
+ it 'rejects files with .mp4 extension' do
1452
+ temp_file = File.join(@test_dir, 'test.mp4')
1453
+ FileUtils.touch(temp_file)
1454
+
1455
+ expect do
1456
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{temp_file}"])
1457
+ end.to raise_error(RubySpriter::ValidationError, /--consolidate expects \.png file, got: \.mp4/)
1458
+ end
1459
+
1460
+ it 'rejects files with no extension' do
1461
+ temp_file = File.join(@test_dir, 'noextension')
1462
+ FileUtils.touch(temp_file)
1463
+
1464
+ expect do
1465
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{temp_file}"])
1466
+ end.to raise_error(RubySpriter::ValidationError, /--consolidate expects \.png file/)
1467
+ end
1468
+
1469
+ it 'validates all files in the list' do
1470
+ temp_file1 = File.join(@test_dir, 'test1.jpg')
1471
+ temp_file2 = File.join(@test_dir, 'test2.gif')
1472
+ FileUtils.touch(temp_file1)
1473
+ FileUtils.touch(temp_file2)
1474
+
1475
+ # Should fail on the first non-PNG file
1476
+ expect do
1477
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{temp_file1},#{temp_file2}"])
1478
+ end.to raise_error(RubySpriter::ValidationError, /--consolidate expects \.png file/)
1479
+ end
1480
+ end
1481
+ end
1482
+
1483
+ describe 'consolidation-specific options' do
1484
+ it 'works with --validate-columns flag (default true)' do
1485
+ processor_double = instance_double(RubySpriter::Processor)
1486
+ allow(processor_double).to receive(:run)
1487
+
1488
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1489
+ expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2])
1490
+ expect(options[:validate_columns]).to eq(true)
1491
+ processor_double
1492
+ end
1493
+
1494
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}", '--validate-columns'])
1495
+ end
1496
+
1497
+ it 'works with --no-validate-columns flag' do
1498
+ processor_double = instance_double(RubySpriter::Processor)
1499
+ allow(processor_double).to receive(:run)
1500
+
1501
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1502
+ expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2])
1503
+ expect(options[:validate_columns]).to eq(false)
1504
+ processor_double
1505
+ end
1506
+
1507
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}", '--no-validate-columns'])
1508
+ end
1509
+ end
1510
+
1511
+ describe 'integration with other options' do
1512
+ it 'works with --output option' do
1513
+ processor_double = instance_double(RubySpriter::Processor)
1514
+ allow(processor_double).to receive(:run)
1515
+
1516
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1517
+ expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2])
1518
+ expect(options[:output]).to eq('consolidated_output.png')
1519
+ processor_double
1520
+ end
1521
+
1522
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}", '--output', 'consolidated_output.png'])
1523
+ end
1524
+
1525
+ it 'works with --debug option' do
1526
+ processor_double = instance_double(RubySpriter::Processor)
1527
+ allow(processor_double).to receive(:run)
1528
+
1529
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1530
+ expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2])
1531
+ expect(options[:debug]).to eq(true)
1532
+ expect(options[:keep_temp]).to eq(true)
1533
+ processor_double
1534
+ end
1535
+
1536
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}", '--debug'])
1537
+ end
1538
+
1539
+ it 'works with multiple options combined' do
1540
+ processor_double = instance_double(RubySpriter::Processor)
1541
+ allow(processor_double).to receive(:run)
1542
+
1543
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1544
+ expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2])
1545
+ expect(options[:validate_columns]).to eq(false)
1546
+ expect(options[:output]).to eq('combined.png')
1547
+ expect(options[:debug]).to eq(true)
1548
+ processor_double
1549
+ end
1550
+
1551
+ described_class.start([
1552
+ '--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}",
1553
+ '--no-validate-columns',
1554
+ '--output', 'combined.png',
1555
+ '--debug'
1556
+ ])
1557
+ end
1558
+
1559
+ it 'works with --overwrite option' do
1560
+ processor_double = instance_double(RubySpriter::Processor)
1561
+ allow(processor_double).to receive(:run)
1562
+
1563
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1564
+ expect(options[:consolidate]).to eq([spritesheet_4x2, spritesheet_6x2])
1565
+ expect(options[:overwrite]).to eq(true)
1566
+ processor_double
1567
+ end
1568
+
1569
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}", '--overwrite'])
1570
+ end
1571
+ end
1572
+
1573
+ describe 'default output filename behavior' do
1574
+ it 'generates consolidated_spritesheet.png when no --output specified' do
1575
+ # Mock all the dependencies
1576
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
1577
+ allow(RubySpriter::Utils::FileHelper).to receive(:ensure_unique_output) do |path, overwrite:|
1578
+ expect(path).to eq('consolidated_spritesheet.png')
1579
+ expect(overwrite).to eq(false)
1580
+ 'consolidated_spritesheet.png'
1581
+ end
1582
+
1583
+ consolidator_double = instance_double(RubySpriter::Consolidator)
1584
+ allow(RubySpriter::Consolidator).to receive(:new).and_return(consolidator_double)
1585
+ allow(consolidator_double).to receive(:consolidate).and_return({
1586
+ output_file: 'consolidated_spritesheet.png',
1587
+ columns: 2,
1588
+ rows: 4,
1589
+ frames: 8
1590
+ })
1591
+
1592
+ processor = RubySpriter::Processor.new(
1593
+ consolidate_mode: true,
1594
+ consolidate: [spritesheet_4x2, spritesheet_6x2],
1595
+ overwrite: false
1596
+ )
1597
+
1598
+ allow(processor).to receive(:check_dependencies!)
1599
+ allow(processor).to receive(:setup_temp_directory)
1600
+ allow(processor).to receive(:cleanup)
1601
+
1602
+ # Capture output to suppress console messages
1603
+ expect { processor.run }.to output(/SUCCESS/).to_stdout
1604
+ end
1605
+
1606
+ it 'respects --overwrite flag with default filename' do
1607
+ # Mock all the dependencies
1608
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
1609
+ allow(RubySpriter::Utils::FileHelper).to receive(:ensure_unique_output) do |path, overwrite:|
1610
+ expect(path).to eq('consolidated_spritesheet.png')
1611
+ expect(overwrite).to eq(true)
1612
+ 'consolidated_spritesheet.png'
1613
+ end
1614
+
1615
+ consolidator_double = instance_double(RubySpriter::Consolidator)
1616
+ allow(RubySpriter::Consolidator).to receive(:new).and_return(consolidator_double)
1617
+ allow(consolidator_double).to receive(:consolidate).and_return({
1618
+ output_file: 'consolidated_spritesheet.png',
1619
+ columns: 2,
1620
+ rows: 4,
1621
+ frames: 8
1622
+ })
1623
+
1624
+ processor = RubySpriter::Processor.new(
1625
+ consolidate_mode: true,
1626
+ consolidate: [spritesheet_4x2, spritesheet_6x2],
1627
+ overwrite: true
1628
+ )
1629
+
1630
+ allow(processor).to receive(:check_dependencies!)
1631
+ allow(processor).to receive(:setup_temp_directory)
1632
+ allow(processor).to receive(:cleanup)
1633
+
1634
+ # Capture output to suppress console messages
1635
+ expect { processor.run }.to output(/SUCCESS/).to_stdout
1636
+ end
1637
+ end
1638
+
1639
+ describe 'directory-based consolidation' do
1640
+ let(:test_dir) { File.join(@test_dir, 'consolidate_dir') }
1641
+
1642
+ before do
1643
+ FileUtils.mkdir_p(test_dir)
1644
+ # Copy fixture spritesheets to test directory
1645
+ FileUtils.cp(spritesheet_4x2, File.join(test_dir, 'sprite1.png'))
1646
+ FileUtils.cp(spritesheet_6x2, File.join(test_dir, 'sprite2.png'))
1647
+ end
1648
+
1649
+ it 'accepts --dir option with --consolidate' do
1650
+ processor_double = instance_double(RubySpriter::Processor)
1651
+ allow(processor_double).to receive(:run)
1652
+
1653
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1654
+ expect(options[:consolidate]).to be_nil
1655
+ expect(options[:dir]).to eq(test_dir)
1656
+ processor_double
1657
+ end
1658
+
1659
+ described_class.start(['--consolidate', '--dir', test_dir])
1660
+ end
1661
+
1662
+ it 'validates directory exists' do
1663
+ expect do
1664
+ described_class.start(['--consolidate', '--dir', 'nonexistent_directory'])
1665
+ end.to raise_error(RubySpriter::ValidationError, /Directory not found/)
1666
+ end
1667
+
1668
+ it 'cannot use --dir with comma-separated file list' do
1669
+ expect do
1670
+ described_class.start(['--consolidate', "#{spritesheet_4x2},#{spritesheet_6x2}", '--dir', test_dir])
1671
+ end.to raise_error(RubySpriter::ValidationError, /Cannot use --dir with comma-separated file list/)
1672
+ end
1673
+
1674
+ it 'works with --output option' do
1675
+ processor_double = instance_double(RubySpriter::Processor)
1676
+ allow(processor_double).to receive(:run)
1677
+
1678
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1679
+ expect(options[:dir]).to eq(test_dir)
1680
+ expect(options[:output]).to eq('custom_output.png')
1681
+ processor_double
1682
+ end
1683
+
1684
+ described_class.start(['--consolidate', '--dir', test_dir, '--output', 'custom_output.png'])
1685
+ end
1686
+
1687
+ it 'works with --outputdir option' do
1688
+ output_dir = File.join(@test_dir, 'output')
1689
+ FileUtils.mkdir_p(output_dir)
1690
+
1691
+ processor_double = instance_double(RubySpriter::Processor)
1692
+ allow(processor_double).to receive(:run)
1693
+
1694
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1695
+ expect(options[:dir]).to eq(test_dir)
1696
+ expect(options[:outputdir]).to eq(output_dir)
1697
+ processor_double
1698
+ end
1699
+
1700
+ described_class.start(['--consolidate', '--dir', test_dir, '--outputdir', output_dir])
1701
+ end
1702
+
1703
+ it 'works with --overwrite option' do
1704
+ processor_double = instance_double(RubySpriter::Processor)
1705
+ allow(processor_double).to receive(:run)
1706
+
1707
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1708
+ expect(options[:dir]).to eq(test_dir)
1709
+ expect(options[:overwrite]).to eq(true)
1710
+ processor_double
1711
+ end
1712
+
1713
+ described_class.start(['--consolidate', '--dir', test_dir, '--overwrite'])
1714
+ end
1715
+
1716
+ it 'works with --max-compress option' do
1717
+ processor_double = instance_double(RubySpriter::Processor)
1718
+ allow(processor_double).to receive(:run)
1719
+
1720
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1721
+ expect(options[:dir]).to eq(test_dir)
1722
+ expect(options[:max_compress]).to eq(true)
1723
+ processor_double
1724
+ end
1725
+
1726
+ described_class.start(['--consolidate', '--dir', test_dir, '--max-compress'])
1727
+ end
1728
+ end
1729
+ end
1730
+
1731
+ describe 'error handling' do
1732
+ describe 'invalid option' do
1733
+ it 'displays error message for invalid option' do
1734
+ expect do
1735
+ expect { described_class.start(['--invalid-option']) }
1736
+ .to output(/Error:.*invalid/).to_stdout
1737
+ end.to raise_error(SystemExit) { |error|
1738
+ expect(error.status).to eq(1)
1739
+ }
1740
+ end
1741
+
1742
+ it 'suggests using --help' do
1743
+ expect do
1744
+ expect { described_class.start(['--invalid-option']) }
1745
+ .to output(/Use --help for usage information/).to_stdout
1746
+ end.to raise_error(SystemExit)
1747
+ end
1748
+ end
1749
+
1750
+ describe '--split option' do
1751
+ it 'parses split option with R:C format' do
1752
+ processor_double = instance_double(RubySpriter::Processor)
1753
+ allow(processor_double).to receive(:run)
1754
+
1755
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1756
+ expect(options[:split]).to eq('4:4')
1757
+ processor_double
1758
+ end
1759
+
1760
+ described_class.start(['--image', 'test.png', '--split', '4:4'])
1761
+ end
1762
+ end
1763
+
1764
+ describe '--override-md option' do
1765
+ it 'sets override_md option to true' do
1766
+ processor_double = instance_double(RubySpriter::Processor)
1767
+ allow(processor_double).to receive(:run)
1768
+
1769
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1770
+ expect(options[:override_md]).to eq(true)
1771
+ processor_double
1772
+ end
1773
+
1774
+ described_class.start(['--image', 'test.png', '--split', '4:4', '--override-md'])
1775
+ end
1776
+ end
1777
+
1778
+ describe '--extract option' do
1779
+ it 'parses extract option with comma-separated frame numbers' do
1780
+ processor_double = instance_double(RubySpriter::Processor)
1781
+ allow(processor_double).to receive(:run)
1782
+
1783
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1784
+ expect(options[:extract]).to eq('1,2,4,5,8')
1785
+ processor_double
1786
+ end
1787
+
1788
+ described_class.start(['--image', 'test.png', '--extract', '1,2,4,5,8'])
1789
+ end
1790
+
1791
+ it 'allows duplicate frame numbers' do
1792
+ processor_double = instance_double(RubySpriter::Processor)
1793
+ allow(processor_double).to receive(:run)
1794
+
1795
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1796
+ expect(options[:extract]).to eq('1,1,2,2,3,3')
1797
+ processor_double
1798
+ end
1799
+
1800
+ described_class.start(['--image', 'test.png', '--extract', '1,1,2,2,3,3'])
1801
+ end
1802
+
1803
+ it 'cannot be used with --split' do
1804
+ expect do
1805
+ described_class.start(['--image', 'test.png', '--extract', '1,2,3', '--split', '4:4'])
1806
+ end.to raise_error(RubySpriter::ValidationError, /--extract and --split are mutually exclusive/)
1807
+ end
1808
+ end
1809
+
1810
+ describe '--columns option' do
1811
+ it 'parses columns option for extraction grid' do
1812
+ processor_double = instance_double(RubySpriter::Processor)
1813
+ allow(processor_double).to receive(:run)
1814
+
1815
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1816
+ expect(options[:columns]).to eq(3)
1817
+ processor_double
1818
+ end
1819
+
1820
+ described_class.start(['--image', 'test.png', '--extract', '1,2,3', '--columns', '3'])
1821
+ end
1822
+
1823
+ it 'works without --extract (for future use)' do
1824
+ processor_double = instance_double(RubySpriter::Processor)
1825
+ allow(processor_double).to receive(:run)
1826
+
1827
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1828
+ expect(options[:columns]).to eq(5)
1829
+ processor_double
1830
+ end
1831
+
1832
+ described_class.start(['--image', 'test.png', '--columns', '5'])
1833
+ end
1834
+ end
1835
+
1836
+ describe '--save-frames option' do
1837
+ it 'sets save_frames option to true' do
1838
+ processor_double = instance_double(RubySpriter::Processor)
1839
+ allow(processor_double).to receive(:run)
1840
+
1841
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1842
+ expect(options[:save_frames]).to eq(true)
1843
+ processor_double
1844
+ end
1845
+
1846
+ described_class.start(['--image', 'test.png', '--extract', '1,2,3', '--save-frames'])
1847
+ end
1848
+
1849
+ it 'can be used without --extract' do
1850
+ processor_double = instance_double(RubySpriter::Processor)
1851
+ allow(processor_double).to receive(:run)
1852
+
1853
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1854
+ expect(options[:save_frames]).to eq(true)
1855
+ processor_double
1856
+ end
1857
+
1858
+ described_class.start(['--image', 'test.png', '--split', '4:4', '--save-frames'])
1859
+ end
1860
+ end
1861
+
1862
+ describe '--add-meta option' do
1863
+ it 'parses add-meta option with R:C format' do
1864
+ processor_double = instance_double(RubySpriter::Processor)
1865
+ allow(processor_double).to receive(:run)
1866
+
1867
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1868
+ expect(options[:add_meta]).to eq('4:4')
1869
+ processor_double
1870
+ end
1871
+
1872
+ described_class.start(['--image', 'test.png', '--add-meta', '4:4'])
1873
+ end
1874
+
1875
+ it 'cannot be combined with --scale' do
1876
+ expect do
1877
+ described_class.start(['--image', 'test.png', '--add-meta', '4:4', '--scale', '50'])
1878
+ end.to raise_error(RubySpriter::ValidationError, /--add-meta cannot be combined with processing options/)
1879
+ end
1880
+
1881
+ it 'cannot be combined with --remove-bg' do
1882
+ expect do
1883
+ described_class.start(['--image', 'test.png', '--add-meta', '4:4', '--remove-bg'])
1884
+ end.to raise_error(RubySpriter::ValidationError, /--add-meta cannot be combined with processing options/)
1885
+ end
1886
+
1887
+ it 'cannot be combined with --sharpen' do
1888
+ expect do
1889
+ described_class.start(['--image', 'test.png', '--add-meta', '4:4', '--sharpen'])
1890
+ end.to raise_error(RubySpriter::ValidationError, /--add-meta cannot be combined with processing options/)
1891
+ end
1892
+ end
1893
+
1894
+ describe '--overwrite-meta option' do
1895
+ it 'sets overwrite_meta option to true' do
1896
+ processor_double = instance_double(RubySpriter::Processor)
1897
+ allow(processor_double).to receive(:run)
1898
+
1899
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1900
+ expect(options[:overwrite_meta]).to eq(true)
1901
+ processor_double
1902
+ end
1903
+
1904
+ described_class.start(['--image', 'test.png', '--add-meta', '4:4', '--overwrite-meta'])
1905
+ end
1906
+ end
1907
+
1908
+ describe '--frames option for partial grids' do
1909
+ it 'parses frames option with integer value' do
1910
+ processor_double = instance_double(RubySpriter::Processor)
1911
+ allow(processor_double).to receive(:run)
1912
+
1913
+ allow(RubySpriter::Processor).to receive(:new) do |options|
1914
+ expect(options[:frame_count]).to eq(14)
1915
+ processor_double
1916
+ end
1917
+
1918
+ described_class.start(['--image', 'test.png', '--add-meta', '4:4', '--frames', '14'])
1919
+ end
1920
+ end
1921
+
1922
+ describe '--by-frame flag validation' do
1923
+ context 'when --by-frame is used without --video or --batch' do
1924
+ it 'raises ValidationError when used with --image' do
1925
+ expect {
1926
+ described_class.start(['--image', 'sprite.png', '--remove-bg', '--by-frame'])
1927
+ }.to raise_error(RubySpriter::ValidationError, /--by-frame requires --video or --batch/)
1928
+ end
1929
+
1930
+ it 'raises ValidationError when used alone with --remove-bg' do
1931
+ expect {
1932
+ described_class.start(['--remove-bg', '--by-frame'])
1933
+ }.to raise_error(RubySpriter::ValidationError, /--by-frame requires --video or --batch/)
1934
+ end
1935
+ end
1936
+
1937
+ context 'when --by-frame is used without --remove-bg' do
1938
+ it 'raises ValidationError' do
1939
+ expect {
1940
+ described_class.start(['--video', 'input.mp4', '--by-frame'])
1941
+ }.to raise_error(RubySpriter::ValidationError, /--by-frame requires --remove-bg/)
1942
+ end
1943
+ end
1944
+
1945
+ context 'when --by-frame is used correctly' do
1946
+ it 'accepts --by-frame with --video and --remove-bg' do
1947
+ # Mock the entire Processor to prevent file validation
1948
+ mock_processor = instance_double(RubySpriter::Processor)
1949
+ allow(RubySpriter::Processor).to receive(:new).and_return(mock_processor)
1950
+ allow(mock_processor).to receive(:run)
1951
+
1952
+ expect {
1953
+ described_class.start(['--video', 'input.mp4', '--remove-bg', '--by-frame'])
1954
+ }.not_to raise_error
1955
+ end
1956
+
1957
+ it 'accepts --by-frame with --batch and --remove-bg' do
1958
+ # Mock the entire Processor to prevent dependency checking
1959
+ mock_processor = instance_double(RubySpriter::Processor)
1960
+ allow(RubySpriter::Processor).to receive(:new).and_return(mock_processor)
1961
+ allow(mock_processor).to receive(:run)
1962
+
1963
+ expect {
1964
+ described_class.start(['--batch', '--dir', 'videos/', '--remove-bg', '--by-frame'])
1965
+ }.not_to raise_error
1966
+ end
1967
+ end
1968
+ end
1969
+
1970
+ describe '--cleanup-cells flag validation' do
1971
+ it 'requires --remove-bg flag' do
1972
+ expect do
1973
+ described_class.start(['--video', 'test.mp4', '--cleanup-cells'])
1974
+ end.to raise_error(RubySpriter::ValidationError, /requires --remove-bg/)
1975
+ end
1976
+
1977
+ it 'cannot be used with --by-frame' do
1978
+ expect do
1979
+ described_class.start(['--video', 'test.mp4', '--remove-bg', '--by-frame', '--cleanup-cells'])
1980
+ end.to raise_error(RubySpriter::ValidationError, /cannot be used with --by-frame/)
1981
+ end
1982
+
1983
+ it 'requires video or batch mode' do
1984
+ # Create a temporary image file for testing
1985
+ temp_dir = Dir.mktmpdir
1986
+ temp_file = File.join(temp_dir, 'test.png')
1987
+ FileUtils.touch(temp_file)
1988
+
1989
+ begin
1990
+ expect do
1991
+ described_class.start(['--image', temp_file, '--remove-bg', '--cleanup-cells'])
1992
+ end.to raise_error(RubySpriter::ValidationError, /requires --video or --batch/)
1993
+ ensure
1994
+ FileUtils.rm_rf(temp_dir)
1995
+ end
1996
+ end
1997
+
1998
+ it 'validates cell-cleanup-threshold range (too low)' do
1999
+ expect do
2000
+ described_class.start(['--video', 'test.mp4', '--remove-bg', '--cleanup-cells', '--cell-cleanup-threshold', '0.5'])
2001
+ end.to raise_error(RubySpriter::ValidationError, /between 1.0 and 50.0/)
2002
+ end
2003
+
2004
+ it 'validates cell-cleanup-threshold range (too high)' do
2005
+ expect do
2006
+ described_class.start(['--video', 'test.mp4', '--remove-bg', '--cleanup-cells', '--cell-cleanup-threshold', '55.0'])
2007
+ end.to raise_error(RubySpriter::ValidationError, /between 1.0 and 50.0/)
2008
+ end
2009
+
2010
+ it 'accepts valid configuration' do
2011
+ processor_double = instance_double(RubySpriter::Processor)
2012
+ allow(processor_double).to receive(:run)
2013
+
2014
+ allow(RubySpriter::Processor).to receive(:new) do |options|
2015
+ expect(options[:cleanup_cells]).to be true
2016
+ expect(options[:cell_cleanup_threshold]).to eq(20.0)
2017
+ processor_double
2018
+ end
2019
+
2020
+ expect do
2021
+ described_class.start(['--video', 'test.mp4', '--remove-bg', '--cleanup-cells', '--cell-cleanup-threshold', '20.0'])
2022
+ end.not_to raise_error
2023
+ end
2024
+ end
2025
+ end
2026
+ end