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
data/test/test_player.rb
ADDED
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require_relative "shared/tk_test_helper"
|
|
5
|
+
|
|
6
|
+
class TestMGBAPlayer < Minitest::Test
|
|
7
|
+
include TeekTestHelper
|
|
8
|
+
|
|
9
|
+
TEST_ROM = File.expand_path("fixtures/test.gba", __dir__)
|
|
10
|
+
|
|
11
|
+
# Launches the full Player with a ROM loaded, runs a few frames,
|
|
12
|
+
# then triggers quit. If the process doesn't exit within the timeout
|
|
13
|
+
# the test fails — catching exit-hang regressions.
|
|
14
|
+
def test_exit_with_rom_loaded_does_not_hang
|
|
15
|
+
code = <<~RUBY
|
|
16
|
+
require "gemba"
|
|
17
|
+
require "support/player_helpers"
|
|
18
|
+
|
|
19
|
+
player = Gemba::Player.new("#{TEST_ROM}")
|
|
20
|
+
app = player.app
|
|
21
|
+
|
|
22
|
+
poll_until_ready(player) { player.running = false }
|
|
23
|
+
|
|
24
|
+
player.run
|
|
25
|
+
RUBY
|
|
26
|
+
|
|
27
|
+
success, stdout, stderr, _status = tk_subprocess(code)
|
|
28
|
+
|
|
29
|
+
output = []
|
|
30
|
+
output << "STDOUT:\n#{stdout}" unless stdout.empty?
|
|
31
|
+
output << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
32
|
+
|
|
33
|
+
assert success, "Player should exit cleanly with ROM loaded (no hang)\n#{output.join("\n")}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Simulate a user pressing F11 twice (fullscreen on → off) then q to quit.
|
|
37
|
+
# Exercises the wm attributes fullscreen path end-to-end. If the toggle
|
|
38
|
+
# causes a hang or crash the subprocess will time out.
|
|
39
|
+
def test_fullscreen_toggle_does_not_hang
|
|
40
|
+
code = <<~RUBY
|
|
41
|
+
require "gemba"
|
|
42
|
+
require "support/player_helpers"
|
|
43
|
+
|
|
44
|
+
player = Gemba::Player.new("#{TEST_ROM}")
|
|
45
|
+
app = player.app
|
|
46
|
+
|
|
47
|
+
poll_until_ready(player) do
|
|
48
|
+
vp = player.viewport
|
|
49
|
+
frame = vp.frame.path
|
|
50
|
+
|
|
51
|
+
# User presses F11 → fullscreen on
|
|
52
|
+
app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'F11')
|
|
53
|
+
app.update
|
|
54
|
+
|
|
55
|
+
app.after(50) do
|
|
56
|
+
# User presses F11 → fullscreen off
|
|
57
|
+
app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'F11')
|
|
58
|
+
app.update
|
|
59
|
+
|
|
60
|
+
app.after(50) do
|
|
61
|
+
# User presses q → quit
|
|
62
|
+
app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'q')
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
player.run
|
|
68
|
+
RUBY
|
|
69
|
+
|
|
70
|
+
success, stdout, stderr, _status = tk_subprocess(code)
|
|
71
|
+
|
|
72
|
+
output = []
|
|
73
|
+
output << "STDOUT:\n#{stdout}" unless stdout.empty?
|
|
74
|
+
output << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
75
|
+
|
|
76
|
+
assert success, "Player should exit cleanly after fullscreen toggle\n#{output.join("\n")}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Simulate a user enabling turbo (Tab), running a few frames at 2x speed,
|
|
80
|
+
# then pressing q to quit. Without the poll_input fix (update_state vs
|
|
81
|
+
# poll_events), SDL_PollEvent steals Tk keyboard events on macOS and
|
|
82
|
+
# the quit key never reaches the KeyPress handler — causing a hang.
|
|
83
|
+
def test_exit_during_turbo_does_not_hang
|
|
84
|
+
code = <<~RUBY
|
|
85
|
+
require "gemba"
|
|
86
|
+
require "support/player_helpers"
|
|
87
|
+
|
|
88
|
+
player = Gemba::Player.new("#{TEST_ROM}")
|
|
89
|
+
app = player.app
|
|
90
|
+
|
|
91
|
+
poll_until_ready(player) do
|
|
92
|
+
vp = player.viewport
|
|
93
|
+
frame = vp.frame.path
|
|
94
|
+
|
|
95
|
+
# User presses Tab → enable turbo (2x default)
|
|
96
|
+
app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'Tab')
|
|
97
|
+
app.update
|
|
98
|
+
|
|
99
|
+
app.after(50) do
|
|
100
|
+
# User presses q → quit (while still in turbo)
|
|
101
|
+
app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'q')
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
player.run
|
|
106
|
+
RUBY
|
|
107
|
+
|
|
108
|
+
success, stdout, stderr, _status = tk_subprocess(code)
|
|
109
|
+
|
|
110
|
+
output = []
|
|
111
|
+
output << "STDOUT:\n#{stdout}" unless stdout.empty?
|
|
112
|
+
output << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
113
|
+
|
|
114
|
+
assert success, "Player should exit cleanly during turbo mode (no hang)\n#{output.join("\n")}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# E2E: quick save (F5), wait for debounce, quick load (F8).
|
|
118
|
+
# Verifies state file + screenshot are created, backup rotation works,
|
|
119
|
+
# and the core remains functional after load.
|
|
120
|
+
def test_quick_save_and_load_creates_files_and_restores_state
|
|
121
|
+
skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
|
|
122
|
+
|
|
123
|
+
code = <<~RUBY
|
|
124
|
+
require "gemba"
|
|
125
|
+
require "tmpdir"
|
|
126
|
+
require "fileutils"
|
|
127
|
+
require "support/player_helpers"
|
|
128
|
+
|
|
129
|
+
# Use a temp dir for all config/states so we don't pollute the real one
|
|
130
|
+
states_dir = Dir.mktmpdir("gemba-states-test")
|
|
131
|
+
|
|
132
|
+
player = Gemba::Player.new("#{TEST_ROM}")
|
|
133
|
+
app = player.app
|
|
134
|
+
config = player.config
|
|
135
|
+
|
|
136
|
+
# Override states dir and reduce debounce for test speed
|
|
137
|
+
config.states_dir = states_dir
|
|
138
|
+
config.save_state_debounce = 0.1
|
|
139
|
+
|
|
140
|
+
poll_until_ready(player) do
|
|
141
|
+
core = player.save_mgr.core
|
|
142
|
+
state_dir = player.save_mgr.state_dir
|
|
143
|
+
vp = player.viewport
|
|
144
|
+
frame_path = vp.frame.path
|
|
145
|
+
|
|
146
|
+
# Quick save (F5)
|
|
147
|
+
app.command(:event, 'generate', frame_path, '<KeyPress>', keysym: 'F5')
|
|
148
|
+
app.update
|
|
149
|
+
|
|
150
|
+
app.after(50) do
|
|
151
|
+
# Verify state file and screenshot exist
|
|
152
|
+
ss_path = File.join(state_dir, "state1.ss")
|
|
153
|
+
png_path = File.join(state_dir, "state1.png")
|
|
154
|
+
|
|
155
|
+
unless File.exist?(ss_path)
|
|
156
|
+
$stderr.puts "FAIL: state file not created at \#{ss_path}"
|
|
157
|
+
$stderr.puts "Dir contents: \#{Dir.glob(state_dir + '/**/*').inspect}"
|
|
158
|
+
exit 1
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
unless File.exist?(png_path)
|
|
162
|
+
$stderr.puts "FAIL: screenshot not created at \#{png_path}"
|
|
163
|
+
exit 1
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
ss_size = File.size(ss_path)
|
|
167
|
+
png_size = File.size(png_path)
|
|
168
|
+
|
|
169
|
+
# Save again to test backup rotation (after debounce)
|
|
170
|
+
app.after(50) do
|
|
171
|
+
app.command(:event, 'generate', frame_path, '<KeyPress>', keysym: 'F5')
|
|
172
|
+
app.update
|
|
173
|
+
|
|
174
|
+
app.after(50) do
|
|
175
|
+
bak_path = ss_path + ".bak"
|
|
176
|
+
png_bak = png_path + ".bak"
|
|
177
|
+
|
|
178
|
+
unless File.exist?(bak_path)
|
|
179
|
+
$stderr.puts "FAIL: backup not created at \#{bak_path}"
|
|
180
|
+
exit 1
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
unless File.exist?(png_bak)
|
|
184
|
+
$stderr.puts "FAIL: PNG backup not created at \#{png_bak}"
|
|
185
|
+
exit 1
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Quick load (F8)
|
|
189
|
+
app.command(:event, 'generate', frame_path, '<KeyPress>', keysym: 'F8')
|
|
190
|
+
app.update
|
|
191
|
+
|
|
192
|
+
app.after(50) do
|
|
193
|
+
# Verify core is still functional after load
|
|
194
|
+
begin
|
|
195
|
+
core.run_frame
|
|
196
|
+
buf = core.video_buffer
|
|
197
|
+
unless buf.bytesize == 240 * 160 * 4
|
|
198
|
+
$stderr.puts "FAIL: video buffer invalid after state load"
|
|
199
|
+
exit 1
|
|
200
|
+
end
|
|
201
|
+
rescue => e
|
|
202
|
+
$stderr.puts "FAIL: core error after load: \#{e.message}"
|
|
203
|
+
exit 1
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
$stdout.puts "PASS"
|
|
207
|
+
$stdout.puts "state_size=\#{ss_size}"
|
|
208
|
+
$stdout.puts "png_size=\#{png_size}"
|
|
209
|
+
|
|
210
|
+
# Clean up and quit
|
|
211
|
+
FileUtils.rm_rf(states_dir)
|
|
212
|
+
player.running = false
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
player.run
|
|
220
|
+
RUBY
|
|
221
|
+
|
|
222
|
+
success, stdout, stderr, _status = tk_subprocess(code)
|
|
223
|
+
|
|
224
|
+
output = []
|
|
225
|
+
output << "STDOUT:\n#{stdout}" unless stdout.empty?
|
|
226
|
+
output << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
227
|
+
|
|
228
|
+
assert success, "Quick save/load E2E test failed\n#{output.join("\n")}"
|
|
229
|
+
assert_includes stdout, "PASS", "Expected PASS in output\n#{output.join("\n")}"
|
|
230
|
+
|
|
231
|
+
# Verify state file was non-trivial
|
|
232
|
+
if stdout =~ /state_size=(\d+)/
|
|
233
|
+
assert $1.to_i > 1000, "State file should be >1KB (got #{$1} bytes)"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Verify PNG was created
|
|
237
|
+
if stdout =~ /png_size=(\d+)/
|
|
238
|
+
assert $1.to_i > 100, "PNG screenshot should be >100 bytes (got #{$1} bytes)"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# E2E: verify debounce blocks rapid-fire saves.
|
|
243
|
+
def test_quick_save_debounce_blocks_rapid_fire
|
|
244
|
+
skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
|
|
245
|
+
|
|
246
|
+
code = <<~RUBY
|
|
247
|
+
require "gemba"
|
|
248
|
+
require "tmpdir"
|
|
249
|
+
require "fileutils"
|
|
250
|
+
require "support/player_helpers"
|
|
251
|
+
|
|
252
|
+
states_dir = Dir.mktmpdir("gemba-debounce-test")
|
|
253
|
+
|
|
254
|
+
player = Gemba::Player.new("#{TEST_ROM}")
|
|
255
|
+
app = player.app
|
|
256
|
+
config = player.config
|
|
257
|
+
|
|
258
|
+
config.states_dir = states_dir
|
|
259
|
+
config.save_state_debounce = 5.0 # long debounce
|
|
260
|
+
|
|
261
|
+
poll_until_ready(player) do
|
|
262
|
+
vp = player.viewport
|
|
263
|
+
frame_path = vp.frame.path
|
|
264
|
+
|
|
265
|
+
# First save should succeed
|
|
266
|
+
app.command(:event, 'generate', frame_path, '<KeyPress>', keysym: 'F5')
|
|
267
|
+
app.update
|
|
268
|
+
|
|
269
|
+
app.after(50) do
|
|
270
|
+
state_dir = player.save_mgr.state_dir
|
|
271
|
+
ss_path = File.join(state_dir, "state1.ss")
|
|
272
|
+
|
|
273
|
+
first_exists = File.exist?(ss_path)
|
|
274
|
+
first_mtime = first_exists ? File.mtime(ss_path) : nil
|
|
275
|
+
|
|
276
|
+
# Immediate second save should be debounced (within 5s window)
|
|
277
|
+
app.command(:event, 'generate', frame_path, '<KeyPress>', keysym: 'F5')
|
|
278
|
+
app.update
|
|
279
|
+
|
|
280
|
+
app.after(50) do
|
|
281
|
+
second_mtime = File.exist?(ss_path) ? File.mtime(ss_path) : nil
|
|
282
|
+
|
|
283
|
+
if !first_exists
|
|
284
|
+
$stderr.puts "FAIL: first save didn't create file"
|
|
285
|
+
exit 1
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
if first_mtime != second_mtime
|
|
289
|
+
$stderr.puts "FAIL: debounce didn't block second save (mtime changed)"
|
|
290
|
+
exit 1
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
$stdout.puts "PASS"
|
|
294
|
+
FileUtils.rm_rf(states_dir)
|
|
295
|
+
player.running = false
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
player.run
|
|
301
|
+
RUBY
|
|
302
|
+
|
|
303
|
+
success, stdout, stderr, _status = tk_subprocess(code)
|
|
304
|
+
|
|
305
|
+
output = []
|
|
306
|
+
output << "STDOUT:\n#{stdout}" unless stdout.empty?
|
|
307
|
+
output << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
308
|
+
|
|
309
|
+
assert success, "Debounce test failed\n#{output.join("\n")}"
|
|
310
|
+
assert_includes stdout, "PASS", "Expected PASS in output\n#{output.join("\n")}"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# E2E: open Settings via menu, navigate to Save States tab,
|
|
314
|
+
# change quick save slot from 1 → 10, click Save, verify persisted.
|
|
315
|
+
def test_settings_change_quick_slot_and_save
|
|
316
|
+
skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
|
|
317
|
+
|
|
318
|
+
code = <<~RUBY
|
|
319
|
+
require "gemba"
|
|
320
|
+
require "tmpdir"
|
|
321
|
+
require "json"
|
|
322
|
+
require "fileutils"
|
|
323
|
+
require "support/player_helpers"
|
|
324
|
+
|
|
325
|
+
config_dir = Dir.mktmpdir("gemba-settings-test")
|
|
326
|
+
config_path = File.join(config_dir, "settings.json")
|
|
327
|
+
|
|
328
|
+
player = Gemba::Player.new("#{TEST_ROM}")
|
|
329
|
+
app = player.app
|
|
330
|
+
config = player.config
|
|
331
|
+
|
|
332
|
+
# Redirect config to a temp file so we can verify persistence
|
|
333
|
+
config.path = config_path
|
|
334
|
+
|
|
335
|
+
poll_until_ready(player) do
|
|
336
|
+
nb = Gemba::SettingsWindow::NB
|
|
337
|
+
ss_tab = Gemba::SettingsWindow::SS_TAB
|
|
338
|
+
slot_combo = Gemba::SettingsWindow::SS_SLOT_COMBO
|
|
339
|
+
save_btn = Gemba::SettingsWindow::SAVE_BTN
|
|
340
|
+
var_slot = Gemba::SettingsWindow::VAR_QUICK_SLOT
|
|
341
|
+
|
|
342
|
+
# Open Settings > Save States via the Settings menu (index 3)
|
|
343
|
+
app.command('.menubar.settings', :invoke, 3)
|
|
344
|
+
app.update
|
|
345
|
+
|
|
346
|
+
# Verify default slot is 1
|
|
347
|
+
current = app.get_variable(var_slot)
|
|
348
|
+
unless current == '1'
|
|
349
|
+
$stderr.puts "FAIL: expected default slot '1', got '\#{current}'"
|
|
350
|
+
exit 1
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Change slot to 10 (simulate user selecting from combobox)
|
|
354
|
+
app.set_variable(var_slot, '10')
|
|
355
|
+
app.command(:event, 'generate', slot_combo, '<<ComboboxSelected>>')
|
|
356
|
+
app.update
|
|
357
|
+
|
|
358
|
+
# Click the Save button
|
|
359
|
+
app.command(save_btn, 'invoke')
|
|
360
|
+
app.update
|
|
361
|
+
|
|
362
|
+
app.after(50) do
|
|
363
|
+
# Verify config file was written
|
|
364
|
+
unless File.exist?(config_path)
|
|
365
|
+
$stderr.puts "FAIL: config file not created at \#{config_path}"
|
|
366
|
+
exit 1
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
data = JSON.parse(File.read(config_path))
|
|
370
|
+
saved_slot = data.dig('global', 'quick_save_slot')
|
|
371
|
+
unless saved_slot == 10
|
|
372
|
+
$stderr.puts "FAIL: expected quick_save_slot=10, got \#{saved_slot.inspect}"
|
|
373
|
+
exit 1
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
$stdout.puts "PASS"
|
|
377
|
+
$stdout.puts "saved_slot=\#{saved_slot}"
|
|
378
|
+
FileUtils.rm_rf(config_dir)
|
|
379
|
+
player.running = false
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
player.run
|
|
384
|
+
RUBY
|
|
385
|
+
|
|
386
|
+
success, stdout, stderr, _status = tk_subprocess(code)
|
|
387
|
+
|
|
388
|
+
output = []
|
|
389
|
+
output << "STDOUT:\n#{stdout}" unless stdout.empty?
|
|
390
|
+
output << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
391
|
+
|
|
392
|
+
assert success, "Settings slot change E2E test failed\n#{output.join("\n")}"
|
|
393
|
+
assert_includes stdout, "PASS", "Expected PASS in output\n#{output.join("\n")}"
|
|
394
|
+
assert_match(/saved_slot=10/, stdout)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# -- Audio fade ramp (pure function, no Tk/SDL2 needed) --------------------
|
|
398
|
+
|
|
399
|
+
def test_fade_ramp_attenuates_first_samples
|
|
400
|
+
require "gemba/player"
|
|
401
|
+
# 10 stereo frames of max-amplitude int16
|
|
402
|
+
pcm = ([32767, 32767] * 10).pack('s*')
|
|
403
|
+
total = 10
|
|
404
|
+
result, remaining = Gemba::Player.apply_fade_ramp(pcm, total, total)
|
|
405
|
+
samples = result.unpack('s*')
|
|
406
|
+
|
|
407
|
+
# First stereo pair: gain = 1 - 10/10 = 0.0 → should be 0
|
|
408
|
+
assert_equal 0, samples[0], "first L sample should be silent"
|
|
409
|
+
assert_equal 0, samples[1], "first R sample should be silent"
|
|
410
|
+
|
|
411
|
+
# Last stereo pair: gain = 1 - 1/10 = 0.9 → should be ~29490
|
|
412
|
+
assert_in_delta 29490, samples[18], 1, "last L sample should be ~90% volume"
|
|
413
|
+
assert_in_delta 29490, samples[19], 1, "last R sample should be ~90% volume"
|
|
414
|
+
|
|
415
|
+
assert_equal 0, remaining, "counter should be fully consumed"
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def test_fade_ramp_returns_remaining_when_pcm_shorter_than_fade
|
|
419
|
+
require "gemba/player"
|
|
420
|
+
# Only 2 stereo frames but fade wants 10
|
|
421
|
+
pcm = ([20000, 20000] * 2).pack('s*')
|
|
422
|
+
_result, remaining = Gemba::Player.apply_fade_ramp(pcm, 10, 10)
|
|
423
|
+
assert_equal 8, remaining, "should have 8 fade samples remaining"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def test_fade_ramp_noop_when_remaining_zero
|
|
427
|
+
require "gemba/player"
|
|
428
|
+
pcm = ([10000, -10000] * 4).pack('s*')
|
|
429
|
+
result, remaining = Gemba::Player.apply_fade_ramp(pcm, 0, 10)
|
|
430
|
+
assert_equal pcm, result, "should not modify samples when remaining is 0"
|
|
431
|
+
assert_equal 0, remaining
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# E2E: verify child windows are modal — only one can be open at a time.
|
|
435
|
+
# Opens Settings via menu, tries F6 for picker (should be blocked),
|
|
436
|
+
# closes Settings, opens picker via F6, tries Settings menu (blocked),
|
|
437
|
+
# closes picker. Checks window visibility via `wm state`.
|
|
438
|
+
def test_modal_child_blocks_concurrent_windows
|
|
439
|
+
skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
|
|
440
|
+
|
|
441
|
+
code = <<~RUBY
|
|
442
|
+
require "gemba"
|
|
443
|
+
require "support/player_helpers"
|
|
444
|
+
|
|
445
|
+
sw_top = Gemba::SettingsWindow::TOP
|
|
446
|
+
sp_top = Gemba::SaveStatePicker::TOP
|
|
447
|
+
|
|
448
|
+
player = Gemba::Player.new("#{TEST_ROM}")
|
|
449
|
+
app = player.app
|
|
450
|
+
|
|
451
|
+
poll_until_ready(player) do
|
|
452
|
+
vp = player.viewport
|
|
453
|
+
frame = vp.frame.path
|
|
454
|
+
|
|
455
|
+
# 1. Open Settings via menu (Settings > Video = index 0)
|
|
456
|
+
app.command('.menubar.settings', :invoke, 0)
|
|
457
|
+
app.update
|
|
458
|
+
|
|
459
|
+
sw_state = app.command(:wm, 'state', sw_top)
|
|
460
|
+
unless sw_state == 'normal'
|
|
461
|
+
$stderr.puts "FAIL: Settings should be visible, got '\#{sw_state}'"
|
|
462
|
+
exit 1
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# 2. Try Save States via Emulation menu — should be blocked by @modal_child
|
|
466
|
+
app.command('.menubar.emu', :invoke, 6)
|
|
467
|
+
app.update
|
|
468
|
+
|
|
469
|
+
# Picker window may not even exist yet (not built), or should be withdrawn
|
|
470
|
+
sp_state = begin
|
|
471
|
+
app.command(:wm, 'state', sp_top)
|
|
472
|
+
rescue
|
|
473
|
+
'withdrawn'
|
|
474
|
+
end
|
|
475
|
+
unless sp_state == 'withdrawn'
|
|
476
|
+
$stderr.puts "FAIL: Picker should be blocked while Settings is open, got '\#{sp_state}'"
|
|
477
|
+
exit 1
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# 3. Close Settings (releases grab + fires on_close)
|
|
481
|
+
player.settings_window.hide
|
|
482
|
+
app.update
|
|
483
|
+
|
|
484
|
+
sw_state = app.command(:wm, 'state', sw_top)
|
|
485
|
+
unless sw_state == 'withdrawn'
|
|
486
|
+
$stderr.puts "FAIL: Settings should be withdrawn after close, got '\#{sw_state}'"
|
|
487
|
+
exit 1
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# 4. Now open picker — focus -force needed under xvfb after grab release
|
|
491
|
+
app.tcl_eval("focus -force \#{frame}")
|
|
492
|
+
app.update
|
|
493
|
+
app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'F6')
|
|
494
|
+
app.update
|
|
495
|
+
|
|
496
|
+
sp_state = app.command(:wm, 'state', sp_top)
|
|
497
|
+
unless sp_state == 'normal'
|
|
498
|
+
$stderr.puts "FAIL: Picker should be visible after Settings closed, got '\#{sp_state}'"
|
|
499
|
+
exit 1
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# 5. Try opening Settings via menu while picker is open — should be blocked
|
|
503
|
+
app.command('.menubar.settings', :invoke, 0)
|
|
504
|
+
app.update
|
|
505
|
+
|
|
506
|
+
sw_state = app.command(:wm, 'state', sw_top)
|
|
507
|
+
unless sw_state == 'withdrawn'
|
|
508
|
+
$stderr.puts "FAIL: Settings should be blocked while Picker is open, got '\#{sw_state}'"
|
|
509
|
+
exit 1
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# 6. Close picker via its close button
|
|
513
|
+
app.command("\#{sp_top}.close_btn", 'invoke')
|
|
514
|
+
app.update
|
|
515
|
+
|
|
516
|
+
sp_state = app.command(:wm, 'state', sp_top)
|
|
517
|
+
unless sp_state == 'withdrawn'
|
|
518
|
+
$stderr.puts "FAIL: Picker should be withdrawn after close, got '\#{sp_state}'"
|
|
519
|
+
exit 1
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
$stdout.puts "PASS"
|
|
523
|
+
player.running = false
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
player.run
|
|
527
|
+
RUBY
|
|
528
|
+
|
|
529
|
+
success, stdout, stderr, _status = tk_subprocess(code)
|
|
530
|
+
|
|
531
|
+
output = []
|
|
532
|
+
output << "STDOUT:\n#{stdout}" unless stdout.empty?
|
|
533
|
+
output << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
534
|
+
|
|
535
|
+
assert success, "Modal child test failed\n#{output.join("\n")}"
|
|
536
|
+
assert_includes stdout, "PASS", "Expected PASS in output\n#{output.join("\n")}"
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# -- File drop (DND) --------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
def test_drop_rom_file_loads_game
|
|
542
|
+
skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
|
|
543
|
+
|
|
544
|
+
code = <<~RUBY
|
|
545
|
+
require "gemba"
|
|
546
|
+
require "support/player_helpers"
|
|
547
|
+
|
|
548
|
+
player = Gemba::Player.new
|
|
549
|
+
app = player.app
|
|
550
|
+
|
|
551
|
+
# Stub tk_messageBox so it never blocks
|
|
552
|
+
app.tcl_eval('proc tk_messageBox {args} { return "ok" }')
|
|
553
|
+
|
|
554
|
+
poll_until_ready(player) do
|
|
555
|
+
# Simulate dropping a ROM file onto the window
|
|
556
|
+
app.tcl_eval('event generate . <<DropFile>> -data {#{TEST_ROM}}')
|
|
557
|
+
app.update
|
|
558
|
+
|
|
559
|
+
app.after(50) do
|
|
560
|
+
core = player.core
|
|
561
|
+
if core && !core.destroyed?
|
|
562
|
+
$stdout.puts "TITLE=\#{core.title}"
|
|
563
|
+
else
|
|
564
|
+
$stdout.puts "FAIL: no core loaded"
|
|
565
|
+
end
|
|
566
|
+
player.running = false
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
player.run
|
|
571
|
+
RUBY
|
|
572
|
+
|
|
573
|
+
success, stdout, stderr, _status = tk_subprocess(code)
|
|
574
|
+
|
|
575
|
+
output = []
|
|
576
|
+
output << "STDOUT:\n#{stdout}" unless stdout.empty?
|
|
577
|
+
output << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
578
|
+
|
|
579
|
+
assert success, "Drop ROM test failed\n#{output.join("\n")}"
|
|
580
|
+
assert_includes stdout, "TITLE=GEMBATEST", "Expected ROM to load via drop\n#{output.join("\n")}"
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def test_drop_unsupported_file_shows_error
|
|
584
|
+
skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
|
|
585
|
+
|
|
586
|
+
code = <<~RUBY
|
|
587
|
+
require "gemba"
|
|
588
|
+
require "support/player_helpers"
|
|
589
|
+
|
|
590
|
+
player = Gemba::Player.new
|
|
591
|
+
app = player.app
|
|
592
|
+
|
|
593
|
+
# Capture tk_messageBox calls instead of blocking
|
|
594
|
+
app.tcl_eval('set ::msgbox_calls {}')
|
|
595
|
+
app.tcl_eval('proc tk_messageBox {args} { lappend ::msgbox_calls $args; return "ok" }')
|
|
596
|
+
|
|
597
|
+
poll_until_ready(player) do
|
|
598
|
+
# Drop a .txt file — should be rejected
|
|
599
|
+
app.tcl_eval('event generate . <<DropFile>> -data {/tmp/readme.txt}')
|
|
600
|
+
app.update
|
|
601
|
+
|
|
602
|
+
app.after(50) do
|
|
603
|
+
calls = app.tcl_eval('set ::msgbox_calls')
|
|
604
|
+
if calls.include?("Unsupported file type")
|
|
605
|
+
$stdout.puts "PASS"
|
|
606
|
+
else
|
|
607
|
+
$stdout.puts "FAIL: no error dialog shown, calls=\#{calls}"
|
|
608
|
+
end
|
|
609
|
+
player.running = false
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
player.run
|
|
614
|
+
RUBY
|
|
615
|
+
|
|
616
|
+
success, stdout, stderr, _status = tk_subprocess(code)
|
|
617
|
+
|
|
618
|
+
output = []
|
|
619
|
+
output << "STDOUT:\n#{stdout}" unless stdout.empty?
|
|
620
|
+
output << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
621
|
+
|
|
622
|
+
assert success, "Drop unsupported file test failed\n#{output.join("\n")}"
|
|
623
|
+
assert_includes stdout, "PASS", "Expected error dialog for unsupported file type\n#{output.join("\n")}"
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def test_drop_multiple_files_shows_error
|
|
627
|
+
skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
|
|
628
|
+
|
|
629
|
+
code = <<~RUBY
|
|
630
|
+
require "gemba"
|
|
631
|
+
require "support/player_helpers"
|
|
632
|
+
|
|
633
|
+
player = Gemba::Player.new
|
|
634
|
+
app = player.app
|
|
635
|
+
|
|
636
|
+
# Capture tk_messageBox calls instead of blocking
|
|
637
|
+
app.tcl_eval('set ::msgbox_calls {}')
|
|
638
|
+
app.tcl_eval('proc tk_messageBox {args} { lappend ::msgbox_calls $args; return "ok" }')
|
|
639
|
+
|
|
640
|
+
poll_until_ready(player) do
|
|
641
|
+
# Drop two ROM files — should be rejected
|
|
642
|
+
app.tcl_eval('event generate . <<DropFile>> -data {#{TEST_ROM} /tmp/other.gba}')
|
|
643
|
+
app.update
|
|
644
|
+
|
|
645
|
+
app.after(50) do
|
|
646
|
+
calls = app.tcl_eval('set ::msgbox_calls')
|
|
647
|
+
if calls.include?("single")
|
|
648
|
+
$stdout.puts "PASS"
|
|
649
|
+
else
|
|
650
|
+
$stdout.puts "FAIL: no single-file error dialog, calls=\#{calls}"
|
|
651
|
+
end
|
|
652
|
+
player.running = false
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
player.run
|
|
657
|
+
RUBY
|
|
658
|
+
|
|
659
|
+
success, stdout, stderr, _status = tk_subprocess(code)
|
|
660
|
+
|
|
661
|
+
output = []
|
|
662
|
+
output << "STDOUT:\n#{stdout}" unless stdout.empty?
|
|
663
|
+
output << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
664
|
+
|
|
665
|
+
assert success, "Drop multiple files test failed\n#{output.join("\n")}"
|
|
666
|
+
assert_includes stdout, "PASS", "Expected error dialog for multiple files\n#{output.join("\n")}"
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# E2E: press F10 to start recording, run a few frames with the red dot
|
|
670
|
+
# indicator rendering, press F10 to stop, verify .grec file was created.
|
|
671
|
+
def test_recording_toggle_creates_trec_file
|
|
672
|
+
skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
|
|
673
|
+
|
|
674
|
+
code = <<~RUBY
|
|
675
|
+
require "gemba"
|
|
676
|
+
require "tmpdir"
|
|
677
|
+
require "fileutils"
|
|
678
|
+
require "support/player_helpers"
|
|
679
|
+
|
|
680
|
+
rec_dir = Dir.mktmpdir("gemba-rec-test")
|
|
681
|
+
|
|
682
|
+
begin
|
|
683
|
+
player = Gemba::Player.new("#{TEST_ROM}")
|
|
684
|
+
app = player.app
|
|
685
|
+
config = player.config
|
|
686
|
+
config.recordings_dir = rec_dir
|
|
687
|
+
|
|
688
|
+
poll_until_ready(player) do
|
|
689
|
+
vp = player.viewport
|
|
690
|
+
frame = vp.frame.path
|
|
691
|
+
|
|
692
|
+
# Press F10 → start recording
|
|
693
|
+
app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'F10')
|
|
694
|
+
app.update
|
|
695
|
+
|
|
696
|
+
# Let a few frames render with the recording indicator (red dot)
|
|
697
|
+
app.after(50) do
|
|
698
|
+
unless player.recording?
|
|
699
|
+
puts "FAIL: recording never started"
|
|
700
|
+
player.running = false
|
|
701
|
+
next
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# Refocus needed under xvfb after timer callbacks
|
|
705
|
+
app.tcl_eval("focus -force \#{frame}")
|
|
706
|
+
# Press F10 → stop recording
|
|
707
|
+
app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'F10')
|
|
708
|
+
app.update
|
|
709
|
+
|
|
710
|
+
app.after(50) do
|
|
711
|
+
trec_files = Dir.glob(File.join(rec_dir, "*.grec"))
|
|
712
|
+
if trec_files.empty?
|
|
713
|
+
puts "FAIL: no .grec file found"
|
|
714
|
+
elsif File.size(trec_files.first) < 32
|
|
715
|
+
puts "FAIL: .grec too small (\#{File.size(trec_files.first)} bytes)"
|
|
716
|
+
else
|
|
717
|
+
puts "PASS: \#{File.basename(trec_files.first)} (\#{File.size(trec_files.first)} bytes)"
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
player.running = false
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
player.run
|
|
726
|
+
ensure
|
|
727
|
+
FileUtils.rm_rf(rec_dir) if rec_dir
|
|
728
|
+
end
|
|
729
|
+
RUBY
|
|
730
|
+
|
|
731
|
+
success, stdout, stderr, _status = tk_subprocess(code)
|
|
732
|
+
|
|
733
|
+
output = []
|
|
734
|
+
output << "STDOUT:\n#{stdout}" unless stdout.empty?
|
|
735
|
+
output << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
736
|
+
|
|
737
|
+
assert success, "Recording toggle test failed\n#{output.join("\n")}"
|
|
738
|
+
assert_includes stdout, "PASS", "Expected .grec file to be created\n#{output.join("\n")}"
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# -- Pause CPU optimization (thread_timer_ms) --------------------------------
|
|
742
|
+
|
|
743
|
+
def test_event_loop_constants
|
|
744
|
+
require "gemba/player"
|
|
745
|
+
assert_equal 1, Gemba::Player::EVENT_LOOP_FAST_MS, "fast loop should be 1ms"
|
|
746
|
+
assert_equal 50, Gemba::Player::EVENT_LOOP_IDLE_MS, "idle loop should be 50ms"
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# E2E: verify thread_timer_ms switches between idle (50ms) and fast (1ms)
|
|
750
|
+
# when pausing and unpausing the emulator.
|
|
751
|
+
def test_pause_switches_event_loop_speed
|
|
752
|
+
skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
|
|
753
|
+
# Flaky under xvfb: SDL2 INPUT_FOCUS is unreliable, causing focus_poll_tick
|
|
754
|
+
# to auto-pause and interfere with manual pause/unpause assertions.
|
|
755
|
+
skip "Flaky under xvfb (SDL2 INPUT_FOCUS unreliable)" if ENV['CI']
|
|
756
|
+
|
|
757
|
+
code = <<~RUBY
|
|
758
|
+
require "gemba"
|
|
759
|
+
require "support/player_helpers"
|
|
760
|
+
|
|
761
|
+
player = Gemba::Player.new("#{TEST_ROM}")
|
|
762
|
+
app = player.app
|
|
763
|
+
|
|
764
|
+
poll_until_ready(player) do
|
|
765
|
+
# Wait for focus so focus_poll_tick won't interfere with pause/unpause
|
|
766
|
+
poll_until_focused(player) do
|
|
767
|
+
vp = player.viewport
|
|
768
|
+
frame = vp.frame.path
|
|
769
|
+
|
|
770
|
+
# Before pause: should be fast (1ms) since ROM is running
|
|
771
|
+
ms_running = app.interp.thread_timer_ms
|
|
772
|
+
unless ms_running == 1
|
|
773
|
+
$stderr.puts "FAIL: expected thread_timer_ms=1 while running, got \#{ms_running}"
|
|
774
|
+
exit 1
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# Pause (p key — default hotkey)
|
|
778
|
+
app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'p')
|
|
779
|
+
app.update
|
|
780
|
+
|
|
781
|
+
app.after(50) do
|
|
782
|
+
ms_paused = app.interp.thread_timer_ms
|
|
783
|
+
unless ms_paused == 50
|
|
784
|
+
$stderr.puts "FAIL: expected thread_timer_ms=50 while paused, got \#{ms_paused}"
|
|
785
|
+
exit 1
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
# Unpause (p key again)
|
|
789
|
+
app.tcl_eval("focus -force \#{frame}")
|
|
790
|
+
app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'p')
|
|
791
|
+
app.update
|
|
792
|
+
|
|
793
|
+
app.after(50) do
|
|
794
|
+
ms_resumed = app.interp.thread_timer_ms
|
|
795
|
+
unless ms_resumed == 1
|
|
796
|
+
xvfb_screenshot("pause_resume_fail")
|
|
797
|
+
$stderr.puts "FAIL: expected thread_timer_ms=1 after resume, got \#{ms_resumed}"
|
|
798
|
+
$stderr.puts "input_focus?=\#{player.viewport.renderer.input_focus?}"
|
|
799
|
+
$stderr.puts "paused=\#{player.instance_variable_get(:@paused)}"
|
|
800
|
+
exit 1
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
$stdout.puts "PASS"
|
|
804
|
+
$stdout.puts "running=\#{ms_running} paused=\#{ms_paused} resumed=\#{ms_resumed}"
|
|
805
|
+
player.running = false
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
end # poll_until_focused
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
player.run
|
|
812
|
+
RUBY
|
|
813
|
+
|
|
814
|
+
success, stdout, stderr, _status = tk_subprocess(code)
|
|
815
|
+
|
|
816
|
+
output = []
|
|
817
|
+
output << "STDOUT:\n#{stdout}" unless stdout.empty?
|
|
818
|
+
output << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
819
|
+
|
|
820
|
+
assert success, "Pause event loop speed test failed\n#{output.join("\n")}"
|
|
821
|
+
assert_includes stdout, "PASS", "Expected PASS in output\n#{output.join("\n")}"
|
|
822
|
+
assert_match(/running=1 paused=50 resumed=1/, stdout)
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
# E2E: verify focus loss pauses emulation and focus regain resumes it.
|
|
826
|
+
# Uses thread_timer_ms as a proxy for paused state (50=idle/paused, 1=fast/running).
|
|
827
|
+
def test_pause_on_focus_loss
|
|
828
|
+
skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
|
|
829
|
+
# Flaky under xvfb: SDL2 INPUT_FOCUS is unreliable, so the window may
|
|
830
|
+
# never report having focus — making focus-loss detection untestable.
|
|
831
|
+
skip "Flaky under xvfb (SDL2 INPUT_FOCUS unreliable)" if ENV['CI']
|
|
832
|
+
|
|
833
|
+
code = <<~RUBY
|
|
834
|
+
require "gemba"
|
|
835
|
+
require "support/player_helpers"
|
|
836
|
+
|
|
837
|
+
player = Gemba::Player.new("#{TEST_ROM}")
|
|
838
|
+
app = player.app
|
|
839
|
+
|
|
840
|
+
poll_until_ready(player) do
|
|
841
|
+
renderer = player.viewport.renderer
|
|
842
|
+
|
|
843
|
+
# Ensure the window has focus before testing focus *loss*
|
|
844
|
+
poll_until_focused(player) do
|
|
845
|
+
|
|
846
|
+
# Confirm running (fast event loop)
|
|
847
|
+
ms_running = app.interp.thread_timer_ms
|
|
848
|
+
unless ms_running == 1
|
|
849
|
+
$stderr.puts "FAIL: expected running at 1ms, got \#{ms_running}"
|
|
850
|
+
exit 1
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
# Hide the SDL2 window to drop input focus
|
|
854
|
+
renderer.hide_window
|
|
855
|
+
|
|
856
|
+
# Wait for focus poll to detect the change (polls every 200ms)
|
|
857
|
+
app.after(300) do
|
|
858
|
+
ms_lost = app.interp.thread_timer_ms
|
|
859
|
+
unless ms_lost == 50
|
|
860
|
+
xvfb_screenshot("focus_loss_fail")
|
|
861
|
+
$stderr.puts "FAIL: expected paused (50ms) after focus loss, got \#{ms_lost}"
|
|
862
|
+
$stderr.puts "input_focus?=\#{renderer.input_focus?}"
|
|
863
|
+
exit 1
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
# WORKAROUND: SDL_ShowWindow/SDL_RaiseWindow don't update
|
|
867
|
+
# SDL_WINDOW_INPUT_FOCUS without pumping the Cocoa event loop,
|
|
868
|
+
# so we can't test auto-resume on focus regain here. Instead
|
|
869
|
+
# we manually unpause with 'p' to verify the auto-pause state
|
|
870
|
+
# is correct and resumable. The auto-resume path works in
|
|
871
|
+
# production (Tk's mainloop pumps Cocoa) but is untested in CI.
|
|
872
|
+
renderer.show_window
|
|
873
|
+
renderer.raise_window
|
|
874
|
+
app.command(:event, 'generate', player.viewport.frame.path, '<KeyPress>', keysym: 'p')
|
|
875
|
+
app.command(:event, 'generate', player.viewport.frame.path, '<KeyRelease>', keysym: 'p')
|
|
876
|
+
|
|
877
|
+
app.after(100) do
|
|
878
|
+
ms_regained = app.interp.thread_timer_ms
|
|
879
|
+
unless ms_regained == 1
|
|
880
|
+
$stderr.puts "FAIL: expected resumed (1ms) after manual unpause, got \#{ms_regained}"
|
|
881
|
+
exit 1
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
$stdout.puts "PASS"
|
|
885
|
+
player.running = false
|
|
886
|
+
end
|
|
887
|
+
end
|
|
888
|
+
end # poll_until_focused
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
player.run
|
|
892
|
+
RUBY
|
|
893
|
+
|
|
894
|
+
success, stdout, stderr, _status = tk_subprocess(code)
|
|
895
|
+
|
|
896
|
+
output = []
|
|
897
|
+
output << "STDOUT:\n#{stdout}" unless stdout.empty?
|
|
898
|
+
output << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
899
|
+
|
|
900
|
+
assert success, "Pause on focus loss test failed\n#{output.join("\n")}"
|
|
901
|
+
assert_includes stdout, "PASS", "Expected PASS in output\n#{output.join("\n")}"
|
|
902
|
+
end
|
|
903
|
+
end
|