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,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "gemba/headless"
5
+
6
+ class TestPlatform < Minitest::Test
7
+ # -- Factory ---------------------------------------------------------------
8
+
9
+ def test_for_gba
10
+ core = MockCore.new("GBA")
11
+ platform = Gemba::Platform.for(core)
12
+ assert_instance_of Gemba::Platform::GBA, platform
13
+ end
14
+
15
+ def test_for_gb
16
+ core = MockCore.new("GB")
17
+ platform = Gemba::Platform.for(core)
18
+ assert_instance_of Gemba::Platform::GB, platform
19
+ end
20
+
21
+ def test_for_gbc
22
+ core = MockCore.new("GBC")
23
+ platform = Gemba::Platform.for(core)
24
+ assert_instance_of Gemba::Platform::GBC, platform
25
+ end
26
+
27
+ def test_for_unknown_defaults_to_gb
28
+ core = MockCore.new("Unknown")
29
+ platform = Gemba::Platform.for(core)
30
+ assert_instance_of Gemba::Platform::GB, platform
31
+ end
32
+
33
+ def test_default_is_gba
34
+ platform = Gemba::Platform.default
35
+ assert_instance_of Gemba::Platform::GBA, platform
36
+ end
37
+
38
+ # -- GBA -------------------------------------------------------------------
39
+
40
+ def test_gba_resolution
41
+ p = Gemba::Platform::GBA.new
42
+ assert_equal 240, p.width
43
+ assert_equal 160, p.height
44
+ end
45
+
46
+ def test_gba_fps
47
+ p = Gemba::Platform::GBA.new
48
+ assert_in_delta 59.7272, p.fps, 0.001
49
+ end
50
+
51
+ def test_gba_fps_fraction
52
+ num, den = Gemba::Platform::GBA.new.fps_fraction
53
+ assert_in_delta 59.7272, num.to_f / den, 0.001
54
+ end
55
+
56
+ def test_gba_aspect
57
+ assert_equal [3, 2], Gemba::Platform::GBA.new.aspect
58
+ end
59
+
60
+ def test_gba_name
61
+ assert_equal "Game Boy Advance", Gemba::Platform::GBA.new.name
62
+ assert_equal "GBA", Gemba::Platform::GBA.new.short_name
63
+ end
64
+
65
+ def test_gba_buttons_include_lr
66
+ buttons = Gemba::Platform::GBA.new.buttons
67
+ assert_includes buttons, :l
68
+ assert_includes buttons, :r
69
+ assert_equal 10, buttons.size
70
+ end
71
+
72
+ def test_gba_thumb_size
73
+ assert_equal [120, 80], Gemba::Platform::GBA.new.thumb_size
74
+ end
75
+
76
+ # -- GB --------------------------------------------------------------------
77
+
78
+ def test_gb_resolution
79
+ p = Gemba::Platform::GB.new
80
+ assert_equal 160, p.width
81
+ assert_equal 144, p.height
82
+ end
83
+
84
+ def test_gb_fps
85
+ assert_in_delta 59.7275, Gemba::Platform::GB.new.fps, 0.001
86
+ end
87
+
88
+ def test_gb_fps_fraction
89
+ num, den = Gemba::Platform::GB.new.fps_fraction
90
+ assert_in_delta 59.7275, num.to_f / den, 0.001
91
+ end
92
+
93
+ def test_gb_aspect
94
+ assert_equal [10, 9], Gemba::Platform::GB.new.aspect
95
+ end
96
+
97
+ def test_gb_name
98
+ assert_equal "Game Boy", Gemba::Platform::GB.new.name
99
+ assert_equal "GB", Gemba::Platform::GB.new.short_name
100
+ end
101
+
102
+ def test_gb_buttons_no_lr
103
+ buttons = Gemba::Platform::GB.new.buttons
104
+ refute_includes buttons, :l
105
+ refute_includes buttons, :r
106
+ assert_equal 8, buttons.size
107
+ end
108
+
109
+ def test_gb_thumb_size
110
+ assert_equal [80, 72], Gemba::Platform::GB.new.thumb_size
111
+ end
112
+
113
+ # -- GBC -------------------------------------------------------------------
114
+
115
+ def test_gbc_resolution_same_as_gb
116
+ p = Gemba::Platform::GBC.new
117
+ assert_equal 160, p.width
118
+ assert_equal 144, p.height
119
+ end
120
+
121
+ def test_gbc_name_differs_from_gb
122
+ assert_equal "Game Boy Color", Gemba::Platform::GBC.new.name
123
+ assert_equal "GBC", Gemba::Platform::GBC.new.short_name
124
+ end
125
+
126
+ def test_gbc_buttons_no_lr
127
+ buttons = Gemba::Platform::GBC.new.buttons
128
+ refute_includes buttons, :l
129
+ refute_includes buttons, :r
130
+ end
131
+
132
+ # -- Equality --------------------------------------------------------------
133
+
134
+ def test_same_platform_equal
135
+ assert_equal Gemba::Platform::GBA.new, Gemba::Platform::GBA.new
136
+ assert_equal Gemba::Platform::GB.new, Gemba::Platform::GB.new
137
+ assert_equal Gemba::Platform::GBC.new, Gemba::Platform::GBC.new
138
+ end
139
+
140
+ def test_different_platforms_not_equal
141
+ refute_equal Gemba::Platform::GBA.new, Gemba::Platform::GB.new
142
+ refute_equal Gemba::Platform::GBA.new, Gemba::Platform::GBC.new
143
+ refute_equal Gemba::Platform::GB.new, Gemba::Platform::GBC.new
144
+ end
145
+
146
+ private
147
+
148
+ MockCore = Struct.new(:platform)
149
+ end
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "gemba/headless"
5
+ require_relative "support/fake_ra_runtime"
6
+ require_relative "support/fake_requester"
7
+ require_relative "support/fake_core"
8
+
9
+ PATCH_RESPONSE = {
10
+ "PatchData" => {
11
+ "RichPresencePatch" => "",
12
+ "Achievements" => [
13
+ { "ID" => 101, "Title" => "First Blood", "Description" => "Get a kill",
14
+ "Points" => 5, "MemAddr" => "0=1", "Flags" => 3 },
15
+ { "ID" => 102, "Title" => "Survivor", "Description" => "Survive 60s",
16
+ "Points" => 10, "MemAddr" => "1=1", "Flags" => 3 },
17
+ ],
18
+ },
19
+ }.freeze
20
+
21
+ # Tests for Gemba::Achievements::RetroAchievements::Backend.
22
+ #
23
+ # FakeRequester replaces BackgroundWork so all HTTP callbacks fire synchronously
24
+ # in-process — no Tk event loop, no subprocesses, no wait_until.
25
+ class TestRABackend < Minitest::Test
26
+ Backend = Gemba::Achievements::RetroAchievements::Backend
27
+
28
+ def setup
29
+ @rt = FakeRARuntime.new
30
+ @req = FakeRequester.new
31
+ @b = Backend.new(app: nil, runtime: @rt, requester: @req)
32
+ end
33
+
34
+ # Authenticate @b via the real login_with_token path.
35
+ def login(username: "user", token: "tok")
36
+ @req.stub(r: "login2", body: { "Success" => true })
37
+ @b.login_with_token(username: username, token: token)
38
+ end
39
+
40
+ # Drive the full gameid→patch→unlocks chain.
41
+ def load_game(earned_ids: [], patch: PATCH_RESPONSE)
42
+ @req.stub(r: "gameid", body: { "GameID" => 42 })
43
+ @req.stub(r: "patch", body: patch)
44
+ @req.stub(r: "unlocks", body: { "Success" => true, "UserUnlocks" => earned_ids })
45
+ Dir.mktmpdir do |dir|
46
+ rom = File.join(dir, "test.gba")
47
+ File.write(rom, "FAKEGBAROM")
48
+ @b.load_game(nil, rom, "deadbeef" * 4)
49
+ end
50
+ end
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Initial state
54
+ # ---------------------------------------------------------------------------
55
+
56
+ def test_not_authenticated_by_default
57
+ refute @b.authenticated?
58
+ end
59
+
60
+ def test_enabled
61
+ assert @b.enabled?
62
+ end
63
+
64
+ def test_achievement_list_empty_before_game_load
65
+ assert_empty @b.achievement_list
66
+ end
67
+
68
+ def test_rich_presence_message_nil_initially
69
+ assert_nil @b.rich_presence_message
70
+ end
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Authentication
74
+ # ---------------------------------------------------------------------------
75
+
76
+ def test_login_with_password_success
77
+ @req.stub(r: "login2", body: { "Success" => true, "Token" => "tok123" })
78
+ result = nil
79
+ @b.on_auth_change { |status, payload| result = [status, payload] }
80
+ @b.login_with_password(username: "user", password: "hunter2")
81
+
82
+ assert_equal :ok, result[0]
83
+ assert_equal "tok123", result[1]
84
+ assert @b.authenticated?
85
+ end
86
+
87
+ def test_login_with_password_failure
88
+ @req.stub(r: "login2", body: { "Success" => false, "Error" => "Invalid credentials" })
89
+ result = nil
90
+ @b.on_auth_change { |status, msg| result = [status, msg] }
91
+ @b.login_with_password(username: "user", password: "wrong")
92
+
93
+ assert_equal :error, result[0]
94
+ assert_match(/invalid credentials/i, result[1])
95
+ refute @b.authenticated?
96
+ end
97
+
98
+ def test_login_with_token_success
99
+ result = nil
100
+ @b.on_auth_change { |status, _| result = status }
101
+ login
102
+ assert_equal :ok, result
103
+ assert @b.authenticated?
104
+ end
105
+
106
+ def test_login_with_token_failure
107
+ @req.stub(r: "login2", body: { "Success" => false, "Error" => "Token invalid" })
108
+ result = nil
109
+ @b.on_auth_change { |status, msg| result = [status, msg] }
110
+ @b.login_with_token(username: "user", token: "bad")
111
+
112
+ assert_equal :error, result[0]
113
+ refute @b.authenticated?
114
+ end
115
+
116
+ def test_token_test_success
117
+ login
118
+ result = nil
119
+ @b.on_auth_change { |status, _| result = status }
120
+ @b.token_test
121
+ assert_equal :ok, result
122
+ end
123
+
124
+ def test_token_test_failure
125
+ login
126
+ @req.stub(r: "login2", body: { "Success" => false, "Error" => "Token invalid" })
127
+ result = nil
128
+ @b.on_auth_change { |status, _| result = status }
129
+ @b.token_test
130
+ assert_equal :error, result
131
+ refute @b.authenticated?
132
+ end
133
+
134
+ def test_logout_clears_auth_state
135
+ login
136
+ @b.logout
137
+ refute @b.authenticated?
138
+ assert_empty @b.achievement_list
139
+ end
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Game load chain
143
+ # ---------------------------------------------------------------------------
144
+
145
+ def test_load_game_skipped_when_not_authenticated
146
+ load_game
147
+ assert_empty @b.achievement_list
148
+ refute @req.requested?("gameid"), "should not hit network when unauthenticated"
149
+ end
150
+
151
+ def test_load_game_populates_achievement_list
152
+ login
153
+ load_game
154
+
155
+ assert_equal 2, @b.total_count
156
+ assert_equal "101", @b.achievement_list[0].id
157
+ assert_equal "102", @b.achievement_list[1].id
158
+ assert_equal 2, @rt.count
159
+ end
160
+
161
+ def test_load_game_marks_preearned_achievements
162
+ login
163
+ load_game(earned_ids: [101])
164
+
165
+ list = @b.achievement_list
166
+ assert list.find { |a| a.id == "101" }&.earned?, "101 should be earned"
167
+ refute list.find { |a| a.id == "102" }&.earned?, "102 should not be earned"
168
+ assert_includes @rt.deactivated, "101"
169
+ end
170
+
171
+ def test_load_game_aborts_when_game_id_zero
172
+ login
173
+ @req.stub(r: "gameid", body: { "GameID" => 0 })
174
+ Dir.mktmpdir do |dir|
175
+ rom = File.join(dir, "test.gba")
176
+ File.write(rom, "FAKE")
177
+ @b.load_game(nil, rom, "deadbeef" * 4)
178
+ end
179
+
180
+ assert_empty @b.achievement_list
181
+ refute @req.requested?("patch"), "patch must not be requested when GameID is 0"
182
+ end
183
+
184
+ def test_load_game_activates_rich_presence_script
185
+ rp_patch = PATCH_RESPONSE.merge(
186
+ "PatchData" => PATCH_RESPONSE["PatchData"].merge("RichPresencePatch" => "Display: Hello")
187
+ )
188
+ login
189
+ load_game(patch: rp_patch)
190
+
191
+ assert_equal "Display: Hello", @rt.rp_script
192
+ end
193
+
194
+ def test_unload_game_clears_achievement_list
195
+ login
196
+ load_game
197
+ @b.unload_game
198
+ assert_empty @b.achievement_list
199
+ end
200
+
201
+ def test_sync_unlocks_repopulates_list
202
+ login
203
+ load_game
204
+
205
+ # Re-stub for the sync re-fetch
206
+ @req.stub(r: "patch", body: PATCH_RESPONSE)
207
+ @req.stub(r: "unlocks", body: { "Success" => true, "UserUnlocks" => [102] })
208
+ @b.sync_unlocks
209
+
210
+ list = @b.achievement_list
211
+ refute list.find { |a| a.id == "101" }&.earned?
212
+ assert list.find { |a| a.id == "102" }&.earned?
213
+ end
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # do_frame
217
+ # ---------------------------------------------------------------------------
218
+
219
+ def test_do_frame_silent_before_game_loaded
220
+ unlocked = []
221
+ @b.on_unlock { |a| unlocked << a }
222
+ @b.do_frame(FakeCore.new)
223
+ assert_empty unlocked
224
+ end
225
+
226
+ def test_do_frame_fires_unlock_and_submits_to_server
227
+ login
228
+ load_game
229
+
230
+ @req.stub(r: "awardachievement", body: { "Success" => true })
231
+ @rt.queue_triggers("101")
232
+
233
+ unlocked = []
234
+ @b.on_unlock { |a| unlocked << a }
235
+ @b.do_frame(FakeCore.new)
236
+
237
+ assert_equal 1, unlocked.size
238
+ assert_equal "101", unlocked.first.id
239
+ assert @req.requested?("awardachievement")
240
+ assert_equal "101", @req.requests_for("awardachievement").first[:a].to_s
241
+ end
242
+
243
+ def test_do_frame_skips_already_earned_achievement
244
+ login
245
+ load_game(earned_ids: [101])
246
+
247
+ @rt.queue_triggers("101")
248
+ unlocked = []
249
+ @b.on_unlock { |a| unlocked << a }
250
+ @b.do_frame(FakeCore.new)
251
+
252
+ assert_empty unlocked, "already-earned achievement must not fire again"
253
+ refute @req.requested?("awardachievement")
254
+ end
255
+
256
+ def test_do_frame_rich_presence_fires_callback_when_enabled
257
+ login
258
+ load_game
259
+
260
+ @rt.rp_message = "Playing Stage 1"
261
+ @b.rich_presence_enabled = true
262
+ @b.instance_variable_set(:@rp_eval_frame, 239)
263
+
264
+ fired = []
265
+ @b.on_rich_presence_changed { |m| fired << m }
266
+ @b.do_frame(FakeCore.new)
267
+
268
+ assert_equal ["Playing Stage 1"], fired
269
+ assert_equal "Playing Stage 1", @b.rich_presence_message
270
+ end
271
+
272
+ def test_do_frame_rich_presence_silent_when_disabled
273
+ login
274
+ load_game
275
+
276
+ @rt.rp_message = "Playing Stage 1"
277
+ @b.rich_presence_enabled = false
278
+ @b.instance_variable_set(:@rp_eval_frame, 239)
279
+
280
+ fired = []
281
+ @b.on_rich_presence_changed { |m| fired << m }
282
+ @b.do_frame(FakeCore.new)
283
+
284
+ assert_empty fired
285
+ end
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # FakeRARuntime self-tests
289
+ # ---------------------------------------------------------------------------
290
+
291
+ def test_fake_runtime_activate_deactivate
292
+ @rt.activate("101", "0=1")
293
+ @rt.activate("102", "1=1")
294
+ assert_equal 2, @rt.count
295
+ @rt.deactivate("101")
296
+ assert_equal 1, @rt.count
297
+ refute @rt.activated.key?("101")
298
+ end
299
+
300
+ def test_fake_runtime_clear_resets_state
301
+ @rt.activate("101", "0=1")
302
+ @rt.queue_triggers("101")
303
+ @rt.clear
304
+ assert_equal 0, @rt.count
305
+ assert_empty @rt.do_frame(nil)
306
+ end
307
+
308
+ def test_fake_runtime_queue_consumed_once
309
+ @rt.queue_triggers("101")
310
+ assert_equal ["101"], @rt.do_frame(nil)
311
+ assert_empty @rt.do_frame(nil)
312
+ end
313
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "gemba/headless"
5
+ require "gemba/achievements"
6
+ require_relative "support/fake_core"
7
+
8
+ # Tests that RetroAchievements::Backend never awards achievements before the
9
+ # server's earned list is known.
10
+ #
11
+ # The bug scenario:
12
+ # fetch_patch_data completes → runtime activated with 89 achievements
13
+ # emulator starts → do_frame fires → some conditions true at frame 0
14
+ # @earned is empty (r=unlocks still in flight) → all are re-awarded
15
+ #
16
+ # The fix: @achievements stays empty until fetch_unlocks completes.
17
+ # do_frame's `return if @achievements.empty?` guards the window by construction.
18
+ #
19
+ # These tests verify the fix through the public API (no instance_variable_set):
20
+ # if @achievements is empty, do_frame must be silent regardless of what the
21
+ # underlying runtime would report.
22
+ class TestRABackendUnlockGate < Minitest::Test
23
+ def setup
24
+ @backend = Gemba::Achievements::RetroAchievements::Backend.new(app: nil)
25
+ @unlocked = []
26
+ @backend.on_unlock { |ach| @unlocked << ach }
27
+ @core = FakeCore.new
28
+ end
29
+
30
+ # Before any game is loaded, @achievements is empty → do_frame is a no-op.
31
+ def test_do_frame_silent_before_game_loaded
32
+ @backend.do_frame(@core)
33
+ assert_empty @unlocked
34
+ end
35
+
36
+ # @achievements only becomes non-empty after fetch_unlocks completes (HTTP).
37
+ # Since we can't make real HTTP calls, verify the state via achievement_list
38
+ # and total_count — they reflect @achievements.
39
+ def test_achievements_empty_until_unlocks_arrive
40
+ assert_equal 0, @backend.total_count,
41
+ "achievement list must be empty before fetch_unlocks completes"
42
+ assert_empty @backend.achievement_list
43
+ end
44
+
45
+ # do_frame with an empty achievement list never fires unlock callbacks.
46
+ # This is the structural guarantee — as long as @achievements is empty,
47
+ # no award can happen even if the C runtime were somehow active.
48
+ def test_do_frame_never_fires_when_achievement_list_empty
49
+ # Simulate being "mid-load": patch data fetched but unlocks not yet back.
50
+ # In the new design @achievements stays [] during this window.
51
+ 5.times { @backend.do_frame(@core) }
52
+ assert_empty @unlocked,
53
+ "no unlock must fire during the patch→unlocks window"
54
+ assert_equal 0, @backend.earned_count
55
+ end
56
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "gemba/headless"
5
+ require_relative "support/fake_ra_runtime"
6
+ require_relative "support/fake_requester"
7
+ require_relative "support/fake_core"
8
+
9
+ # Tests for the unlock retry queue in RetroAchievements::Backend.
10
+ #
11
+ # FakeRequester fires on_progress synchronously so no event loop is needed.
12
+ # For worker-style calls (drain_unlock_queue) it yields [ok, id] to match
13
+ # what UnlockRetryWorker produces in production.
14
+ class TestRABackendUnlockRetry < Minitest::Test
15
+ Backend = Gemba::Achievements::RetroAchievements::Backend
16
+
17
+ PATCH = {
18
+ "PatchData" => {
19
+ "RichPresencePatch" => "",
20
+ "Achievements" => [
21
+ { "ID" => 101, "Title" => "First Blood", "Description" => "Kill",
22
+ "Points" => 5, "MemAddr" => "0=1", "Flags" => 3 },
23
+ { "ID" => 102, "Title" => "Survivor", "Description" => "Survive",
24
+ "Points" => 10, "MemAddr" => "1=1", "Flags" => 3 },
25
+ ],
26
+ },
27
+ }.freeze
28
+
29
+ def setup
30
+ @rt = FakeRARuntime.new
31
+ @req = FakeRequester.new
32
+ @b = Backend.new(app: nil, runtime: @rt, requester: @req)
33
+ end
34
+
35
+ def login_and_load
36
+ @req.stub(r: "login2", body: { "Success" => true })
37
+ @req.stub(r: "gameid", body: { "GameID" => 42 })
38
+ @req.stub(r: "patch", body: PATCH)
39
+ @req.stub(r: "unlocks", body: { "Success" => true, "UserUnlocks" => [] })
40
+ @b.login_with_token(username: "user", token: "tok")
41
+ Dir.mktmpdir do |dir|
42
+ rom = File.join(dir, "test.gba")
43
+ File.write(rom, "FAKEGBA")
44
+ @b.load_game(nil, rom, "deadbeef" * 4)
45
+ end
46
+ end
47
+
48
+ # -- Queue builds on initial failure ----------------------------------------
49
+
50
+ def test_failed_unlock_enqueues_for_retry
51
+ login_and_load
52
+ @req.stub(r: "awardachievement", ok: false, body: { "Success" => false })
53
+ @rt.queue_triggers("101")
54
+ @b.do_frame(FakeCore.new)
55
+ assert_equal 1, @b.unlock_queue.size
56
+ assert_equal "101", @b.unlock_queue.first[:id]
57
+ end
58
+
59
+ def test_successful_unlock_does_not_enqueue
60
+ login_and_load
61
+ @req.stub(r: "awardachievement", ok: true, body: { "Success" => true })
62
+ @rt.queue_triggers("101")
63
+ @b.do_frame(FakeCore.new)
64
+ assert_empty @b.unlock_queue
65
+ end
66
+
67
+ def test_multiple_failed_unlocks_all_enqueue
68
+ login_and_load
69
+ @req.stub(r: "awardachievement", ok: false, body: { "Success" => false })
70
+ @rt.queue_triggers("101", "102")
71
+ @b.do_frame(FakeCore.new)
72
+ assert_equal 2, @b.unlock_queue.size
73
+ end
74
+
75
+ # -- drain_unlock_queue -----------------------------------------------------
76
+
77
+ def test_drain_sends_retry_request_per_entry
78
+ login_and_load
79
+ @req.stub(r: "awardachievement", ok: true, body: { "Success" => true })
80
+ @b.unlock_queue << { id: "101", hardcore: false }
81
+ @b.unlock_queue << { id: "102", hardcore: false }
82
+ @b.drain_unlock_queue
83
+ assert_equal 2, @req.requests_for("awardachievement").size
84
+ end
85
+
86
+ def test_drain_clears_queue_on_success
87
+ login_and_load
88
+ @req.stub(r: "awardachievement", ok: true, body: { "Success" => true })
89
+ @b.unlock_queue << { id: "101", hardcore: false }
90
+ @b.drain_unlock_queue
91
+ assert_empty @b.unlock_queue
92
+ end
93
+
94
+ def test_drain_keeps_queue_on_failure
95
+ login_and_load
96
+ @req.stub(r: "awardachievement", ok: false, body: { "Success" => false })
97
+ @b.unlock_queue << { id: "101", hardcore: false }
98
+ @b.drain_unlock_queue
99
+ assert_equal 1, @b.unlock_queue.size
100
+ end
101
+
102
+ def test_drain_partial_success_removes_only_succeeded
103
+ login_and_load
104
+ @req.stub_queue(r: "awardachievement", ok: true, body: { "Success" => true })
105
+ @req.stub_queue(r: "awardachievement", ok: false, body: { "Success" => false })
106
+ @b.unlock_queue << { id: "101", hardcore: false }
107
+ @b.unlock_queue << { id: "102", hardcore: false }
108
+ @b.drain_unlock_queue
109
+ assert_equal 1, @b.unlock_queue.size
110
+ assert_equal "102", @b.unlock_queue.first[:id]
111
+ end
112
+
113
+ # -- shutdown ---------------------------------------------------------------
114
+
115
+ def test_shutdown_logs_pending_and_does_not_raise
116
+ @b.unlock_queue << { id: "101", hardcore: false }
117
+ @b.shutdown
118
+ end
119
+
120
+ def test_shutdown_with_empty_queue_does_not_raise
121
+ @b.shutdown
122
+ end
123
+ end
@@ -7,9 +7,6 @@ require "tmpdir"
7
7
  class TestRecorder < Minitest::Test
8
8
  TEST_ROM = File.expand_path("fixtures/test.gba", __dir__)
9
9
 
10
- def setup
11
- skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
12
- end
13
10
 
14
11
  def test_record_and_decode_round_trip
15
12
  skip "ffmpeg not installed" unless ffmpeg_available?