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.
- checksums.yaml +7 -0
- data/THIRD_PARTY_NOTICES +113 -0
- data/assets/JetBrainsMonoNL-Regular.ttf +0 -0
- data/assets/ark-pixel-12px-monospaced-ja.ttf +0 -0
- data/bin/gemba +14 -0
- data/ext/gemba/extconf.rb +185 -0
- data/ext/gemba/gemba_ext.c +1051 -0
- data/ext/gemba/gemba_ext.h +15 -0
- data/gemba.gemspec +38 -0
- data/lib/gemba/child_window.rb +62 -0
- data/lib/gemba/cli.rb +384 -0
- data/lib/gemba/config.rb +621 -0
- data/lib/gemba/core.rb +121 -0
- data/lib/gemba/headless.rb +12 -0
- data/lib/gemba/headless_player.rb +206 -0
- data/lib/gemba/hotkey_map.rb +202 -0
- data/lib/gemba/input_mappings.rb +214 -0
- data/lib/gemba/locale.rb +92 -0
- data/lib/gemba/locales/en.yml +157 -0
- data/lib/gemba/locales/ja.yml +157 -0
- data/lib/gemba/method_coverage_service.rb +265 -0
- data/lib/gemba/overlay_renderer.rb +109 -0
- data/lib/gemba/player.rb +1515 -0
- data/lib/gemba/recorder.rb +156 -0
- data/lib/gemba/recorder_decoder.rb +325 -0
- data/lib/gemba/rom_info_window.rb +346 -0
- data/lib/gemba/rom_loader.rb +100 -0
- data/lib/gemba/runtime.rb +39 -0
- data/lib/gemba/save_state_manager.rb +155 -0
- data/lib/gemba/save_state_picker.rb +199 -0
- data/lib/gemba/settings_window.rb +1173 -0
- data/lib/gemba/tip_service.rb +133 -0
- data/lib/gemba/toast_overlay.rb +128 -0
- data/lib/gemba/version.rb +5 -0
- data/lib/gemba.rb +17 -0
- data/test/fixtures/test.gba +0 -0
- data/test/fixtures/test.sav +0 -0
- data/test/shared/screenshot_helper.rb +113 -0
- data/test/shared/simplecov_config.rb +59 -0
- data/test/shared/teek_test_worker.rb +388 -0
- data/test/shared/tk_test_helper.rb +354 -0
- data/test/support/input_mocks.rb +61 -0
- data/test/support/player_helpers.rb +77 -0
- data/test/test_cli.rb +281 -0
- data/test/test_config.rb +897 -0
- data/test/test_core.rb +401 -0
- data/test/test_gamepad_map.rb +116 -0
- data/test/test_headless_player.rb +205 -0
- data/test/test_helper.rb +19 -0
- data/test/test_hotkey_map.rb +396 -0
- data/test/test_keyboard_map.rb +108 -0
- data/test/test_locale.rb +159 -0
- data/test/test_mgba.rb +26 -0
- data/test/test_overlay_renderer.rb +199 -0
- data/test/test_player.rb +903 -0
- data/test/test_recorder.rb +180 -0
- data/test/test_rom_loader.rb +149 -0
- data/test/test_save_state_manager.rb +289 -0
- data/test/test_settings_hotkeys.rb +434 -0
- data/test/test_settings_window.rb +1039 -0
- data/test/test_tip_service.rb +138 -0
- data/test/test_toast_overlay.rb +216 -0
- data/test/test_virtual_keyboard.rb +39 -0
- data/test/test_xor_delta.rb +61 -0
- 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
|