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
@@ -49,6 +49,52 @@ module TeekTestHelper
49
49
  # Default timeout for subprocess tests (can be overridden via TK_TEST_TIMEOUT env var)
50
50
  DEFAULT_SUBPROCESS_TIMEOUT = 5
51
51
 
52
+ # Tcl bgerror capture preamble — prepended to subprocess code so that
53
+ # any unhandled Tcl background error is written to a log file instead
54
+ # of vanishing into the void. tk_subprocess reads the log after the
55
+ # subprocess exits and appends it to stderr.
56
+ BGERROR_PREAMBLE = <<~'RUBY'
57
+ require 'teek'
58
+ module BgerrorCapture
59
+ def initialize(*)
60
+ super
61
+ if (path = ENV['GEMBA_BGERROR_LOG'])
62
+ tcl_eval(%Q{
63
+ proc bgerror {msg} {
64
+ set fd [open {#{path}} a]
65
+ puts $fd "bgerror: $msg"
66
+ if {[info exists ::errorInfo]} {
67
+ puts $fd $::errorInfo
68
+ }
69
+ puts $fd "---"
70
+ close $fd
71
+ }
72
+ })
73
+ end
74
+ end
75
+
76
+ # Wrap after() to capture the full Ruby backtrace before Teek converts
77
+ # the exception to a Tcl error (at which point the backtrace is lost).
78
+ def after(ms, on_error: :raise, &block)
79
+ path = ENV['GEMBA_BGERROR_LOG']
80
+ return super unless path
81
+ super(ms, on_error: on_error) do
82
+ begin
83
+ block.call
84
+ rescue => e
85
+ File.open(path, 'a') do |f|
86
+ f.puts "Ruby exception in after(#{ms}ms): #{e.class}: #{e.message}"
87
+ f.puts e.backtrace.join("\n")
88
+ f.puts "---"
89
+ end
90
+ raise
91
+ end
92
+ end
93
+ end
94
+ end
95
+ Teek::App.prepend(BgerrorCapture)
96
+ RUBY
97
+
52
98
  def tk_subprocess(code, coverage: true, timeout: nil)
53
99
  timeout ||= Integer(ENV['TK_TEST_TIMEOUT'] || DEFAULT_SUBPROCESS_TIMEOUT)
54
100
 
@@ -57,18 +103,31 @@ module TeekTestHelper
57
103
  load_paths = $LOAD_PATH.select { |p| p.start_with?(project_root) }
58
104
  load_path_args = load_paths.flat_map { |p| ["-I", p] }
59
105
 
60
- # Prepend SimpleCov setup for coverage merging
61
- full_code = coverage ? "#{TeekTestHelper.simplecov_preamble}\n#{code}" : code
106
+ # Temp file for capturing Tcl bgerror output
107
+ require 'tempfile'
108
+ bgerror_file = Tempfile.new(['bgerror', '.log'])
109
+ bgerror_path = bgerror_file.path
110
+ bgerror_file.close
111
+
112
+ # Prepend SimpleCov + bgerror capture
113
+ preamble = coverage ? "#{TeekTestHelper.simplecov_preamble}\n" : ""
114
+ full_code = "#{preamble}#{BGERROR_PREAMBLE}\n#{code}"
115
+
116
+ # Write code to temp file so backtraces show real line numbers
117
+ code_file = Tempfile.new(['tk_test', '.rb'])
118
+ code_file.write(full_code)
119
+ code_file.close
62
120
 
63
121
  # Pass env vars to subprocess
64
122
  env = {}
65
123
  env['VISUAL'] = '1' if ENV['VISUAL']
66
124
  env['COVERAGE'] = '1' if ENV['COVERAGE']
125
+ env['GEMBA_BGERROR_LOG'] = bgerror_path
67
126
 
68
127
  # -rbundler/setup activates Bundler in the subprocess so path: gems
69
128
  # (e.g. teek, teek-sdl2 from sibling repos) are on the load path.
70
129
  stdin, stdout, stderr, wait_thr = Open3.popen3(
71
- env, RbConfig.ruby, "-rbundler/setup", *load_path_args, "-e", full_code
130
+ env, RbConfig.ruby, "-rbundler/setup", *load_path_args, code_file.path
72
131
  )
73
132
  stdin.close
74
133
 
@@ -84,6 +143,10 @@ module TeekTestHelper
84
143
  end
85
144
  end
86
145
 
146
+ # Read bgerror log before cleanup
147
+ bgerrors = File.read(bgerror_path).strip rescue ""
148
+ File.delete(bgerror_path) rescue nil
149
+
87
150
  unless status
88
151
  # Timed out - capture any output before killing so errors are visible
89
152
  out = stdout.read_nonblock(64 * 1024) rescue ""
@@ -92,7 +155,10 @@ module TeekTestHelper
92
155
  wait_thr.join
93
156
  stdout.close
94
157
  stderr.close
95
- return [false, out, "Test timed out after #{timeout}s\n#{err}", nil]
158
+ err = "Test timed out after #{timeout}s\n#{err}"
159
+ err = "#{err}\nTcl bgerror log:\n#{bgerrors}" unless bgerrors.empty?
160
+ code_file.unlink rescue nil
161
+ return [false, out, err, nil]
96
162
  end
97
163
 
98
164
  out = stdout.read
@@ -100,6 +166,10 @@ module TeekTestHelper
100
166
  stdout.close
101
167
  stderr.close
102
168
 
169
+ # Append bgerrors to stderr so they surface in test output
170
+ err = "#{err}\nTcl bgerror log:\n#{bgerrors}" unless bgerrors.empty?
171
+ code_file.unlink rescue nil
172
+
103
173
  [status.success?, out, err, status]
104
174
  end
105
175
 
@@ -283,6 +353,23 @@ module TeekTestHelper
283
353
  # The test code has access to `app` (a Teek::App instance) and minitest assertions.
284
354
  # Do NOT create your own TkRoot or call root.destroy - worker manages this.
285
355
  #
356
+ # == event generate gotcha: mouse button events ==
357
+ #
358
+ # The TestWorker calls `app.hide` (wm withdraw .) after every test.
359
+ # `event generate <Button-N>` silently does nothing when the root window is
360
+ # withdrawn because widgets are not mapped/viewable without a visible ancestor.
361
+ # Always call `app.show` + `app.update` before generating mouse button events:
362
+ #
363
+ # picker.show
364
+ # app.show # ← required: deiconify root so widgets are viewable
365
+ # app.update # ← let Tk map all windows
366
+ # app.tcl_eval("event generate .widget <Button-3> -x 10 -y 10")
367
+ # app.update
368
+ #
369
+ # Key events additionally require focus:
370
+ # app.tcl_eval("focus -force .widget")
371
+ # app.update
372
+ #
286
373
  # Example:
287
374
  # def test_something
288
375
  # assert_tk_app("should work") do
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minimal config stand-in for AchievementsWindow tests.
4
+ # The window only reads ra_unofficial? from config at build time.
5
+ FakeConfig = Struct.new(:ra_unofficial) {
6
+ def ra_unofficial? = ra_unofficial
7
+ } unless defined?(FakeConfig)
8
+
9
+ # Build a plain rom hash suitable for RomLibrary stubbing.
10
+ def make_rom_entry(id:, title:, platform: 'gba', game_code: 'AGB-TEST', md5: "#{id}abcd")
11
+ { 'rom_id' => id, 'title' => title, 'platform' => platform,
12
+ 'game_code' => game_code, 'path' => "/games/#{id}.gba", 'md5' => md5 }
13
+ end unless respond_to?(:make_rom_entry, true)
14
+
15
+ # Wrap an array of rom hashes in a minimal RomLibrary-compatible struct.
16
+ def make_rom_library(*roms)
17
+ Struct.new(:roms) { def all = roms }.new(roms)
18
+ end unless respond_to?(:make_rom_library, true)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minimal Core stub — no mGBA dependency, just a programmable memory map.
4
+ # Used by achievement backend tests to simulate bus reads without a real ROM.
5
+ class FakeCore
6
+ def initialize
7
+ @mem = Hash.new(0)
8
+ end
9
+
10
+ # Write a byte into the fake memory map.
11
+ # @param address [Integer] GBA address
12
+ # @param value [Integer] 0..255
13
+ def poke(address, value)
14
+ @mem[address] = value & 0xFF
15
+ end
16
+
17
+ # Reads back what was poked (or 0 for anything not explicitly written).
18
+ def bus_read8(address)
19
+ @mem[address]
20
+ end
21
+
22
+ def destroyed?
23
+ false
24
+ end
25
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Test double for Gemba::RARuntime (the C extension).
4
+ #
5
+ # Mirrors the full RARuntime interface so RetroAchievements::Backend can be
6
+ # tested without a real rcheevos instance or ROM memory.
7
+ #
8
+ # Usage:
9
+ # rt = FakeRARuntime.new
10
+ # rt.queue_triggers("101", "102") # do_frame will return these once
11
+ # rt.rp_message = "Playing Stage 1" # get_richpresence returns this
12
+ # rt.rp_activate_result = false # activate_richpresence returns false
13
+ class FakeRARuntime
14
+ attr_reader :activated, :deactivated, :cleared, :reset_count
15
+ attr_reader :rp_script
16
+ attr_accessor :rp_message, :rp_activate_result
17
+
18
+ def initialize
19
+ @activated = {} # id => memaddr
20
+ @deactivated = []
21
+ @trigger_queue = [] # Array<Array<String>> — one entry consumed per do_frame
22
+ @cleared = false
23
+ @reset_count = 0
24
+ @rp_script = nil
25
+ @rp_message = nil
26
+ @rp_activate_result = true
27
+ end
28
+
29
+ # Queue one frame's worth of triggered achievement IDs.
30
+ # Each call adds one "frame": the next do_frame call pops and returns it.
31
+ def queue_triggers(*ids)
32
+ @trigger_queue << ids.flatten.map(&:to_s)
33
+ end
34
+
35
+ # -- RARuntime interface ----------------------------------------------------
36
+
37
+ def activate(id, memaddr)
38
+ @activated[id.to_s] = memaddr.to_s
39
+ end
40
+
41
+ def deactivate(id)
42
+ @deactivated << id.to_s
43
+ @activated.delete(id.to_s)
44
+ end
45
+
46
+ def reset_all
47
+ @reset_count += 1
48
+ end
49
+
50
+ def clear
51
+ @activated.clear
52
+ @deactivated.clear
53
+ @trigger_queue.clear
54
+ @cleared = true
55
+ end
56
+
57
+ # Returns the next queued batch of triggered IDs, or [] if nothing queued.
58
+ def do_frame(_core)
59
+ @trigger_queue.shift || []
60
+ end
61
+
62
+ def count
63
+ @activated.size
64
+ end
65
+
66
+ def activate_richpresence(script)
67
+ @rp_script = script
68
+ @rp_activate_result
69
+ end
70
+
71
+ def get_richpresence(_core)
72
+ @rp_message
73
+ end
74
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Synchronous test double for the RetroAchievements HTTP transport.
4
+ #
5
+ # Designed to match Teek::BackgroundWork's fluent interface so it can be
6
+ # injected without any branching in ra_request — the same code path runs
7
+ # in production and tests.
8
+ #
9
+ # Usage:
10
+ # req = FakeRequester.new
11
+ # req.stub(r: "login2", body: { "Success" => true, "Token" => "tok" })
12
+ # req.stub(r: "gameid", body: { "GameID" => 42 })
13
+ #
14
+ # backend = Backend.new(app: nil, runtime: FakeRARuntime.new, requester: req)
15
+ # backend.login_with_token(username: "u", token: "t")
16
+ # assert backend.authenticated?
17
+ # assert req.requested?("login2")
18
+ class FakeRequester
19
+ # Returned by call() — mirrors BackgroundWork's fluent on_progress/on_done chain.
20
+ # on_progress fires synchronously with the canned result so callers behave
21
+ # identically to the async production path without needing an event loop.
22
+ class Result
23
+ def initialize(value)
24
+ @value = value
25
+ end
26
+
27
+ def on_progress(&block)
28
+ block.call(@value)
29
+ self
30
+ end
31
+
32
+ def on_done(&block)
33
+ self
34
+ end
35
+ end
36
+
37
+ attr_reader :requests
38
+
39
+ def initialize
40
+ @stubs = {} # r_string => [json_or_nil, ok_bool]
41
+ @requests = [] # all params hashes, in call order
42
+ end
43
+
44
+ # Register a canned response for a given r= value.
45
+ def stub(r:, body: nil, ok: true)
46
+ @stubs[r.to_s] = [body, ok]
47
+ end
48
+
49
+ # Called by ra_request with the same signature as Teek::BackgroundWork.new.
50
+ # Ignores the block (which contains real Net::HTTP code) and returns a
51
+ # Result that fires on_progress synchronously with the canned response.
52
+ def call(_app, params, mode: nil, **_opts, &_block)
53
+ @requests << params.dup
54
+ r = (params[:r] || params["r"]).to_s
55
+ result = @stubs.fetch(r, [nil, false])
56
+ Result.new(result)
57
+ end
58
+
59
+ # True if at least one request with the given r= value was made.
60
+ def requested?(r)
61
+ @requests.any? { |p| (p[:r] || p["r"]).to_s == r.to_s }
62
+ end
63
+
64
+ # All params hashes for requests with the given r= value.
65
+ def requests_for(r)
66
+ @requests.select { |p| (p[:r] || p["r"]).to_s == r.to_s }
67
+ end
68
+ end
@@ -36,7 +36,7 @@ end
36
36
  # Falls back to xdotool to force focus if polling alone doesn't work.
37
37
  def poll_until_focused(player, timeout_ms: 2_000, &block)
38
38
  app = player.app
39
- renderer = player.viewport.renderer
39
+ renderer = player.frame.viewport.renderer
40
40
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_ms / 1000.0
41
41
  tried_xdotool = false
42
42
  check = proc do
@@ -60,14 +60,21 @@ def poll_until_focused(player, timeout_ms: 2_000, &block)
60
60
  app.after(50, &check)
61
61
  end
62
62
 
63
- def poll_until_ready(player, timeout_ms: 5_000, &block)
64
- app = player.app
63
+ # Polls every 50ms until the given condition block returns truthy, then
64
+ # yields the action block. Aborts with exit 1 if the deadline is exceeded.
65
+ #
66
+ # Example — wait for a menu entry to become enabled:
67
+ # poll_until(app, timeout_ms: 3_000,
68
+ # condition: -> { app.tcl_eval('...entrycget 0 -state').strip == 'normal' }) do
69
+ # app.command(...)
70
+ # end
71
+ def poll_until(app, timeout_ms: 5_000, condition:, label: "condition", &block)
65
72
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_ms / 1000.0
66
73
  check = proc do
67
- if player.ready?
74
+ if condition.call
68
75
  block.call
69
76
  elsif Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
70
- $stderr.puts "FAIL: Player not ready within #{timeout_ms}ms"
77
+ $stderr.puts "FAIL: #{label} not true within #{timeout_ms}ms"
71
78
  exit 1
72
79
  else
73
80
  app.after(50, &check)
@@ -75,3 +82,11 @@ def poll_until_ready(player, timeout_ms: 5_000, &block)
75
82
  end
76
83
  app.after(50, &check)
77
84
  end
85
+
86
+ def poll_until_ready(player, timeout_ms: 5_000, &block)
87
+ poll_until(player.app, timeout_ms: timeout_ms,
88
+ condition: -> { player.ready? },
89
+ label: "Player not ready") do
90
+ block.call
91
+ end
92
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "gemba/headless"
5
+ require "gemba/achievements"
6
+
7
+ class TestAchievement < Minitest::Test
8
+ def test_unearned_by_default
9
+ ach = Gemba::Achievements::Achievement.new(
10
+ id: 'x', title: 'T', description: 'D', points: 10, earned_at: nil
11
+ )
12
+ refute ach.earned?
13
+ assert_nil ach.earned_at
14
+ end
15
+
16
+ def test_earn_returns_copy_with_timestamp
17
+ ach = Gemba::Achievements::Achievement.new(
18
+ id: 'x', title: 'T', description: 'D', points: 10, earned_at: nil
19
+ )
20
+ earned = ach.earn
21
+ assert earned.earned?
22
+ assert_instance_of Time, earned.earned_at
23
+ end
24
+
25
+ def test_earn_does_not_mutate_original
26
+ ach = Gemba::Achievements::Achievement.new(
27
+ id: 'x', title: 'T', description: 'D', points: 10, earned_at: nil
28
+ )
29
+ ach.earn
30
+ refute ach.earned?
31
+ end
32
+ end