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,897 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "tmpdir"
5
+ require "json"
6
+ require_relative "../lib/gemba/config"
7
+ require_relative "../lib/gemba/version"
8
+
9
+ class TestMGBAConfig < Minitest::Test
10
+ def setup
11
+ @dir = Dir.mktmpdir("gemba-test")
12
+ @path = File.join(@dir, "settings.json")
13
+ end
14
+
15
+ def teardown
16
+ FileUtils.rm_rf(@dir)
17
+ end
18
+
19
+ def new_config
20
+ Gemba::Config.new(path: @path)
21
+ end
22
+
23
+ # -- Platform paths -------------------------------------------------------
24
+
25
+ def test_default_path_ends_with_settings_json
26
+ if ENV['GEMBA_CONFIG_DIR']
27
+ assert Gemba::Config.default_path.start_with?(ENV['GEMBA_CONFIG_DIR'])
28
+ else
29
+ assert Gemba::Config.default_path.end_with?("gemba/settings.json")
30
+ end
31
+ end
32
+
33
+ def test_config_dir_contains_app_name
34
+ if ENV['GEMBA_CONFIG_DIR']
35
+ assert_equal ENV['GEMBA_CONFIG_DIR'], Gemba::Config.config_dir
36
+ else
37
+ assert_includes Gemba::Config.config_dir, "gemba"
38
+ end
39
+ end
40
+
41
+ # -- Global defaults ------------------------------------------------------
42
+
43
+ def test_defaults_scale
44
+ assert_equal 3, new_config.scale
45
+ end
46
+
47
+ def test_defaults_volume
48
+ assert_equal 100, new_config.volume
49
+ end
50
+
51
+ def test_defaults_muted
52
+ refute new_config.muted?
53
+ end
54
+
55
+ # -- Global setters -------------------------------------------------------
56
+
57
+ def test_set_scale
58
+ c = new_config
59
+ c.scale = 2
60
+ assert_equal 2, c.scale
61
+ end
62
+
63
+ def test_scale_clamps_low
64
+ c = new_config
65
+ c.scale = 0
66
+ assert_equal 1, c.scale
67
+ end
68
+
69
+ def test_scale_clamps_high
70
+ c = new_config
71
+ c.scale = 10
72
+ assert_equal 4, c.scale
73
+ end
74
+
75
+ def test_set_volume
76
+ c = new_config
77
+ c.volume = 75
78
+ assert_equal 75, c.volume
79
+ end
80
+
81
+ def test_volume_clamps
82
+ c = new_config
83
+ c.volume = -5
84
+ assert_equal 0, c.volume
85
+ c.volume = 200
86
+ assert_equal 100, c.volume
87
+ end
88
+
89
+ def test_set_muted
90
+ c = new_config
91
+ c.muted = true
92
+ assert c.muted?
93
+ c.muted = false
94
+ refute c.muted?
95
+ end
96
+
97
+ # -- Persistence ----------------------------------------------------------
98
+
99
+ def test_save_creates_file
100
+ c = new_config
101
+ c.scale = 2
102
+ c.save!
103
+ assert File.exist?(@path)
104
+ end
105
+
106
+ def test_save_creates_directory
107
+ nested = File.join(@dir, "sub", "dir", "settings.json")
108
+ c = Gemba::Config.new(path: nested)
109
+ c.save!
110
+ assert File.exist?(nested)
111
+ end
112
+
113
+ def test_save_writes_metadata
114
+ c = new_config
115
+ c.save!
116
+ data = JSON.parse(File.read(@path))
117
+ assert_equal Gemba::VERSION, data["meta"]["gemba_version"]
118
+ assert data.key?("meta")
119
+ assert data["meta"].key?("saved_at")
120
+ end
121
+
122
+ def test_round_trip_global
123
+ c = new_config
124
+ c.scale = 2
125
+ c.volume = 42
126
+ c.muted = true
127
+ c.save!
128
+
129
+ c2 = Gemba::Config.new(path: @path)
130
+ assert_equal 2, c2.scale
131
+ assert_equal 42, c2.volume
132
+ assert c2.muted?
133
+ end
134
+
135
+ def test_round_trip_gamepad
136
+ guid = "030000007e0500000920000001800000"
137
+ c = new_config
138
+ c.gamepad(guid, name: "Switch Pro")
139
+ c.set_dead_zone(guid, 15)
140
+ c.set_mapping(guid, :a, :x)
141
+ c.save!
142
+
143
+ c2 = Gemba::Config.new(path: @path)
144
+ assert_equal 15, c2.dead_zone(guid)
145
+ assert_equal "x", c2.mappings(guid)["a"]
146
+ gp = c2.gamepad(guid)
147
+ assert_equal "Switch Pro", gp["name"]
148
+ end
149
+
150
+ # -- Gamepad defaults -----------------------------------------------------
151
+
152
+ def test_gamepad_defaults
153
+ guid = "abcd1234"
154
+ c = new_config
155
+ assert_equal 25, c.dead_zone(guid)
156
+ assert_equal "a", c.mappings(guid)["a"]
157
+ assert_equal "dpad_up", c.mappings(guid)["up"]
158
+ assert_equal "left_shoulder", c.mappings(guid)["l"]
159
+ end
160
+
161
+ def test_set_dead_zone_clamps
162
+ guid = "abcd"
163
+ c = new_config
164
+ c.set_dead_zone(guid, -5)
165
+ assert_equal 0, c.dead_zone(guid)
166
+ c.set_dead_zone(guid, 99)
167
+ assert_equal 50, c.dead_zone(guid)
168
+ end
169
+
170
+ def test_set_mapping_removes_duplicate
171
+ guid = "abcd"
172
+ c = new_config
173
+ # Default: a -> a, b -> b
174
+ c.set_mapping(guid, :a, :x)
175
+ m = c.mappings(guid)
176
+ assert_equal "x", m["a"]
177
+ # :x should not be mapped to anything else
178
+ assert_nil m.values.count("x") > 1 ? "dup" : nil
179
+ end
180
+
181
+ def test_reset_gamepad
182
+ guid = "abcd"
183
+ c = new_config
184
+ c.set_dead_zone(guid, 10)
185
+ c.set_mapping(guid, :a, :y)
186
+ c.reset_gamepad(guid)
187
+ assert_equal 25, c.dead_zone(guid)
188
+ assert_equal "a", c.mappings(guid)["a"]
189
+ end
190
+
191
+ # -- Multiple gamepads ----------------------------------------------------
192
+
193
+ def test_separate_guids
194
+ c = new_config
195
+ c.set_dead_zone("guid_a", 10)
196
+ c.set_dead_zone("guid_b", 40)
197
+ assert_equal 10, c.dead_zone("guid_a")
198
+ assert_equal 40, c.dead_zone("guid_b")
199
+ end
200
+
201
+ def test_mapping_change_on_one_guid_does_not_affect_other
202
+ c = new_config
203
+ c.set_mapping("guid_a", :a, :y)
204
+ assert_equal "a", c.mappings("guid_b")["a"]
205
+ assert_equal "y", c.mappings("guid_a")["a"]
206
+ end
207
+
208
+ # -- Keyboard config (sentinel GUID) -------------------------------------
209
+
210
+ def test_keyboard_guid_defaults_to_keysyms
211
+ c = new_config
212
+ m = c.mappings(Gemba::Config::KEYBOARD_GUID)
213
+ assert_equal "z", m["a"]
214
+ assert_equal "x", m["b"]
215
+ assert_equal "Up", m["up"]
216
+ assert_equal "Return", m["start"]
217
+ assert_equal "BackSpace", m["select"]
218
+ end
219
+
220
+ def test_keyboard_guid_dead_zone_is_zero
221
+ c = new_config
222
+ assert_equal 0, c.dead_zone(Gemba::Config::KEYBOARD_GUID)
223
+ end
224
+
225
+ def test_keyboard_does_not_get_gamepad_defaults
226
+ c = new_config
227
+ m = c.mappings(Gemba::Config::KEYBOARD_GUID)
228
+ # Should NOT have gamepad button names like 'dpad_up'
229
+ refute_includes m.values, "dpad_up"
230
+ refute_includes m.values, "left_shoulder"
231
+ end
232
+
233
+ def test_regular_guid_does_not_get_keyboard_defaults
234
+ c = new_config
235
+ m = c.mappings("some_real_guid")
236
+ # Should NOT have keysyms like 'z' or 'Return'
237
+ refute_includes m.values, "z"
238
+ refute_includes m.values, "Return"
239
+ end
240
+
241
+ def test_round_trip_keyboard
242
+ c = new_config
243
+ c.set_mapping(Gemba::Config::KEYBOARD_GUID, :a, "q")
244
+ c.save!
245
+
246
+ c2 = Gemba::Config.new(path: @path)
247
+ assert_equal "q", c2.mappings(Gemba::Config::KEYBOARD_GUID)["a"]
248
+ end
249
+
250
+ def test_reset_keyboard_restores_defaults
251
+ guid = Gemba::Config::KEYBOARD_GUID
252
+ c = new_config
253
+ c.set_mapping(guid, :a, "q")
254
+ c.reset_gamepad(guid)
255
+ assert_equal "z", c.mappings(guid)["a"]
256
+ assert_equal 0, c.dead_zone(guid)
257
+ end
258
+
259
+ # -- Turbo settings ------------------------------------------------------
260
+
261
+ def test_defaults_turbo_speed
262
+ assert_equal 2, new_config.turbo_speed
263
+ end
264
+
265
+ def test_set_turbo_speed
266
+ c = new_config
267
+ c.turbo_speed = 4
268
+ assert_equal 4, c.turbo_speed
269
+ end
270
+
271
+ def test_defaults_turbo_volume_pct
272
+ assert_equal 25, new_config.turbo_volume_pct
273
+ end
274
+
275
+ def test_set_turbo_volume_pct
276
+ c = new_config
277
+ c.turbo_volume_pct = 50
278
+ assert_equal 50, c.turbo_volume_pct
279
+ end
280
+
281
+ def test_turbo_volume_pct_clamps
282
+ c = new_config
283
+ c.turbo_volume_pct = -10
284
+ assert_equal 0, c.turbo_volume_pct
285
+ c.turbo_volume_pct = 200
286
+ assert_equal 100, c.turbo_volume_pct
287
+ end
288
+
289
+ def test_round_trip_turbo
290
+ c = new_config
291
+ c.turbo_speed = 3
292
+ c.turbo_volume_pct = 40
293
+ c.save!
294
+
295
+ c2 = Gemba::Config.new(path: @path)
296
+ assert_equal 3, c2.turbo_speed
297
+ assert_equal 40, c2.turbo_volume_pct
298
+ end
299
+
300
+ # -- Aspect ratio --------------------------------------------------------
301
+
302
+ def test_defaults_keep_aspect_ratio
303
+ assert new_config.keep_aspect_ratio?
304
+ end
305
+
306
+ def test_set_keep_aspect_ratio
307
+ c = new_config
308
+ c.keep_aspect_ratio = false
309
+ refute c.keep_aspect_ratio?
310
+ c.keep_aspect_ratio = true
311
+ assert c.keep_aspect_ratio?
312
+ end
313
+
314
+ def test_round_trip_keep_aspect_ratio
315
+ c = new_config
316
+ c.keep_aspect_ratio = false
317
+ c.save!
318
+
319
+ c2 = Gemba::Config.new(path: @path)
320
+ refute c2.keep_aspect_ratio?
321
+ end
322
+
323
+ # -- Show FPS ------------------------------------------------------------
324
+
325
+ def test_defaults_show_fps
326
+ assert new_config.show_fps?
327
+ end
328
+
329
+ def test_set_show_fps
330
+ c = new_config
331
+ c.show_fps = false
332
+ refute c.show_fps?
333
+ end
334
+
335
+ def test_round_trip_show_fps
336
+ c = new_config
337
+ c.show_fps = false
338
+ c.save!
339
+
340
+ c2 = Gemba::Config.new(path: @path)
341
+ refute c2.show_fps?
342
+ end
343
+
344
+ # -- Saves dir -----------------------------------------------------------
345
+
346
+ def test_defaults_saves_dir
347
+ if ENV['GEMBA_CONFIG_DIR']
348
+ assert new_config.saves_dir.end_with?("saves")
349
+ else
350
+ assert new_config.saves_dir.end_with?("gemba/saves")
351
+ end
352
+ end
353
+
354
+ def test_set_saves_dir
355
+ c = new_config
356
+ c.saves_dir = "/custom/saves"
357
+ assert_equal "/custom/saves", c.saves_dir
358
+ end
359
+
360
+ def test_round_trip_saves_dir
361
+ c = new_config
362
+ c.saves_dir = "/my/saves"
363
+ c.save!
364
+
365
+ c2 = Gemba::Config.new(path: @path)
366
+ assert_equal "/my/saves", c2.saves_dir
367
+ end
368
+
369
+ def test_default_saves_dir_class_method
370
+ if ENV['GEMBA_CONFIG_DIR']
371
+ assert Gemba::Config.default_saves_dir.start_with?(ENV['GEMBA_CONFIG_DIR'])
372
+ else
373
+ assert Gemba::Config.default_saves_dir.end_with?("gemba/saves")
374
+ end
375
+ end
376
+
377
+ # -- Recent ROMs ---------------------------------------------------------
378
+
379
+ def test_recent_roms_default_empty
380
+ assert_equal [], new_config.recent_roms
381
+ end
382
+
383
+ def test_add_recent_rom
384
+ c = new_config
385
+ c.add_recent_rom("/roms/a.gba")
386
+ c.add_recent_rom("/roms/b.gba")
387
+ assert_equal ["/roms/b.gba", "/roms/a.gba"], c.recent_roms
388
+ end
389
+
390
+ def test_add_recent_rom_deduplicates
391
+ c = new_config
392
+ c.add_recent_rom("/roms/a.gba")
393
+ c.add_recent_rom("/roms/b.gba")
394
+ c.add_recent_rom("/roms/a.gba")
395
+ assert_equal ["/roms/a.gba", "/roms/b.gba"], c.recent_roms
396
+ end
397
+
398
+ def test_add_recent_rom_caps_at_max
399
+ c = new_config
400
+ 7.times { |i| c.add_recent_rom("/roms/#{i}.gba") }
401
+ assert_equal Gemba::Config::MAX_RECENT_ROMS, c.recent_roms.size
402
+ assert_equal "/roms/6.gba", c.recent_roms.first
403
+ assert_equal "/roms/2.gba", c.recent_roms.last
404
+ end
405
+
406
+ def test_remove_recent_rom
407
+ c = new_config
408
+ c.add_recent_rom("/roms/a.gba")
409
+ c.add_recent_rom("/roms/b.gba")
410
+ c.remove_recent_rom("/roms/a.gba")
411
+ assert_equal ["/roms/b.gba"], c.recent_roms
412
+ end
413
+
414
+ def test_remove_recent_rom_noop_if_missing
415
+ c = new_config
416
+ c.add_recent_rom("/roms/a.gba")
417
+ c.remove_recent_rom("/roms/nope.gba")
418
+ assert_equal ["/roms/a.gba"], c.recent_roms
419
+ end
420
+
421
+ def test_clear_recent_roms
422
+ c = new_config
423
+ c.add_recent_rom("/roms/a.gba")
424
+ c.add_recent_rom("/roms/b.gba")
425
+ c.clear_recent_roms
426
+ assert_equal [], c.recent_roms
427
+ end
428
+
429
+ def test_round_trip_recent_roms
430
+ c = new_config
431
+ c.add_recent_rom("/roms/a.gba")
432
+ c.add_recent_rom("/roms/b.gba")
433
+ c.save!
434
+
435
+ c2 = Gemba::Config.new(path: @path)
436
+ assert_equal ["/roms/b.gba", "/roms/a.gba"], c2.recent_roms
437
+ end
438
+
439
+ # -- States dir ----------------------------------------------------------
440
+
441
+ def test_defaults_states_dir
442
+ if ENV['GEMBA_CONFIG_DIR']
443
+ assert new_config.states_dir.end_with?("states")
444
+ else
445
+ assert new_config.states_dir.end_with?("gemba/states")
446
+ end
447
+ end
448
+
449
+ def test_set_states_dir
450
+ c = new_config
451
+ c.states_dir = "/custom/states"
452
+ assert_equal "/custom/states", c.states_dir
453
+ end
454
+
455
+ def test_round_trip_states_dir
456
+ c = new_config
457
+ c.states_dir = "/my/states"
458
+ c.save!
459
+
460
+ c2 = Gemba::Config.new(path: @path)
461
+ assert_equal "/my/states", c2.states_dir
462
+ end
463
+
464
+ def test_default_states_dir_class_method
465
+ if ENV['GEMBA_CONFIG_DIR']
466
+ assert Gemba::Config.default_states_dir.start_with?(ENV['GEMBA_CONFIG_DIR'])
467
+ else
468
+ assert Gemba::Config.default_states_dir.end_with?("gemba/states")
469
+ end
470
+ end
471
+
472
+ # -- Save state debounce -------------------------------------------------
473
+
474
+ def test_defaults_save_state_debounce
475
+ assert_in_delta 3.0, new_config.save_state_debounce, 0.01
476
+ end
477
+
478
+ def test_set_save_state_debounce
479
+ c = new_config
480
+ c.save_state_debounce = 5.0
481
+ assert_in_delta 5.0, c.save_state_debounce, 0.01
482
+ end
483
+
484
+ def test_save_state_debounce_clamps
485
+ c = new_config
486
+ c.save_state_debounce = -1.0
487
+ assert_in_delta 0.0, c.save_state_debounce, 0.01
488
+ c.save_state_debounce = 99.0
489
+ assert_in_delta 30.0, c.save_state_debounce, 0.01
490
+ end
491
+
492
+ def test_round_trip_save_state_debounce
493
+ c = new_config
494
+ c.save_state_debounce = 1.5
495
+ c.save!
496
+
497
+ c2 = Gemba::Config.new(path: @path)
498
+ assert_in_delta 1.5, c2.save_state_debounce, 0.01
499
+ end
500
+
501
+ # -- Quick save slot -----------------------------------------------------
502
+
503
+ def test_defaults_quick_save_slot
504
+ assert_equal 1, new_config.quick_save_slot
505
+ end
506
+
507
+ def test_set_quick_save_slot
508
+ c = new_config
509
+ c.quick_save_slot = 5
510
+ assert_equal 5, c.quick_save_slot
511
+ end
512
+
513
+ def test_quick_save_slot_clamps
514
+ c = new_config
515
+ c.quick_save_slot = 0
516
+ assert_equal 1, c.quick_save_slot
517
+ c.quick_save_slot = 99
518
+ assert_equal 10, c.quick_save_slot
519
+ end
520
+
521
+ def test_round_trip_quick_save_slot
522
+ c = new_config
523
+ c.quick_save_slot = 7
524
+ c.save!
525
+
526
+ c2 = Gemba::Config.new(path: @path)
527
+ assert_equal 7, c2.quick_save_slot
528
+ end
529
+
530
+ # -- Save state backup ---------------------------------------------------
531
+
532
+ def test_defaults_save_state_backup
533
+ assert new_config.save_state_backup?
534
+ end
535
+
536
+ def test_set_save_state_backup
537
+ c = new_config
538
+ c.save_state_backup = false
539
+ refute c.save_state_backup?
540
+ c.save_state_backup = true
541
+ assert c.save_state_backup?
542
+ end
543
+
544
+ def test_round_trip_save_state_backup
545
+ c = new_config
546
+ c.save_state_backup = false
547
+ c.save!
548
+
549
+ c2 = Gemba::Config.new(path: @path)
550
+ refute c2.save_state_backup?
551
+ end
552
+
553
+ # -- Locale --------------------------------------------------------------
554
+
555
+ def test_defaults_locale
556
+ assert_equal 'auto', new_config.locale
557
+ end
558
+
559
+ def test_set_locale
560
+ c = new_config
561
+ c.locale = 'ja'
562
+ assert_equal 'ja', c.locale
563
+ end
564
+
565
+ def test_round_trip_locale
566
+ c = new_config
567
+ c.locale = 'ja'
568
+ c.save!
569
+
570
+ c2 = Gemba::Config.new(path: @path)
571
+ assert_equal 'ja', c2.locale
572
+ end
573
+
574
+ # -- Per-game settings ---------------------------------------------------
575
+
576
+ def test_per_game_settings_default_false
577
+ refute new_config.per_game_settings?
578
+ end
579
+
580
+ def test_set_per_game_settings
581
+ c = new_config
582
+ c.per_game_settings = true
583
+ assert c.per_game_settings?
584
+ c.per_game_settings = false
585
+ refute c.per_game_settings?
586
+ end
587
+
588
+ def test_rom_id
589
+ assert_equal "AGB-BGBE-DEADBEEF",
590
+ Gemba::Config.rom_id("AGB-BGBE", 0xDEADBEEF)
591
+ end
592
+
593
+ def test_rom_id_sanitizes_special_chars
594
+ # Spaces and slashes get replaced; hyphens, dots, underscores are kept
595
+ assert_equal "AGB_BGBE-0000CAFE",
596
+ Gemba::Config.rom_id("AGB BGBE", 0xCAFE)
597
+ end
598
+
599
+ def test_game_config_path
600
+ path = Gemba::Config.game_config_path("AGB_BGBE-DEADBEEF")
601
+ assert path.end_with?("games/AGB_BGBE-DEADBEEF/settings.json")
602
+ end
603
+
604
+ def test_activate_game_without_per_game_reads_global
605
+ c = new_config
606
+ c.scale = 2
607
+ c.activate_game("TESTROM-00000001")
608
+ assert_equal 2, c.scale # still reads from global
609
+ end
610
+
611
+ def test_enable_per_game_seeds_game_data
612
+ c = new_config
613
+ c.scale = 2
614
+ c.activate_game("TESTROM-00000001")
615
+ c.enable_per_game
616
+ assert_equal 2, c.scale # seeded from global
617
+ end
618
+
619
+ def test_per_game_write_goes_to_overlay
620
+ c = new_config
621
+ c.scale = 3
622
+ c.activate_game("TESTROM-00000001")
623
+ c.enable_per_game
624
+ c.scale = 1
625
+ assert_equal 1, c.scale # reads from overlay
626
+ # Disable — should revert to global value
627
+ c.disable_per_game
628
+ assert_equal 3, c.scale
629
+ end
630
+
631
+ def test_global_key_bypasses_overlay
632
+ c = new_config
633
+ c.activate_game("TESTROM-00000001")
634
+ c.enable_per_game
635
+ c.keep_aspect_ratio = false
636
+ refute c.keep_aspect_ratio?
637
+ # This key is NOT in PER_GAME_KEYS, so it went to global
638
+ c.disable_per_game
639
+ refute c.keep_aspect_ratio? # still false (it was written to global)
640
+ end
641
+
642
+ def test_save_writes_both_files
643
+ c = new_config
644
+ c.activate_game("TESTROM-00000001")
645
+ c.enable_per_game
646
+ c.scale = 1
647
+ c.save!
648
+
649
+ assert File.exist?(@path), "Global config should exist"
650
+ game_path = Gemba::Config.game_config_path("TESTROM-00000001")
651
+ assert File.exist?(game_path), "Game config should exist"
652
+
653
+ game_data = JSON.parse(File.read(game_path))
654
+ assert_equal 1, game_data['scale']
655
+ ensure
656
+ game_path = Gemba::Config.game_config_path("TESTROM-00000001")
657
+ FileUtils.rm_rf(File.dirname(game_path)) if game_path
658
+ end
659
+
660
+ def test_round_trip_per_game
661
+ c = new_config
662
+ c.activate_game("TESTROM-00000001")
663
+ c.enable_per_game
664
+ c.scale = 1
665
+ c.volume = 42
666
+ c.save!
667
+
668
+ c2 = Gemba::Config.new(path: @path)
669
+ c2.activate_game("TESTROM-00000001")
670
+ assert_equal 1, c2.scale
671
+ assert_equal 42, c2.volume
672
+ ensure
673
+ game_path = Gemba::Config.game_config_path("TESTROM-00000001")
674
+ FileUtils.rm_rf(File.dirname(game_path)) if game_path
675
+ end
676
+
677
+ def test_activate_different_rom_uses_global
678
+ c = new_config
679
+ c.scale = 3
680
+ c.activate_game("ROM_A-00000001")
681
+ c.enable_per_game
682
+ c.scale = 1
683
+ c.save!
684
+
685
+ # Switch to a different ROM — no game file exists
686
+ c.activate_game("ROM_B-00000002")
687
+ assert_equal 3, c.scale # falls through to global
688
+ ensure
689
+ %w[ROM_A-00000001 ROM_B-00000002].each do |id|
690
+ p = Gemba::Config.game_config_path(id)
691
+ FileUtils.rm_rf(File.dirname(p)) if p
692
+ end
693
+ end
694
+
695
+ def test_disable_per_game_preserves_file
696
+ c = new_config
697
+ c.activate_game("TESTROM-00000001")
698
+ c.enable_per_game
699
+ c.scale = 1
700
+ c.save!
701
+
702
+ game_path = Gemba::Config.game_config_path("TESTROM-00000001")
703
+ assert File.exist?(game_path)
704
+
705
+ c.disable_per_game
706
+ c.save!
707
+ assert File.exist?(game_path), "Game file should NOT be deleted on disable"
708
+ ensure
709
+ FileUtils.rm_rf(File.dirname(game_path)) if game_path
710
+ end
711
+
712
+ def test_corrupt_game_file_falls_back_to_global
713
+ c = new_config
714
+ c.per_game_settings = true
715
+ c.scale = 3
716
+ c.save!
717
+
718
+ # Write corrupt game file
719
+ game_path = Gemba::Config.game_config_path("TESTROM-00000001")
720
+ FileUtils.mkdir_p(File.dirname(game_path))
721
+ File.write(game_path, "NOT VALID JSON {{{")
722
+
723
+ c.activate_game("TESTROM-00000001")
724
+ assert_equal 3, c.scale # falls back to global
725
+ ensure
726
+ FileUtils.rm_rf(File.dirname(game_path)) if game_path
727
+ end
728
+
729
+ def test_reload_with_active_game
730
+ c = new_config
731
+ c.activate_game("TESTROM-00000001")
732
+ c.enable_per_game
733
+ c.scale = 1
734
+ c.save!
735
+
736
+ # Externally modify the game file
737
+ game_path = Gemba::Config.game_config_path("TESTROM-00000001")
738
+ game_data = JSON.parse(File.read(game_path))
739
+ game_data['scale'] = 4
740
+ File.write(game_path, JSON.generate(game_data))
741
+
742
+ c.reload!
743
+ assert_equal 4, c.scale
744
+ ensure
745
+ FileUtils.rm_rf(File.dirname(game_path)) if game_path
746
+ end
747
+
748
+ def test_per_game_settings_constant_keys
749
+ expected = %w[scale pixel_filter integer_scale color_correction frame_blending
750
+ volume muted turbo_speed quick_save_slot save_state_backup]
751
+ assert_equal expected.sort, Gemba::Config::PER_GAME_SETTINGS.keys.sort
752
+ end
753
+
754
+ # -- Rewind settings ----------------------------------------------------
755
+
756
+ def test_defaults_rewind_enabled
757
+ assert new_config.rewind_enabled?
758
+ end
759
+
760
+ def test_set_rewind_enabled
761
+ c = new_config
762
+ c.rewind_enabled = false
763
+ refute c.rewind_enabled?
764
+ c.rewind_enabled = true
765
+ assert c.rewind_enabled?
766
+ end
767
+
768
+ def test_defaults_rewind_seconds
769
+ assert_equal 10, new_config.rewind_seconds
770
+ end
771
+
772
+ def test_set_rewind_seconds
773
+ c = new_config
774
+ c.rewind_seconds = 30
775
+ assert_equal 30, c.rewind_seconds
776
+ end
777
+
778
+ def test_rewind_seconds_clamps
779
+ c = new_config
780
+ c.rewind_seconds = 0
781
+ assert_equal 1, c.rewind_seconds
782
+ c.rewind_seconds = 100
783
+ assert_equal 60, c.rewind_seconds
784
+ end
785
+
786
+ def test_round_trip_rewind
787
+ c = new_config
788
+ c.rewind_enabled = false
789
+ c.rewind_seconds = 20
790
+ c.save!
791
+
792
+ c2 = Gemba::Config.new(path: @path)
793
+ refute c2.rewind_enabled?
794
+ assert_equal 20, c2.rewind_seconds
795
+ end
796
+
797
+ # -- Edge cases -----------------------------------------------------------
798
+
799
+ def test_corrupt_json_falls_back_to_defaults
800
+ File.write(@path, "NOT VALID JSON {{{")
801
+ c = Gemba::Config.new(path: @path)
802
+ assert_equal 3, c.scale
803
+ assert_equal 100, c.volume
804
+ end
805
+
806
+ def test_missing_file_uses_defaults
807
+ c = Gemba::Config.new(path: File.join(@dir, "nope.json"))
808
+ assert_equal 3, c.scale
809
+ end
810
+
811
+ def test_forward_compat_new_global_key
812
+ # Simulate an old config file that doesn't have 'muted'
813
+ data = { "global" => { "scale" => 2, "volume" => 80 }, "gamepads" => {} }
814
+ File.write(@path, JSON.generate(data))
815
+ c = Gemba::Config.new(path: @path)
816
+ assert_equal 2, c.scale
817
+ assert_equal 80, c.volume
818
+ refute c.muted? # filled in from defaults
819
+ end
820
+
821
+ def test_reload
822
+ c = new_config
823
+ c.scale = 2
824
+ c.save!
825
+
826
+ # Externally modify the file
827
+ data = JSON.parse(File.read(@path))
828
+ data["global"]["scale"] = 4
829
+ File.write(@path, JSON.generate(data))
830
+
831
+ c.reload!
832
+ assert_equal 4, c.scale
833
+ end
834
+
835
+ # -- reset! ---------------------------------------------------------------
836
+
837
+ def test_reset_deletes_settings_file
838
+ c = new_config
839
+ c.save!
840
+ assert File.exist?(@path)
841
+
842
+ deleted = Gemba::Config.reset!(path: @path)
843
+ assert_equal @path, deleted
844
+ refute File.exist?(@path)
845
+ end
846
+
847
+ def test_reset_returns_nil_when_no_file
848
+ refute File.exist?(@path)
849
+ assert_nil Gemba::Config.reset!(path: @path)
850
+ end
851
+
852
+ # -- Recording settings ---------------------------------------------------
853
+
854
+ def test_recording_compression_default
855
+ assert_equal 1, new_config.recording_compression
856
+ end
857
+
858
+ def test_recording_compression_setter
859
+ c = new_config
860
+ c.recording_compression = 6
861
+ assert_equal 6, c.recording_compression
862
+ end
863
+
864
+ def test_recording_compression_clamps
865
+ c = new_config
866
+ c.recording_compression = 0
867
+ assert_equal 1, c.recording_compression
868
+ c.recording_compression = 99
869
+ assert_equal 9, c.recording_compression
870
+ end
871
+
872
+ def test_recordings_dir
873
+ dir = new_config.recordings_dir
874
+ assert dir.end_with?('recordings'), "Expected recordings dir to end with 'recordings', got: #{dir}"
875
+ end
876
+
877
+ # -- Pause on focus loss ---------------------------------------------------
878
+
879
+ def test_pause_on_focus_loss_default
880
+ assert new_config.pause_on_focus_loss?
881
+ end
882
+
883
+ def test_set_pause_on_focus_loss
884
+ c = new_config
885
+ c.pause_on_focus_loss = false
886
+ refute c.pause_on_focus_loss?
887
+ end
888
+
889
+ def test_round_trip_pause_on_focus_loss
890
+ c = new_config
891
+ c.pause_on_focus_loss = false
892
+ c.save!
893
+
894
+ c2 = Gemba::Config.new(path: @path)
895
+ refute c2.pause_on_focus_loss?
896
+ end
897
+ end