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,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ if ENV['COVERAGE']
4
+ require 'simplecov'
5
+ require_relative 'shared/simplecov_config'
6
+
7
+ coverage_name = ENV['COVERAGE_NAME'] || 'gemba'
8
+ SimpleCov.coverage_dir "#{SimpleCovConfig::PROJECT_ROOT}/coverage/results/#{coverage_name}"
9
+ SimpleCov.command_name "gemba:#{coverage_name}"
10
+ SimpleCov.print_error_status = false
11
+ SimpleCov.formatter SimpleCov::Formatter::SimpleFormatter
12
+
13
+ SimpleCov.start do
14
+ SimpleCovConfig.apply_filters(self)
15
+ track_files "#{SimpleCovConfig::PROJECT_ROOT}/lib/**/*.rb"
16
+ end
17
+ end
18
+
19
+ require "minitest/autorun"
@@ -0,0 +1,396 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "tmpdir"
5
+ require "json"
6
+ require "set"
7
+
8
+ # Minimal config stub for unit testing HotkeyMap without Tk or SDL2.
9
+ class MockHotkeyConfig
10
+ attr_reader :hotkey_data, :saved_hotkeys
11
+
12
+ def initialize(hotkey_data = {})
13
+ @hotkey_data = hotkey_data
14
+ @saved_hotkeys = {}
15
+ end
16
+
17
+ def hotkeys
18
+ @hotkey_data
19
+ end
20
+
21
+ def set_hotkey(action, hk)
22
+ @saved_hotkeys[action.to_s] = hk
23
+ end
24
+
25
+ def reload!
26
+ # no-op for unit tests
27
+ end
28
+ end
29
+
30
+ class TestHotkeyMap < Minitest::Test
31
+ def setup
32
+ require "gemba/hotkey_map"
33
+ end
34
+
35
+ def make_map(hotkey_data = {})
36
+ config = MockHotkeyConfig.new(hotkey_data)
37
+ [Gemba::HotkeyMap.new(config), config]
38
+ end
39
+
40
+ # -- Defaults -------------------------------------------------------------
41
+
42
+ def test_defaults_match_expected_keysyms
43
+ map, = make_map
44
+ assert_equal 'q', map.key_for(:quit)
45
+ assert_equal 'p', map.key_for(:pause)
46
+ assert_equal 'Tab', map.key_for(:fast_forward)
47
+ assert_equal 'F11', map.key_for(:fullscreen)
48
+ assert_equal 'F3', map.key_for(:show_fps)
49
+ assert_equal 'F5', map.key_for(:quick_save)
50
+ assert_equal 'F8', map.key_for(:quick_load)
51
+ assert_equal 'F6', map.key_for(:save_states)
52
+ assert_equal 'F9', map.key_for(:screenshot)
53
+ end
54
+
55
+ def test_all_actions_have_defaults
56
+ map, = make_map
57
+ Gemba::HotkeyMap::ACTIONS.each do |action|
58
+ refute_nil map.key_for(action), "Missing default for #{action}"
59
+ end
60
+ end
61
+
62
+ # -- Reverse lookup -------------------------------------------------------
63
+
64
+ def test_action_for_returns_correct_action
65
+ map, = make_map
66
+ assert_equal :quit, map.action_for('q')
67
+ assert_equal :pause, map.action_for('p')
68
+ assert_equal :fast_forward, map.action_for('Tab')
69
+ assert_equal :quick_save, map.action_for('F5')
70
+ end
71
+
72
+ def test_action_for_returns_nil_for_unknown_key
73
+ map, = make_map
74
+ assert_nil map.action_for('z')
75
+ assert_nil map.action_for('unknown')
76
+ end
77
+
78
+ # -- Rebinding ------------------------------------------------------------
79
+
80
+ def test_set_rebinds_action
81
+ map, = make_map
82
+ map.set(:quit, 'Escape')
83
+ assert_equal 'Escape', map.key_for(:quit)
84
+ assert_equal :quit, map.action_for('Escape')
85
+ end
86
+
87
+ def test_set_clears_conflict
88
+ map, = make_map
89
+ # 'p' is currently bound to :pause — rebind :quit to 'p'
90
+ map.set(:quit, 'p')
91
+ assert_equal 'p', map.key_for(:quit)
92
+ assert_nil map.key_for(:pause), "Old action should be unbound"
93
+ end
94
+
95
+ def test_set_does_not_affect_other_bindings
96
+ map, = make_map
97
+ map.set(:quit, 'Escape')
98
+ assert_equal 'p', map.key_for(:pause)
99
+ assert_equal 'F5', map.key_for(:quick_save)
100
+ end
101
+
102
+ # -- Reset ----------------------------------------------------------------
103
+
104
+ def test_reset_restores_defaults
105
+ map, = make_map
106
+ map.set(:quit, 'Escape')
107
+ map.set(:pause, 'F12')
108
+ map.reset!
109
+ assert_equal 'q', map.key_for(:quit)
110
+ assert_equal 'p', map.key_for(:pause)
111
+ end
112
+
113
+ # -- Config loading -------------------------------------------------------
114
+
115
+ def test_load_config_applies_saved_bindings
116
+ map, = make_map({ 'quit' => 'Escape', 'pause' => 'F12' })
117
+ assert_equal 'Escape', map.key_for(:quit)
118
+ assert_equal 'F12', map.key_for(:pause)
119
+ # Unsaved actions keep defaults
120
+ assert_equal 'Tab', map.key_for(:fast_forward)
121
+ end
122
+
123
+ def test_load_config_ignores_unknown_actions
124
+ map, = make_map({ 'bogus' => 'F1' })
125
+ assert_nil map.action_for('F1')
126
+ assert_equal 'q', map.key_for(:quit)
127
+ end
128
+
129
+ def test_load_config_empty_uses_defaults
130
+ map, = make_map({})
131
+ assert_equal 'q', map.key_for(:quit)
132
+ end
133
+
134
+ # -- Save to config -------------------------------------------------------
135
+
136
+ def test_save_to_config_writes_all_bindings
137
+ map, config = make_map
138
+ map.set(:quit, 'Escape')
139
+ map.save_to_config
140
+ assert_equal 'Escape', config.saved_hotkeys['quit']
141
+ assert_equal 'p', config.saved_hotkeys['pause']
142
+ end
143
+
144
+ def test_save_to_config_preserves_combo_arrays
145
+ map, config = make_map
146
+ map.set(:quit, ['Control', 'q'])
147
+ map.save_to_config
148
+ assert_equal ['Control', 'q'], config.saved_hotkeys['quit']
149
+ end
150
+
151
+ # -- Labels ---------------------------------------------------------------
152
+
153
+ def test_labels_returns_copy
154
+ map, = make_map
155
+ labels = map.labels
156
+ labels[:quit] = 'CHANGED'
157
+ assert_equal 'q', map.key_for(:quit), "Modifying labels hash should not affect map"
158
+ end
159
+
160
+ def test_labels_reflects_current_state
161
+ map, = make_map
162
+ map.set(:quit, 'Escape')
163
+ labels = map.labels
164
+ assert_equal 'Escape', labels[:quit]
165
+ end
166
+
167
+ # -- Modifier combo support -----------------------------------------------
168
+
169
+ def test_set_combo_hotkey
170
+ map, = make_map
171
+ map.set(:quit, ['Control', 'q'])
172
+ assert_equal ['Control', 'q'], map.key_for(:quit)
173
+ end
174
+
175
+ def test_action_for_combo_with_modifiers
176
+ map, = make_map
177
+ map.set(:quit, ['Control', 'q'])
178
+ result = map.action_for('q', modifiers: Set.new(['Control']))
179
+ assert_equal :quit, result
180
+ end
181
+
182
+ def test_action_for_combo_wrong_modifiers
183
+ map, = make_map
184
+ map.set(:quit, ['Control', 'q'])
185
+ assert_nil map.action_for('q', modifiers: Set.new(['Shift']))
186
+ end
187
+
188
+ def test_action_for_combo_no_modifiers
189
+ map, = make_map
190
+ map.set(:quit, ['Control', 'q'])
191
+ assert_nil map.action_for('q'), "Combo should not match without modifiers"
192
+ end
193
+
194
+ def test_action_for_plain_key_ignores_active_modifiers
195
+ map, = make_map
196
+ # 'p' (pause) is a plain key — should only match without modifiers
197
+ assert_nil map.action_for('p', modifiers: Set.new(['Control']))
198
+ assert_equal :pause, map.action_for('p')
199
+ end
200
+
201
+ def test_action_for_multi_modifier_combo
202
+ map, = make_map
203
+ map.set(:screenshot, ['Control', 'Shift', 's'])
204
+ result = map.action_for('s', modifiers: Set.new(['Control', 'Shift']))
205
+ assert_equal :screenshot, result
206
+ end
207
+
208
+ def test_action_for_multi_modifier_partial_match_fails
209
+ map, = make_map
210
+ map.set(:screenshot, ['Control', 'Shift', 's'])
211
+ assert_nil map.action_for('s', modifiers: Set.new(['Control']))
212
+ end
213
+
214
+ def test_set_combo_clears_conflicting_combo
215
+ map, = make_map
216
+ map.set(:quit, ['Control', 'q'])
217
+ map.set(:pause, ['Control', 'q'])
218
+ assert_nil map.key_for(:quit), "Old combo should be unbound"
219
+ assert_equal ['Control', 'q'], map.key_for(:pause)
220
+ end
221
+
222
+ def test_load_config_with_combo_array
223
+ map, = make_map({ 'quit' => ['Control', 'q'] })
224
+ assert_equal ['Control', 'q'], map.key_for(:quit)
225
+ result = map.action_for('q', modifiers: Set.new(['Control']))
226
+ assert_equal :quit, result
227
+ end
228
+
229
+ # -- normalize (class method) ---------------------------------------------
230
+
231
+ def test_normalize_plain_string
232
+ assert_equal 'F5', Gemba::HotkeyMap.normalize('F5')
233
+ end
234
+
235
+ def test_normalize_single_element_array
236
+ assert_equal 'q', Gemba::HotkeyMap.normalize(['q'])
237
+ end
238
+
239
+ def test_normalize_sorts_modifiers_canonically
240
+ result = Gemba::HotkeyMap.normalize(['Shift', 'Control', 's'])
241
+ assert_equal ['Control', 'Shift', 's'], result
242
+ end
243
+
244
+ def test_normalize_preserves_correct_order
245
+ result = Gemba::HotkeyMap.normalize(['Control', 'Shift', 's'])
246
+ assert_equal ['Control', 'Shift', 's'], result
247
+ end
248
+
249
+ # -- display_name (class method) ------------------------------------------
250
+
251
+ def test_display_name_plain_string
252
+ assert_equal 'F5', Gemba::HotkeyMap.display_name('F5')
253
+ end
254
+
255
+ def test_display_name_combo
256
+ result = Gemba::HotkeyMap.display_name(['Control', 'q'])
257
+ assert_equal 'Ctrl+Q', result
258
+ end
259
+
260
+ def test_display_name_multi_modifier
261
+ result = Gemba::HotkeyMap.display_name(['Control', 'Shift', 's'])
262
+ assert_equal 'Ctrl+Shift+S', result
263
+ end
264
+
265
+ # -- modifier helpers (class methods) -------------------------------------
266
+
267
+ def test_modifier_key_recognizes_modifiers
268
+ assert Gemba::HotkeyMap.modifier_key?('Control_L')
269
+ assert Gemba::HotkeyMap.modifier_key?('Shift_R')
270
+ assert Gemba::HotkeyMap.modifier_key?('Alt_L')
271
+ assert Gemba::HotkeyMap.modifier_key?('Meta_L')
272
+ assert Gemba::HotkeyMap.modifier_key?('Super_R')
273
+ end
274
+
275
+ def test_modifier_key_rejects_non_modifiers
276
+ refute Gemba::HotkeyMap.modifier_key?('a')
277
+ refute Gemba::HotkeyMap.modifier_key?('F5')
278
+ refute Gemba::HotkeyMap.modifier_key?('Return')
279
+ end
280
+
281
+ def test_normalize_modifier
282
+ assert_equal 'Control', Gemba::HotkeyMap.normalize_modifier('Control_L')
283
+ assert_equal 'Control', Gemba::HotkeyMap.normalize_modifier('Control_R')
284
+ assert_equal 'Shift', Gemba::HotkeyMap.normalize_modifier('Shift_L')
285
+ assert_equal 'Alt', Gemba::HotkeyMap.normalize_modifier('Alt_L')
286
+ assert_equal 'Alt', Gemba::HotkeyMap.normalize_modifier('Meta_L')
287
+ assert_nil Gemba::HotkeyMap.normalize_modifier('a')
288
+ end
289
+
290
+ def test_modifiers_from_state_empty
291
+ result = Gemba::HotkeyMap.modifiers_from_state(0)
292
+ assert_empty result
293
+ end
294
+
295
+ def test_modifiers_from_state_shift
296
+ result = Gemba::HotkeyMap.modifiers_from_state(1)
297
+ assert_equal Set.new(['Shift']), result
298
+ end
299
+
300
+ def test_modifiers_from_state_control
301
+ result = Gemba::HotkeyMap.modifiers_from_state(4)
302
+ assert_equal Set.new(['Control']), result
303
+ end
304
+
305
+ def test_modifiers_from_state_alt
306
+ result = Gemba::HotkeyMap.modifiers_from_state(8)
307
+ assert_equal Set.new(['Alt']), result
308
+ end
309
+
310
+ def test_modifiers_from_state_control_shift
311
+ result = Gemba::HotkeyMap.modifiers_from_state(5) # 4|1
312
+ assert_equal Set.new(['Control', 'Shift']), result
313
+ end
314
+
315
+ def test_action_for_empty_modifiers_set_matches_plain
316
+ map, = make_map
317
+ # Empty set should behave same as nil (match plain keys)
318
+ assert_equal :pause, map.action_for('p', modifiers: Set.new)
319
+ end
320
+
321
+ # -- Rewind action ---------------------------------------------------------
322
+
323
+ def test_rewind_in_actions
324
+ assert_includes Gemba::HotkeyMap::ACTIONS, :rewind
325
+ end
326
+
327
+ def test_rewind_default_is_shift_tab
328
+ map, = make_map
329
+ assert_equal ['Shift', 'Tab'], map.key_for(:rewind)
330
+ end
331
+
332
+ def test_rewind_action_for_shift_tab
333
+ map, = make_map
334
+ result = map.action_for('Tab', modifiers: Set.new(['Shift']))
335
+ assert_equal :rewind, result
336
+ end
337
+
338
+ def test_rewind_does_not_match_plain_tab
339
+ map, = make_map
340
+ # Plain Tab is fast_forward, not rewind
341
+ assert_equal :fast_forward, map.action_for('Tab')
342
+ end
343
+
344
+ def test_iso_left_tab_normalized_to_rewind
345
+ map, = make_map
346
+ # Shift+Tab produces ISO_Left_Tab on many platforms
347
+ result = map.action_for('ISO_Left_Tab', modifiers: Set.new(['Shift']))
348
+ assert_equal :rewind, result
349
+ end
350
+
351
+ def test_normalize_keysym_iso_left_tab
352
+ assert_equal 'Tab', Gemba::HotkeyMap.normalize_keysym('ISO_Left_Tab')
353
+ end
354
+
355
+ def test_normalize_keysym_uppercase_letter
356
+ assert_equal 'q', Gemba::HotkeyMap.normalize_keysym('Q')
357
+ assert_equal 's', Gemba::HotkeyMap.normalize_keysym('S')
358
+ assert_equal 'a', Gemba::HotkeyMap.normalize_keysym('A')
359
+ end
360
+
361
+ def test_normalize_keysym_shifted_numbers
362
+ assert_equal '1', Gemba::HotkeyMap.normalize_keysym('exclam')
363
+ assert_equal '2', Gemba::HotkeyMap.normalize_keysym('at')
364
+ assert_equal '0', Gemba::HotkeyMap.normalize_keysym('parenright')
365
+ end
366
+
367
+ def test_normalize_keysym_passthrough
368
+ assert_equal 'q', Gemba::HotkeyMap.normalize_keysym('q')
369
+ assert_equal 'F5', Gemba::HotkeyMap.normalize_keysym('F5')
370
+ assert_equal 'Tab', Gemba::HotkeyMap.normalize_keysym('Tab')
371
+ end
372
+
373
+ def test_action_for_shift_uppercase_matches_plain_combo
374
+ map, = make_map
375
+ map.set(:screenshot, ['Shift', 's'])
376
+ # Tk sends 'S' (uppercase) when Shift+s is pressed
377
+ result = map.action_for('S', modifiers: Set.new(['Shift']))
378
+ assert_equal :screenshot, result
379
+ end
380
+
381
+ # -- Record action ---------------------------------------------------------
382
+
383
+ def test_record_in_actions
384
+ assert_includes Gemba::HotkeyMap::ACTIONS, :record
385
+ end
386
+
387
+ def test_record_default_is_f10
388
+ map, = make_map
389
+ assert_equal 'F10', map.key_for(:record)
390
+ end
391
+
392
+ def test_record_dispatches_on_f10
393
+ map, = make_map
394
+ assert_equal :record, map.action_for('F10')
395
+ end
396
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "gemba"
5
+ require_relative "../lib/gemba/config"
6
+ require_relative "../lib/gemba/input_mappings"
7
+ require_relative "support/input_mocks"
8
+
9
+ class TestKeyboardMap < Minitest::Test
10
+ def setup
11
+ @config = MockInputConfig.new
12
+ @map = Gemba::KeyboardMap.new(@config)
13
+ end
14
+
15
+ def test_default_labels
16
+ labels = @map.labels
17
+ assert_equal 'z', labels[:a]
18
+ assert_equal 'x', labels[:b]
19
+ assert_equal 'Return', labels[:start]
20
+ end
21
+
22
+ def test_set_remap
23
+ @map.set(:a, 'q')
24
+ assert_equal 'q', @map.labels[:a]
25
+ end
26
+
27
+ def test_set_removes_old_binding
28
+ @map.set(:a, 'q')
29
+ refute @map.labels.values.include?('z')
30
+ end
31
+
32
+ def test_set_unknown_button
33
+ @map.set(:nonexistent, 'q')
34
+ assert_equal 'z', @map.labels[:a]
35
+ end
36
+
37
+ def test_reset
38
+ @map.set(:a, 'q')
39
+ @map.reset!
40
+ assert_equal 'z', @map.labels[:a]
41
+ end
42
+
43
+ def test_mask_no_device
44
+ assert_equal 0, @map.mask
45
+ end
46
+
47
+ def test_mask_with_device
48
+ kb = Gemba::VirtualKeyboard.new
49
+ @map.device = kb
50
+ kb.press('z')
51
+ mask = @map.mask
52
+ assert_equal Gemba::KEY_A, mask & Gemba::KEY_A
53
+ end
54
+
55
+ def test_mask_multiple_keys
56
+ kb = Gemba::VirtualKeyboard.new
57
+ @map.device = kb
58
+ kb.press('z')
59
+ kb.press('x')
60
+ mask = @map.mask
61
+ assert_equal Gemba::KEY_A, mask & Gemba::KEY_A
62
+ assert_equal Gemba::KEY_B, mask & Gemba::KEY_B
63
+ end
64
+
65
+ def test_mask_released_key_not_in_mask
66
+ kb = Gemba::VirtualKeyboard.new
67
+ @map.device = kb
68
+ kb.press('z')
69
+ kb.release('z')
70
+ assert_equal 0, @map.mask
71
+ end
72
+
73
+ def test_supports_deadzone
74
+ refute @map.supports_deadzone?
75
+ end
76
+
77
+ def test_dead_zone_pct
78
+ assert_equal 0, @map.dead_zone_pct
79
+ end
80
+
81
+ def test_set_dead_zone_raises
82
+ assert_raises(NotImplementedError) { @map.set_dead_zone(100) }
83
+ end
84
+
85
+ def test_load_config
86
+ cfg = MockInputConfig.new(keyboard_mappings: { 'a' => 'q', 'b' => 'w' })
87
+ map = Gemba::KeyboardMap.new(cfg)
88
+ assert_equal 'q', map.labels[:a]
89
+ assert_equal 'w', map.labels[:b]
90
+ end
91
+
92
+ def test_reload
93
+ @map.set(:a, 'q')
94
+ @map.reload!
95
+ assert @config.calls.any? { |c| c[0] == :reload! }
96
+ # After reload with empty config, defaults restored
97
+ assert_equal 'z', @map.labels[:a]
98
+ end
99
+
100
+ def test_save_to_config
101
+ @map.save_to_config
102
+ set_calls = @config.calls.select { |c| c[0] == :set_mapping }
103
+ assert_equal 10, set_calls.size
104
+ a_call = set_calls.find { |c| c[2] == :a }
105
+ assert_equal 'keyboard', a_call[1]
106
+ assert_equal 'z', a_call[3]
107
+ end
108
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "yaml"
5
+ require_relative "../lib/gemba/locale"
6
+
7
+ class TestMGBALocale < Minitest::Test
8
+ # -- Loading ---------------------------------------------------------------
9
+
10
+ def test_load_english
11
+ Gemba::Locale.load('en')
12
+ assert_equal 'en', Gemba::Locale.language
13
+ end
14
+
15
+ def test_load_japanese
16
+ Gemba::Locale.load('ja')
17
+ assert_equal 'ja', Gemba::Locale.language
18
+ end
19
+
20
+ def test_fallback_to_english_for_unknown_locale
21
+ Gemba::Locale.load('zz')
22
+ # Should still load (fell back to en.yml) and return translations
23
+ assert_equal 'File', Gemba::Locale.translate('menu.file')
24
+ end
25
+
26
+ def test_load_auto_detects_from_env
27
+ original = ENV['LANG']
28
+ ENV['LANG'] = 'ja_JP.UTF-8'
29
+ Gemba::Locale.load
30
+ assert_equal 'ja', Gemba::Locale.language
31
+ ensure
32
+ ENV['LANG'] = original
33
+ Gemba::Locale.load('en')
34
+ end
35
+
36
+ def test_load_auto_string_treated_as_auto_detect
37
+ original = ENV['LANG']
38
+ ENV['LANG'] = 'en_US.UTF-8'
39
+ Gemba::Locale.load('auto')
40
+ assert_equal 'en', Gemba::Locale.language
41
+ ensure
42
+ ENV['LANG'] = original
43
+ Gemba::Locale.load('en')
44
+ end
45
+
46
+ # -- Translation -----------------------------------------------------------
47
+
48
+ def test_translate_english_string
49
+ Gemba::Locale.load('en')
50
+ assert_equal 'File', Gemba::Locale.translate('menu.file')
51
+ end
52
+
53
+ def test_translate_japanese_string
54
+ Gemba::Locale.load('ja')
55
+ assert_equal 'ファイル', Gemba::Locale.translate('menu.file')
56
+ end
57
+
58
+ def test_translate_nested_key
59
+ Gemba::Locale.load('en')
60
+ assert_equal 'Video', Gemba::Locale.translate('settings.video')
61
+ assert_equal 'ROM Info', Gemba::Locale.translate('rom_info.title')
62
+ end
63
+
64
+ def test_translate_with_interpolation
65
+ Gemba::Locale.load('en')
66
+ result = Gemba::Locale.translate('toast.state_saved', slot: 3)
67
+ assert_equal 'State saved to slot 3', result
68
+ end
69
+
70
+ def test_translate_with_multiple_vars
71
+ Gemba::Locale.load('en')
72
+ # dialog.game_running_msg has {name}
73
+ result = Gemba::Locale.translate('dialog.game_running_msg', name: 'Zelda')
74
+ assert_equal 'Another game is running. Switch to Zelda?', result
75
+ end
76
+
77
+ def test_translate_japanese_with_interpolation
78
+ Gemba::Locale.load('ja')
79
+ result = Gemba::Locale.translate('toast.state_saved', slot: 5)
80
+ assert_includes result, '5'
81
+ end
82
+
83
+ def test_translate_missing_key_returns_key
84
+ Gemba::Locale.load('en')
85
+ assert_equal 'nonexistent.key', Gemba::Locale.translate('nonexistent.key')
86
+ end
87
+
88
+ def test_translate_partial_key_returns_key
89
+ Gemba::Locale.load('en')
90
+ # 'menu' exists but is a Hash, not a string
91
+ assert_equal 'menu', Gemba::Locale.translate('menu')
92
+ end
93
+
94
+ # -- Alias -----------------------------------------------------------------
95
+
96
+ def test_t_alias
97
+ Gemba::Locale.load('en')
98
+ assert_equal 'File', Gemba::Locale.t('menu.file')
99
+ assert_equal Gemba::Locale.translate('menu.file'),
100
+ Gemba::Locale.t('menu.file')
101
+ end
102
+
103
+ # -- Available languages ---------------------------------------------------
104
+
105
+ def test_available_languages
106
+ langs = Gemba::Locale.available_languages
107
+ assert_includes langs, 'en'
108
+ assert_includes langs, 'ja'
109
+ assert_equal langs, langs.sort, 'should be sorted'
110
+ end
111
+
112
+ # -- Translatable mixin ----------------------------------------------------
113
+
114
+ def test_translatable_mixin
115
+ klass = Class.new { include Gemba::Locale::Translatable; public :translate, :t }
116
+ obj = klass.new
117
+ Gemba::Locale.load('en')
118
+ assert_equal 'File', obj.translate('menu.file')
119
+ assert_equal 'File', obj.t('menu.file')
120
+ end
121
+
122
+ def test_translatable_mixin_with_interpolation
123
+ klass = Class.new { include Gemba::Locale::Translatable; public :translate }
124
+ obj = klass.new
125
+ Gemba::Locale.load('en')
126
+ assert_equal 'State saved to slot 7', obj.translate('toast.state_saved', slot: 7)
127
+ end
128
+
129
+ # -- Completeness ----------------------------------------------------------
130
+
131
+ def test_en_and_ja_have_same_keys
132
+ en_path = File.expand_path('../lib/gemba/locales/en.yml', __dir__)
133
+ ja_path = File.expand_path('../lib/gemba/locales/ja.yml', __dir__)
134
+ en = YAML.safe_load_file(en_path)
135
+ ja = YAML.safe_load_file(ja_path)
136
+
137
+ en_keys = flatten_keys(en)
138
+ ja_keys = flatten_keys(ja)
139
+
140
+ missing_in_ja = en_keys - ja_keys
141
+ missing_in_en = ja_keys - en_keys
142
+
143
+ assert_empty missing_in_ja, "Keys in en.yml missing from ja.yml: #{missing_in_ja.join(', ')}"
144
+ assert_empty missing_in_en, "Keys in ja.yml missing from en.yml: #{missing_in_en.join(', ')}"
145
+ end
146
+
147
+ private
148
+
149
+ def flatten_keys(hash, prefix = nil)
150
+ hash.flat_map do |key, value|
151
+ full_key = prefix ? "#{prefix}.#{key}" : key.to_s
152
+ if value.is_a?(Hash)
153
+ flatten_keys(value, full_key)
154
+ else
155
+ [full_key]
156
+ end
157
+ end
158
+ end
159
+ end
data/test/test_mgba.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "gemba"
5
+
6
+ class TestMGBA < Minitest::Test
7
+ def test_version_constant
8
+ assert_match(/\A\d+\.\d+\.\d+\z/, Gemba::VERSION)
9
+ end
10
+
11
+ def test_module_structure
12
+ assert_kind_of Module, Gemba
13
+ assert_equal Class, Gemba::Core.class
14
+ end
15
+
16
+ def test_key_constants_are_unique_powers_of_two
17
+ keys = %i[KEY_A KEY_B KEY_SELECT KEY_START
18
+ KEY_RIGHT KEY_LEFT KEY_UP KEY_DOWN KEY_R KEY_L]
19
+
20
+ values = keys.map { |k| Gemba.const_get(k) }
21
+ assert_equal values.size, values.uniq.size, "all key constants should be unique"
22
+ values.each do |v|
23
+ assert_equal 0, v & (v - 1), "#{v} should be a power of 2"
24
+ end
25
+ end
26
+ end