gemba 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/THIRD_PARTY_NOTICES +113 -0
  3. data/assets/JetBrainsMonoNL-Regular.ttf +0 -0
  4. data/assets/ark-pixel-12px-monospaced-ja.ttf +0 -0
  5. data/bin/gemba +14 -0
  6. data/ext/gemba/extconf.rb +185 -0
  7. data/ext/gemba/gemba_ext.c +1051 -0
  8. data/ext/gemba/gemba_ext.h +15 -0
  9. data/gemba.gemspec +38 -0
  10. data/lib/gemba/child_window.rb +62 -0
  11. data/lib/gemba/cli.rb +384 -0
  12. data/lib/gemba/config.rb +621 -0
  13. data/lib/gemba/core.rb +121 -0
  14. data/lib/gemba/headless.rb +12 -0
  15. data/lib/gemba/headless_player.rb +206 -0
  16. data/lib/gemba/hotkey_map.rb +202 -0
  17. data/lib/gemba/input_mappings.rb +214 -0
  18. data/lib/gemba/locale.rb +92 -0
  19. data/lib/gemba/locales/en.yml +157 -0
  20. data/lib/gemba/locales/ja.yml +157 -0
  21. data/lib/gemba/method_coverage_service.rb +265 -0
  22. data/lib/gemba/overlay_renderer.rb +109 -0
  23. data/lib/gemba/player.rb +1515 -0
  24. data/lib/gemba/recorder.rb +156 -0
  25. data/lib/gemba/recorder_decoder.rb +325 -0
  26. data/lib/gemba/rom_info_window.rb +346 -0
  27. data/lib/gemba/rom_loader.rb +100 -0
  28. data/lib/gemba/runtime.rb +39 -0
  29. data/lib/gemba/save_state_manager.rb +155 -0
  30. data/lib/gemba/save_state_picker.rb +199 -0
  31. data/lib/gemba/settings_window.rb +1173 -0
  32. data/lib/gemba/tip_service.rb +133 -0
  33. data/lib/gemba/toast_overlay.rb +128 -0
  34. data/lib/gemba/version.rb +5 -0
  35. data/lib/gemba.rb +17 -0
  36. data/test/fixtures/test.gba +0 -0
  37. data/test/fixtures/test.sav +0 -0
  38. data/test/shared/screenshot_helper.rb +113 -0
  39. data/test/shared/simplecov_config.rb +59 -0
  40. data/test/shared/teek_test_worker.rb +388 -0
  41. data/test/shared/tk_test_helper.rb +354 -0
  42. data/test/support/input_mocks.rb +61 -0
  43. data/test/support/player_helpers.rb +77 -0
  44. data/test/test_cli.rb +281 -0
  45. data/test/test_config.rb +897 -0
  46. data/test/test_core.rb +401 -0
  47. data/test/test_gamepad_map.rb +116 -0
  48. data/test/test_headless_player.rb +205 -0
  49. data/test/test_helper.rb +19 -0
  50. data/test/test_hotkey_map.rb +396 -0
  51. data/test/test_keyboard_map.rb +108 -0
  52. data/test/test_locale.rb +159 -0
  53. data/test/test_mgba.rb +26 -0
  54. data/test/test_overlay_renderer.rb +199 -0
  55. data/test/test_player.rb +903 -0
  56. data/test/test_recorder.rb +180 -0
  57. data/test/test_rom_loader.rb +149 -0
  58. data/test/test_save_state_manager.rb +289 -0
  59. data/test/test_settings_hotkeys.rb +434 -0
  60. data/test/test_settings_window.rb +1039 -0
  61. data/test/test_tip_service.rb +138 -0
  62. data/test/test_toast_overlay.rb +216 -0
  63. data/test/test_virtual_keyboard.rb +39 -0
  64. data/test/test_xor_delta.rb +61 -0
  65. metadata +234 -0
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "gemba/headless"
5
+ require "tmpdir"
6
+
7
+ class TestRecorder < Minitest::Test
8
+ TEST_ROM = File.expand_path("fixtures/test.gba", __dir__)
9
+
10
+ def setup
11
+ skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
12
+ end
13
+
14
+ def test_record_and_decode_round_trip
15
+ skip "ffmpeg not installed" unless ffmpeg_available?
16
+
17
+ Dir.mktmpdir do |dir|
18
+ trec_path = File.join(dir, "test.grec")
19
+ output_path = File.join(dir, "test.mp4")
20
+ frames = 10
21
+
22
+ # Record
23
+ Gemba::HeadlessPlayer.open(TEST_ROM) do |player|
24
+ player.start_recording(trec_path)
25
+ player.step(frames)
26
+ player.stop_recording
27
+ end
28
+
29
+ assert File.exist?(trec_path)
30
+ assert_operator File.size(trec_path), :>, 32 # at least header
31
+
32
+ # Decode → encode
33
+ info = Gemba::RecorderDecoder.decode(trec_path, output_path)
34
+
35
+ assert_equal 240, info[:width]
36
+ assert_equal 160, info[:height]
37
+ assert_equal frames, info[:frame_count]
38
+ assert_in_delta 59.7272, info[:fps], 0.01
39
+ assert_equal 44100, info[:audio_rate]
40
+ assert_equal 2, info[:audio_channels]
41
+
42
+ # Output video file should exist and have data
43
+ assert File.exist?(output_path)
44
+ assert_operator File.size(output_path), :>, 0
45
+ end
46
+ end
47
+
48
+ def test_decode_without_ffmpeg_raises
49
+ Dir.mktmpdir do |dir|
50
+ trec_path = File.join(dir, "test.grec")
51
+
52
+ Gemba::HeadlessPlayer.open(TEST_ROM) do |player|
53
+ player.start_recording(trec_path)
54
+ player.step(1)
55
+ player.stop_recording
56
+ end
57
+
58
+ output_path = File.join(dir, "test.mp4")
59
+ with_empty_path do
60
+ assert_raises(Gemba::RecorderDecoder::FfmpegNotFound) do
61
+ Gemba::RecorderDecoder.decode(trec_path, output_path)
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ def test_delta_compression_reduces_size
68
+ Dir.mktmpdir do |dir|
69
+ trec_path = File.join(dir, "test.grec")
70
+
71
+ Gemba::HeadlessPlayer.open(TEST_ROM) do |player|
72
+ player.start_recording(trec_path)
73
+ player.step(30)
74
+ player.stop_recording
75
+ end
76
+
77
+ trec_size = File.size(trec_path)
78
+ raw_size = 240 * 160 * 4 * 30 # ~4.6MB uncompressed video alone
79
+ assert_operator trec_size, :<, raw_size, "Compressed .grec should be smaller than raw"
80
+ end
81
+ end
82
+
83
+ def test_recording_state
84
+ Dir.mktmpdir do |dir|
85
+ trec_path = File.join(dir, "test.grec")
86
+
87
+ Gemba::HeadlessPlayer.open(TEST_ROM) do |player|
88
+ refute player.recording?
89
+ player.start_recording(trec_path)
90
+ assert player.recording?
91
+ player.step(5)
92
+ player.stop_recording
93
+ refute player.recording?
94
+ end
95
+ end
96
+ end
97
+
98
+ def test_close_stops_recording
99
+ Dir.mktmpdir do |dir|
100
+ trec_path = File.join(dir, "test.grec")
101
+
102
+ player = Gemba::HeadlessPlayer.new(TEST_ROM)
103
+ player.start_recording(trec_path)
104
+ player.step(5)
105
+ player.close # should stop recording gracefully
106
+
107
+ assert File.exist?(trec_path)
108
+ assert_operator File.size(trec_path), :>, 32
109
+ end
110
+ end
111
+
112
+ def test_double_start_raises
113
+ Dir.mktmpdir do |dir|
114
+ Gemba::HeadlessPlayer.open(TEST_ROM) do |player|
115
+ player.start_recording(File.join(dir, "a.grec"))
116
+ assert_raises(RuntimeError) do
117
+ player.start_recording(File.join(dir, "b.grec"))
118
+ end
119
+ player.stop_recording
120
+ end
121
+ end
122
+ end
123
+
124
+ def test_header_format
125
+ Dir.mktmpdir do |dir|
126
+ trec_path = File.join(dir, "test.grec")
127
+
128
+ Gemba::HeadlessPlayer.open(TEST_ROM) do |player|
129
+ player.start_recording(trec_path)
130
+ player.step(1)
131
+ player.stop_recording
132
+ end
133
+
134
+ File.open(trec_path, 'rb') do |f|
135
+ header = f.read(32)
136
+ assert_equal "GEMBAREC", header[0, 8]
137
+ assert_equal 1, header[8].unpack1('C') # version
138
+ w, h = header[9, 4].unpack('v2')
139
+ assert_equal 240, w
140
+ assert_equal 160, h
141
+ end
142
+ end
143
+ end
144
+
145
+ def test_footer_present
146
+ Dir.mktmpdir do |dir|
147
+ trec_path = File.join(dir, "test.grec")
148
+
149
+ Gemba::HeadlessPlayer.open(TEST_ROM) do |player|
150
+ player.start_recording(trec_path)
151
+ player.step(5)
152
+ player.stop_recording
153
+ end
154
+
155
+ File.open(trec_path, 'rb') do |f|
156
+ f.seek(-8, IO::SEEK_END)
157
+ footer = f.read(8)
158
+ frame_count, magic = footer.unpack('Va4')
159
+ assert_equal 5, frame_count
160
+ assert_equal "GEND", magic
161
+ end
162
+ end
163
+ end
164
+
165
+ private
166
+
167
+ def ffmpeg_available?
168
+ system('ffmpeg', '-version', out: File::NULL, err: File::NULL)
169
+ rescue Errno::ENOENT
170
+ false
171
+ end
172
+
173
+ def with_empty_path
174
+ old_path = ENV['PATH']
175
+ ENV['PATH'] = ''
176
+ yield
177
+ ensure
178
+ ENV['PATH'] = old_path
179
+ end
180
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "gemba"
5
+ require "tmpdir"
6
+ require "zip"
7
+
8
+ class TestRomLoader < Minitest::Test
9
+ TEST_ROM = File.expand_path("fixtures/test.gba", __dir__)
10
+
11
+ def setup
12
+ skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
13
+ @tmpdir = Dir.mktmpdir("rom_loader_test")
14
+ end
15
+
16
+ def teardown
17
+ FileUtils.rm_rf(@tmpdir) if @tmpdir && File.directory?(@tmpdir)
18
+ Gemba::RomLoader.cleanup_temp
19
+ end
20
+
21
+ # -- resolve passthrough --
22
+
23
+ def test_resolve_gba_returns_path_unchanged
24
+ assert_equal TEST_ROM, Gemba::RomLoader.resolve(TEST_ROM)
25
+ end
26
+
27
+ def test_resolve_gb_returns_path_unchanged
28
+ path = "/some/game.gb"
29
+ assert_equal path, Gemba::RomLoader.resolve(path)
30
+ end
31
+
32
+ def test_resolve_gbc_returns_path_unchanged
33
+ path = "/some/game.gbc"
34
+ assert_equal path, Gemba::RomLoader.resolve(path)
35
+ end
36
+
37
+ # -- resolve from zip --
38
+
39
+ def test_resolve_zip_extracts_rom
40
+ zip_path = create_zip("game.zip", "game.gba" => File.binread(TEST_ROM))
41
+ result = Gemba::RomLoader.resolve(zip_path)
42
+
43
+ assert File.exist?(result), "extracted ROM should exist"
44
+ assert_equal ".gba", File.extname(result).downcase
45
+ assert_equal File.binread(TEST_ROM), File.binread(result)
46
+ end
47
+
48
+ def test_resolve_zip_loads_in_core
49
+ zip_path = create_zip("game.zip", "game.gba" => File.binread(TEST_ROM))
50
+ rom_path = Gemba::RomLoader.resolve(zip_path)
51
+
52
+ core = Gemba::Core.new(rom_path)
53
+ assert_equal "GEMBATEST", core.title
54
+ ensure
55
+ core&.destroy
56
+ end
57
+
58
+ # -- error cases --
59
+
60
+ def test_resolve_zip_no_rom_raises
61
+ zip_path = create_zip("empty.zip", "readme.txt" => "hello")
62
+
63
+ err = assert_raises(Gemba::RomLoader::NoRomInZip) do
64
+ Gemba::RomLoader.resolve(zip_path)
65
+ end
66
+ assert_includes err.message, "empty.zip"
67
+ end
68
+
69
+ def test_resolve_zip_multiple_roms_raises
70
+ rom_data = File.binread(TEST_ROM)
71
+ zip_path = create_zip("multi.zip",
72
+ "game1.gba" => rom_data,
73
+ "game2.gba" => rom_data)
74
+
75
+ err = assert_raises(Gemba::RomLoader::MultipleRomsInZip) do
76
+ Gemba::RomLoader.resolve(zip_path)
77
+ end
78
+ assert_includes err.message, "multi.zip"
79
+ end
80
+
81
+ def test_resolve_unsupported_extension_raises
82
+ assert_raises(Gemba::RomLoader::UnsupportedFormat) do
83
+ Gemba::RomLoader.resolve("/some/file.rar")
84
+ end
85
+ end
86
+
87
+ def test_resolve_corrupt_zip_raises
88
+ corrupt = File.join(@tmpdir, "corrupt.zip")
89
+ File.binwrite(corrupt, "this is not a zip file")
90
+
91
+ assert_raises(Gemba::RomLoader::ZipReadError) do
92
+ Gemba::RomLoader.resolve(corrupt)
93
+ end
94
+ end
95
+
96
+ # -- zip with subdirectories (only root-level ROMs) --
97
+
98
+ def test_resolve_zip_ignores_roms_in_subdirectories
99
+ zip_path = File.join(@tmpdir, "nested.zip")
100
+ Zip::OutputStream.open(zip_path) do |zos|
101
+ zos.put_next_entry("subdir/game.gba")
102
+ zos.write(File.binread(TEST_ROM))
103
+ end
104
+
105
+ assert_raises(Gemba::RomLoader::NoRomInZip) do
106
+ Gemba::RomLoader.resolve(zip_path)
107
+ end
108
+ end
109
+
110
+ # -- constants --
111
+
112
+ def test_rom_extensions
113
+ assert_includes Gemba::RomLoader::ROM_EXTENSIONS, ".gba"
114
+ assert_includes Gemba::RomLoader::ROM_EXTENSIONS, ".gb"
115
+ assert_includes Gemba::RomLoader::ROM_EXTENSIONS, ".gbc"
116
+ end
117
+
118
+ def test_supported_extensions_includes_zip
119
+ assert_includes Gemba::RomLoader::SUPPORTED_EXTENSIONS, ".zip"
120
+ end
121
+
122
+ # -- cleanup --
123
+
124
+ def test_cleanup_temp_removes_directory
125
+ zip_path = create_zip("game.zip", "game.gba" => File.binread(TEST_ROM))
126
+ Gemba::RomLoader.resolve(zip_path)
127
+ assert File.directory?(Gemba::RomLoader.tmp_dir)
128
+
129
+ Gemba::RomLoader.cleanup_temp
130
+ refute File.directory?(Gemba::RomLoader.tmp_dir)
131
+ end
132
+
133
+ private
134
+
135
+ # Build a ZIP file using rubyzip.
136
+ # @param name [String] output filename
137
+ # @param entries [Hash{String => String}] filename => content
138
+ # @return [String] path to the created ZIP file
139
+ def create_zip(name, entries)
140
+ path = File.join(@tmpdir, name)
141
+ Zip::OutputStream.open(path) do |zos|
142
+ entries.each do |fname, content|
143
+ zos.put_next_entry(fname)
144
+ zos.write(content)
145
+ end
146
+ end
147
+ path
148
+ end
149
+ end
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "tmpdir"
5
+ require "json"
6
+ require_relative "../lib/gemba/config"
7
+ require_relative "../lib/gemba/locale"
8
+ require_relative "../lib/gemba/save_state_manager"
9
+
10
+ class TestSaveStateManager < Minitest::Test
11
+ # Recording mock for the mGBA Core.
12
+ # Responds to the duck-type interface SaveStateManager needs and
13
+ # records every call for test assertions.
14
+ class MockCore
15
+ attr_reader :calls
16
+ attr_accessor :game_code, :checksum, :destroyed, :save_result, :load_result
17
+
18
+ def initialize(game_code: "AGB-BGBE", checksum: 0xDEADBEEF)
19
+ @calls = []
20
+ @game_code = game_code
21
+ @checksum = checksum
22
+ @destroyed = false
23
+ @save_result = true
24
+ @load_result = true
25
+ end
26
+
27
+ def destroyed?
28
+ @destroyed
29
+ end
30
+
31
+ def save_state_to_file(path)
32
+ @calls << [:save_state_to_file, path]
33
+ File.write(path, "STATE") if @save_result
34
+ @save_result
35
+ end
36
+
37
+ def load_state_from_file(path)
38
+ @calls << [:load_state_from_file, path]
39
+ @load_result
40
+ end
41
+
42
+ def video_buffer_argb
43
+ @calls << [:video_buffer_argb]
44
+ "\x00".b * (240 * 160 * 4)
45
+ end
46
+ end
47
+
48
+ # Recording mock for Tk's Tcl interpreter (provides photo_put_block).
49
+ class MockInterp
50
+ attr_reader :calls
51
+
52
+ def initialize
53
+ @calls = []
54
+ end
55
+
56
+ def photo_put_block(name, pixels, w, h, format:)
57
+ @calls << [:photo_put_block, name, w, h, format]
58
+ end
59
+ end
60
+
61
+ # Recording mock for the Teek::App.
62
+ # Records command() calls and exposes a mock interp.
63
+ class MockApp
64
+ attr_reader :calls, :interp
65
+
66
+ def initialize
67
+ @calls = []
68
+ @interp = MockInterp.new
69
+ end
70
+
71
+ def command(*args, **kwargs)
72
+ @calls << [args, kwargs]
73
+ nil
74
+ end
75
+ end
76
+
77
+ def setup
78
+ @dir = Dir.mktmpdir("gemba-ssm-test")
79
+ @config_path = File.join(@dir, "settings.json")
80
+ @config = Gemba::Config.new(path: @config_path)
81
+ @config.save_state_debounce = 0 # disable debounce for tests
82
+ @states_dir = File.join(@dir, "states")
83
+ @config.states_dir = @states_dir
84
+
85
+ @core = MockCore.new
86
+ @app = MockApp.new
87
+ @mgr = new_manager
88
+ end
89
+
90
+ def teardown
91
+ FileUtils.rm_rf(@dir)
92
+ end
93
+
94
+ def new_manager(core: @core, config: @config, app: @app)
95
+ mgr = Gemba::SaveStateManager.new(core: core, config: config, app: app)
96
+ mgr.state_dir = mgr.state_dir_for_rom(core)
97
+ mgr
98
+ end
99
+
100
+ # -- state_dir_for_rom -----------------------------------------------------
101
+
102
+ def test_state_dir_for_rom_includes_game_code_and_crc
103
+ dir = @mgr.state_dir_for_rom(@core)
104
+ assert_includes dir, "AGB-BGBE"
105
+ assert_includes dir, "DEADBEEF"
106
+ end
107
+
108
+ def test_state_dir_for_rom_sanitizes_special_chars
109
+ @core.game_code = "AB/CD\\EF"
110
+ dir = @mgr.state_dir_for_rom(@core)
111
+ basename = File.basename(dir)
112
+ refute_match %r{[/\\]}, basename
113
+ assert_includes basename, "AB_CD_EF"
114
+ end
115
+
116
+ # -- state_path / screenshot_path ------------------------------------------
117
+
118
+ def test_state_path
119
+ assert @mgr.state_path(1).end_with?("state1.ss")
120
+ end
121
+
122
+ def test_screenshot_path
123
+ assert @mgr.screenshot_path(1).end_with?("state1.png")
124
+ end
125
+
126
+ # -- save_state ------------------------------------------------------------
127
+
128
+ def test_save_state_success
129
+ success, msg = @mgr.save_state(1)
130
+ assert success
131
+ assert_includes msg, "1" # slot number in message
132
+ assert File.exist?(@mgr.state_path(1)), "state file should be written"
133
+ end
134
+
135
+ def test_save_state_calls_core
136
+ @mgr.save_state(1)
137
+ save_calls = @core.calls.select { |c| c[0] == :save_state_to_file }
138
+ assert_equal 1, save_calls.size
139
+ assert save_calls[0][1].end_with?("state1.ss")
140
+ end
141
+
142
+ def test_save_state_takes_screenshot
143
+ @mgr.save_state(1)
144
+ # Should have called video_buffer_argb
145
+ assert @core.calls.any? { |c| c[0] == :video_buffer_argb }
146
+ # Should have created a Tk photo via app.command
147
+ image_creates = @app.calls.select { |args, _| args[0] == :image && args[1] == :create }
148
+ assert_equal 1, image_creates.size
149
+ # Should have written the photo via app.command
150
+ write_calls = @app.calls.select { |args, _| args[1] == :write }
151
+ assert_equal 1, write_calls.size
152
+ # Should have cleaned up the photo
153
+ delete_calls = @app.calls.select { |args, _| args[0] == :image && args[1] == :delete }
154
+ assert_equal 1, delete_calls.size
155
+ end
156
+
157
+ def test_save_state_interp_photo_put_block
158
+ @mgr.save_state(1)
159
+ ppb = @app.interp.calls.select { |c| c[0] == :photo_put_block }
160
+ assert_equal 1, ppb.size
161
+ assert_equal 240, ppb[0][2] # width
162
+ assert_equal 160, ppb[0][3] # height
163
+ assert_equal :argb, ppb[0][4]
164
+ end
165
+
166
+ def test_save_state_failure
167
+ @core.save_result = false
168
+ success, msg = @mgr.save_state(1)
169
+ refute success
170
+ refute_nil msg
171
+ end
172
+
173
+ def test_save_state_with_destroyed_core
174
+ @core.destroyed = true
175
+ success, msg = @mgr.save_state(1)
176
+ refute success
177
+ assert_nil msg
178
+ end
179
+
180
+ # -- debounce --------------------------------------------------------------
181
+
182
+ def test_save_state_debounce_blocks_rapid_saves
183
+ @config.save_state_debounce = 10.0 # very long debounce
184
+ mgr = new_manager
185
+
186
+ success1, _ = mgr.save_state(1)
187
+ assert success1
188
+
189
+ success2, msg2 = mgr.save_state(2)
190
+ refute success2
191
+ refute_nil msg2 # should have a "blocked" message
192
+ end
193
+
194
+ # -- backup rotation -------------------------------------------------------
195
+
196
+ def test_save_state_backup_creates_bak_files
197
+ @mgr.backup = true
198
+
199
+ # First save
200
+ @mgr.save_state(1)
201
+ ss = @mgr.state_path(1)
202
+ assert File.exist?(ss)
203
+
204
+ # Second save — original should be renamed to .bak
205
+ @mgr.save_state(1)
206
+ assert File.exist?("#{ss}.bak"), ".bak file should exist after second save"
207
+ end
208
+
209
+ def test_save_state_no_backup_when_disabled
210
+ @mgr.backup = false
211
+
212
+ @mgr.save_state(1)
213
+ ss = @mgr.state_path(1)
214
+
215
+ @mgr.save_state(1)
216
+ refute File.exist?("#{ss}.bak"), ".bak should not exist when backup disabled"
217
+ end
218
+
219
+ # -- load_state ------------------------------------------------------------
220
+
221
+ def test_load_state_success
222
+ # Create a state file first
223
+ @mgr.save_state(1)
224
+
225
+ success, msg = @mgr.load_state(1)
226
+ assert success
227
+ assert_includes msg, "1"
228
+ load_calls = @core.calls.select { |c| c[0] == :load_state_from_file }
229
+ assert_equal 1, load_calls.size
230
+ end
231
+
232
+ def test_load_state_missing_file
233
+ success, msg = @mgr.load_state(7)
234
+ refute success
235
+ refute_nil msg # "no state" message
236
+ end
237
+
238
+ def test_load_state_failure
239
+ @mgr.save_state(1) # create the file
240
+ @core.load_result = false
241
+
242
+ success, msg = @mgr.load_state(1)
243
+ refute success
244
+ refute_nil msg
245
+ end
246
+
247
+ def test_load_state_with_destroyed_core
248
+ @core.destroyed = true
249
+ success, msg = @mgr.load_state(1)
250
+ refute success
251
+ assert_nil msg
252
+ end
253
+
254
+ # -- quick save / quick load -----------------------------------------------
255
+
256
+ def test_quick_save_uses_configured_slot
257
+ @mgr.quick_save_slot = 3
258
+ @mgr.quick_save
259
+ save_calls = @core.calls.select { |c| c[0] == :save_state_to_file }
260
+ assert_equal 1, save_calls.size
261
+ assert save_calls[0][1].end_with?("state3.ss")
262
+ end
263
+
264
+ def test_quick_load_uses_configured_slot
265
+ @mgr.quick_save_slot = 3
266
+ @mgr.quick_save # create the file first
267
+ @mgr.quick_load
268
+ load_calls = @core.calls.select { |c| c[0] == :load_state_from_file }
269
+ assert_equal 1, load_calls.size
270
+ assert load_calls[0][1].end_with?("state3.ss")
271
+ end
272
+
273
+ # -- screenshot failure doesn't break save ---------------------------------
274
+
275
+ def test_screenshot_failure_does_not_prevent_save
276
+ # Make interp.photo_put_block raise
277
+ def @app.interp
278
+ obj = Object.new
279
+ def obj.photo_put_block(*)
280
+ raise "boom"
281
+ end
282
+ obj
283
+ end
284
+
285
+ success, msg = @mgr.save_state(1)
286
+ assert success, "save should still succeed even if screenshot fails"
287
+ assert File.exist?(@mgr.state_path(1))
288
+ end
289
+ end