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,434 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require_relative "shared/tk_test_helper"
5
+
6
+ class TestMGBASettingsHotkeys < Minitest::Test
7
+ include TeekTestHelper
8
+
9
+ def test_hotkeys_tab_exists
10
+ assert_tk_app("hotkeys tab exists in notebook") do
11
+ require "gemba/settings_window"
12
+ require "gemba/hotkey_map"
13
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
14
+ sw.show
15
+ app.update
16
+
17
+ tabs = app.command(Gemba::SettingsWindow::NB, 'tabs')
18
+ assert_includes tabs, Gemba::SettingsWindow::HK_TAB
19
+ end
20
+ end
21
+
22
+ def test_hotkey_buttons_show_default_keysyms
23
+ assert_tk_app("hotkey buttons show default keysyms") do
24
+ require "gemba/settings_window"
25
+ require "gemba/hotkey_map"
26
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
27
+ sw.show
28
+ app.update
29
+
30
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'cget', '-text')
31
+ assert_equal 'q', text
32
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:pause], 'cget', '-text')
33
+ assert_equal 'p', text
34
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:quick_save], 'cget', '-text')
35
+ assert_equal 'F5', text
36
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:screenshot], 'cget', '-text')
37
+ assert_equal 'F9', text
38
+ end
39
+ end
40
+
41
+ def test_clicking_hotkey_button_enters_listen_mode
42
+ assert_tk_app("clicking hotkey button enters listen mode") do
43
+ require "gemba/settings_window"
44
+ require "gemba/hotkey_map"
45
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
46
+ sw.show
47
+ app.update
48
+
49
+ app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'invoke')
50
+ app.update
51
+
52
+ assert_equal :quit, sw.hk_listening_for
53
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'cget', '-text')
54
+ assert_equal "Press\u2026", text
55
+ end
56
+ end
57
+
58
+ def test_capture_updates_label_and_fires_callback
59
+ assert_tk_app("capturing hotkey updates label and fires callback") do
60
+ require "gemba/settings_window"
61
+ require "gemba/hotkey_map"
62
+ received_action = nil
63
+ received_key = nil
64
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
65
+ on_hotkey_change: proc { |a, k| received_action = a; received_key = k }
66
+ })
67
+ sw.show
68
+ app.update
69
+
70
+ # Click to start listening for quit hotkey
71
+ app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'invoke')
72
+ app.update
73
+
74
+ # Simulate key capture
75
+ sw.capture_hk_mapping('Escape')
76
+ app.update
77
+
78
+ assert_nil sw.hk_listening_for
79
+ assert_equal :quit, received_action
80
+ assert_equal 'Escape', received_key
81
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'cget', '-text')
82
+ assert_equal 'Escape', text
83
+ end
84
+ end
85
+
86
+ def test_capture_enables_undo_button
87
+ assert_tk_app("capturing hotkey enables undo button") do
88
+ require "gemba/settings_window"
89
+ require "gemba/hotkey_map"
90
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
91
+ sw.show
92
+ app.update
93
+
94
+ # Initially disabled
95
+ state = app.command(Gemba::SettingsWindow::HK_UNDO_BTN, 'cget', '-state')
96
+ assert_equal 'disabled', state
97
+
98
+ # Rebind
99
+ app.command(Gemba::SettingsWindow::HK_ACTIONS[:pause], 'invoke')
100
+ app.update
101
+ sw.capture_hk_mapping('F12')
102
+ app.update
103
+
104
+ state = app.command(Gemba::SettingsWindow::HK_UNDO_BTN, 'cget', '-state')
105
+ assert_equal 'normal', state
106
+ end
107
+ end
108
+
109
+ def test_undo_fires_callback_and_disables
110
+ assert_tk_app("undo fires on_undo_hotkeys and disables button") do
111
+ require "gemba/settings_window"
112
+ require "gemba/hotkey_map"
113
+ undo_called = false
114
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
115
+ on_undo_hotkeys: proc { undo_called = true }
116
+ })
117
+ sw.show
118
+ app.update
119
+
120
+ # Rebind to enable undo
121
+ app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'invoke')
122
+ app.update
123
+ sw.capture_hk_mapping('Escape')
124
+ app.update
125
+
126
+ # Click undo
127
+ app.command(Gemba::SettingsWindow::HK_UNDO_BTN, 'invoke')
128
+ app.update
129
+
130
+ assert undo_called
131
+ state = app.command(Gemba::SettingsWindow::HK_UNDO_BTN, 'cget', '-state')
132
+ assert_equal 'disabled', state
133
+ end
134
+ end
135
+
136
+ def test_reset_restores_defaults
137
+ assert_tk_app("reset restores default hotkey labels") do
138
+ require "gemba/settings_window"
139
+ require "gemba/hotkey_map"
140
+ reset_called = false
141
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
142
+ on_hotkey_reset: proc { reset_called = true },
143
+ on_confirm_reset_hotkeys: -> { true },
144
+ })
145
+ sw.show
146
+ app.update
147
+
148
+ # Rebind quit
149
+ app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'invoke')
150
+ app.update
151
+ sw.capture_hk_mapping('Escape')
152
+ app.update
153
+
154
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'cget', '-text')
155
+ assert_equal 'Escape', text
156
+
157
+ # Click Reset to Defaults
158
+ app.command(Gemba::SettingsWindow::HK_RESET_BTN, 'invoke')
159
+ app.update
160
+
161
+ assert reset_called
162
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'cget', '-text')
163
+ assert_equal 'q', text
164
+ state = app.command(Gemba::SettingsWindow::HK_UNDO_BTN, 'cget', '-state')
165
+ assert_equal 'disabled', state
166
+ end
167
+ end
168
+
169
+ def test_refresh_hotkeys_updates_labels
170
+ assert_tk_app("refresh_hotkeys updates button labels") do
171
+ require "gemba/settings_window"
172
+ require "gemba/hotkey_map"
173
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
174
+ sw.show
175
+ app.update
176
+
177
+ new_labels = Gemba::HotkeyMap::DEFAULTS.merge(quit: 'Escape', pause: 'F12')
178
+ sw.refresh_hotkeys(new_labels)
179
+ app.update
180
+
181
+ assert_equal 'Escape', app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'cget', '-text')
182
+ assert_equal 'F12', app.command(Gemba::SettingsWindow::HK_ACTIONS[:pause], 'cget', '-text')
183
+ # Unchanged bindings stay the same
184
+ assert_equal 'Tab', app.command(Gemba::SettingsWindow::HK_ACTIONS[:fast_forward], 'cget', '-text')
185
+ end
186
+ end
187
+
188
+ def test_cancel_listen_restores_label
189
+ assert_tk_app("canceling listen restores original label") do
190
+ require "gemba/settings_window"
191
+ require "gemba/hotkey_map"
192
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
193
+ sw.show
194
+ app.update
195
+
196
+ # Enter listen for quit
197
+ app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'invoke')
198
+ app.update
199
+ assert_equal :quit, sw.hk_listening_for
200
+
201
+ # Start listening for a different one — cancels the first
202
+ app.command(Gemba::SettingsWindow::HK_ACTIONS[:pause], 'invoke')
203
+ app.update
204
+
205
+ assert_equal :pause, sw.hk_listening_for
206
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'cget', '-text')
207
+ assert_equal 'q', text, "Original quit label should be restored"
208
+ end
209
+ end
210
+
211
+ def test_capture_without_listen_is_noop
212
+ assert_tk_app("capture without listen mode is a no-op") do
213
+ require "gemba/settings_window"
214
+ require "gemba/hotkey_map"
215
+ received = false
216
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
217
+ on_hotkey_change: proc { |*| received = true }
218
+ })
219
+ sw.show
220
+ app.update
221
+
222
+ # Capture without entering listen mode
223
+ sw.capture_hk_mapping('F12')
224
+ app.update
225
+
226
+ refute received
227
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'cget', '-text')
228
+ assert_equal 'q', text, "Label should be unchanged"
229
+ end
230
+ end
231
+
232
+ # -- Conflict validation ---------------------------------------------------
233
+
234
+ def test_hotkey_rejected_when_conflicting_with_gamepad_key
235
+ assert_tk_app("hotkey rejected when key conflicts with gamepad mapping") do
236
+ require "gemba/settings_window"
237
+ require "gemba/hotkey_map"
238
+ received = false
239
+ conflict_msg = nil
240
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
241
+ on_hotkey_change: proc { |*| received = true },
242
+ on_validate_hotkey: ->(keysym) {
243
+ # Simulate: 'z' is GBA button A
244
+ keysym == 'z' ? '"z" is mapped to GBA button A' : nil
245
+ },
246
+ on_key_conflict: proc { |msg| conflict_msg = msg },
247
+ })
248
+ sw.show
249
+ app.update
250
+
251
+ # Try to bind quit to 'z' (conflicts with GBA A)
252
+ app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'invoke')
253
+ app.update
254
+ sw.capture_hk_mapping('z')
255
+ app.update
256
+
257
+ refute received, "on_hotkey_change should not fire for rejected key"
258
+ assert_equal '"z" is mapped to GBA button A', conflict_msg
259
+ # Label should revert to original, not show 'z'
260
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'cget', '-text')
261
+ assert_equal 'q', text
262
+ assert_nil sw.hk_listening_for
263
+ end
264
+ end
265
+
266
+ def test_hotkey_accepted_when_no_conflict
267
+ assert_tk_app("hotkey accepted when no conflict") do
268
+ require "gemba/settings_window"
269
+ require "gemba/hotkey_map"
270
+ received_action = nil
271
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
272
+ on_hotkey_change: proc { |a, _| received_action = a },
273
+ on_validate_hotkey: ->(_) { nil },
274
+ })
275
+ sw.show
276
+ app.update
277
+
278
+ app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'invoke')
279
+ app.update
280
+ sw.capture_hk_mapping('F12')
281
+ app.update
282
+
283
+ assert_equal :quit, received_action
284
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'cget', '-text')
285
+ assert_equal 'F12', text
286
+ end
287
+ end
288
+
289
+ # -- Modifier combo capture -----------------------------------------------
290
+
291
+ def test_capture_modifier_then_key_produces_combo
292
+ assert_tk_app("modifier + key produces combo hotkey") do
293
+ require "gemba/settings_window"
294
+ require "gemba/hotkey_map"
295
+ received_action = nil
296
+ received_hk = nil
297
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
298
+ on_hotkey_change: proc { |a, hk| received_action = a; received_hk = hk },
299
+ })
300
+ sw.show
301
+ app.update
302
+
303
+ # Enter listen mode for quit
304
+ app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'invoke')
305
+ app.update
306
+
307
+ # Simulate pressing Control_L (modifier), then 'k' (non-modifier)
308
+ sw.capture_hk_mapping('Control_L')
309
+ sw.capture_hk_mapping('k')
310
+ app.update
311
+
312
+ assert_equal :quit, received_action
313
+ assert_equal ['Control', 'k'], received_hk
314
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'cget', '-text')
315
+ assert_equal 'Ctrl+K', text
316
+ assert_nil sw.hk_listening_for
317
+ end
318
+ end
319
+
320
+ def test_capture_multi_modifier_combo
321
+ assert_tk_app("multi-modifier combo (Ctrl+Shift+S)") do
322
+ require "gemba/settings_window"
323
+ require "gemba/hotkey_map"
324
+ received_hk = nil
325
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
326
+ on_hotkey_change: proc { |_, hk| received_hk = hk },
327
+ })
328
+ sw.show
329
+ app.update
330
+
331
+ app.command(Gemba::SettingsWindow::HK_ACTIONS[:screenshot], 'invoke')
332
+ app.update
333
+
334
+ sw.capture_hk_mapping('Control_L')
335
+ sw.capture_hk_mapping('Shift_L')
336
+ sw.capture_hk_mapping('s')
337
+ app.update
338
+
339
+ assert_equal ['Control', 'Shift', 's'], received_hk
340
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:screenshot], 'cget', '-text')
341
+ assert_equal 'Ctrl+Shift+S', text
342
+ end
343
+ end
344
+
345
+ def test_combo_hotkey_skips_gamepad_conflict_validation
346
+ assert_tk_app("combo hotkey skips gamepad conflict validation") do
347
+ require "gemba/settings_window"
348
+ require "gemba/hotkey_map"
349
+ received_hk = nil
350
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
351
+ on_hotkey_change: proc { |_, hk| received_hk = hk },
352
+ # 'z' conflicts as a plain key, but Ctrl+z should be fine
353
+ on_validate_hotkey: ->(key) {
354
+ key == 'z' ? '"z" is mapped to GBA button A' : nil
355
+ },
356
+ })
357
+ sw.show
358
+ app.update
359
+
360
+ app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'invoke')
361
+ app.update
362
+
363
+ sw.capture_hk_mapping('Control_L')
364
+ sw.capture_hk_mapping('z')
365
+ app.update
366
+
367
+ assert_equal ['Control', 'z'], received_hk, "Ctrl+Z combo should bypass plain-key conflict"
368
+ end
369
+ end
370
+
371
+ def test_refresh_hotkeys_shows_combo_display_name
372
+ assert_tk_app("refresh_hotkeys shows combo display name") do
373
+ require "gemba/settings_window"
374
+ require "gemba/hotkey_map"
375
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
376
+ sw.show
377
+ app.update
378
+
379
+ new_labels = Gemba::HotkeyMap::DEFAULTS.merge(quit: ['Control', 'q'])
380
+ sw.refresh_hotkeys(new_labels)
381
+ app.update
382
+
383
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'cget', '-text')
384
+ assert_equal 'Ctrl+Q', text
385
+ end
386
+ end
387
+
388
+ def test_bind_script_modifier_combo_roundtrip
389
+ assert_tk_app("Tcl bind script round-trip with modifier+key combo") do
390
+ require "gemba/settings_window"
391
+ require "gemba/hotkey_map"
392
+ received_hk = nil
393
+ sw = Gemba::SettingsWindow.new(app, callbacks: {
394
+ on_hotkey_change: proc { |_, hk| received_hk = hk },
395
+ })
396
+ sw.show
397
+ app.update
398
+
399
+ # Enter listen mode
400
+ top = Gemba::SettingsWindow::TOP
401
+ app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'invoke')
402
+ app.update
403
+
404
+ # Verify the <Key> bind script was installed on the toplevel
405
+ bind_script = app.tcl_eval("bind #{top} <Key>")
406
+ refute_empty bind_script, "Key binding should be set during listen mode"
407
+ assert_match(/ruby_callback/, bind_script)
408
+
409
+ # Simulate what Tk does on key events: evaluate the bind script
410
+ # with %K substituted to the keysym value
411
+ app.tcl_eval(bind_script.gsub('%K', 'Control_L'))
412
+ app.update
413
+ app.tcl_eval(bind_script.gsub('%K', 'k'))
414
+ app.update
415
+
416
+ assert_equal ['Control', 'k'], received_hk
417
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:quit], 'cget', '-text')
418
+ assert_equal 'Ctrl+K', text
419
+ end
420
+ end
421
+
422
+ def test_record_hotkey_button_shows_default
423
+ assert_tk_app("record hotkey button shows F10") do
424
+ require "gemba/settings_window"
425
+ require "gemba/hotkey_map"
426
+ sw = Gemba::SettingsWindow.new(app, callbacks: {})
427
+ sw.show
428
+ app.update
429
+
430
+ text = app.command(Gemba::SettingsWindow::HK_ACTIONS[:record], 'cget', '-text')
431
+ assert_equal 'F10', text
432
+ end
433
+ end
434
+ end