gemba 0.1.1 → 0.2.1

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 (289) hide show
  1. checksums.yaml +4 -4
  2. data/THIRD_PARTY_NOTICES +37 -2
  3. data/assets/placeholder_boxart.png +0 -0
  4. data/bin/gemba +2 -2
  5. data/ext/gemba/extconf.rb +23 -1
  6. data/ext/gemba/gemba_ext.c +436 -2
  7. data/ext/gemba/gemba_ext.h +2 -0
  8. data/gemba.gemspec +5 -3
  9. data/lib/gemba/achievements/achievement.rb +23 -0
  10. data/lib/gemba/achievements/backend.rb +190 -0
  11. data/lib/gemba/achievements/cache.rb +70 -0
  12. data/lib/gemba/achievements/credentials_presenter.rb +142 -0
  13. data/lib/gemba/achievements/fake_backend.rb +205 -0
  14. data/lib/gemba/achievements/null_backend.rb +11 -0
  15. data/lib/gemba/achievements/offline_backend.rb +168 -0
  16. data/lib/gemba/achievements/retro_achievements/backend.rb +511 -0
  17. data/lib/gemba/achievements/retro_achievements/cli_sync_requester.rb +64 -0
  18. data/lib/gemba/achievements/retro_achievements/ping_worker.rb +28 -0
  19. data/lib/gemba/achievements/retro_achievements/unlock_retry_worker.rb +35 -0
  20. data/lib/gemba/achievements.rb +19 -0
  21. data/lib/gemba/achievements_window.rb +556 -0
  22. data/lib/gemba/app_controller.rb +1036 -0
  23. data/lib/gemba/bios.rb +54 -0
  24. data/lib/gemba/boxart_fetcher/libretro_backend.rb +39 -0
  25. data/lib/gemba/boxart_fetcher/null_backend.rb +12 -0
  26. data/lib/gemba/boxart_fetcher.rb +79 -0
  27. data/lib/gemba/bus_emitter.rb +13 -0
  28. data/lib/gemba/child_window.rb +24 -1
  29. data/lib/gemba/cli/commands/config_cmd.rb +83 -0
  30. data/lib/gemba/cli/commands/decode.rb +154 -0
  31. data/lib/gemba/cli/commands/patch.rb +78 -0
  32. data/lib/gemba/cli/commands/play.rb +78 -0
  33. data/lib/gemba/cli/commands/record.rb +114 -0
  34. data/lib/gemba/cli/commands/replay.rb +161 -0
  35. data/lib/gemba/cli/commands/retro_achievements.rb +213 -0
  36. data/lib/gemba/cli/commands/version.rb +22 -0
  37. data/lib/gemba/cli.rb +52 -364
  38. data/lib/gemba/config.rb +154 -1
  39. data/lib/gemba/data/gb_games.json +1 -0
  40. data/lib/gemba/data/gb_md5.json +1 -0
  41. data/lib/gemba/data/gba_games.json +1 -0
  42. data/lib/gemba/data/gba_md5.json +1 -0
  43. data/lib/gemba/data/gbc_games.json +1 -0
  44. data/lib/gemba/data/gbc_md5.json +1 -0
  45. data/lib/gemba/emulator_frame.rb +1084 -0
  46. data/lib/gemba/event_bus.rb +48 -0
  47. data/lib/gemba/frame_stack.rb +70 -0
  48. data/lib/gemba/game_index.rb +84 -0
  49. data/lib/gemba/game_picker_frame.rb +309 -0
  50. data/lib/gemba/gamepad_map.rb +103 -0
  51. data/lib/gemba/headless.rb +6 -5
  52. data/lib/gemba/headless_player.rb +33 -3
  53. data/lib/gemba/help_window.rb +61 -0
  54. data/lib/gemba/hotkey_map.rb +3 -1
  55. data/lib/gemba/input_recorder.rb +107 -0
  56. data/lib/gemba/input_replayer.rb +119 -0
  57. data/lib/gemba/keyboard_map.rb +90 -0
  58. data/lib/gemba/list_picker_frame.rb +271 -0
  59. data/lib/gemba/locales/en.yml +109 -5
  60. data/lib/gemba/locales/ja.yml +109 -5
  61. data/lib/gemba/main_window.rb +56 -0
  62. data/lib/gemba/modal_stack.rb +81 -0
  63. data/lib/gemba/patcher_window.rb +223 -0
  64. data/lib/gemba/platform/gb.rb +21 -0
  65. data/lib/gemba/platform/gba.rb +21 -0
  66. data/lib/gemba/platform/gbc.rb +23 -0
  67. data/lib/gemba/platform.rb +20 -0
  68. data/lib/gemba/platform_open.rb +19 -0
  69. data/lib/gemba/recorder.rb +4 -3
  70. data/lib/gemba/replay_player.rb +691 -0
  71. data/lib/gemba/rom_info.rb +57 -0
  72. data/lib/gemba/rom_info_window.rb +16 -3
  73. data/lib/gemba/rom_library.rb +106 -0
  74. data/lib/gemba/rom_overrides.rb +47 -0
  75. data/lib/gemba/rom_patcher/bps.rb +161 -0
  76. data/lib/gemba/rom_patcher/ips.rb +101 -0
  77. data/lib/gemba/rom_patcher/ups.rb +119 -0
  78. data/lib/gemba/rom_patcher.rb +109 -0
  79. data/lib/gemba/{rom_loader.rb → rom_resolver.rb} +7 -6
  80. data/lib/gemba/runtime.rb +59 -26
  81. data/lib/gemba/save_state_manager.rb +4 -7
  82. data/lib/gemba/save_state_picker.rb +17 -4
  83. data/lib/gemba/session_logger.rb +64 -0
  84. data/lib/gemba/settings/audio_tab.rb +77 -0
  85. data/lib/gemba/settings/gamepad_tab.rb +351 -0
  86. data/lib/gemba/settings/hotkeys_tab.rb +259 -0
  87. data/lib/gemba/settings/paths.rb +11 -0
  88. data/lib/gemba/settings/recording_tab.rb +83 -0
  89. data/lib/gemba/settings/save_states_tab.rb +91 -0
  90. data/lib/gemba/settings/system_tab.rb +377 -0
  91. data/lib/gemba/settings/video_tab.rb +318 -0
  92. data/lib/gemba/settings_window.rb +162 -1036
  93. data/lib/gemba/version.rb +1 -1
  94. data/lib/gemba/virtual_keyboard.rb +19 -0
  95. data/lib/gemba.rb +2 -12
  96. data/test/achievements_window/test_bulk_sync.rb +218 -0
  97. data/test/achievements_window/test_bus_events.rb +125 -0
  98. data/test/achievements_window/test_close_confirmation.rb +201 -0
  99. data/test/achievements_window/test_initial_state.rb +164 -0
  100. data/test/achievements_window/test_sorting.rb +227 -0
  101. data/test/achievements_window/test_tree_rendering.rb +133 -0
  102. data/test/fixtures/fake_bios.bin +0 -0
  103. data/test/fixtures/pong.gba +0 -0
  104. data/test/fixtures/test.gb +0 -0
  105. data/test/fixtures/test.gbc +0 -0
  106. data/test/fixtures/test_quicksave.ss +0 -0
  107. data/test/screenshots/no_focus.png +0 -0
  108. data/test/shared/teek_test_worker.rb +17 -1
  109. data/test/shared/tk_test_helper.rb +92 -4
  110. data/test/support/achievements_window_helpers.rb +18 -0
  111. data/test/support/fake_core.rb +25 -0
  112. data/test/support/fake_ra_runtime.rb +74 -0
  113. data/test/support/fake_requester.rb +78 -0
  114. data/test/support/player_helpers.rb +20 -5
  115. data/test/test_achievement.rb +32 -0
  116. data/test/{test_player.rb → test_app_controller.rb} +353 -85
  117. data/test/test_bios.rb +123 -0
  118. data/test/test_boxart_fetcher.rb +150 -0
  119. data/test/test_cli.rb +17 -265
  120. data/test/test_cli_config.rb +64 -0
  121. data/test/test_cli_decode.rb +97 -0
  122. data/test/test_cli_patch.rb +58 -0
  123. data/test/test_cli_play.rb +213 -0
  124. data/test/test_cli_ra.rb +175 -0
  125. data/test/test_cli_record.rb +69 -0
  126. data/test/test_cli_replay.rb +72 -0
  127. data/test/test_cli_sync_requester.rb +152 -0
  128. data/test/test_cli_version.rb +27 -0
  129. data/test/test_config.rb +3 -3
  130. data/test/test_config_ra.rb +69 -0
  131. data/test/test_core.rb +62 -1
  132. data/test/test_credentials_presenter.rb +192 -0
  133. data/test/test_event_bus.rb +100 -0
  134. data/test/test_fake_backend_achievements.rb +130 -0
  135. data/test/test_fake_backend_auth.rb +68 -0
  136. data/test/test_game_index.rb +77 -0
  137. data/test/test_game_picker_frame.rb +310 -0
  138. data/test/test_gamepad_map.rb +1 -3
  139. data/test/test_headless_player.rb +17 -3
  140. data/test/test_help_window.rb +82 -0
  141. data/test/test_hotkey_map.rb +22 -1
  142. data/test/test_input_recorder.rb +179 -0
  143. data/test/test_input_replay_determinism.rb +113 -0
  144. data/test/test_input_replayer.rb +162 -0
  145. data/test/test_keyboard_map.rb +1 -3
  146. data/test/test_libretro_backend.rb +41 -0
  147. data/test/test_list_picker_frame.rb +391 -0
  148. data/test/test_locale.rb +1 -1
  149. data/test/test_logging.rb +123 -0
  150. data/test/test_null_backend.rb +42 -0
  151. data/test/test_offline_backend.rb +116 -0
  152. data/test/test_overlay_renderer.rb +1 -1
  153. data/test/test_platform.rb +149 -0
  154. data/test/test_ra_backend.rb +313 -0
  155. data/test/test_ra_backend_unlock_gate.rb +56 -0
  156. data/test/test_ra_backend_unlock_retry.rb +123 -0
  157. data/test/test_recorder.rb +0 -3
  158. data/test/test_replay_player.rb +316 -0
  159. data/test/test_rom_info.rb +149 -0
  160. data/test/test_rom_overrides.rb +86 -0
  161. data/test/test_rom_patcher.rb +383 -0
  162. data/test/{test_rom_loader.rb → test_rom_resolver.rb} +25 -26
  163. data/test/test_save_state_manager.rb +2 -4
  164. data/test/test_settings_audio.rb +107 -0
  165. data/test/test_settings_hotkeys.rb +83 -66
  166. data/test/test_settings_recording.rb +49 -0
  167. data/test/test_settings_save_states.rb +97 -0
  168. data/test/test_settings_system.rb +133 -0
  169. data/test/test_settings_video.rb +450 -0
  170. data/test/test_settings_window.rb +76 -507
  171. data/test/test_tip_service.rb +6 -6
  172. data/test/test_toast_overlay.rb +1 -1
  173. data/test/test_virtual_events.rb +221 -0
  174. data/test/test_virtual_keyboard.rb +1 -1
  175. data/vendor/rcheevos/CHANGELOG.md +495 -0
  176. data/vendor/rcheevos/LICENSE +21 -0
  177. data/vendor/rcheevos/Package.swift +33 -0
  178. data/vendor/rcheevos/README.md +67 -0
  179. data/vendor/rcheevos/include/module.modulemap +70 -0
  180. data/vendor/rcheevos/include/rc_api_editor.h +296 -0
  181. data/vendor/rcheevos/include/rc_api_info.h +280 -0
  182. data/vendor/rcheevos/include/rc_api_request.h +77 -0
  183. data/vendor/rcheevos/include/rc_api_runtime.h +417 -0
  184. data/vendor/rcheevos/include/rc_api_user.h +262 -0
  185. data/vendor/rcheevos/include/rc_client.h +877 -0
  186. data/vendor/rcheevos/include/rc_client_raintegration.h +101 -0
  187. data/vendor/rcheevos/include/rc_consoles.h +138 -0
  188. data/vendor/rcheevos/include/rc_error.h +59 -0
  189. data/vendor/rcheevos/include/rc_export.h +100 -0
  190. data/vendor/rcheevos/include/rc_hash.h +200 -0
  191. data/vendor/rcheevos/include/rc_runtime.h +148 -0
  192. data/vendor/rcheevos/include/rc_runtime_types.h +452 -0
  193. data/vendor/rcheevos/include/rc_util.h +51 -0
  194. data/vendor/rcheevos/include/rcheevos.h +8 -0
  195. data/vendor/rcheevos/src/rapi/rc_api_common.c +1379 -0
  196. data/vendor/rcheevos/src/rapi/rc_api_common.h +88 -0
  197. data/vendor/rcheevos/src/rapi/rc_api_editor.c +625 -0
  198. data/vendor/rcheevos/src/rapi/rc_api_info.c +587 -0
  199. data/vendor/rcheevos/src/rapi/rc_api_runtime.c +901 -0
  200. data/vendor/rcheevos/src/rapi/rc_api_user.c +483 -0
  201. data/vendor/rcheevos/src/rc_client.c +6941 -0
  202. data/vendor/rcheevos/src/rc_client_external.c +281 -0
  203. data/vendor/rcheevos/src/rc_client_external.h +177 -0
  204. data/vendor/rcheevos/src/rc_client_external_versions.h +171 -0
  205. data/vendor/rcheevos/src/rc_client_internal.h +409 -0
  206. data/vendor/rcheevos/src/rc_client_raintegration.c +566 -0
  207. data/vendor/rcheevos/src/rc_client_raintegration_internal.h +61 -0
  208. data/vendor/rcheevos/src/rc_client_types.natvis +396 -0
  209. data/vendor/rcheevos/src/rc_compat.c +251 -0
  210. data/vendor/rcheevos/src/rc_compat.h +121 -0
  211. data/vendor/rcheevos/src/rc_libretro.c +915 -0
  212. data/vendor/rcheevos/src/rc_libretro.h +98 -0
  213. data/vendor/rcheevos/src/rc_util.c +199 -0
  214. data/vendor/rcheevos/src/rc_version.c +11 -0
  215. data/vendor/rcheevos/src/rc_version.h +32 -0
  216. data/vendor/rcheevos/src/rcheevos/alloc.c +312 -0
  217. data/vendor/rcheevos/src/rcheevos/condition.c +754 -0
  218. data/vendor/rcheevos/src/rcheevos/condset.c +777 -0
  219. data/vendor/rcheevos/src/rcheevos/consoleinfo.c +1215 -0
  220. data/vendor/rcheevos/src/rcheevos/format.c +330 -0
  221. data/vendor/rcheevos/src/rcheevos/lboard.c +287 -0
  222. data/vendor/rcheevos/src/rcheevos/memref.c +805 -0
  223. data/vendor/rcheevos/src/rcheevos/operand.c +607 -0
  224. data/vendor/rcheevos/src/rcheevos/rc_internal.h +390 -0
  225. data/vendor/rcheevos/src/rcheevos/rc_runtime_types.natvis +541 -0
  226. data/vendor/rcheevos/src/rcheevos/rc_validate.c +1406 -0
  227. data/vendor/rcheevos/src/rcheevos/rc_validate.h +18 -0
  228. data/vendor/rcheevos/src/rcheevos/richpresence.c +922 -0
  229. data/vendor/rcheevos/src/rcheevos/runtime.c +852 -0
  230. data/vendor/rcheevos/src/rcheevos/runtime_progress.c +1073 -0
  231. data/vendor/rcheevos/src/rcheevos/trigger.c +344 -0
  232. data/vendor/rcheevos/src/rcheevos/value.c +935 -0
  233. data/vendor/rcheevos/src/rhash/aes.c +480 -0
  234. data/vendor/rcheevos/src/rhash/aes.h +49 -0
  235. data/vendor/rcheevos/src/rhash/cdreader.c +838 -0
  236. data/vendor/rcheevos/src/rhash/hash.c +1402 -0
  237. data/vendor/rcheevos/src/rhash/hash_disc.c +1340 -0
  238. data/vendor/rcheevos/src/rhash/hash_encrypted.c +566 -0
  239. data/vendor/rcheevos/src/rhash/hash_rom.c +426 -0
  240. data/vendor/rcheevos/src/rhash/hash_zip.c +460 -0
  241. data/vendor/rcheevos/src/rhash/md5.c +382 -0
  242. data/vendor/rcheevos/src/rhash/md5.h +91 -0
  243. data/vendor/rcheevos/src/rhash/rc_hash_internal.h +116 -0
  244. data/vendor/rcheevos/test/libretro.h +205 -0
  245. data/vendor/rcheevos/test/rapi/test_rc_api_common.c +941 -0
  246. data/vendor/rcheevos/test/rapi/test_rc_api_editor.c +931 -0
  247. data/vendor/rcheevos/test/rapi/test_rc_api_info.c +545 -0
  248. data/vendor/rcheevos/test/rapi/test_rc_api_runtime.c +2213 -0
  249. data/vendor/rcheevos/test/rapi/test_rc_api_user.c +998 -0
  250. data/vendor/rcheevos/test/rcheevos/mock_memory.h +32 -0
  251. data/vendor/rcheevos/test/rcheevos/test_condition.c +570 -0
  252. data/vendor/rcheevos/test/rcheevos/test_condset.c +5170 -0
  253. data/vendor/rcheevos/test/rcheevos/test_consoleinfo.c +203 -0
  254. data/vendor/rcheevos/test/rcheevos/test_format.c +112 -0
  255. data/vendor/rcheevos/test/rcheevos/test_lboard.c +746 -0
  256. data/vendor/rcheevos/test/rcheevos/test_memref.c +520 -0
  257. data/vendor/rcheevos/test/rcheevos/test_operand.c +692 -0
  258. data/vendor/rcheevos/test/rcheevos/test_rc_validate.c +502 -0
  259. data/vendor/rcheevos/test/rcheevos/test_richpresence.c +1564 -0
  260. data/vendor/rcheevos/test/rcheevos/test_runtime.c +1667 -0
  261. data/vendor/rcheevos/test/rcheevos/test_runtime_progress.c +1821 -0
  262. data/vendor/rcheevos/test/rcheevos/test_timing.c +166 -0
  263. data/vendor/rcheevos/test/rcheevos/test_trigger.c +2521 -0
  264. data/vendor/rcheevos/test/rcheevos/test_value.c +870 -0
  265. data/vendor/rcheevos/test/rcheevos-test.sln +46 -0
  266. data/vendor/rcheevos/test/rcheevos-test.vcxproj +239 -0
  267. data/vendor/rcheevos/test/rcheevos-test.vcxproj.filters +335 -0
  268. data/vendor/rcheevos/test/rhash/data.c +657 -0
  269. data/vendor/rcheevos/test/rhash/data.h +32 -0
  270. data/vendor/rcheevos/test/rhash/mock_filereader.c +236 -0
  271. data/vendor/rcheevos/test/rhash/mock_filereader.h +31 -0
  272. data/vendor/rcheevos/test/rhash/test_cdreader.c +920 -0
  273. data/vendor/rcheevos/test/rhash/test_hash.c +310 -0
  274. data/vendor/rcheevos/test/rhash/test_hash_disc.c +1450 -0
  275. data/vendor/rcheevos/test/rhash/test_hash_rom.c +899 -0
  276. data/vendor/rcheevos/test/rhash/test_hash_zip.c +551 -0
  277. data/vendor/rcheevos/test/test.c +113 -0
  278. data/vendor/rcheevos/test/test_framework.h +205 -0
  279. data/vendor/rcheevos/test/test_rc_client.c +10509 -0
  280. data/vendor/rcheevos/test/test_rc_client_external.c +2197 -0
  281. data/vendor/rcheevos/test/test_rc_client_raintegration.c +441 -0
  282. data/vendor/rcheevos/test/test_rc_libretro.c +952 -0
  283. data/vendor/rcheevos/test/test_types.natvis +9 -0
  284. data/vendor/rcheevos/validator/validator.c +658 -0
  285. data/vendor/rcheevos/validator/validator.vcxproj +152 -0
  286. data/vendor/rcheevos/validator/validator.vcxproj.filters +82 -0
  287. metadata +277 -10
  288. data/lib/gemba/input_mappings.rb +0 -214
  289. data/lib/gemba/player.rb +0 -1525
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require_relative "shared/tk_test_helper"
5
+
6
+ class TestReplayPlayer < Minitest::Test
7
+ include TeekTestHelper
8
+
9
+ PONG_ROM = File.expand_path("fixtures/pong.gba", __dir__)
10
+
11
+ # Generate a short .gir fixture for all GUI tests.
12
+ # Uses HeadlessPlayer + InputRecorder to record 60 frames of pong.
13
+ def self.gir_fixture_dir
14
+ @gir_fixture_dir ||= begin
15
+ require "tmpdir"
16
+ dir = Dir.mktmpdir("gemba_replay_test")
17
+ at_exit { FileUtils.rm_rf(dir) }
18
+
19
+ require "gemba/headless"
20
+ require "gemba/headless"
21
+
22
+ gir_path = File.join(dir, "pong_test.gir")
23
+ Gemba::HeadlessPlayer.open(PONG_ROM) do |player|
24
+ player.step(10)
25
+ core = player.core
26
+ rec = Gemba::InputRecorder.new(gir_path, core: core, rom_path: PONG_ROM)
27
+ rec.start
28
+ 60.times do |i|
29
+ mask = i < 30 ? Gemba::KEY_START : 0
30
+ rec.capture(mask)
31
+ core.set_keys(mask)
32
+ core.run_frame
33
+ end
34
+ rec.stop
35
+ end
36
+
37
+ dir
38
+ end
39
+ end
40
+
41
+ def gir_path
42
+ File.join(self.class.gir_fixture_dir, "pong_test.gir")
43
+ end
44
+
45
+ # ReplayPlayer opens a window, plays frames, then exits cleanly.
46
+ def test_replay_exits_cleanly
47
+ code = <<~RUBY
48
+ require "gemba"
49
+ require "support/player_helpers"
50
+
51
+ rp = Gemba::ReplayPlayer.new("#{gir_path}")
52
+ app = rp.app
53
+
54
+ poll_until_ready(rp) { rp.running = false }
55
+
56
+ rp.run
57
+ RUBY
58
+
59
+ success, stdout, stderr, _status = tk_subprocess(code, timeout: 10)
60
+
61
+ output = []
62
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
63
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
64
+
65
+ assert success, "ReplayPlayer should exit cleanly\n#{output.join("\n")}"
66
+ end
67
+
68
+ # After replay ends (60 frames), player should pause on last frame.
69
+ def test_replay_pauses_on_end
70
+ code = <<~RUBY
71
+ require "gemba"
72
+ require "support/player_helpers"
73
+
74
+ rp = Gemba::ReplayPlayer.new("#{gir_path}")
75
+ app = rp.app
76
+
77
+ poll_until_ready(rp) do
78
+ check = proc do
79
+ if rp.replay_ended?
80
+ unless rp.paused?
81
+ $stderr.puts "FAIL: replay ended but not paused"
82
+ exit 1
83
+ end
84
+ rp.running = false
85
+ else
86
+ app.after(100, &check)
87
+ end
88
+ end
89
+ app.after(100, &check)
90
+ end
91
+
92
+ rp.run
93
+ RUBY
94
+
95
+ success, stdout, stderr, _status = tk_subprocess(code, timeout: 10)
96
+
97
+ output = []
98
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
99
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
100
+
101
+ assert success, "ReplayPlayer should pause on replay end\n#{output.join("\n")}"
102
+ end
103
+
104
+ # Fullscreen toggle (F11 twice) should not hang.
105
+ def test_fullscreen_toggle
106
+ code = <<~RUBY
107
+ require "gemba"
108
+ require "support/player_helpers"
109
+
110
+ rp = Gemba::ReplayPlayer.new("#{gir_path}")
111
+ app = rp.app
112
+
113
+ poll_until_ready(rp) do
114
+ vp = rp.viewport
115
+ frame = vp.frame.path
116
+
117
+ app.tcl_eval("focus -force \#{frame}")
118
+ app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'F11')
119
+ app.update
120
+
121
+ app.after(50) do
122
+ app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'F11')
123
+ app.update
124
+ app.after(50) { rp.running = false }
125
+ end
126
+ end
127
+
128
+ rp.run
129
+ RUBY
130
+
131
+ success, stdout, stderr, _status = tk_subprocess(code, timeout: 10)
132
+
133
+ output = []
134
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
135
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
136
+
137
+ assert success, "ReplayPlayer fullscreen toggle should not hang\n#{output.join("\n")}"
138
+ end
139
+
140
+ # Fast-forward toggle (Tab) should not hang.
141
+ def test_fast_forward_toggle
142
+ code = <<~RUBY
143
+ require "gemba"
144
+ require "support/player_helpers"
145
+
146
+ rp = Gemba::ReplayPlayer.new("#{gir_path}")
147
+ app = rp.app
148
+
149
+ poll_until_ready(rp) do
150
+ vp = rp.viewport
151
+ frame = vp.frame.path
152
+
153
+ app.tcl_eval("focus -force \#{frame}")
154
+ app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'Tab')
155
+ app.update
156
+
157
+ app.after(200) do
158
+ app.tcl_eval("focus -force \#{frame}")
159
+ app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'Tab')
160
+ app.update
161
+ app.after(50) { rp.running = false }
162
+ end
163
+ end
164
+
165
+ rp.run
166
+ RUBY
167
+
168
+ success, stdout, stderr, _status = tk_subprocess(code, timeout: 10)
169
+
170
+ output = []
171
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
172
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
173
+
174
+ assert success, "ReplayPlayer fast-forward toggle should not hang\n#{output.join("\n")}"
175
+ end
176
+
177
+ # Pause via public method, verify predicate.
178
+ def test_pause_via_method
179
+ code = <<~RUBY
180
+ require "gemba"
181
+ require "support/player_helpers"
182
+
183
+ rp = Gemba::ReplayPlayer.new("#{gir_path}")
184
+ app = rp.app
185
+
186
+ poll_until_ready(rp) do
187
+ app.after(100) do
188
+ rp.pause
189
+ unless rp.paused?
190
+ $stderr.puts "FAIL: pause method should set paused?"
191
+ exit 1
192
+ end
193
+
194
+ rp.resume
195
+ if rp.paused?
196
+ $stderr.puts "FAIL: resume should clear paused?"
197
+ exit 1
198
+ end
199
+
200
+ rp.running = false
201
+ end
202
+ end
203
+
204
+ rp.run
205
+ RUBY
206
+
207
+ success, stdout, stderr, _status = tk_subprocess(code, timeout: 10)
208
+
209
+ output = []
210
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
211
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
212
+
213
+ assert success, "ReplayPlayer pause/resume methods should work\n#{output.join("\n")}"
214
+ end
215
+
216
+ # Pressing P should toggle pause via hotkey.
217
+ def test_pause_hotkey
218
+ code = <<~RUBY
219
+ require "gemba"
220
+ require "support/player_helpers"
221
+
222
+ rp = Gemba::ReplayPlayer.new("#{gir_path}")
223
+ app = rp.app
224
+
225
+ poll_until_ready(rp) do
226
+ vp = rp.viewport
227
+ frame = vp.frame.path
228
+
229
+ app.after(200) do
230
+ app.tcl_eval("focus -force \#{frame}")
231
+ app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'p')
232
+ app.update
233
+
234
+ app.after(200) do
235
+ unless rp.paused?
236
+ $stderr.puts "FAIL: P should pause"
237
+ exit 1
238
+ end
239
+ rp.running = false
240
+ end
241
+ end
242
+ end
243
+
244
+ rp.run
245
+ RUBY
246
+
247
+ success, stdout, stderr, _status = tk_subprocess(code, timeout: 10)
248
+
249
+ output = []
250
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
251
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
252
+
253
+ assert success, "ReplayPlayer P hotkey should toggle pause\n#{output.join("\n")}"
254
+ end
255
+
256
+ # Escape should exit (when not fullscreen).
257
+ def test_escape_exits
258
+ code = <<~RUBY
259
+ require "gemba"
260
+ require "support/player_helpers"
261
+
262
+ rp = Gemba::ReplayPlayer.new("#{gir_path}")
263
+ app = rp.app
264
+
265
+ poll_until_ready(rp) do
266
+ vp = rp.viewport
267
+ frame = vp.frame.path
268
+
269
+ app.tcl_eval("focus -force \#{frame}")
270
+ app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'Escape')
271
+ end
272
+
273
+ rp.run
274
+ RUBY
275
+
276
+ success, stdout, stderr, _status = tk_subprocess(code, timeout: 10)
277
+
278
+ output = []
279
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
280
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
281
+
282
+ assert success, "ReplayPlayer Escape should exit\n#{output.join("\n")}"
283
+ end
284
+
285
+ # frame_index should advance during replay.
286
+ def test_frame_index_advances
287
+ code = <<~RUBY
288
+ require "gemba"
289
+ require "support/player_helpers"
290
+
291
+ rp = Gemba::ReplayPlayer.new("#{gir_path}")
292
+ app = rp.app
293
+
294
+ poll_until_ready(rp) do
295
+ app.after(500) do
296
+ idx = rp.frame_index
297
+ if idx <= 0
298
+ $stderr.puts "FAIL: frame_index should advance, got \#{idx}"
299
+ exit 1
300
+ end
301
+ rp.running = false
302
+ end
303
+ end
304
+
305
+ rp.run
306
+ RUBY
307
+
308
+ success, stdout, stderr, _status = tk_subprocess(code, timeout: 10)
309
+
310
+ output = []
311
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
312
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
313
+
314
+ assert success, "ReplayPlayer frame_index should advance\n#{output.join("\n")}"
315
+ end
316
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+ require "gemba/headless"
7
+
8
+ class TestRomInfo < Minitest::Test
9
+ # Stub that returns nil for every lookup — isolates RomInfo from real DAT data.
10
+ NULL_INDEX = Struct.new(:_) {
11
+ def lookup(_) = nil
12
+ def lookup_by_md5(*) = nil
13
+ }.new(nil)
14
+
15
+ ROM = {
16
+ 'rom_id' => 'AGB_AXVE-DEADBEEF',
17
+ 'title' => 'Pokemon Ruby',
18
+ 'platform' => 'gba',
19
+ 'game_code' => 'AGB-AXVE',
20
+ 'path' => '/games/ruby.gba',
21
+ }.freeze
22
+
23
+ def test_from_rom_sets_basic_fields
24
+ info = Gemba::RomInfo.from_rom(ROM, game_index: NULL_INDEX)
25
+ assert_equal 'AGB_AXVE-DEADBEEF', info.rom_id
26
+ assert_equal 'Pokemon Ruby', info.title
27
+ assert_equal 'GBA', info.platform
28
+ assert_equal 'AGB-AXVE', info.game_code
29
+ assert_equal '/games/ruby.gba', info.path
30
+ end
31
+
32
+ def test_platform_is_uppercased
33
+ info = Gemba::RomInfo.from_rom(ROM.merge('platform' => 'gbc'), game_index: NULL_INDEX)
34
+ assert_equal 'GBC', info.platform
35
+ end
36
+
37
+ def test_title_falls_back_to_rom_id
38
+ rom = ROM.merge('title' => nil)
39
+ info = Gemba::RomInfo.from_rom(rom, game_index: NULL_INDEX)
40
+ assert_equal 'AGB_AXVE-DEADBEEF', info.title
41
+ end
42
+
43
+ def test_title_from_game_index_wins_over_stored_title
44
+ index = Struct.new(:_) {
45
+ def lookup(_) = 'Index Title'
46
+ def lookup_by_md5(*) = nil
47
+ }.new(nil)
48
+ info = Gemba::RomInfo.from_rom(ROM, game_index: index)
49
+ assert_equal 'Index Title', info.title
50
+ end
51
+
52
+ def test_no_fetcher_or_overrides_yields_nil_boxart_fields
53
+ info = Gemba::RomInfo.from_rom(ROM, game_index: NULL_INDEX)
54
+ assert_nil info.cached_boxart_path
55
+ assert_nil info.custom_boxart_path
56
+ assert_nil info.boxart_path
57
+ end
58
+
59
+ def test_has_official_entry_true_when_index_returns_title
60
+ index = Struct.new(:_) {
61
+ def lookup(_) = 'Some Game'
62
+ def lookup_by_md5(*) = nil
63
+ }.new(nil)
64
+ info = Gemba::RomInfo.from_rom(ROM, game_index: index)
65
+ assert info.has_official_entry
66
+ end
67
+
68
+ def test_has_official_entry_false_when_index_returns_nil
69
+ info = Gemba::RomInfo.from_rom(ROM, game_index: NULL_INDEX)
70
+ refute info.has_official_entry
71
+ end
72
+
73
+ def test_has_official_entry_false_when_no_game_code
74
+ rom = ROM.merge('game_code' => nil)
75
+ info = Gemba::RomInfo.from_rom(rom, game_index: NULL_INDEX)
76
+ refute info.has_official_entry
77
+ end
78
+
79
+ def test_boxart_path_returns_custom_when_file_exists
80
+ Dir.mktmpdir do |dir|
81
+ ENV['GEMBA_CONFIG_DIR'] = dir
82
+ custom = File.join(dir, "custom.png")
83
+ File.write(custom, "fake")
84
+ overrides = Gemba::RomOverrides.new(File.join(dir, "overrides.json"))
85
+ overrides.set_custom_boxart('AGB_AXVE-DEADBEEF', custom)
86
+
87
+ info = Gemba::RomInfo.from_rom(ROM, overrides: overrides, game_index: NULL_INDEX)
88
+ assert_equal File.join(dir, 'boxart', 'AGB_AXVE-DEADBEEF', 'custom.png'), info.boxart_path
89
+ ensure
90
+ ENV.delete('GEMBA_CONFIG_DIR')
91
+ end
92
+ end
93
+
94
+ def test_boxart_path_falls_back_to_cache_when_no_custom
95
+ Dir.mktmpdir do |dir|
96
+ ENV['GEMBA_CONFIG_DIR'] = dir
97
+ cache_dir = File.join(dir, "boxart")
98
+ fetcher = Gemba::BoxartFetcher.new(app: nil, cache_dir: cache_dir,
99
+ backend: Gemba::BoxartFetcher::NullBackend.new)
100
+ overrides = Gemba::RomOverrides.new(File.join(dir, "overrides.json"))
101
+
102
+ cached = fetcher.cached_path('AGB-AXVE')
103
+ FileUtils.mkdir_p(File.dirname(cached))
104
+ File.write(cached, "fake")
105
+
106
+ info = Gemba::RomInfo.from_rom(ROM, fetcher: fetcher, overrides: overrides, game_index: NULL_INDEX)
107
+ assert_equal cached, info.boxart_path
108
+ ensure
109
+ ENV.delete('GEMBA_CONFIG_DIR')
110
+ end
111
+ end
112
+
113
+ def test_boxart_path_nil_when_neither_present
114
+ Dir.mktmpdir do |dir|
115
+ ENV['GEMBA_CONFIG_DIR'] = dir
116
+ fetcher = Gemba::BoxartFetcher.new(app: nil, cache_dir: File.join(dir, "boxart"),
117
+ backend: Gemba::BoxartFetcher::NullBackend.new)
118
+ overrides = Gemba::RomOverrides.new(File.join(dir, "overrides.json"))
119
+
120
+ info = Gemba::RomInfo.from_rom(ROM, fetcher: fetcher, overrides: overrides, game_index: NULL_INDEX)
121
+ assert_nil info.boxart_path
122
+ ensure
123
+ ENV.delete('GEMBA_CONFIG_DIR')
124
+ end
125
+ end
126
+
127
+ def test_custom_beats_cache_in_boxart_path
128
+ Dir.mktmpdir do |dir|
129
+ ENV['GEMBA_CONFIG_DIR'] = dir
130
+ cache_dir = File.join(dir, "boxart")
131
+ fetcher = Gemba::BoxartFetcher.new(app: nil, cache_dir: cache_dir,
132
+ backend: Gemba::BoxartFetcher::NullBackend.new)
133
+ overrides = Gemba::RomOverrides.new(File.join(dir, "overrides.json"))
134
+
135
+ cached = fetcher.cached_path('AGB-AXVE')
136
+ FileUtils.mkdir_p(File.dirname(cached))
137
+ File.write(cached, "cached")
138
+
139
+ src = File.join(dir, "my_cover.png")
140
+ File.write(src, "custom")
141
+ overrides.set_custom_boxart('AGB_AXVE-DEADBEEF', src)
142
+
143
+ info = Gemba::RomInfo.from_rom(ROM, fetcher: fetcher, overrides: overrides, game_index: NULL_INDEX)
144
+ assert_match %r{custom\.png$}, info.boxart_path, "Custom should beat cached"
145
+ ensure
146
+ ENV.delete('GEMBA_CONFIG_DIR')
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+ require "gemba/headless"
7
+ require "gemba/headless"
8
+
9
+ class TestRomOverrides < Minitest::Test
10
+ def setup
11
+ @tmpdir = Dir.mktmpdir("rom_overrides_test")
12
+ @json = File.join(@tmpdir, "rom_overrides.json")
13
+ @boxart = File.join(@tmpdir, "boxart")
14
+ # Point Config.boxart_dir at our tmpdir so copies land there
15
+ @orig_env = ENV['GEMBA_CONFIG_DIR']
16
+ ENV['GEMBA_CONFIG_DIR'] = @tmpdir
17
+ end
18
+
19
+ def teardown
20
+ ENV['GEMBA_CONFIG_DIR'] = @orig_env
21
+ FileUtils.rm_rf(@tmpdir)
22
+ end
23
+
24
+ def test_custom_boxart_returns_nil_when_nothing_set
25
+ overrides = Gemba::RomOverrides.new(@json)
26
+ assert_nil overrides.custom_boxart("AGB_AXVE-DEADBEEF")
27
+ end
28
+
29
+ def test_set_custom_boxart_copies_file_and_returns_dest
30
+ src = File.join(@tmpdir, "cover.png")
31
+ File.write(src, "fake png")
32
+
33
+ overrides = Gemba::RomOverrides.new(@json)
34
+ dest = overrides.set_custom_boxart("AGB_AXVE-DEADBEEF", src)
35
+
36
+ assert File.exist?(dest), "Copied file should exist at dest"
37
+ assert_equal "fake png", File.read(dest)
38
+ assert_match %r{/AGB_AXVE-DEADBEEF/custom\.png$}, dest
39
+ end
40
+
41
+ def test_set_custom_boxart_persists_across_reload
42
+ src = File.join(@tmpdir, "cover.png")
43
+ File.write(src, "fake png")
44
+
45
+ Gemba::RomOverrides.new(@json).set_custom_boxart("AGB_AXVE-DEADBEEF", src)
46
+
47
+ reloaded = Gemba::RomOverrides.new(@json)
48
+ stored = reloaded.custom_boxart("AGB_AXVE-DEADBEEF")
49
+ refute_nil stored
50
+ assert File.exist?(stored)
51
+ end
52
+
53
+ def test_set_custom_boxart_preserves_extension
54
+ src = File.join(@tmpdir, "cover.jpg")
55
+ File.write(src, "fake jpg")
56
+
57
+ overrides = Gemba::RomOverrides.new(@json)
58
+ dest = overrides.set_custom_boxart("AGB_AXVE-DEADBEEF", src)
59
+
60
+ assert dest.end_with?(".jpg"), "Extension should be preserved"
61
+ end
62
+
63
+ def test_multiple_rom_ids_stored_independently
64
+ src1 = File.join(@tmpdir, "a.png"); File.write(src1, "a")
65
+ src2 = File.join(@tmpdir, "b.png"); File.write(src2, "b")
66
+
67
+ overrides = Gemba::RomOverrides.new(@json)
68
+ overrides.set_custom_boxart("AGB_AXVE-AAAAAAAA", src1)
69
+ overrides.set_custom_boxart("AGB_BPEE-BBBBBBBB", src2)
70
+
71
+ assert_match %r{AAAAAAAA}, overrides.custom_boxart("AGB_AXVE-AAAAAAAA")
72
+ assert_match %r{BBBBBBBB}, overrides.custom_boxart("AGB_BPEE-BBBBBBBB")
73
+ assert_nil overrides.custom_boxart("AGB_ZZZZ-ZZZZZZZZ")
74
+ end
75
+
76
+ def test_json_file_is_valid_json
77
+ src = File.join(@tmpdir, "cover.png")
78
+ File.write(src, "fake")
79
+
80
+ Gemba::RomOverrides.new(@json).set_custom_boxart("AGB_AXVE-DEADBEEF", src)
81
+
82
+ parsed = JSON.parse(File.read(@json))
83
+ assert_instance_of Hash, parsed
84
+ assert parsed.key?("AGB_AXVE-DEADBEEF")
85
+ end
86
+ end