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,29 +1,346 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
-
5
- RSpec.describe RubySpriter::VideoProcessor do
6
- describe '#create_spritesheet' do
7
- let(:video_file) { 'test_video.mp4' }
8
- let(:output_file) { 'spritesheet.png' }
9
- let(:processor) { described_class.new(frame_count: 16, columns: 4) }
10
-
11
- before do
12
- allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
13
- allow(File).to receive(:size).and_return(1000)
14
- allow(File).to receive(:exist?).and_return(true)
15
- allow(File).to receive(:delete)
16
- allow(RubySpriter::MetadataManager).to receive(:embed)
17
- allow(Open3).to receive(:capture3).and_return(['2.0', '', instance_double(Process::Status, success?: true)])
18
- end
19
-
20
- it 'creates spritesheet from video file' do
21
- result = processor.create_spritesheet(video_file, output_file)
22
-
23
- expect(result[:output_file]).to eq(output_file)
24
- expect(result[:columns]).to eq(4)
25
- expect(result[:rows]).to eq(4)
26
- expect(result[:frames]).to eq(16)
27
- end
28
- end
29
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../../lib/ruby_spriter/cell_cleanup_processor'
5
+
6
+ RSpec.describe RubySpriter::VideoProcessor do
7
+ describe '#create_spritesheet' do
8
+ let(:video_file) { 'test_video.mp4' }
9
+ let(:output_file) { 'spritesheet.png' }
10
+ let(:processor) { described_class.new(frame_count: 16, columns: 4) }
11
+
12
+ before do
13
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_readable!)
14
+ allow(File).to receive(:size).and_return(1000)
15
+ allow(File).to receive(:exist?).and_return(true)
16
+ allow(File).to receive(:delete)
17
+ allow(RubySpriter::MetadataManager).to receive(:embed)
18
+ allow(Open3).to receive(:capture3).and_return(['2.0', '', instance_double(Process::Status, success?: true)])
19
+ end
20
+
21
+ it 'creates spritesheet from video file' do
22
+ result = processor.create_spritesheet(video_file, output_file)
23
+
24
+ expect(result[:output_file]).to eq(output_file)
25
+ expect(result[:columns]).to eq(4)
26
+ expect(result[:rows]).to eq(4)
27
+ expect(result[:frames]).to eq(16)
28
+ end
29
+
30
+ end
31
+
32
+ describe '#process_with_background_removal' do
33
+ let(:video_processor) { described_class.new }
34
+ let(:temp_dir) { 'temp_test_dir' }
35
+ let(:video_path) { 'test_video.mp4' }
36
+ let(:output_path) { 'output_spritesheet.png' }
37
+
38
+ before do
39
+ allow(Dir).to receive(:mktmpdir).and_return(temp_dir)
40
+ allow(FileUtils).to receive(:rm_rf)
41
+
42
+ # Mock file operations for metadata embedding
43
+ allow(FileUtils).to receive(:mv)
44
+ allow(File).to receive(:delete)
45
+ allow(File).to receive(:exist?).and_return(true)
46
+ end
47
+
48
+ context 'when by_frame option is true' do
49
+ let(:options) do
50
+ {
51
+ by_frame: true,
52
+ remove_bg: true,
53
+ frames: 4,
54
+ columns: 2,
55
+ gimp_path: '/usr/bin/gimp'
56
+ }
57
+ end
58
+
59
+ it 'processes each frame individually with background removal' do
60
+ # Mock frame extraction
61
+ frame_files = ['frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png']
62
+ allow(video_processor).to receive(:extract_frames).and_return(frame_files)
63
+
64
+ # Mock background removal for each frame
65
+ gimp_processor = instance_double(RubySpriter::GimpProcessor)
66
+ allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_processor)
67
+
68
+ # Expect .process() to be called for EACH frame (4 times)
69
+ # process() returns the output path (same as input for simplicity)
70
+ allow(gimp_processor).to receive(:process) do |input_path|
71
+ # Return the _nobg version of the input path
72
+ input_path.sub('.png', '_nobg.png')
73
+ end
74
+ expect(gimp_processor).to receive(:process).exactly(4).times
75
+
76
+ # Mock spritesheet assembly
77
+ allow(video_processor).to receive(:assemble_spritesheet_from_frames)
78
+
79
+ # Mock metadata
80
+ allow(RubySpriter::MetadataManager).to receive(:embed)
81
+
82
+ video_processor.process_with_background_removal(video_path, output_path, options)
83
+ end
84
+
85
+ it 'displays progress indicator for each frame' do
86
+ frame_files = ['frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png']
87
+ allow(video_processor).to receive(:extract_frames).and_return(frame_files)
88
+
89
+ gimp_processor = instance_double(RubySpriter::GimpProcessor)
90
+ allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_processor)
91
+
92
+ # Mock .process() to return output path
93
+ allow(gimp_processor).to receive(:process) do |input_path|
94
+ input_path.sub('.png', '_nobg.png')
95
+ end
96
+
97
+ allow(video_processor).to receive(:assemble_spritesheet_from_frames)
98
+ allow(RubySpriter::MetadataManager).to receive(:embed)
99
+
100
+ # Verify progress messages are displayed (use regex that matches any frame number)
101
+ expect { video_processor.process_with_background_removal(video_path, output_path, options) }
102
+ .to output(/Processing frame \d+\/4.*Processing frame \d+\/4.*Processing frame \d+\/4.*Processing frame \d+\/4/m).to_stdout
103
+ end
104
+
105
+ it 'passes temp_dir in options to assemble_spritesheet_from_frames' do
106
+ allow(video_processor).to receive(:extract_frames).and_return(['frame_001.png'])
107
+ allow(video_processor).to receive(:process_frames_individually)
108
+ allow(RubySpriter::MetadataManager).to receive(:embed)
109
+
110
+ expect(video_processor).to receive(:assemble_spritesheet_from_frames) do |frames, output, opts|
111
+ expect(opts[:temp_dir]).to be_a(String)
112
+ expect(opts[:temp_dir]).to eq('temp_test_dir')
113
+ end
114
+
115
+ video_processor.process_with_background_removal(video_path, output_path, options)
116
+ end
117
+ end
118
+
119
+ context 'when by_frame option is false' do
120
+ let(:options) do
121
+ {
122
+ by_frame: false,
123
+ remove_bg: true,
124
+ frames: 4,
125
+ columns: 2,
126
+ gimp_path: '/usr/bin/gimp'
127
+ }
128
+ end
129
+
130
+ it 'processes the entire spritesheet at once (existing behavior)' do
131
+ # Mock frame extraction
132
+ frame_files = ['frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png']
133
+ allow(video_processor).to receive(:extract_frames).and_return(frame_files)
134
+
135
+ # Mock spritesheet assembly
136
+ allow(video_processor).to receive(:assemble_spritesheet_from_frames)
137
+
138
+ # Mock background removal for spritesheet (called ONCE, not per frame)
139
+ gimp_processor = instance_double(RubySpriter::GimpProcessor)
140
+ allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_processor)
141
+
142
+ # Mock .process() to return the same path (no change)
143
+ allow(gimp_processor).to receive(:process).with(output_path).and_return(output_path)
144
+ expect(gimp_processor).to receive(:process).once
145
+
146
+ # Mock metadata
147
+ allow(RubySpriter::MetadataManager).to receive(:embed)
148
+
149
+ video_processor.process_with_background_removal(video_path, output_path, options)
150
+ end
151
+
152
+ context 'with cell cleanup enabled' do
153
+ let(:cleanup_options) do
154
+ {
155
+ by_frame: false,
156
+ remove_bg: true,
157
+ cleanup_cells: true,
158
+ frames: 4,
159
+ columns: 2,
160
+ gimp_path: '/usr/bin/gimp',
161
+ cell_cleanup_threshold: 15.0
162
+ }
163
+ end
164
+
165
+ it 'applies cell cleanup after background removal' do
166
+ # Mock frame extraction
167
+ frame_files = ['frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png']
168
+ allow(video_processor).to receive(:extract_frames).and_return(frame_files)
169
+
170
+ # Mock spritesheet assembly
171
+ allow(video_processor).to receive(:assemble_spritesheet_from_frames)
172
+
173
+ # Mock GIMP processing
174
+ gimp_processor = instance_double(RubySpriter::GimpProcessor)
175
+ allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_processor)
176
+ allow(gimp_processor).to receive(:process).and_return(output_path)
177
+
178
+ # Mock cell cleanup
179
+ mock_cell_processor = instance_double(RubySpriter::CellCleanupProcessor)
180
+ expect(RubySpriter::CellCleanupProcessor).to receive(:new)
181
+ .with(cleanup_options)
182
+ .and_return(mock_cell_processor)
183
+ expect(mock_cell_processor).to receive(:cleanup_cells)
184
+ .with(output_path, cleanup_options)
185
+ .and_return({ processed: 4, cleaned: 2, skipped: 2 })
186
+
187
+ # Mock metadata
188
+ allow(RubySpriter::MetadataManager).to receive(:embed)
189
+
190
+ video_processor.process_with_background_removal(video_path, output_path, cleanup_options)
191
+ end
192
+
193
+ it 'skips cell cleanup when flag is false' do
194
+ # Mock frame extraction
195
+ frame_files = ['frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png']
196
+ allow(video_processor).to receive(:extract_frames).and_return(frame_files)
197
+
198
+ # Mock spritesheet assembly
199
+ allow(video_processor).to receive(:assemble_spritesheet_from_frames)
200
+
201
+ # Mock GIMP processing
202
+ gimp_processor = instance_double(RubySpriter::GimpProcessor)
203
+ allow(RubySpriter::GimpProcessor).to receive(:new).and_return(gimp_processor)
204
+ allow(gimp_processor).to receive(:process).and_return(output_path)
205
+
206
+ # Should NOT instantiate CellCleanupProcessor
207
+ expect(RubySpriter::CellCleanupProcessor).not_to receive(:new)
208
+
209
+ # Mock metadata
210
+ allow(RubySpriter::MetadataManager).to receive(:embed)
211
+
212
+ no_cleanup_options = cleanup_options.merge(cleanup_cells: false)
213
+ video_processor.process_with_background_removal(video_path, output_path, no_cleanup_options)
214
+ end
215
+ end
216
+ end
217
+ end
218
+ describe '#extract_frames' do
219
+ let(:video_processor) { described_class.new }
220
+ let(:video_file) { 'test_video.mp4' }
221
+ let(:temp_dir) { 'temp_frames' }
222
+ let(:options) { { frames: 16, max_width: 320, debug: false } }
223
+
224
+ before do
225
+ # Mock get_duration to return 2.0 seconds
226
+ allow(video_processor).to receive(:get_duration).and_return(2.0)
227
+
228
+ # Mock Open3.capture3 for FFmpeg execution
229
+ allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
230
+ end
231
+
232
+ it 'extracts frames from video using FFmpeg' do
233
+ expect(Open3).to receive(:capture3) do |cmd|
234
+ expect(cmd).to include('ffmpeg')
235
+ expect(cmd).to include('-i')
236
+ expect(cmd).to include(video_file)
237
+ expect(cmd).to include('fps=8.0') # 16 frames / 2.0 seconds
238
+ expect(cmd).to include('scale=320:-1')
239
+ expect(cmd).to include('-frames:v 16')
240
+ ['', '', double(success?: true)]
241
+ end
242
+
243
+ result = video_processor.send(:extract_frames, video_file, temp_dir, options)
244
+
245
+ expect(result).to be_an(Array)
246
+ expect(result.length).to eq(16)
247
+ end
248
+
249
+ it 'returns array of frame filenames' do
250
+ result = video_processor.send(:extract_frames, video_file, temp_dir, options)
251
+
252
+ expect(result).to eq([
253
+ 'frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png',
254
+ 'frame_005.png', 'frame_006.png', 'frame_007.png', 'frame_008.png',
255
+ 'frame_009.png', 'frame_010.png', 'frame_011.png', 'frame_012.png',
256
+ 'frame_013.png', 'frame_014.png', 'frame_015.png', 'frame_016.png'
257
+ ])
258
+ end
259
+
260
+ it 'raises ProcessingError if FFmpeg fails' do
261
+ allow(Open3).to receive(:capture3).and_return(['', 'FFmpeg error', double(success?: false)])
262
+
263
+ expect {
264
+ video_processor.send(:extract_frames, video_file, temp_dir, options)
265
+ }.to raise_error(RubySpriter::ProcessingError, /Failed to extract frames/)
266
+ end
267
+ end
268
+ describe '#assemble_spritesheet_from_frames' do
269
+ let(:video_processor) { described_class.new }
270
+ let(:frame_files) { ['frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png'] }
271
+ let(:output_path) { 'output_spritesheet.png' }
272
+ let(:temp_dir) { 'temp_frames' }
273
+ let(:options) { { columns: 2, temp_dir: temp_dir, debug: false } }
274
+
275
+ before do
276
+ # Mock Open3.capture3 for FFmpeg execution
277
+ allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
278
+
279
+ # Mock file validation
280
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!)
281
+ end
282
+
283
+ it 'assembles frames into spritesheet using FFmpeg tile filter' do
284
+ expect(Open3).to receive(:capture3) do |cmd|
285
+ expect(cmd).to include('ffmpeg')
286
+ expect(cmd).to include('-i')
287
+ expect(cmd).to include('frame_%03d.png')
288
+ expect(cmd).to include('tile=2x2') # 4 frames / 2 columns = 2 rows
289
+ expect(cmd).to include('-frames:v 1')
290
+ expect(cmd).to include(output_path)
291
+ ['', '', double(success?: true)]
292
+ end
293
+
294
+ video_processor.send(:assemble_spritesheet_from_frames, frame_files, output_path, options)
295
+ end
296
+
297
+ it 'calculates rows correctly from frame count and columns' do
298
+ # 4 frames / 2 columns = 2 rows
299
+ expect(Open3).to receive(:capture3) do |cmd|
300
+ expect(cmd).to include('tile=2x2')
301
+ ['', '', double(success?: true)]
302
+ end
303
+
304
+ video_processor.send(:assemble_spritesheet_from_frames, frame_files, output_path, options)
305
+ end
306
+
307
+ it 'handles non-evenly divisible frame counts with ceiling' do
308
+ # 5 frames / 2 columns = 2.5 → 3 rows (ceiling)
309
+ frame_files_odd = ['frame_001.png', 'frame_002.png', 'frame_003.png', 'frame_004.png', 'frame_005.png']
310
+
311
+ expect(Open3).to receive(:capture3) do |cmd|
312
+ expect(cmd).to include('tile=2x3')
313
+ ['', '', double(success?: true)]
314
+ end
315
+
316
+ video_processor.send(:assemble_spritesheet_from_frames, frame_files_odd, output_path, options)
317
+ end
318
+
319
+ it 'raises ProcessingError if FFmpeg fails' do
320
+ allow(Open3).to receive(:capture3).and_return(['', 'FFmpeg error', double(success?: false)])
321
+
322
+ expect {
323
+ video_processor.send(:assemble_spritesheet_from_frames, frame_files, output_path, options)
324
+ }.to raise_error(RubySpriter::ProcessingError, /Failed to assemble spritesheet/)
325
+ end
326
+
327
+ it 'validates output file exists after assembly' do
328
+ expect(RubySpriter::Utils::FileHelper).to receive(:validate_exists!).with(output_path)
329
+
330
+ video_processor.send(:assemble_spritesheet_from_frames, frame_files, output_path, options)
331
+ end
332
+
333
+ it 'handles frame files with _nobg suffix' do
334
+ frame_files_nobg = ['frame_001_nobg.png', 'frame_002_nobg.png', 'frame_003_nobg.png', 'frame_004_nobg.png']
335
+
336
+ expect(Open3).to receive(:capture3) do |cmd|
337
+ expect(cmd).to include('frame_%03d_nobg.png')
338
+ expect(cmd).to include('tile=2x2')
339
+ ['', '', double(success?: true)]
340
+ end
341
+
342
+ video_processor.send(:assemble_spritesheet_from_frames, frame_files_nobg, output_path, options)
343
+ end
344
+ end
345
+
346
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,41 +1,41 @@
1
- # frozen_string_literal: true
2
-
3
- require 'simplecov'
4
- SimpleCov.start do
5
- add_filter '/spec/'
6
- add_filter '/vendor/'
7
- end
8
-
9
- require 'ruby_spriter'
10
-
11
- RSpec.configure do |config|
12
- # Enable flags like --only-failures and --next-failure
13
- config.example_status_persistence_file_path = '.rspec_status'
14
-
15
- # Disable RSpec exposing methods globally on `Module` and `main`
16
- config.disable_monkey_patching!
17
-
18
- config.expect_with :rspec do |c|
19
- c.syntax = :expect
20
- end
21
-
22
- # Helpers for creating temp files in tests
23
- config.before(:suite) do
24
- $test_temp_dir = File.join(Dir.tmpdir, 'ruby_spriter_test')
25
- FileUtils.mkdir_p($test_temp_dir)
26
- end
27
-
28
- config.after(:suite) do
29
- FileUtils.rm_rf($test_temp_dir) if $test_temp_dir && File.exist?($test_temp_dir)
30
- end
31
-
32
- # Make test temp directory available to all specs
33
- config.before(:each) do
34
- @test_dir = File.join($test_temp_dir, "test_#{Time.now.to_i}_#{rand(10000)}")
35
- FileUtils.mkdir_p(@test_dir)
36
- end
37
-
38
- config.after(:each) do
39
- FileUtils.rm_rf(@test_dir) if @test_dir && File.exist?(@test_dir)
40
- end
41
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'simplecov'
4
+ SimpleCov.start do
5
+ add_filter '/spec/'
6
+ add_filter '/vendor/'
7
+ end
8
+
9
+ require 'ruby_spriter'
10
+
11
+ RSpec.configure do |config|
12
+ # Enable flags like --only-failures and --next-failure
13
+ config.example_status_persistence_file_path = '.rspec_status'
14
+
15
+ # Disable RSpec exposing methods globally on `Module` and `main`
16
+ config.disable_monkey_patching!
17
+
18
+ config.expect_with :rspec do |c|
19
+ c.syntax = :expect
20
+ end
21
+
22
+ # Helpers for creating temp files in tests
23
+ config.before(:suite) do
24
+ $test_temp_dir = File.join(Dir.tmpdir, 'ruby_spriter_test')
25
+ FileUtils.mkdir_p($test_temp_dir)
26
+ end
27
+
28
+ config.after(:suite) do
29
+ FileUtils.rm_rf($test_temp_dir) if $test_temp_dir && File.exist?($test_temp_dir)
30
+ end
31
+
32
+ # Make test temp directory available to all specs
33
+ config.before(:each) do
34
+ @test_dir = File.join($test_temp_dir, "test_#{Time.now.to_i}_#{rand(10000)}")
35
+ FileUtils.mkdir_p(@test_dir)
36
+ end
37
+
38
+ config.after(:each) do
39
+ FileUtils.rm_rf(@test_dir) if @test_dir && File.exist?(@test_dir)
40
+ end
41
+ end
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,132 @@
1
+ require 'spec_helper'
2
+ require 'ruby_spriter/background_sampler'
3
+
4
+ RSpec.describe RubySpriter::BackgroundSampler do
5
+ let(:image_path) { 'spec/fixtures/walk_north_sprite-sheet.png' }
6
+ let(:sample_offset) { 5 }
7
+ let(:sample_count) { 10 }
8
+ let(:max_rows) { 20 }
9
+
10
+ subject { described_class.new(image_path, sample_offset, sample_count, max_rows) }
11
+
12
+ describe '#initialize' do
13
+ it 'sets the image path' do
14
+ expect(subject.image_path).to eq(image_path)
15
+ end
16
+
17
+ it 'sets the sample offset' do
18
+ expect(subject.sample_offset).to eq(5)
19
+ end
20
+
21
+ it 'sets the sample count' do
22
+ expect(subject.sample_count).to eq(10)
23
+ end
24
+
25
+ it 'sets the max rows' do
26
+ expect(subject.max_rows).to eq(20)
27
+ end
28
+ end
29
+
30
+ describe '#collect_unique_colors' do
31
+ it 'returns an array of unique RGB color hashes' do
32
+ colors = subject.collect_unique_colors
33
+
34
+ expect(colors).to be_an(Array)
35
+ # May return fewer than sample_count if image has limited unique colors
36
+ expect(colors.length).to be > 0
37
+
38
+ colors.each do |color|
39
+ expect(color).to have_key(:r)
40
+ expect(color).to have_key(:g)
41
+ expect(color).to have_key(:b)
42
+ expect(color[:r]).to be_between(0, 255)
43
+ expect(color[:g]).to be_between(0, 255)
44
+ expect(color[:b]).to be_between(0, 255)
45
+ end
46
+ end
47
+
48
+ it 'collects up to the specified number of unique colors' do
49
+ colors = subject.collect_unique_colors
50
+
51
+ expect(colors.length).to be <= sample_count
52
+ end
53
+
54
+ it 'returns only unique colors (no duplicates)' do
55
+ colors = subject.collect_unique_colors
56
+
57
+ # Convert to strings for comparison
58
+ color_strings = colors.map { |c| "#{c[:r]},#{c[:g]},#{c[:b]}" }
59
+ expect(color_strings.uniq.length).to eq(colors.length)
60
+ end
61
+
62
+ it 'samples starting at offset pixels from edge' do
63
+ # Mock ImageMagick calls to verify coordinates
64
+ allow(Open3).to receive(:capture3) do |cmd|
65
+ # Extract x,y from command like: magick <path> -format "%[pixel:p{5,5}]" info:
66
+ if cmd =~ /pixel:p\{(\d+),(\d+)\}/
67
+ x = $1.to_i
68
+ y = $2.to_i
69
+
70
+ # Verify x and y are at least offset pixels from edge
71
+ expect(x).to be >= sample_offset
72
+ expect(y).to be >= sample_offset
73
+
74
+ # Return mock color
75
+ ["srgb(255,255,255)", "", double(success?: true)]
76
+ else
77
+ ["100 100", "", double(success?: true)] # Dimensions
78
+ end
79
+ end
80
+
81
+ subject.collect_unique_colors
82
+ end
83
+
84
+ it 'moves to next row if not enough unique colors found in first row' do
85
+ # Create a sampler that needs more colors than available in one row
86
+ sampler = described_class.new(image_path, 5, 10, 20)
87
+
88
+ colors = sampler.collect_unique_colors
89
+
90
+ # Should have collected some colors (exact count depends on image)
91
+ expect(colors).to be_an(Array)
92
+ expect(colors.length).to be > 0
93
+
94
+ # All colors should be unique
95
+ color_strings = colors.map { |c| "#{c[:r]},#{c[:g]},#{c[:b]}" }
96
+ expect(color_strings.uniq.length).to eq(colors.length)
97
+ end
98
+ end
99
+
100
+ describe '#sample_pixel' do
101
+ it 'uses ImageMagick to get pixel color at specific coordinates' do
102
+ # Matches both 'magick' (Windows) and 'convert' (Unix)
103
+ expect(Open3).to receive(:capture3).with(/(magick|convert).*pixel:p\{10,10\}/).and_return(
104
+ ["srgb(128,64,32)", "", double(success?: true)]
105
+ )
106
+
107
+ color = subject.send(:sample_pixel, 10, 10)
108
+
109
+ expect(color).to eq({ r: 128, g: 64, b: 32 })
110
+ end
111
+
112
+ it 'returns nil if ImageMagick command fails' do
113
+ allow(Open3).to receive(:capture3).and_return(
114
+ ["", "error", double(success?: false)]
115
+ )
116
+
117
+ color = subject.send(:sample_pixel, 10, 10)
118
+
119
+ expect(color).to be_nil
120
+ end
121
+
122
+ it 'handles grayscale images' do
123
+ allow(Open3).to receive(:capture3).and_return(
124
+ ["gray(128)", "", double(success?: true)]
125
+ )
126
+
127
+ color = subject.send(:sample_pixel, 10, 10)
128
+
129
+ expect(color).to eq({ r: 128, g: 128, b: 128 })
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,32 @@
1
+ require 'spec_helper'
2
+ require_relative '../../lib/ruby_spriter/cell_cleanup_config'
3
+
4
+ RSpec.describe RubySpriter::CellCleanupConfig do
5
+ describe '#initialize' do
6
+ context 'with default options' do
7
+ it 'sets threshold to 15.0' do
8
+ config = described_class.new
9
+ expect(config.threshold).to eq(15.0)
10
+ end
11
+ end
12
+
13
+ context 'with custom threshold' do
14
+ it 'accepts valid custom threshold' do
15
+ config = described_class.new(cell_cleanup_threshold: 20.0)
16
+ expect(config.threshold).to eq(20.0)
17
+ end
18
+ end
19
+
20
+ context 'with invalid threshold' do
21
+ it 'raises error when threshold is too low' do
22
+ expect { described_class.new(cell_cleanup_threshold: 0.5) }
23
+ .to raise_error(RubySpriter::ValidationError, /between 1.0 and 50.0/)
24
+ end
25
+
26
+ it 'raises error when threshold is too high' do
27
+ expect { described_class.new(cell_cleanup_threshold: 55.0) }
28
+ .to raise_error(RubySpriter::ValidationError, /between 1.0 and 50.0/)
29
+ end
30
+ end
31
+ end
32
+ end