ruby_spriter 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubySpriter::CompressionManager do
6
+ let(:input_file) { 'E:/test/input.png' }
7
+ let(:output_file) { 'E:/test/output.png' }
8
+ let(:temp_file) { 'E:/test/temp.png' }
9
+
10
+ before do
11
+ allow(File).to receive(:exist?).and_return(true)
12
+ allow(File).to receive(:readable?).and_return(true)
13
+ allow(File).to receive(:size).with(input_file).and_return(100000)
14
+ allow(File).to receive(:size).with(output_file).and_return(80000)
15
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
16
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
17
+ end
18
+
19
+ describe '.compress' do
20
+ it 'compresses PNG file using ImageMagick' do
21
+ magick_cmd = RubySpriter::Platform.imagemagick_convert_cmd
22
+
23
+ expect(Open3).to receive(:capture3) do |cmd|
24
+ expect(cmd).to include(magick_cmd.split.first) # Check for 'convert' or 'magick'
25
+ expect(cmd).to include(input_file)
26
+ expect(cmd).to include(output_file)
27
+ expect(cmd).to include('-strip')
28
+ expect(cmd).to include('-define png:compression-level=9')
29
+ expect(cmd).to include('-define png:compression-filter=5')
30
+ expect(cmd).to include('-define png:compression-strategy=1')
31
+ ['', '', double(success?: true)]
32
+ end
33
+
34
+ described_class.compress(input_file, output_file)
35
+ end
36
+
37
+ it 'raises error if compression fails' do
38
+ allow(Open3).to receive(:capture3).and_return(['', 'error', double(success?: false)])
39
+
40
+ expect {
41
+ described_class.compress(input_file, output_file)
42
+ }.to raise_error(RubySpriter::ProcessingError, /Failed to compress/)
43
+ end
44
+
45
+ it 'validates input file exists and is readable' do
46
+ expect(RubySpriter::Utils::FileHelper).to receive(:validate_readable!).with(input_file)
47
+
48
+ allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
49
+
50
+ described_class.compress(input_file, output_file)
51
+ end
52
+
53
+ it 'validates output file was created' do
54
+ allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
55
+ expect(RubySpriter::Utils::FileHelper).to receive(:validate_exists!).with(output_file)
56
+
57
+ described_class.compress(input_file, output_file)
58
+ end
59
+ end
60
+
61
+ describe '.compress_with_metadata' do
62
+ let(:metadata) { { columns: 4, rows: 4, frames: 16 } }
63
+
64
+ before do
65
+ allow(RubySpriter::MetadataManager).to receive(:read).and_return(metadata)
66
+ allow(RubySpriter::MetadataManager).to receive(:embed)
67
+ allow(FileUtils).to receive(:mv)
68
+ allow(File).to receive(:exist?).with(temp_file).and_return(true)
69
+ allow(FileUtils).to receive(:rm_f)
70
+ end
71
+
72
+ it 'reads metadata before compression' do
73
+ expect(RubySpriter::MetadataManager).to receive(:read).with(input_file)
74
+
75
+ allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
76
+
77
+ described_class.compress_with_metadata(input_file, output_file)
78
+ end
79
+
80
+ it 'compresses file with metadata preservation' do
81
+ allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
82
+
83
+ expect(described_class).to receive(:compress).with(input_file, anything, debug: false)
84
+ expect(RubySpriter::MetadataManager).to receive(:embed)
85
+
86
+ described_class.compress_with_metadata(input_file, output_file)
87
+ end
88
+
89
+ it 're-embeds metadata after compression' do
90
+ allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
91
+
92
+ expect(RubySpriter::MetadataManager).to receive(:embed) do |temp, output, **opts|
93
+ expect(opts[:columns]).to eq(4)
94
+ expect(opts[:rows]).to eq(4)
95
+ expect(opts[:frames]).to eq(16)
96
+ end
97
+
98
+ described_class.compress_with_metadata(input_file, output_file)
99
+ end
100
+
101
+ it 'handles files without metadata' do
102
+ allow(RubySpriter::MetadataManager).to receive(:read).and_return(nil)
103
+ allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
104
+
105
+ # Should just compress without re-embedding metadata
106
+ expect(RubySpriter::MetadataManager).not_to receive(:embed)
107
+
108
+ described_class.compress_with_metadata(input_file, output_file)
109
+ end
110
+
111
+ it 'cleans up temp file after successful compression' do
112
+ allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
113
+ allow(RubySpriter::MetadataManager).to receive(:read).and_return(metadata)
114
+ allow(RubySpriter::MetadataManager).to receive(:embed)
115
+
116
+ # Simulate temp file creation
117
+ temp_path = nil
118
+ allow(described_class).to receive(:compress) do |input, output|
119
+ temp_path = output
120
+ end
121
+
122
+ expect(FileUtils).to receive(:rm_f).with(anything)
123
+
124
+ described_class.compress_with_metadata(input_file, output_file)
125
+ end
126
+ end
127
+
128
+ describe '.compression_stats' do
129
+ it 'returns compression statistics' do
130
+ original_size = 100000
131
+ compressed_size = 80000
132
+
133
+ allow(File).to receive(:size).with(input_file).and_return(original_size)
134
+ allow(File).to receive(:size).with(output_file).and_return(compressed_size)
135
+
136
+ stats = described_class.compression_stats(input_file, output_file)
137
+
138
+ expect(stats[:original_size]).to eq(original_size)
139
+ expect(stats[:compressed_size]).to eq(compressed_size)
140
+ expect(stats[:saved_bytes]).to eq(20000)
141
+ expect(stats[:reduction_percent]).to be_within(0.1).of(20.0)
142
+ end
143
+
144
+ it 'handles case where compressed file is larger' do
145
+ original_size = 100000
146
+ compressed_size = 120000
147
+
148
+ allow(File).to receive(:size).with(input_file).and_return(original_size)
149
+ allow(File).to receive(:size).with(output_file).and_return(compressed_size)
150
+
151
+ stats = described_class.compression_stats(input_file, output_file)
152
+
153
+ expect(stats[:saved_bytes]).to eq(-20000)
154
+ expect(stats[:reduction_percent]).to be_within(0.1).of(-20.0)
155
+ end
156
+ end
157
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'securerandom'
4
5
 
