gemba 0.1.1 → 0.2.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 (285) 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 +186 -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 +453 -0
  17. data/lib/gemba/achievements/retro_achievements/cli_sync_requester.rb +64 -0
  18. data/lib/gemba/achievements/retro_achievements/ping_worker.rb +27 -0
  19. data/lib/gemba/achievements.rb +19 -0
  20. data/lib/gemba/achievements_window.rb +556 -0
  21. data/lib/gemba/app_controller.rb +1015 -0
  22. data/lib/gemba/bios.rb +54 -0
  23. data/lib/gemba/boxart_fetcher/libretro_backend.rb +39 -0
  24. data/lib/gemba/boxart_fetcher/null_backend.rb +12 -0
  25. data/lib/gemba/boxart_fetcher.rb +79 -0
  26. data/lib/gemba/bus_emitter.rb +13 -0
  27. data/lib/gemba/child_window.rb +24 -1
  28. data/lib/gemba/cli/commands/config_cmd.rb +83 -0
  29. data/lib/gemba/cli/commands/decode.rb +154 -0
  30. data/lib/gemba/cli/commands/patch.rb +78 -0
  31. data/lib/gemba/cli/commands/play.rb +78 -0
  32. data/lib/gemba/cli/commands/record.rb +114 -0
  33. data/lib/gemba/cli/commands/replay.rb +161 -0
  34. data/lib/gemba/cli/commands/retro_achievements.rb +213 -0
  35. data/lib/gemba/cli/commands/version.rb +22 -0
  36. data/lib/gemba/cli.rb +52 -364
  37. data/lib/gemba/config.rb +134 -1
  38. data/lib/gemba/data/gb_games.json +1 -0
  39. data/lib/gemba/data/gb_md5.json +1 -0
  40. data/lib/gemba/data/gba_games.json +1 -0
  41. data/lib/gemba/data/gba_md5.json +1 -0
  42. data/lib/gemba/data/gbc_games.json +1 -0
  43. data/lib/gemba/data/gbc_md5.json +1 -0
  44. data/lib/gemba/emulator_frame.rb +1060 -0
  45. data/lib/gemba/event_bus.rb +48 -0
  46. data/lib/gemba/frame_stack.rb +60 -0
  47. data/lib/gemba/game_index.rb +84 -0
  48. data/lib/gemba/game_picker_frame.rb +268 -0
  49. data/lib/gemba/gamepad_map.rb +103 -0
  50. data/lib/gemba/headless.rb +6 -5
  51. data/lib/gemba/headless_player.rb +33 -3
  52. data/lib/gemba/help_window.rb +61 -0
  53. data/lib/gemba/hotkey_map.rb +3 -1
  54. data/lib/gemba/input_recorder.rb +107 -0
  55. data/lib/gemba/input_replayer.rb +119 -0
  56. data/lib/gemba/keyboard_map.rb +90 -0
  57. data/lib/gemba/locales/en.yml +97 -5
  58. data/lib/gemba/locales/ja.yml +97 -5
  59. data/lib/gemba/main_window.rb +56 -0
  60. data/lib/gemba/modal_stack.rb +81 -0
  61. data/lib/gemba/patcher_window.rb +223 -0
  62. data/lib/gemba/platform/gb.rb +21 -0
  63. data/lib/gemba/platform/gba.rb +21 -0
  64. data/lib/gemba/platform/gbc.rb +23 -0
  65. data/lib/gemba/platform.rb +20 -0
  66. data/lib/gemba/platform_open.rb +19 -0
  67. data/lib/gemba/recorder.rb +4 -3
  68. data/lib/gemba/replay_player.rb +691 -0
  69. data/lib/gemba/rom_info.rb +57 -0
  70. data/lib/gemba/rom_info_window.rb +16 -3
  71. data/lib/gemba/rom_library.rb +106 -0
  72. data/lib/gemba/rom_overrides.rb +47 -0
  73. data/lib/gemba/rom_patcher/bps.rb +161 -0
  74. data/lib/gemba/rom_patcher/ips.rb +101 -0
  75. data/lib/gemba/rom_patcher/ups.rb +118 -0
  76. data/lib/gemba/rom_patcher.rb +109 -0
  77. data/lib/gemba/{rom_loader.rb → rom_resolver.rb} +7 -6
  78. data/lib/gemba/runtime.rb +59 -26
  79. data/lib/gemba/save_state_manager.rb +4 -7
  80. data/lib/gemba/save_state_picker.rb +17 -4
  81. data/lib/gemba/session_logger.rb +64 -0
  82. data/lib/gemba/settings/audio_tab.rb +77 -0
  83. data/lib/gemba/settings/gamepad_tab.rb +351 -0
  84. data/lib/gemba/settings/hotkeys_tab.rb +259 -0
  85. data/lib/gemba/settings/paths.rb +11 -0
  86. data/lib/gemba/settings/recording_tab.rb +83 -0
  87. data/lib/gemba/settings/save_states_tab.rb +91 -0
  88. data/lib/gemba/settings/system_tab.rb +362 -0
  89. data/lib/gemba/settings/video_tab.rb +318 -0
  90. data/lib/gemba/settings_window.rb +162 -1036
  91. data/lib/gemba/version.rb +1 -1
  92. data/lib/gemba/virtual_keyboard.rb +19 -0
  93. data/lib/gemba.rb +2 -12
  94. data/test/achievements_window/test_bulk_sync.rb +218 -0
  95. data/test/achievements_window/test_bus_events.rb +125 -0
  96. data/test/achievements_window/test_close_confirmation.rb +201 -0
  97. data/test/achievements_window/test_initial_state.rb +164 -0
  98. data/test/achievements_window/test_sorting.rb +227 -0
  99. data/test/achievements_window/test_tree_rendering.rb +133 -0
  100. data/test/fixtures/fake_bios.bin +0 -0
  101. data/test/fixtures/pong.gba +0 -0
  102. data/test/fixtures/test.gb +0 -0
  103. data/test/fixtures/test.gbc +0 -0
  104. data/test/fixtures/test_quicksave.ss +0 -0
  105. data/test/screenshots/no_focus.png +0 -0
  106. data/test/shared/teek_test_worker.rb +17 -1
  107. data/test/shared/tk_test_helper.rb +91 -4
  108. data/test/support/achievements_window_helpers.rb +18 -0
  109. data/test/support/fake_core.rb +25 -0
  110. data/test/support/fake_ra_runtime.rb +74 -0
  111. data/test/support/fake_requester.rb +68 -0
  112. data/test/support/player_helpers.rb +20 -5
  113. data/test/test_achievement.rb +32 -0
  114. data/test/{test_player.rb → test_app_controller.rb} +353 -85
  115. data/test/test_bios.rb +123 -0
  116. data/test/test_boxart_fetcher.rb +150 -0
  117. data/test/test_cli.rb +17 -265
  118. data/test/test_cli_config.rb +64 -0
  119. data/test/test_cli_decode.rb +97 -0
  120. data/test/test_cli_patch.rb +58 -0
  121. data/test/test_cli_play.rb +213 -0
  122. data/test/test_cli_ra.rb +175 -0
  123. data/test/test_cli_record.rb +69 -0
  124. data/test/test_cli_replay.rb +72 -0
  125. data/test/test_cli_sync_requester.rb +152 -0
  126. data/test/test_cli_version.rb +27 -0
  127. data/test/test_config.rb +2 -3
  128. data/test/test_config_ra.rb +69 -0
  129. data/test/test_core.rb +62 -1
  130. data/test/test_credentials_presenter.rb +192 -0
  131. data/test/test_event_bus.rb +100 -0
  132. data/test/test_fake_backend_achievements.rb +130 -0
  133. data/test/test_fake_backend_auth.rb +68 -0
  134. data/test/test_game_index.rb +77 -0
  135. data/test/test_game_picker_frame.rb +310 -0
  136. data/test/test_gamepad_map.rb +1 -3
  137. data/test/test_headless_player.rb +17 -3
  138. data/test/test_help_window.rb +82 -0
  139. data/test/test_hotkey_map.rb +22 -1
  140. data/test/test_input_recorder.rb +179 -0
  141. data/test/test_input_replay_determinism.rb +113 -0
  142. data/test/test_input_replayer.rb +162 -0
  143. data/test/test_keyboard_map.rb +1 -3
  144. data/test/test_libretro_backend.rb +41 -0
  145. data/test/test_locale.rb +1 -1
  146. data/test/test_logging.rb +123 -0
  147. data/test/test_null_backend.rb +42 -0
  148. data/test/test_offline_backend.rb +116 -0
  149. data/test/test_overlay_renderer.rb +1 -1
  150. data/test/test_platform.rb +149 -0
  151. data/test/test_ra_backend.rb +313 -0
  152. data/test/test_ra_backend_unlock_gate.rb +56 -0
  153. data/test/test_recorder.rb +0 -3
  154. data/test/test_replay_player.rb +316 -0
  155. data/test/test_rom_info.rb +149 -0
  156. data/test/test_rom_overrides.rb +86 -0
  157. data/test/test_rom_patcher.rb +382 -0
  158. data/test/{test_rom_loader.rb → test_rom_resolver.rb} +25 -26
  159. data/test/test_save_state_manager.rb +2 -4
  160. data/test/test_settings_audio.rb +107 -0
  161. data/test/test_settings_hotkeys.rb +83 -66
  162. data/test/test_settings_recording.rb +49 -0
  163. data/test/test_settings_save_states.rb +97 -0
  164. data/test/test_settings_system.rb +133 -0
  165. data/test/test_settings_video.rb +450 -0
  166. data/test/test_settings_window.rb +76 -507
  167. data/test/test_tip_service.rb +6 -6
  168. data/test/test_toast_overlay.rb +1 -1
  169. data/test/test_virtual_events.rb +156 -0
  170. data/test/test_virtual_keyboard.rb +1 -1
  171. data/vendor/rcheevos/CHANGELOG.md +495 -0
  172. data/vendor/rcheevos/LICENSE +21 -0
  173. data/vendor/rcheevos/Package.swift +33 -0
  174. data/vendor/rcheevos/README.md +67 -0
  175. data/vendor/rcheevos/include/module.modulemap +70 -0
  176. data/vendor/rcheevos/include/rc_api_editor.h +296 -0
  177. data/vendor/rcheevos/include/rc_api_info.h +280 -0
  178. data/vendor/rcheevos/include/rc_api_request.h +77 -0
  179. data/vendor/rcheevos/include/rc_api_runtime.h +417 -0
  180. data/vendor/rcheevos/include/rc_api_user.h +262 -0
  181. data/vendor/rcheevos/include/rc_client.h +877 -0
  182. data/vendor/rcheevos/include/rc_client_raintegration.h +101 -0
  183. data/vendor/rcheevos/include/rc_consoles.h +138 -0
  184. data/vendor/rcheevos/include/rc_error.h +59 -0
  185. data/vendor/rcheevos/include/rc_export.h +100 -0
  186. data/vendor/rcheevos/include/rc_hash.h +200 -0
  187. data/vendor/rcheevos/include/rc_runtime.h +148 -0
  188. data/vendor/rcheevos/include/rc_runtime_types.h +452 -0
  189. data/vendor/rcheevos/include/rc_util.h +51 -0
  190. data/vendor/rcheevos/include/rcheevos.h +8 -0
  191. data/vendor/rcheevos/src/rapi/rc_api_common.c +1379 -0
  192. data/vendor/rcheevos/src/rapi/rc_api_common.h +88 -0
  193. data/vendor/rcheevos/src/rapi/rc_api_editor.c +625 -0
  194. data/vendor/rcheevos/src/rapi/rc_api_info.c +587 -0
  195. data/vendor/rcheevos/src/rapi/rc_api_runtime.c +901 -0
  196. data/vendor/rcheevos/src/rapi/rc_api_user.c +483 -0
  197. data/vendor/rcheevos/src/rc_client.c +6941 -0
  198. data/vendor/rcheevos/src/rc_client_external.c +281 -0
  199. data/vendor/rcheevos/src/rc_client_external.h +177 -0
  200. data/vendor/rcheevos/src/rc_client_external_versions.h +171 -0
  201. data/vendor/rcheevos/src/rc_client_internal.h +409 -0
  202. data/vendor/rcheevos/src/rc_client_raintegration.c +566 -0
  203. data/vendor/rcheevos/src/rc_client_raintegration_internal.h +61 -0
  204. data/vendor/rcheevos/src/rc_client_types.natvis +396 -0
  205. data/vendor/rcheevos/src/rc_compat.c +251 -0
  206. data/vendor/rcheevos/src/rc_compat.h +121 -0
  207. data/vendor/rcheevos/src/rc_libretro.c +915 -0
  208. data/vendor/rcheevos/src/rc_libretro.h +98 -0
  209. data/vendor/rcheevos/src/rc_util.c +199 -0
  210. data/vendor/rcheevos/src/rc_version.c +11 -0
  211. data/vendor/rcheevos/src/rc_version.h +32 -0
  212. data/vendor/rcheevos/src/rcheevos/alloc.c +312 -0
  213. data/vendor/rcheevos/src/rcheevos/condition.c +754 -0
  214. data/vendor/rcheevos/src/rcheevos/condset.c +777 -0
  215. data/vendor/rcheevos/src/rcheevos/consoleinfo.c +1215 -0
  216. data/vendor/rcheevos/src/rcheevos/format.c +330 -0
  217. data/vendor/rcheevos/src/rcheevos/lboard.c +287 -0
  218. data/vendor/rcheevos/src/rcheevos/memref.c +805 -0
  219. data/vendor/rcheevos/src/rcheevos/operand.c +607 -0
  220. data/vendor/rcheevos/src/rcheevos/rc_internal.h +390 -0
  221. data/vendor/rcheevos/src/rcheevos/rc_runtime_types.natvis +541 -0
  222. data/vendor/rcheevos/src/rcheevos/rc_validate.c +1406 -0
  223. data/vendor/rcheevos/src/rcheevos/rc_validate.h +18 -0
  224. data/vendor/rcheevos/src/rcheevos/richpresence.c +922 -0
  225. data/vendor/rcheevos/src/rcheevos/runtime.c +852 -0
  226. data/vendor/rcheevos/src/rcheevos/runtime_progress.c +1073 -0
  227. data/vendor/rcheevos/src/rcheevos/trigger.c +344 -0
  228. data/vendor/rcheevos/src/rcheevos/value.c +935 -0
  229. data/vendor/rcheevos/src/rhash/aes.c +480 -0
  230. data/vendor/rcheevos/src/rhash/aes.h +49 -0
  231. data/vendor/rcheevos/src/rhash/cdreader.c +838 -0
  232. data/vendor/rcheevos/src/rhash/hash.c +1402 -0
  233. data/vendor/rcheevos/src/rhash/hash_disc.c +1340 -0
  234. data/vendor/rcheevos/src/rhash/hash_encrypted.c +566 -0
  235. data/vendor/rcheevos/src/rhash/hash_rom.c +426 -0
  236. data/vendor/rcheevos/src/rhash/hash_zip.c +460 -0
  237. data/vendor/rcheevos/src/rhash/md5.c +382 -0
  238. data/vendor/rcheevos/src/rhash/md5.h +91 -0
  239. data/vendor/rcheevos/src/rhash/rc_hash_internal.h +116 -0
  240. data/vendor/rcheevos/test/libretro.h +205 -0
  241. data/vendor/rcheevos/test/rapi/test_rc_api_common.c +941 -0
  242. data/vendor/rcheevos/test/rapi/test_rc_api_editor.c +931 -0
  243. data/vendor/rcheevos/test/rapi/test_rc_api_info.c +545 -0
  244. data/vendor/rcheevos/test/rapi/test_rc_api_runtime.c +2213 -0
  245. data/vendor/rcheevos/test/rapi/test_rc_api_user.c +998 -0
  246. data/vendor/rcheevos/test/rcheevos/mock_memory.h +32 -0
  247. data/vendor/rcheevos/test/rcheevos/test_condition.c +570 -0
  248. data/vendor/rcheevos/test/rcheevos/test_condset.c +5170 -0
  249. data/vendor/rcheevos/test/rcheevos/test_consoleinfo.c +203 -0
  250. data/vendor/rcheevos/test/rcheevos/test_format.c +112 -0
  251. data/vendor/rcheevos/test/rcheevos/test_lboard.c +746 -0
  252. data/vendor/rcheevos/test/rcheevos/test_memref.c +520 -0
  253. data/vendor/rcheevos/test/rcheevos/test_operand.c +692 -0
  254. data/vendor/rcheevos/test/rcheevos/test_rc_validate.c +502 -0
  255. data/vendor/rcheevos/test/rcheevos/test_richpresence.c +1564 -0
  256. data/vendor/rcheevos/test/rcheevos/test_runtime.c +1667 -0
  257. data/vendor/rcheevos/test/rcheevos/test_runtime_progress.c +1821 -0
  258. data/vendor/rcheevos/test/rcheevos/test_timing.c +166 -0
  259. data/vendor/rcheevos/test/rcheevos/test_trigger.c +2521 -0
  260. data/vendor/rcheevos/test/rcheevos/test_value.c +870 -0
  261. data/vendor/rcheevos/test/rcheevos-test.sln +46 -0
  262. data/vendor/rcheevos/test/rcheevos-test.vcxproj +239 -0
  263. data/vendor/rcheevos/test/rcheevos-test.vcxproj.filters +335 -0
  264. data/vendor/rcheevos/test/rhash/data.c +657 -0
  265. data/vendor/rcheevos/test/rhash/data.h +32 -0
  266. data/vendor/rcheevos/test/rhash/mock_filereader.c +236 -0
  267. data/vendor/rcheevos/test/rhash/mock_filereader.h +31 -0
  268. data/vendor/rcheevos/test/rhash/test_cdreader.c +920 -0
  269. data/vendor/rcheevos/test/rhash/test_hash.c +310 -0
  270. data/vendor/rcheevos/test/rhash/test_hash_disc.c +1450 -0
  271. data/vendor/rcheevos/test/rhash/test_hash_rom.c +899 -0
  272. data/vendor/rcheevos/test/rhash/test_hash_zip.c +551 -0
  273. data/vendor/rcheevos/test/test.c +113 -0
  274. data/vendor/rcheevos/test/test_framework.h +205 -0
  275. data/vendor/rcheevos/test/test_rc_client.c +10509 -0
  276. data/vendor/rcheevos/test/test_rc_client_external.c +2197 -0
  277. data/vendor/rcheevos/test/test_rc_client_raintegration.c +441 -0
  278. data/vendor/rcheevos/test/test_rc_libretro.c +952 -0
  279. data/vendor/rcheevos/test/test_types.natvis +9 -0
  280. data/vendor/rcheevos/validator/validator.c +658 -0
  281. data/vendor/rcheevos/validator/validator.vcxproj +152 -0
  282. data/vendor/rcheevos/validator/validator.vcxproj.filters +82 -0
  283. metadata +274 -11
  284. data/lib/gemba/input_mappings.rb +0 -214
  285. data/lib/gemba/player.rb +0 -1525
@@ -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
@@ -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?
@@ -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