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,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