5
6
  RSpec.describe RubySpriter::Consolidator do
6
7
  let(:spritesheet1) { File.join(__dir__, '..', 'fixtures', 'spritesheet_4x2.png') }
@@ -372,4 +373,166 @@ RSpec.describe RubySpriter::Consolidator do
372
373
  end
373
374
  end
374
375
  end
376
+
377
+ describe '#find_spritesheets_in_directory' do
378
+ let(:consolidator) { described_class.new }
379
+ let(:image_without_meta) { File.join(__dir__, '..', 'fixtures', 'image_without_metadata.png') }
380
+
381
+ # Helper to create a unique test directory for each test
382
+ def create_test_dir
383
+ dir = File.join($test_temp_dir, "consolidate_test_#{SecureRandom.hex(8)}")
384
+ FileUtils.mkdir_p(dir)
385
+ dir
386
+ end
387
+
388
+ it 'finds all PNG files with metadata in directory' do
389
+ test_dir = create_test_dir
390
+ # Copy spritesheets with metadata to test directory
391
+ sprite1_path = File.join(test_dir, 'sprite1.png')
392
+ sprite2_path = File.join(test_dir, 'sprite2.png')
393
+ FileUtils.cp(spritesheet1, sprite1_path)
394
+ FileUtils.cp(spritesheet2, sprite2_path)
395
+
396
+ # Mock metadata reading to return valid metadata for these files
397
+ allow(RubySpriter::MetadataManager).to receive(:read).and_call_original
398
+ allow(RubySpriter::MetadataManager).to receive(:read).with(sprite1_path).and_return(
399
+ { columns: 2, rows: 2, frames: 4, version: '0.6' }
400
+ )
401
+ allow(RubySpriter::MetadataManager).to receive(:read).with(sprite2_path).and_return(
402
+ { columns: 2, rows: 3, frames: 6, version: '0.6' }
403
+ )
404
+
405
+ found_files = consolidator.find_spritesheets_in_directory(test_dir)
406
+
407
+ expect(found_files.length).to eq(2)
408
+ expect(found_files).to include(sprite1_path)
409
+ expect(found_files).to include(sprite2_path)
410
+ end
411
+
412
+ it 'excludes PNG files without metadata' do
413
+ test_dir = create_test_dir
414
+ # Copy two spritesheets with metadata and one image without
415
+ sprite1_path = File.join(test_dir, 'sprite1.png')
416
+ sprite2_path = File.join(test_dir, 'sprite2.png')
417
+ no_meta_path = File.join(test_dir, 'no_meta.png')
418
+ FileUtils.cp(spritesheet1, sprite1_path)
419
+ FileUtils.cp(spritesheet2, sprite2_path)
420
+ FileUtils.cp(image_without_meta, no_meta_path)
421
+
422
+ # Mock metadata reading
423
+ allow(RubySpriter::MetadataManager).to receive(:read).and_call_original
424
+ allow(RubySpriter::MetadataManager).to receive(:read).with(sprite1_path).and_return(
425
+ { columns: 2, rows: 2, frames: 4, version: '0.6' }
426
+ )
427
+ allow(RubySpriter::MetadataManager).to receive(:read).with(sprite2_path).and_return(
428
+ { columns: 2, rows: 3, frames: 6, version: '0.6' }
429
+ )
430
+ allow(RubySpriter::MetadataManager).to receive(:read).with(no_meta_path).and_return(nil)
431
+
432
+ found_files = consolidator.find_spritesheets_in_directory(test_dir)
433
+
434
+ expect(found_files.length).to eq(2)
435
+ expect(found_files).to include(sprite1_path)
436
+ expect(found_files).to include(sprite2_path)
437
+ expect(found_files).not_to include(no_meta_path)
438
+ end
439
+
440
+ it 'returns files sorted alphabetically' do
441
+ test_dir = create_test_dir
442
+ # Copy spritesheets with names that test alphabetical sorting
443
+ c_sprite_path = File.join(test_dir, 'c_sprite.png')
444
+ a_sprite_path = File.join(test_dir, 'a_sprite.png')
445
+ b_sprite_path = File.join(test_dir, 'b_sprite.png')
446
+ FileUtils.cp(spritesheet1, c_sprite_path)
447
+ FileUtils.cp(spritesheet2, a_sprite_path)
448
+ FileUtils.cp(spritesheet3, b_sprite_path)
449
+
450
+ # Mock metadata reading
451
+ allow(RubySpriter::MetadataManager).to receive(:read).and_call_original
452
+ allow(RubySpriter::MetadataManager).to receive(:read).with(c_sprite_path).and_return(
453
+ { columns: 4, rows: 1, frames: 4, version: '0.6' }
454
+ )
455
+ allow(RubySpriter::MetadataManager).to receive(:read).with(a_sprite_path).and_return(
456
+ { columns: 2, rows: 3, frames: 6, version: '0.6' }
457
+ )
458
+ allow(RubySpriter::MetadataManager).to receive(:read).with(b_sprite_path).and_return(
459
+ { columns: 4, rows: 4, frames: 16, version: '0.6' }
460
+ )
461
+
462
+ found_files = consolidator.find_spritesheets_in_directory(test_dir)
463
+
464
+ expect(found_files.length).to eq(3)
465
+ expect(found_files[0]).to end_with('a_sprite.png')
466
+ expect(found_files[1]).to end_with('b_sprite.png')
467
+ expect(found_files[2]).to end_with('c_sprite.png')
468
+ end
469
+
470
+ it 'raises error when directory does not exist' do
471
+ expect {
472
+ consolidator.find_spritesheets_in_directory('nonexistent_directory')
473
+ }.to raise_error(RubySpriter::ValidationError, /Directory not found/)
474
+ end
475
+
476
+ it 'raises error when no PNG files with metadata found' do
477
+ # Create empty directory
478
+ empty_dir = File.join($test_temp_dir, 'empty_dir')
479
+ FileUtils.mkdir_p(empty_dir)
480
+
481
+ expect {
482
+ consolidator.find_spritesheets_in_directory(empty_dir)
483
+ }.to raise_error(RubySpriter::ValidationError, /No PNG files with spritesheet metadata found/)
484
+ end
485
+
486
+ it 'raises error when directory has PNGs but none with metadata' do
487
+ test_dir = create_test_dir
488
+ # Copy only image without metadata
489
+ FileUtils.cp(image_without_meta, File.join(test_dir, 'no_meta.png'))
490
+
491
+ expect {
492
+ consolidator.find_spritesheets_in_directory(test_dir)
493
+ }.to raise_error(RubySpriter::ValidationError, /No PNG files with spritesheet metadata found/)
494
+ end
495
+
496
+ it 'handles directory with mixed file types' do
497
+ test_dir = create_test_dir
498
+ # Copy spritesheets and create non-PNG files
499
+ sprite1_path = File.join(test_dir, 'sprite1.png')
500
+ sprite2_path = File.join(test_dir, 'sprite2.png')
501
+ FileUtils.cp(spritesheet1, sprite1_path)
502
+ FileUtils.cp(spritesheet2, sprite2_path)
503
+ File.write(File.join(test_dir, 'readme.txt'), 'test')
504
+ File.write(File.join(test_dir, 'data.json'), '{}')
505
+
506
+ # Mock metadata reading
507
+ allow(RubySpriter::MetadataManager).to receive(:read).and_call_original
508
+ allow(RubySpriter::MetadataManager).to receive(:read).with(sprite1_path).and_return(
509
+ { columns: 2, rows: 2, frames: 4, version: '0.6' }
510
+ )
511
+ allow(RubySpriter::MetadataManager).to receive(:read).with(sprite2_path).and_return(
512
+ { columns: 2, rows: 3, frames: 6, version: '0.6' }
513
+ )
514
+
515
+ found_files = consolidator.find_spritesheets_in_directory(test_dir)
516
+
517
+ expect(found_files.length).to eq(2)
518
+ expect(found_files).to all(end_with('.png'))
519
+ end
520
+
521
+ it 'requires at least 2 spritesheets' do
522
+ test_dir = create_test_dir
523
+ # Copy only one spritesheet
524
+ sprite1_path = File.join(test_dir, 'sprite1.png')
525
+ FileUtils.cp(spritesheet1, sprite1_path)
526
+
527
+ # Mock metadata reading
528
+ allow(RubySpriter::MetadataManager).to receive(:read).and_call_original
529
+ allow(RubySpriter::MetadataManager).to receive(:read).with(sprite1_path).and_return(
530
+ { columns: 2, rows: 2, frames: 4, version: '0.6' }
531
+ )
532
+
533
+ expect {
534
+ consolidator.find_spritesheets_in_directory(test_dir)
535
+ }.to raise_error(RubySpriter::ValidationError, /Found only 1 spritesheet, need at least 2/)
536
+ end
537
+ end
375
538
  end
