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,1039 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require_relative "shared/tk_test_helper"
5
+
6
+ class TestMGBASettingsWindow < Minitest::Test
7
+ include TeekTestHelper
8
+
9
+ # -- Video scale --------------------------------------------------------
10
+
11
+ def test_scale_combobox_defaults_to_3x
12
+ assert_tk_app("scale combobox defaults to 3x") do
13
+ require "gemba/settings_window"
14
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
15
+ sw.show
16
+ app.update
17
+
18
+ assert_equal '3x', app.get_variable(Gemba::SettingsWindow::VAR_SCALE)
19
+ end
20
+ end
21
+
22
+ def test_selecting_2x_scale_fires_callback
23
+ assert_tk_app("selecting 2x scale fires on_scale_change") do
24
+ require "gemba/settings_window"
25
+ received = nil
26
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
27
+ on_scale_change: proc { |s| received = s }
28
+ })
29
+ sw.show
30
+ app.update
31
+
32
+ # Simulate user selecting "2x" from the combobox
33
+ app.set_variable(Gemba::SettingsWindow::VAR_SCALE, '2x')
34
+ app.command(:event, 'generate', Gemba::SettingsWindow::SCALE_COMBO, '<<ComboboxSelected>>')
35
+ app.update
36
+
37
+ assert_equal 2, received
38
+ end
39
+ end
40
+
41
+ def test_selecting_4x_scale_fires_callback
42
+ assert_tk_app("selecting 4x scale fires on_scale_change") do
43
+ require "gemba/settings_window"
44
+ received = nil
45
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
46
+ on_scale_change: proc { |s| received = s }
47
+ })
48
+ sw.show
49
+ app.update
50
+
51
+ app.set_variable(Gemba::SettingsWindow::VAR_SCALE, '4x')
52
+ app.command(:event, 'generate', Gemba::SettingsWindow::SCALE_COMBO, '<<ComboboxSelected>>')
53
+ app.update
54
+
55
+ assert_equal 4, received
56
+ end
57
+ end
58
+
59
+ # -- Volume slider ------------------------------------------------------
60
+
61
+ def test_volume_defaults_to_100
62
+ assert_tk_app("volume defaults to 100") do
63
+ require "gemba/settings_window"
64
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
65
+ sw.show
66
+ app.update
67
+
68
+ assert_equal '100', app.get_variable(Gemba::SettingsWindow::VAR_VOLUME)
69
+ end
70
+ end
71
+
72
+ def test_dragging_volume_to_50_fires_callback
73
+ assert_tk_app("dragging volume to 50 fires on_volume_change") do
74
+ require "gemba/settings_window"
75
+ received = nil
76
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
77
+ on_volume_change: proc { |v| received = v }
78
+ })
79
+ sw.show
80
+ app.update
81
+
82
+ # Simulate user dragging volume slider to 50
83
+ app.command(Gemba::SettingsWindow::VOLUME_SCALE, 'set', 50)
84
+ app.update
85
+
86
+ assert_in_delta 0.5, received, 0.01
87
+ end
88
+ end
89
+
90
+ def test_volume_at_zero
91
+ assert_tk_app("volume at zero") do
92
+ require "gemba/settings_window"
93
+ received = nil
94
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
95
+ on_volume_change: proc { |v| received = v }
96
+ })
97
+ sw.show
98
+ app.update
99
+
100
+ app.command(Gemba::SettingsWindow::VOLUME_SCALE, 'set', 0)
101
+ app.update
102
+
103
+ assert_in_delta 0.0, received, 0.01
104
+ end
105
+ end
106
+
107
+ # -- Mute checkbox ------------------------------------------------------
108
+
109
+ def test_mute_defaults_to_off
110
+ assert_tk_app("mute defaults to off") do
111
+ require "gemba/settings_window"
112
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
113
+ sw.show
114
+ app.update
115
+
116
+ assert_equal '0', app.get_variable(Gemba::SettingsWindow::VAR_MUTE)
117
+ end
118
+ end
119
+
120
+ def test_clicking_mute_fires_callback
121
+ assert_tk_app("clicking mute fires on_mute_change") do
122
+ require "gemba/settings_window"
123
+ received = nil
124
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
125
+ on_mute_change: proc { |m| received = m }
126
+ })
127
+ sw.show
128
+ app.update
129
+
130
+ # Simulate user clicking the mute checkbox
131
+ app.command(Gemba::SettingsWindow::MUTE_CHECK, 'invoke')
132
+ app.update
133
+
134
+ assert_equal true, received
135
+ end
136
+ end
137
+
138
+ def test_clicking_mute_twice_unmutes
139
+ assert_tk_app("clicking mute twice unmutes") do
140
+ require "gemba/settings_window"
141
+ received = nil
142
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
143
+ on_mute_change: proc { |m| received = m }
144
+ })
145
+ sw.show
146
+ app.update
147
+
148
+ app.command(Gemba::SettingsWindow::MUTE_CHECK, 'invoke')
149
+ app.update
150
+ assert_equal true, received
151
+
152
+ app.command(Gemba::SettingsWindow::MUTE_CHECK, 'invoke')
153
+ app.update
154
+ assert_equal false, received
155
+ end
156
+ end
157
+
158
+ # -- Window lifecycle ---------------------------------------------------
159
+
160
+ def test_settings_starts_hidden
161
+ assert_tk_app("settings starts hidden") do
162
+ require "gemba/settings_window"
163
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
164
+ app.update
165
+
166
+ assert_equal 'withdrawn', app.command(:wm, 'state', Gemba::SettingsWindow::TOP)
167
+ end
168
+ end
169
+
170
+ def test_show_and_hide
171
+ assert_tk_app("show makes window visible, hide withdraws it") do
172
+ require "gemba/settings_window"
173
+ app.show
174
+ app.update
175
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
176
+
177
+ sw.show
178
+ app.command(:focus, '-force', Gemba::SettingsWindow::TOP)
179
+ app.update
180
+ assert_equal 'normal', app.command(:wm, 'state', Gemba::SettingsWindow::TOP)
181
+
182
+ sw.hide
183
+ app.update
184
+ assert_equal 'withdrawn', app.command(:wm, 'state', Gemba::SettingsWindow::TOP)
185
+ end
186
+ end
187
+
188
+ # -- Gamepad tab ---------------------------------------------------------
189
+
190
+ def test_gamepad_tab_exists
191
+ assert_tk_app("gamepad tab exists in notebook") do
192
+ require "gemba/settings_window"
193
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
194
+ sw.show
195
+ app.update
196
+
197
+ tabs = app.command(Gemba::SettingsWindow::NB, 'tabs')
198
+ assert_includes tabs, Gemba::SettingsWindow::GAMEPAD_TAB
199
+ end
200
+ end
201
+
202
+ def test_deadzone_defaults_to_25
203
+ assert_tk_app("dead zone defaults to 25") do
204
+ require "gemba/settings_window"
205
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
206
+ sw.show
207
+ app.update
208
+
209
+ assert_equal '25', app.get_variable(Gemba::SettingsWindow::VAR_DEADZONE)
210
+ end
211
+ end
212
+
213
+ def test_deadzone_change_fires_callback
214
+ assert_tk_app("dead zone change fires on_deadzone_change") do
215
+ require "gemba/settings_window"
216
+ received = nil
217
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
218
+ on_deadzone_change: proc { |t| received = t }
219
+ })
220
+ sw.show
221
+ app.update
222
+
223
+ # Switch to gamepad mode (dead zone is disabled in keyboard mode)
224
+ sw.update_gamepad_list(['Keyboard Only', 'Test Gamepad'])
225
+ app.set_variable(Gemba::SettingsWindow::VAR_GAMEPAD, 'Test Gamepad')
226
+ app.command(:event, 'generate', Gemba::SettingsWindow::GAMEPAD_COMBO, '<<ComboboxSelected>>')
227
+ app.update
228
+
229
+ app.command(Gemba::SettingsWindow::DEADZONE_SCALE, 'set', 15)
230
+ app.update
231
+
232
+ # 15% of 32767 ≈ 4915
233
+ assert_equal 4915, received
234
+ end
235
+ end
236
+
237
+ def test_clicking_gba_button_enters_listen_mode
238
+ assert_tk_app("clicking GBA button enters listen mode") do
239
+ require "gemba/settings_window"
240
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
241
+ sw.show
242
+ app.update
243
+
244
+ # Click the A button
245
+ app.command(Gemba::SettingsWindow::GP_BTN_A, 'invoke')
246
+ app.update
247
+
248
+ assert_equal :a, sw.listening_for
249
+ text = app.command(Gemba::SettingsWindow::GP_BTN_A, 'cget', '-text')
250
+ assert_equal "Press\u2026", text
251
+ end
252
+ end
253
+
254
+ def test_capture_mapping_updates_button_label
255
+ assert_tk_app("capture_mapping updates button label") do
256
+ require "gemba/settings_window"
257
+ received_gba = nil
258
+ received_key = nil
259
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
260
+ on_keyboard_map_change: proc { |g, b| received_gba = g; received_key = b }
261
+ })
262
+ sw.show
263
+ app.update
264
+
265
+ # Default mode is keyboard — enter listen mode for A
266
+ app.command(Gemba::SettingsWindow::GP_BTN_A, 'invoke')
267
+ app.update
268
+
269
+ # Simulate keyboard key capture
270
+ sw.capture_mapping('q')
271
+ app.update
272
+
273
+ assert_nil sw.listening_for
274
+ assert_equal :a, received_gba
275
+ assert_equal 'q', received_key
276
+ text = app.command(Gemba::SettingsWindow::GP_BTN_A, 'cget', '-text')
277
+ assert_equal 'q', text
278
+ end
279
+ end
280
+
281
+ def test_gamepad_selector_defaults_to_keyboard_only
282
+ assert_tk_app("gamepad selector defaults to Keyboard Only") do
283
+ require "gemba/settings_window"
284
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
285
+ sw.show
286
+ app.update
287
+
288
+ assert_equal 'Keyboard Only', app.get_variable(Gemba::SettingsWindow::VAR_GAMEPAD)
289
+ end
290
+ end
291
+
292
+ # -- Undo button ----------------------------------------------------------
293
+
294
+ def test_undo_starts_disabled
295
+ assert_tk_app("undo button starts disabled") do
296
+ require "gemba/settings_window"
297
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
298
+ sw.show
299
+ app.update
300
+
301
+ state = app.command(Gemba::SettingsWindow::GP_UNDO_BTN, 'cget', '-state')
302
+ assert_equal 'disabled', state
303
+ end
304
+ end
305
+
306
+ def test_undo_enabled_after_remap
307
+ assert_tk_app("undo enabled after capturing a mapping") do
308
+ require "gemba/settings_window"
309
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
310
+ sw.show
311
+ app.update
312
+
313
+ app.command(Gemba::SettingsWindow::GP_BTN_A, 'invoke')
314
+ app.update
315
+ sw.capture_mapping(:x)
316
+ app.update
317
+
318
+ state = app.command(Gemba::SettingsWindow::GP_UNDO_BTN, 'cget', '-state')
319
+ assert_equal 'normal', state
320
+ end
321
+ end
322
+
323
+ def test_undo_fires_callback_and_disables
324
+ assert_tk_app("undo fires on_undo_gamepad and disables itself") do
325
+ require "gemba/settings_window"
326
+ undo_called = false
327
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
328
+ on_undo_gamepad: proc { undo_called = true }
329
+ })
330
+ sw.show
331
+ app.update
332
+
333
+ # Remap to enable undo
334
+ app.command(Gemba::SettingsWindow::GP_BTN_A, 'invoke')
335
+ app.update
336
+ sw.capture_mapping(:x)
337
+ app.update
338
+
339
+ # Click undo
340
+ app.command(Gemba::SettingsWindow::GP_UNDO_BTN, 'invoke')
341
+ app.update
342
+
343
+ assert undo_called
344
+ state = app.command(Gemba::SettingsWindow::GP_UNDO_BTN, 'cget', '-state')
345
+ assert_equal 'disabled', state
346
+ end
347
+ end
348
+
349
+ def test_reset_disables_undo
350
+ assert_tk_app("reset to defaults disables undo button") do
351
+ require "gemba/settings_window"
352
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
353
+ sw.show
354
+ app.update
355
+
356
+ # Remap to enable undo
357
+ app.command(Gemba::SettingsWindow::GP_BTN_A, 'invoke')
358
+ app.update
359
+ sw.capture_mapping(:x)
360
+ app.update
361
+
362
+ state = app.command(Gemba::SettingsWindow::GP_UNDO_BTN, 'cget', '-state')
363
+ assert_equal 'normal', state
364
+
365
+ # refresh_gamepad simulates what reset/undo would do from the player side
366
+ sw.refresh_gamepad(Gemba::SettingsWindow::DEFAULT_GP_LABELS, 25)
367
+ app.update
368
+
369
+ # Verify labels restored
370
+ text = app.command(Gemba::SettingsWindow::GP_BTN_A, 'cget', '-text')
371
+ assert_equal 'a', text
372
+ end
373
+ end
374
+
375
+ # -- Aspect ratio checkbox ------------------------------------------------
376
+
377
+ def test_aspect_ratio_defaults_to_on
378
+ assert_tk_app("aspect ratio checkbox defaults to on") do
379
+ require "gemba/settings_window"
380
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
381
+ sw.show
382
+ app.update
383
+
384
+ assert_equal '1', app.get_variable(Gemba::SettingsWindow::VAR_ASPECT_RATIO)
385
+ end
386
+ end
387
+
388
+ def test_unchecking_aspect_ratio_fires_callback
389
+ assert_tk_app("unchecking aspect ratio fires on_aspect_ratio_change") do
390
+ require "gemba/settings_window"
391
+ received = nil
392
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
393
+ on_aspect_ratio_change: proc { |keep| received = keep }
394
+ })
395
+ sw.show
396
+ app.update
397
+
398
+ # Simulate user unchecking the checkbox
399
+ app.command(Gemba::SettingsWindow::ASPECT_CHECK, 'invoke')
400
+ app.update
401
+
402
+ assert_equal false, received
403
+ end
404
+ end
405
+
406
+ def test_checking_aspect_ratio_fires_callback
407
+ assert_tk_app("re-checking aspect ratio fires callback with true") do
408
+ require "gemba/settings_window"
409
+ received = nil
410
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
411
+ on_aspect_ratio_change: proc { |keep| received = keep }
412
+ })
413
+ sw.show
414
+ app.update
415
+
416
+ # Uncheck then re-check
417
+ app.command(Gemba::SettingsWindow::ASPECT_CHECK, 'invoke')
418
+ app.update
419
+ app.command(Gemba::SettingsWindow::ASPECT_CHECK, 'invoke')
420
+ app.update
421
+
422
+ assert_equal true, received
423
+ end
424
+ end
425
+
426
+ # -- Show FPS checkbox ----------------------------------------------------
427
+
428
+ def test_show_fps_defaults_to_on
429
+ assert_tk_app("show fps checkbox defaults to on") do
430
+ require "gemba/settings_window"
431
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
432
+ sw.show
433
+ app.update
434
+
435
+ assert_equal '1', app.get_variable(Gemba::SettingsWindow::VAR_SHOW_FPS)
436
+ end
437
+ end
438
+
439
+ def test_unchecking_show_fps_fires_callback
440
+ assert_tk_app("unchecking show fps fires on_show_fps_change") do
441
+ require "gemba/settings_window"
442
+ received = nil
443
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
444
+ on_show_fps_change: proc { |show| received = show }
445
+ })
446
+ sw.show
447
+ app.update
448
+
449
+ app.command(Gemba::SettingsWindow::SHOW_FPS_CHECK, 'invoke')
450
+ app.update
451
+
452
+ assert_equal false, received
453
+ end
454
+ end
455
+
456
+ # -- Keyboard mode --------------------------------------------------------
457
+
458
+ def test_starts_in_keyboard_mode
459
+ assert_tk_app("starts in keyboard mode") do
460
+ require "gemba/settings_window"
461
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
462
+ sw.show
463
+ app.update
464
+
465
+ assert sw.keyboard_mode?
466
+ end
467
+ end
468
+
469
+ def test_keyboard_mode_labels_show_keysyms
470
+ assert_tk_app("keyboard mode shows keysym labels") do
471
+ require "gemba/settings_window"
472
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
473
+ sw.show
474
+ app.update
475
+
476
+ text = app.command(Gemba::SettingsWindow::GP_BTN_A, 'cget', '-text')
477
+ assert_equal 'z', text
478
+ text = app.command(Gemba::SettingsWindow::GP_BTN_START, 'cget', '-text')
479
+ assert_equal 'Return', text
480
+ end
481
+ end
482
+
483
+ def test_switching_to_gamepad_mode_changes_labels
484
+ assert_tk_app("switching to gamepad shows gamepad labels") do
485
+ require "gemba/settings_window"
486
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
487
+ sw.show
488
+ app.update
489
+
490
+ sw.update_gamepad_list(['Keyboard Only', 'Test Gamepad'])
491
+ app.set_variable(Gemba::SettingsWindow::VAR_GAMEPAD, 'Test Gamepad')
492
+ app.command(:event, 'generate', Gemba::SettingsWindow::GAMEPAD_COMBO, '<<ComboboxSelected>>')
493
+ app.update
494
+
495
+ refute sw.keyboard_mode?
496
+ text = app.command(Gemba::SettingsWindow::GP_BTN_A, 'cget', '-text')
497
+ assert_equal 'a', text
498
+ text = app.command(Gemba::SettingsWindow::GP_BTN_START, 'cget', '-text')
499
+ assert_equal 'start', text
500
+ end
501
+ end
502
+
503
+ def test_deadzone_disabled_in_keyboard_mode
504
+ assert_tk_app("dead zone slider disabled in keyboard mode") do
505
+ require "gemba/settings_window"
506
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
507
+ sw.show
508
+ app.update
509
+
510
+ state = app.command(Gemba::SettingsWindow::DEADZONE_SCALE, 'cget', '-state')
511
+ assert_equal 'disabled', state
512
+ end
513
+ end
514
+
515
+ def test_deadzone_enabled_in_gamepad_mode
516
+ assert_tk_app("dead zone slider enabled in gamepad mode") do
517
+ require "gemba/settings_window"
518
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
519
+ sw.show
520
+ app.update
521
+
522
+ sw.update_gamepad_list(['Keyboard Only', 'Test Gamepad'])
523
+ app.set_variable(Gemba::SettingsWindow::VAR_GAMEPAD, 'Test Gamepad')
524
+ app.command(:event, 'generate', Gemba::SettingsWindow::GAMEPAD_COMBO, '<<ComboboxSelected>>')
525
+ app.update
526
+
527
+ state = app.command(Gemba::SettingsWindow::DEADZONE_SCALE, 'cget', '-state')
528
+ assert_equal 'normal', state
529
+ end
530
+ end
531
+
532
+ def test_keyboard_capture_fires_keyboard_callback
533
+ assert_tk_app("keyboard capture fires on_keyboard_map_change") do
534
+ require "gemba/settings_window"
535
+ received_gba = nil
536
+ received_key = nil
537
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
538
+ on_keyboard_map_change: proc { |g, k| received_gba = g; received_key = k }
539
+ })
540
+ sw.show
541
+ app.update
542
+
543
+ app.command(Gemba::SettingsWindow::GP_BTN_B, 'invoke')
544
+ app.update
545
+ sw.capture_mapping('space')
546
+ app.update
547
+
548
+ assert_equal :b, received_gba
549
+ assert_equal 'space', received_key
550
+ end
551
+ end
552
+
553
+ def test_switching_mode_cancels_listen
554
+ assert_tk_app("switching input mode cancels active listen") do
555
+ require "gemba/settings_window"
556
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
557
+ sw.show
558
+ app.update
559
+
560
+ # Start listening in keyboard mode
561
+ app.command(Gemba::SettingsWindow::GP_BTN_A, 'invoke')
562
+ app.update
563
+ assert_equal :a, sw.listening_for
564
+
565
+ # Switch to gamepad mode — should cancel listen
566
+ sw.update_gamepad_list(['Keyboard Only', 'Test Gamepad'])
567
+ app.set_variable(Gemba::SettingsWindow::VAR_GAMEPAD, 'Test Gamepad')
568
+ app.command(:event, 'generate', Gemba::SettingsWindow::GAMEPAD_COMBO, '<<ComboboxSelected>>')
569
+ app.update
570
+
571
+ assert_nil sw.listening_for
572
+ text = app.command(Gemba::SettingsWindow::GP_BTN_A, 'cget', '-text')
573
+ assert_equal 'a', text # reverted to gamepad default (not "Press...")
574
+ end
575
+ end
576
+
577
+ # -- Virtual gamepad integration ------------------------------------------
578
+
579
+ def test_virtual_gamepad_listen_and_capture
580
+ assert_tk_app("virtual gamepad button press captured in listen mode") do
581
+ require "gemba/settings_window"
582
+ require "teek/sdl2"
583
+ gp_cls = Teek::SDL2::Gamepad
584
+
585
+ gp_cls.init_subsystem
586
+ idx = gp_cls.attach_virtual
587
+ gp = gp_cls.open(idx)
588
+
589
+ received_gba = nil
590
+ received_gp = nil
591
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
592
+ on_gamepad_map_change: proc { |g, b| received_gba = g; received_gp = b }
593
+ })
594
+ sw.show
595
+ app.update
596
+
597
+ # Switch to gamepad mode (default is keyboard)
598
+ sw.update_gamepad_list(['Keyboard Only', gp.name])
599
+ app.set_variable(Gemba::SettingsWindow::VAR_GAMEPAD, gp.name)
600
+ app.command(:event, 'generate', Gemba::SettingsWindow::GAMEPAD_COMBO, '<<ComboboxSelected>>')
601
+ app.update
602
+ refute sw.keyboard_mode?
603
+
604
+ # Enter listen mode for B button
605
+ app.command(Gemba::SettingsWindow::GP_BTN_B, 'invoke')
606
+ app.update
607
+ assert_equal :b, sw.listening_for
608
+
609
+ # Press X on virtual gamepad
610
+ gp.set_virtual_button(:x, true)
611
+ gp_cls.poll_events
612
+
613
+ # Simulate what the player's probe timer does
614
+ gp_cls.buttons.each do |btn|
615
+ if gp.button?(btn)
616
+ sw.capture_mapping(btn)
617
+ break
618
+ end
619
+ end
620
+ app.update
621
+
622
+ assert_nil sw.listening_for
623
+ assert_equal :b, received_gba
624
+ assert_equal :x, received_gp
625
+
626
+ text = app.command(Gemba::SettingsWindow::GP_BTN_B, 'cget', '-text')
627
+ assert_equal 'x', text
628
+
629
+ gp.set_virtual_button(:x, false)
630
+ gp.close
631
+ gp_cls.detach_virtual
632
+ gp_cls.shutdown_subsystem
633
+ end
634
+ end
635
+
636
+ # -- Keyboard mapping conflict with hotkeys --------------------------------
637
+
638
+ def test_kb_mapping_rejected_when_conflicting_with_hotkey
639
+ assert_tk_app("keyboard mapping rejected when key conflicts with hotkey") do
640
+ require "gemba/settings_window"
641
+ require "gemba/hotkey_map"
642
+ received = false
643
+ conflict_msg = nil
644
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
645
+ on_keyboard_map_change: proc { |*| received = true },
646
+ on_validate_kb_mapping: ->(keysym) {
647
+ # Simulate: 'F5' is the Quick Save hotkey
648
+ keysym == 'F5' ? '"F5" is assigned to hotkey: Quick save' : nil
649
+ },
650
+ on_key_conflict: proc { |msg| conflict_msg = msg },
651
+ })
652
+ sw.show
653
+ app.update
654
+
655
+ # Try to bind GBA A to F5 (conflicts with hotkey)
656
+ app.command(Gemba::SettingsWindow::GP_BTN_A, 'invoke')
657
+ app.update
658
+ sw.capture_mapping('F5')
659
+ app.update
660
+
661
+ refute received, "on_keyboard_map_change should not fire for rejected key"
662
+ assert_equal '"F5" is assigned to hotkey: Quick save', conflict_msg
663
+ # Label should revert to original default
664
+ text = app.command(Gemba::SettingsWindow::GP_BTN_A, 'cget', '-text')
665
+ assert_equal 'z', text
666
+ assert_nil sw.listening_for
667
+ end
668
+ end
669
+
670
+ def test_kb_mapping_accepted_when_no_conflict
671
+ assert_tk_app("keyboard mapping accepted when no conflict") do
672
+ require "gemba/settings_window"
673
+ require "gemba/hotkey_map"
674
+ received_gba = nil
675
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
676
+ on_keyboard_map_change: proc { |g, _| received_gba = g },
677
+ on_validate_kb_mapping: ->(_) { nil },
678
+ })
679
+ sw.show
680
+ app.update
681
+
682
+ app.command(Gemba::SettingsWindow::GP_BTN_A, 'invoke')
683
+ app.update
684
+ sw.capture_mapping('m')
685
+ app.update
686
+
687
+ assert_equal :a, received_gba
688
+ text = app.command(Gemba::SettingsWindow::GP_BTN_A, 'cget', '-text')
689
+ assert_equal 'm', text
690
+ end
691
+ end
692
+
693
+ def test_gamepad_mapping_skips_validation
694
+ assert_tk_app("gamepad mode skips keyboard validation") do
695
+ require "gemba/settings_window"
696
+ require "gemba/hotkey_map"
697
+ require "teek/sdl2"
698
+ gp_cls = Teek::SDL2::Gamepad
699
+ gp_cls.init_subsystem
700
+ idx = gp_cls.attach_virtual
701
+ gp = gp_cls.open(idx)
702
+
703
+ received_gba = nil
704
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
705
+ on_gamepad_map_change: proc { |g, _| received_gba = g },
706
+ on_validate_kb_mapping: ->(_) { "should not be called" },
707
+ })
708
+ sw.show
709
+ app.update
710
+
711
+ # Switch to gamepad mode
712
+ sw.update_gamepad_list(['Keyboard Only', gp.name])
713
+ app.set_variable(Gemba::SettingsWindow::VAR_GAMEPAD, gp.name)
714
+ app.command(:event, 'generate', Gemba::SettingsWindow::GAMEPAD_COMBO, '<<ComboboxSelected>>')
715
+ app.update
716
+
717
+ # Gamepad capture — no keyboard validation applies
718
+ app.command(Gemba::SettingsWindow::GP_BTN_A, 'invoke')
719
+ app.update
720
+ sw.capture_mapping(:x)
721
+ app.update
722
+
723
+ assert_equal :a, received_gba
724
+
725
+ gp.close
726
+ gp_cls.detach_virtual
727
+ gp_cls.shutdown_subsystem
728
+ end
729
+ end
730
+
731
+ # -- Pixel filter combo ----------------------------------------------------
732
+
733
+ def test_pixel_filter_defaults_to_nearest
734
+ assert_tk_app("pixel filter defaults to Nearest Neighbor") do
735
+ require "gemba/settings_window"
736
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
737
+ sw.show
738
+ app.update
739
+
740
+ assert_equal 'Nearest Neighbor', app.get_variable(Gemba::SettingsWindow::VAR_FILTER)
741
+ end
742
+ end
743
+
744
+ def test_selecting_bilinear_fires_callback
745
+ assert_tk_app("selecting Bilinear fires on_filter_change") do
746
+ require "gemba/settings_window"
747
+ received = nil
748
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
749
+ on_filter_change: proc { |f| received = f }
750
+ })
751
+ sw.show
752
+ app.update
753
+
754
+ app.set_variable(Gemba::SettingsWindow::VAR_FILTER, 'Bilinear')
755
+ app.command(:event, 'generate', Gemba::SettingsWindow::FILTER_COMBO, '<<ComboboxSelected>>')
756
+ app.update
757
+
758
+ assert_equal 'linear', received
759
+ end
760
+ end
761
+
762
+ # -- Integer scaling checkbox ----------------------------------------------
763
+
764
+ def test_integer_scale_defaults_to_off
765
+ assert_tk_app("integer scale checkbox defaults to off") do
766
+ require "gemba/settings_window"
767
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
768
+ sw.show
769
+ app.update
770
+
771
+ assert_equal '0', app.get_variable(Gemba::SettingsWindow::VAR_INTEGER_SCALE)
772
+ end
773
+ end
774
+
775
+ def test_clicking_integer_scale_fires_callback
776
+ assert_tk_app("clicking integer scale fires on_integer_scale_change") do
777
+ require "gemba/settings_window"
778
+ received = nil
779
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
780
+ on_integer_scale_change: proc { |v| received = v }
781
+ })
782
+ sw.show
783
+ app.update
784
+
785
+ app.command(Gemba::SettingsWindow::INTEGER_SCALE_CHECK, 'invoke')
786
+ app.update
787
+
788
+ assert_equal true, received
789
+ end
790
+ end
791
+
792
+ # -- Color correction checkbox --------------------------------------------
793
+
794
+ def test_color_correction_defaults_to_off
795
+ assert_tk_app("color correction checkbox defaults to off") do
796
+ require "gemba/settings_window"
797
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
798
+ sw.show
799
+ app.update
800
+
801
+ assert_equal '0', app.get_variable(Gemba::SettingsWindow::VAR_COLOR_CORRECTION)
802
+ end
803
+ end
804
+
805
+ def test_clicking_color_correction_fires_callback
806
+ assert_tk_app("clicking color correction fires on_color_correction_change") do
807
+ require "gemba/settings_window"
808
+ received = nil
809
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
810
+ on_color_correction_change: proc { |v| received = v }
811
+ })
812
+ sw.show
813
+ app.update
814
+
815
+ app.command(Gemba::SettingsWindow::COLOR_CORRECTION_CHECK, 'invoke')
816
+ app.update
817
+
818
+ assert_equal true, received
819
+ end
820
+ end
821
+
822
+ def test_unchecking_color_correction_fires_false
823
+ assert_tk_app("unchecking color correction fires callback with false") do
824
+ require "gemba/settings_window"
825
+ received = nil
826
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
827
+ on_color_correction_change: proc { |v| received = v }
828
+ })
829
+ sw.show
830
+ app.update
831
+
832
+ # Check then uncheck
833
+ app.command(Gemba::SettingsWindow::COLOR_CORRECTION_CHECK, 'invoke')
834
+ app.update
835
+ app.command(Gemba::SettingsWindow::COLOR_CORRECTION_CHECK, 'invoke')
836
+ app.update
837
+
838
+ assert_equal false, received
839
+ end
840
+ end
841
+
842
+ # -- Frame blending checkbox -----------------------------------------------
843
+
844
+ def test_frame_blending_defaults_to_off
845
+ assert_tk_app("frame blending checkbox defaults to off") do
846
+ require "gemba/settings_window"
847
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
848
+ sw.show
849
+ app.update
850
+
851
+ assert_equal '0', app.get_variable(Gemba::SettingsWindow::VAR_FRAME_BLENDING)
852
+ end
853
+ end
854
+
855
+ def test_clicking_frame_blending_fires_callback
856
+ assert_tk_app("clicking frame blending fires on_frame_blending_change") do
857
+ require "gemba/settings_window"
858
+ received = nil
859
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
860
+ on_frame_blending_change: proc { |v| received = v }
861
+ })
862
+ sw.show
863
+ app.update
864
+
865
+ app.command(Gemba::SettingsWindow::FRAME_BLENDING_CHECK, 'invoke')
866
+ app.update
867
+
868
+ assert_equal true, received
869
+ end
870
+ end
871
+
872
+ def test_unchecking_frame_blending_fires_false
873
+ assert_tk_app("unchecking frame blending fires callback with false") do
874
+ require "gemba/settings_window"
875
+ received = nil
876
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
877
+ on_frame_blending_change: proc { |v| received = v }
878
+ })
879
+ sw.show
880
+ app.update
881
+
882
+ # Check then uncheck
883
+ app.command(Gemba::SettingsWindow::FRAME_BLENDING_CHECK, 'invoke')
884
+ app.update
885
+ app.command(Gemba::SettingsWindow::FRAME_BLENDING_CHECK, 'invoke')
886
+ app.update
887
+
888
+ assert_equal false, received
889
+ end
890
+ end
891
+
892
+ # -- Per-game settings checkbox -------------------------------------------
893
+
894
+ def test_per_game_checkbox_defaults_disabled
895
+ assert_tk_app("per-game checkbox defaults disabled") do
896
+ require "gemba/settings_window"
897
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
898
+ sw.show
899
+ app.update
900
+
901
+ state = app.command(Gemba::SettingsWindow::PER_GAME_CHECK, 'state')
902
+ assert_includes state, 'disabled'
903
+ end
904
+ end
905
+
906
+ def test_set_per_game_available_enables_checkbox
907
+ assert_tk_app("set_per_game_available enables checkbox") do
908
+ require "gemba/settings_window"
909
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
910
+ sw.show
911
+ app.update
912
+
913
+ sw.set_per_game_available(true)
914
+ app.update
915
+
916
+ state = app.command(Gemba::SettingsWindow::PER_GAME_CHECK, 'state')
917
+ refute_includes state, 'disabled'
918
+ end
919
+ end
920
+
921
+ def test_per_game_toggle_fires_callback
922
+ assert_tk_app("per-game toggle fires on_per_game_toggle") do
923
+ require "gemba/settings_window"
924
+ received = nil
925
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
926
+ on_per_game_toggle: proc { |v| received = v }
927
+ })
928
+ sw.show
929
+ app.update
930
+
931
+ sw.set_per_game_available(true)
932
+ app.update
933
+ app.command(Gemba::SettingsWindow::PER_GAME_CHECK, 'invoke')
934
+ app.update
935
+
936
+ assert_equal true, received
937
+ end
938
+ end
939
+
940
+ def test_set_per_game_active_syncs_variable
941
+ assert_tk_app("set_per_game_active syncs variable") do
942
+ require "gemba/settings_window"
943
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
944
+ sw.show
945
+ app.update
946
+
947
+ sw.set_per_game_active(true)
948
+ assert_equal '1', app.get_variable(Gemba::SettingsWindow::VAR_PER_GAME)
949
+
950
+ sw.set_per_game_active(false)
951
+ assert_equal '0', app.get_variable(Gemba::SettingsWindow::VAR_PER_GAME)
952
+ end
953
+ end
954
+
955
+ def test_per_game_disabled_on_gamepad_tab
956
+ assert_tk_app("per-game disabled on gamepad tab") do
957
+ require "gemba/settings_window"
958
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
959
+ sw.show
960
+ app.update
961
+
962
+ # Enable per-game (simulating ROM loaded)
963
+ sw.set_per_game_available(true)
964
+ app.update
965
+
966
+ # Switch to gamepad tab — checkbox should become disabled
967
+ app.command(Gemba::SettingsWindow::NB, 'select', Gemba::SettingsWindow::GAMEPAD_TAB)
968
+ app.update
969
+
970
+ state = app.command(Gemba::SettingsWindow::PER_GAME_CHECK, 'state')
971
+ assert_includes state, 'disabled', "Per-game checkbox should be disabled on gamepad tab"
972
+ end
973
+ end
974
+
975
+ def test_per_game_re_enabled_on_video_tab
976
+ assert_tk_app("per-game re-enabled on video tab") do
977
+ require "gemba/settings_window"
978
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
979
+ sw.show
980
+ app.update
981
+
982
+ sw.set_per_game_available(true)
983
+ app.update
984
+
985
+ # Switch to gamepad (disables), then back to video (re-enables)
986
+ app.command(Gemba::SettingsWindow::NB, 'select', Gemba::SettingsWindow::GAMEPAD_TAB)
987
+ app.update
988
+ app.command(Gemba::SettingsWindow::NB, 'select', "#{Gemba::SettingsWindow::NB}.video")
989
+ app.update
990
+
991
+ state = app.command(Gemba::SettingsWindow::PER_GAME_CHECK, 'state')
992
+ refute_includes state, 'disabled', "Per-game checkbox should be enabled on video tab"
993
+ end
994
+ end
995
+
996
+ # -- Recording tab -------------------------------------------------------
997
+
998
+ def test_recording_tab_exists
999
+ assert_tk_app("recording tab exists in notebook") do
1000
+ require "gemba/settings_window"
1001
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
1002
+ sw.show
1003
+ app.update
1004
+
1005
+ tabs = app.command(Gemba::SettingsWindow::NB, 'tabs')
1006
+ assert_includes tabs, Gemba::SettingsWindow::REC_TAB
1007
+ end
1008
+ end
1009
+
1010
+ def test_compression_combobox_defaults_to_1
1011
+ assert_tk_app("compression combobox defaults to 1") do
1012
+ require "gemba/settings_window"
1013
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
1014
+ sw.show
1015
+ app.update
1016
+
1017
+ assert_equal '1', app.get_variable(Gemba::SettingsWindow::VAR_REC_COMPRESSION)
1018
+ end
1019
+ end
1020
+
1021
+ def test_selecting_compression_fires_callback
1022
+ assert_tk_app("selecting compression fires on_compression_change") do
1023
+ require "gemba/settings_window"
1024
+ received = nil
1025
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
1026
+ on_compression_change: proc { |v| received = v }
1027
+ })
1028
+ sw.show
1029
+ app.update
1030
+
1031
+ app.set_variable(Gemba::SettingsWindow::VAR_REC_COMPRESSION, '6')
1032
+ app.command(:event, 'generate', Gemba::SettingsWindow::REC_COMPRESSION_COMBO, '<<ComboboxSelected>>')
1033
+ app.update
1034
+
1035
+ assert_equal 6, received
1036
+ end
1037
+ end
1038
+
1039
+ end