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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +138 -0
- data/README.md +336 -33
- data/lib/ruby_spriter/batch_processor.rb +212 -0
- data/lib/ruby_spriter/cli.rb +354 -7
- data/lib/ruby_spriter/compression_manager.rb +101 -0
- data/lib/ruby_spriter/consolidator.rb +33 -0
- data/lib/ruby_spriter/processor.rb +412 -7
- data/lib/ruby_spriter/version.rb +2 -2
- data/lib/ruby_spriter.rb +2 -0
- data/spec/ruby_spriter/batch_processor_spec.rb +200 -0
- data/spec/ruby_spriter/cli_spec.rb +387 -0
- data/spec/ruby_spriter/compression_manager_spec.rb +157 -0
- data/spec/ruby_spriter/consolidator_spec.rb +163 -0
- data/spec/ruby_spriter/processor_spec.rb +350 -0
- metadata +5 -1
|
@@ -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') }
|