@@ -204,6 +204,356 @@ RSpec.describe RubySpriter::Processor do
204
204
  end
205
205
  end
206
206
 
207
+ describe '--extract validation' do
208
+ let(:temp_image) { File.join(Dir.mktmpdir, 'test.png') }
209
+
210
+ before do
211
+ FileUtils.touch(temp_image)
212
+
213
+ allow_any_instance_of(RubySpriter::DependencyChecker).to receive(:check_all).and_return({
214
+ ffmpeg: { available: true },
215
+ ffprobe: { available: true },
216
+ imagemagick: { available: true },
217
+ gimp: { available: true }
218
+ })
219
+ end
220
+
221
+ after do
222
+ FileUtils.rm_f(temp_image)
223
+ end
224
+
225
+ describe 'format validation' do
226
+ it 'raises error for invalid extract format (non-numeric)' do
227
+ expect {
228
+ described_class.new(image: temp_image, extract: '1,a,3')
229
+ }.to raise_error(RubySpriter::ValidationError, /Invalid --extract format/)
230
+ end
231
+
232
+ it 'raises error for invalid extract format (empty)' do
233
+ expect {
234
+ described_class.new(image: temp_image, extract: '')
235
+ }.to raise_error(RubySpriter::ValidationError, /Invalid --extract format/)
236
+ end
237
+
238
+ it 'raises error for invalid extract format (spaces)' do
239
+ expect {
240
+ described_class.new(image: temp_image, extract: '1, 2, 3')
241
+ }.to raise_error(RubySpriter::ValidationError, /Invalid --extract format/)
242
+ end
243
+
244
+ it 'accepts valid comma-separated frame numbers' do
245
+ allow(RubySpriter::MetadataManager).to receive(:read).with(temp_image).and_return({
246
+ columns: 4,
247
+ rows: 4,
248
+ frames: 16
249
+ })
250
+
251
+ expect {
252
+ described_class.new(image: temp_image, extract: '1,2,4,5,8')
253
+ }.not_to raise_error
254
+ end
255
+ end
256
+
257
+ describe 'minimum frames validation' do
258
+ it 'raises error when less than 2 frames requested' do
259
+ allow(RubySpriter::MetadataManager).to receive(:read).with(temp_image).and_return({
260
+ columns: 4,
261
+ rows: 4,
262
+ frames: 16
263
+ })
264
+
265
+ expect {
266
+ described_class.new(image: temp_image, extract: '1')
267
+ }.to raise_error(RubySpriter::ValidationError, /--extract requires at least 2 frames/)
268
+ end
269
+
270
+ it 'allows exactly 2 frames' do
271
+ allow(RubySpriter::MetadataManager).to receive(:read).with(temp_image).and_return({
272
+ columns: 4,
273
+ rows: 4,
274
+ frames: 16
275
+ })
276
+
277
+ expect {
278
+ described_class.new(image: temp_image, extract: '1,2')
279
+ }.not_to raise_error
280
+ end
281
+ end
282
+
283
+ describe 'frame number validation' do
284
+ it 'raises error when frame number is 0' do
285
+ allow(RubySpriter::MetadataManager).to receive(:read).with(temp_image).and_return({
286
+ columns: 4,
287
+ rows: 4,
288
+ frames: 16
289
+ })
290
+
291
+ expect {
292
+ described_class.new(image: temp_image, extract: '0,1,2')
293
+ }.to raise_error(RubySpriter::ValidationError, /Frame numbers must be 1-indexed/)
294
+ end
295
+
296
+ it 'raises error when frame number is negative' do
297
+ allow(RubySpriter::MetadataManager).to receive(:read).with(temp_image).and_return({
298
+ columns: 4,
299
+ rows: 4,
300
+ frames: 16
301
+ })
302
+
303
+ expect {
304
+ described_class.new(image: temp_image, extract: '1,-2,3')
305
+ }.to raise_error(RubySpriter::ValidationError, /Frame numbers must be 1-indexed/)
306
+ end
307
+
308
+ it 'allows duplicate frame numbers' do
309
+ allow(RubySpriter::MetadataManager).to receive(:read).with(temp_image).and_return({
310
+ columns: 4,
311
+ rows: 4,
312
+ frames: 16
313
+ })
314
+
315
+ expect {
316
+ described_class.new(image: temp_image, extract: '1,1,2,2,3,3')
317
+ }.not_to raise_error
318
+ end
319
+ end
320
+
321
+ describe 'metadata requirement' do
322
+ it 'raises error when image has no metadata' do
323
+ allow(RubySpriter::MetadataManager).to receive(:read).with(temp_image).and_return(nil)
324
+
325
+ expect {
326
+ described_class.new(image: temp_image, extract: '1,2,3')
327
+ }.to raise_error(RubySpriter::ValidationError, /Image has no metadata.*Cannot extract frames/)
328
+ end
329
+ end
330
+
331
+ describe 'out of bounds validation' do
332
+ before do
333
+ allow(RubySpriter::MetadataManager).to receive(:read).with(temp_image).and_return({
334
+ columns: 4,
335
+ rows: 4,
336
+ frames: 16
337
+ })
338
+ end
339
+
340
+ it 'raises error when frame number exceeds total frames' do
341
+ expect {
342
+ described_class.new(image: temp_image, extract: '1,2,17')
343
+ }.to raise_error(RubySpriter::ValidationError, /Frame 17 is out of bounds.*only has 16 frames/)
344
+ end
345
+
346
+ it 'allows frame number equal to total frames' do
347
+ expect {
348
+ described_class.new(image: temp_image, extract: '1,2,16')
349
+ }.not_to raise_error
350
+ end
351
+
352
+ it 'raises error for multiple out of bounds frames' do
353
+ expect {
354
+ described_class.new(image: temp_image, extract: '1,20,25')
355
+ }.to raise_error(RubySpriter::ValidationError, /Frame 20 is out of bounds/)
356
+ end
357
+ end
358
+
359
+ describe '--columns default' do
360
+ before do
361
+ allow(RubySpriter::MetadataManager).to receive(:read).with(temp_image).and_return({
362
+ columns: 4,
363
+ rows: 4,
364
+ frames: 16
365
+ })
366
+ end
367
+
368
+ it 'defaults to 4 columns when not specified' do
369
+ processor = described_class.new(image: temp_image, extract: '1,2,3,4')
370
+ expect(processor.instance_variable_get(:@options)[:columns]).to eq(4)
371
+ end
372
+
373
+ it 'uses specified columns when provided' do
374
+ processor = described_class.new(image: temp_image, extract: '1,2,3', columns: 3)
375
+ expect(processor.instance_variable_get(:@options)[:columns]).to eq(3)
376
+ end
377
+ end
378
+ end
379
+
380
+ describe '--add-meta validation' do
381
+ let(:temp_image) { File.join(Dir.mktmpdir, 'test.png') }
382
+
383
+ before do
384
+ FileUtils.touch(temp_image)
385
+
386
+ allow_any_instance_of(RubySpriter::DependencyChecker).to receive(:check_all).and_return({
387
+ ffmpeg: { available: true },
388
+ ffprobe: { available: true },
389
+ imagemagick: { available: true },
390
+ gimp: { available: true }
391
+ })
392
+ end
393
+
394
+ after do
395
+ FileUtils.rm_f(temp_image)
396
+ end
397
+
398
+ describe 'format validation' do
399
+ it 'raises error for invalid format (missing colon)' do
400
+ expect {
401
+ described_class.new(image: temp_image, add_meta: '44')
402
+ }.to raise_error(RubySpriter::ValidationError, /Invalid --add-meta format/)
403
+ end
404
+
405
+ it 'raises error for invalid format (non-numeric rows)' do
406
+ expect {
407
+ described_class.new(image: temp_image, add_meta: 'a:4')
408
+ }.to raise_error(RubySpriter::ValidationError, /Invalid --add-meta format/)
409
+ end
410
+
411
+ it 'raises error for invalid format (non-numeric columns)' do
412
+ expect {
413
+ described_class.new(image: temp_image, add_meta: '4:b')
414
+ }.to raise_error(RubySpriter::ValidationError, /Invalid --add-meta format/)
415
+ end
416
+
417
+ it 'accepts valid R:C format' do
418
+ # Mock ImageMagick identify for dimension validation
419
+ allow(Open3).to receive(:capture3).with(/identify/).and_return(
420
+ ["800x800\n", '', instance_double(Process::Status, success?: true)]
421
+ )
422
+
423
+ allow(RubySpriter::MetadataManager).to receive(:read).with(temp_image).and_return(nil)
424
+
425
+ expect {
426
+ described_class.new(image: temp_image, add_meta: '4:4')
427
+ }.not_to raise_error
428
+ end
429
+ end
430
+
431
+ describe 'range validation' do
432
+ it 'raises error when rows is below minimum (0)' do
433
+ expect {
434
+ described_class.new(image: temp_image, add_meta: '0:4')
435
+ }.to raise_error(RubySpriter::ValidationError, /rows must be between 1 and 99/)
436
+ end
437
+
438
+ it 'raises error when columns is below minimum (0)' do
439
+ expect {
440
+ described_class.new(image: temp_image, add_meta: '4:0')
441
+ }.to raise_error(RubySpriter::ValidationError, /columns must be between 1 and 99/)
442
+ end
443
+
444
+ it 'raises error when rows exceeds maximum (100)' do
445
+ expect {
446
+ described_class.new(image: temp_image, add_meta: '100:4')
447
+ }.to raise_error(RubySpriter::ValidationError, /rows must be between 1 and 99/)
448
+ end
449
+
450
+ it 'raises error when total frames exceeds 999' do
451
+ expect {
452
+ described_class.new(image: temp_image, add_meta: '20:50')
453
+ }.to raise_error(RubySpriter::ValidationError, /Total frames \(1000\) must be less than 1000/)
454
+ end
455
+
456
+ it 'allows maximum valid frames (999)' do
457
+ # Mock ImageMagick identify - 3700x2700 divides evenly by 27 rows x 37 columns (100x100 per frame)
458
+ allow(Open3).to receive(:capture3).with(/identify/).and_return(
459
+ ["3700x2700\n", '', instance_double(Process::Status, success?: true)]
460
+ )
461
+
462
+ allow(RubySpriter::MetadataManager).to receive(:read).with(temp_image).and_return(nil)
463
+
464
+ expect {
465
+ described_class.new(image: temp_image, add_meta: '27:37')
466
+ }.not_to raise_error
467
+ end
468
+ end
469
+
470
+ describe 'existing metadata handling' do
471
+ it 'raises error when image already has metadata' do
472
+ allow(RubySpriter::MetadataManager).to receive(:read).with(temp_image).and_return({
473
+ columns: 4,
474
+ rows: 4,
475
+ frames: 16
476
+ })
477
+
478
+ expect {
479
+ described_class.new(image: temp_image, add_meta: '4:4')
480
+ }.to raise_error(RubySpriter::ValidationError, /Image already has spritesheet metadata.*Use --overwrite-meta/)
481
+ end
482
+
483
+ it 'allows replacing metadata with --overwrite-meta' do
484
+ allow(RubySpriter::MetadataManager).to receive(:read).with(temp_image).and_return({
485
+ columns: 4,
486
+ rows: 4,
487
+ frames: 16
488
+ })
489
+
490
+ # Mock ImageMagick identify
491
+ allow(Open3).to receive(:capture3).with(/identify/).and_return(
492
+ ["800x800\n", '', instance_double(Process::Status, success?: true)]
493
+ )
494
+
495
+ expect {
496
+ described_class.new(image: temp_image, add_meta: '8:8', overwrite_meta: true)
497
+ }.not_to raise_error
498
+ end
499
+ end
500
+
501
+ describe 'dimension validation' do
502
+ before do
503
+ allow(RubySpriter::MetadataManager).to receive(:read).with(temp_image).and_return(nil)
504
+ end
505
+
506
+ it 'raises error when image dimensions do not divide evenly' do
507
+ # Mock ImageMagick identify - 800x600 doesn't divide by 3x3
508
+ allow(Open3).to receive(:capture3).with(/identify/).and_return(
509
+ ["800x600\n", '', instance_double(Process::Status, success?: true)]
510
+ )
511
+
512
+ expect {
513
+ described_class.new(image: temp_image, add_meta: '3:3')
514
+ }.to raise_error(RubySpriter::ValidationError, /Image dimensions \(800x600\) must divide evenly by grid \(3x3\)/)
515
+ end
516
+
517
+ it 'allows dimensions that divide evenly' do
518
+ # Mock ImageMagick identify - 800x800 divides by 4x4
519
+ allow(Open3).to receive(:capture3).with(/identify/).and_return(
520
+ ["800x800\n", '', instance_double(Process::Status, success?: true)]
521
+ )
522
+
523
+ expect {
524
+ described_class.new(image: temp_image, add_meta: '4:4')
525
+ }.not_to raise_error
526
+ end
527
+ end
528
+
529
+ describe 'frame count handling' do
530
+ before do
531
+ allow(RubySpriter::MetadataManager).to receive(:read).with(temp_image).and_return(nil)
532
+ allow(Open3).to receive(:capture3).with(/identify/).and_return(
533
+ ["800x800\n", '', instance_double(Process::Status, success?: true)]
534
+ )
535
+ end
536
+
537
+ it 'calculates frame count from rows * columns by default' do
538
+ processor = described_class.new(image: temp_image, add_meta: '4:4')
539
+ # Frame count should be 16 (4x4)
540
+ # We'll verify this in the workflow implementation
541
+ expect(processor.instance_variable_get(:@options)[:add_meta]).to eq('4:4')
542
+ end
543
+
544
+ it 'allows custom frame count with --frames for partial grids' do
545
+ processor = described_class.new(image: temp_image, add_meta: '4:4', frame_count: 14)
546
+ expect(processor.instance_variable_get(:@options)[:frame_count]).to eq(14)
547
+ end
548
+
549
+ it 'raises error when custom frame count exceeds grid size' do
550
+ expect {
551
+ described_class.new(image: temp_image, add_meta: '4:4', frame_count: 20)
552
+ }.to raise_error(RubySpriter::ValidationError, /Frame count \(20\) exceeds grid size \(16\)/)
553
+ end
554
+ end
555
+ end
556
+
207
557
  describe '--split metadata priority logic' do
208
558
  let(:temp_dir) { Dir.mktmpdir('test_split_') }
209
559
  let(:image_file) { File.join(temp_dir, 'spritesheet.png') }