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
@@ -16,7 +16,8 @@ class TestMGBAPlayer < Minitest::Test
16
16
  require "gemba"
17
17
  require "support/player_helpers"
18
18
 
19
- player = Gemba::Player.new("#{TEST_ROM}")
19
+ player = Gemba::AppController.new("#{TEST_ROM}")
20
+ player.disable_confirmations!
20
21
  app = player.app
21
22
 
22
23
  poll_until_ready(player) { player.running = false }
@@ -33,6 +34,122 @@ class TestMGBAPlayer < Minitest::Test
33
34
  assert success, "Player should exit cleanly with ROM loaded (no hang)\n#{output.join("\n")}"
34
35
  end
35
36
 
37
+ # Quit hotkey works without a ROM loaded (no viewport/SDL2).
38
+ def test_quit_hotkey_without_rom
39
+ code = <<~RUBY
40
+ require "gemba"
41
+
42
+ player = Gemba::AppController.new
43
+ player.disable_confirmations!
44
+ app = player.app
45
+
46
+ app.after(100) do
47
+ app.tcl_eval("focus -force .")
48
+ app.command(:event, 'generate', '.', '<KeyPress>', keysym: 'q')
49
+ end
50
+
51
+ player.run
52
+ puts "PASS"
53
+ RUBY
54
+
55
+ success, stdout, stderr, _status = tk_subprocess(code)
56
+
57
+ output = []
58
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
59
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
60
+
61
+ assert success, "Quit hotkey should work without ROM loaded\n#{output.join("\n")}"
62
+ assert_includes stdout, "PASS"
63
+ end
64
+
65
+ # View > Game Library returns to picker after loading a ROM.
66
+ def test_view_menu_game_library_returns_to_picker
67
+ code = <<~'RUBY'.sub('ROM_PATH', TEST_ROM)
68
+ require "gemba"
69
+ require "support/player_helpers"
70
+
71
+ player = Gemba::AppController.new("ROM_PATH")
72
+ player.disable_confirmations!
73
+ app = player.app
74
+
75
+ poll_until(app, timeout_ms: 5_000,
76
+ condition: -> { app.tcl_eval('.menubar.view entrycget 0 -state').strip == 'normal' },
77
+ label: "View menu entry never became enabled") do
78
+ puts "BEFORE=#{player.current_view}"
79
+ app.tcl_eval('.menubar.view invoke 0')
80
+ app.after(100) do
81
+ puts "AFTER=#{player.current_view}"
82
+ player.running = false
83
+ end
84
+ end
85
+
86
+ player.run
87
+ RUBY
88
+
89
+ success, stdout, stderr, _status = tk_subprocess(code)
90
+
91
+ output = []
92
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
93
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
94
+
95
+ assert success, "View > Game Library should return to picker\n#{output.join("\n")}"
96
+ assert_includes stdout, "BEFORE=emulator"
97
+ assert_includes stdout, "AFTER=picker"
98
+ end
99
+
100
+ # File > Quit menu item exits cleanly (invokes the actual menu command).
101
+ def test_file_menu_quit_without_rom
102
+ code = <<~RUBY
103
+ require "gemba"
104
+
105
+ player = Gemba::AppController.new
106
+ app = player.app
107
+
108
+ app.after(100) do
109
+ app.command('.menubar.file', 'invoke', 'last')
110
+ end
111
+
112
+ player.run
113
+ puts "PASS"
114
+ RUBY
115
+
116
+ success, stdout, stderr, _status = tk_subprocess(code)
117
+
118
+ output = []
119
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
120
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
121
+
122
+ assert success, "File > Quit should exit cleanly\n#{output.join("\n")}"
123
+ assert_includes stdout, "PASS"
124
+ end
125
+
126
+ # Escape key quits without a ROM loaded.
127
+ def test_escape_quits_without_rom
128
+ code = <<~RUBY
129
+ require "gemba"
130
+
131
+ player = Gemba::AppController.new
132
+ app = player.app
133
+
134
+ app.after(100) do
135
+ app.tcl_eval("focus -force .")
136
+ app.command(:event, 'generate', '.', '<KeyPress>', keysym: 'Escape')
137
+ end
138
+
139
+ player.run
140
+ puts "PASS"
141
+ RUBY
142
+
143
+ success, stdout, stderr, _status = tk_subprocess(code)
144
+
145
+ output = []
146
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
147
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
148
+
149
+ assert success, "Escape should quit without ROM loaded\n#{output.join("\n")}"
150
+ assert_includes stdout, "PASS"
151
+ end
152
+
36
153
  # Simulate a user pressing F11 twice (fullscreen on → off) then q to quit.
37
154
  # Exercises the wm attributes fullscreen path end-to-end. If the toggle
38
155
  # causes a hang or crash the subprocess will time out.
@@ -41,11 +158,12 @@ class TestMGBAPlayer < Minitest::Test
41
158
  require "gemba"
42
159
  require "support/player_helpers"
43
160
 
44
- player = Gemba::Player.new("#{TEST_ROM}")
161
+ player = Gemba::AppController.new("#{TEST_ROM}")
162
+ player.disable_confirmations!
45
163
  app = player.app
46
164
 
47
165
  poll_until_ready(player) do
48
- vp = player.viewport
166
+ vp = player.frame.viewport
49
167
  frame = vp.frame.path
50
168
 
51
169
  # User presses F11 → fullscreen on
@@ -58,8 +176,8 @@ class TestMGBAPlayer < Minitest::Test
58
176
  app.update
59
177
 
60
178
  app.after(50) do
61
- # User presses q quit
62
- app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'q')
179
+ # Trigger quit via virtual event — no focus dependency
180
+ app.command(:event, 'generate', '.', '<<Quit>>')
63
181
  end
64
182
  end
65
183
  end
@@ -85,11 +203,12 @@ class TestMGBAPlayer < Minitest::Test
85
203
  require "gemba"
86
204
  require "support/player_helpers"
87
205
 
88
- player = Gemba::Player.new("#{TEST_ROM}")
206
+ player = Gemba::AppController.new("#{TEST_ROM}")
207
+ player.disable_confirmations!
89
208
  app = player.app
90
209
 
91
210
  poll_until_ready(player) do
92
- vp = player.viewport
211
+ vp = player.frame.viewport
93
212
  frame = vp.frame.path
94
213
 
95
214
  # User presses Tab → enable turbo (2x default)
@@ -97,8 +216,8 @@ class TestMGBAPlayer < Minitest::Test
97
216
  app.update
98
217
 
99
218
  app.after(50) do
100
- # User presses q quit (while still in turbo)
101
- app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'q')
219
+ # Trigger quit via virtual event no focus dependency
220
+ app.command(:event, 'generate', '.', '<<Quit>>')
102
221
  end
103
222
  end
104
223
 
@@ -118,8 +237,6 @@ class TestMGBAPlayer < Minitest::Test
118
237
  # Verifies state file + screenshot are created, backup rotation works,
119
238
  # and the core remains functional after load.
120
239
  def test_quick_save_and_load_creates_files_and_restores_state
121
- skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
122
-
123
240
  code = <<~RUBY
124
241
  require "gemba"
125
242
  require "tmpdir"
@@ -129,7 +246,7 @@ class TestMGBAPlayer < Minitest::Test
129
246
  # Use a temp dir for all config/states so we don't pollute the real one
130
247
  states_dir = Dir.mktmpdir("gemba-states-test")
131
248
 
132
- player = Gemba::Player.new("#{TEST_ROM}")
249
+ player = Gemba::AppController.new("#{TEST_ROM}")
133
250
  app = player.app
134
251
  config = player.config
135
252
 
@@ -138,13 +255,12 @@ class TestMGBAPlayer < Minitest::Test
138
255
  config.save_state_debounce = 0.1
139
256
 
140
257
  poll_until_ready(player) do
141
- core = player.save_mgr.core
142
- state_dir = player.save_mgr.state_dir
143
- vp = player.viewport
144
- frame_path = vp.frame.path
145
-
146
- # Quick save (F5)
147
- app.command(:event, 'generate', frame_path, '<KeyPress>', keysym: 'F5')
258
+ core = player.frame.save_mgr.core
259
+ # Recompute state_dir after overriding config.states_dir
260
+ player.frame.save_mgr.state_dir = player.frame.save_mgr.state_dir_for_rom(core)
261
+ state_dir = player.frame.save_mgr.state_dir
262
+ # Quick save
263
+ app.command(:event, 'generate', '.', '<<QuickSave>>')
148
264
  app.update
149
265
 
150
266
  app.after(50) do
@@ -168,7 +284,7 @@ class TestMGBAPlayer < Minitest::Test
168
284
 
169
285
  # Save again to test backup rotation (after debounce)
170
286
  app.after(50) do
171
- app.command(:event, 'generate', frame_path, '<KeyPress>', keysym: 'F5')
287
+ app.command(:event, 'generate', '.', '<<QuickSave>>')
172
288
  app.update
173
289
 
174
290
  app.after(50) do
@@ -185,8 +301,8 @@ class TestMGBAPlayer < Minitest::Test
185
301
  exit 1
186
302
  end
187
303
 
188
- # Quick load (F8)
189
- app.command(:event, 'generate', frame_path, '<KeyPress>', keysym: 'F8')
304
+ # Quick load
305
+ app.command(:event, 'generate', '.', '<<QuickLoad>>')
190
306
  app.update
191
307
 
192
308
  app.after(50) do
@@ -241,8 +357,6 @@ class TestMGBAPlayer < Minitest::Test
241
357
 
242
358
  # E2E: verify debounce blocks rapid-fire saves.
243
359
  def test_quick_save_debounce_blocks_rapid_fire
244
- skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
245
-
246
360
  code = <<~RUBY
247
361
  require "gemba"
248
362
  require "tmpdir"
@@ -251,7 +365,7 @@ class TestMGBAPlayer < Minitest::Test
251
365
 
252
366
  states_dir = Dir.mktmpdir("gemba-debounce-test")
253
367
 
254
- player = Gemba::Player.new("#{TEST_ROM}")
368
+ player = Gemba::AppController.new("#{TEST_ROM}")
255
369
  app = player.app
256
370
  config = player.config
257
371
 
@@ -259,22 +373,22 @@ class TestMGBAPlayer < Minitest::Test
259
373
  config.save_state_debounce = 5.0 # long debounce
260
374
 
261
375
  poll_until_ready(player) do
262
- vp = player.viewport
263
- frame_path = vp.frame.path
376
+ # Recompute state_dir after overriding config.states_dir
377
+ player.frame.save_mgr.state_dir = player.frame.save_mgr.state_dir_for_rom(player.frame.save_mgr.core)
264
378
 
265
379
  # First save should succeed
266
- app.command(:event, 'generate', frame_path, '<KeyPress>', keysym: 'F5')
380
+ app.command(:event, 'generate', '.', '<<QuickSave>>')
267
381
  app.update
268
382
 
269
383
  app.after(50) do
270
- state_dir = player.save_mgr.state_dir
384
+ state_dir = player.frame.save_mgr.state_dir
271
385
  ss_path = File.join(state_dir, "state1.ss")
272
386
 
273
387
  first_exists = File.exist?(ss_path)
274
388
  first_mtime = first_exists ? File.mtime(ss_path) : nil
275
389
 
276
390
  # Immediate second save should be debounced (within 5s window)
277
- app.command(:event, 'generate', frame_path, '<KeyPress>', keysym: 'F5')
391
+ app.command(:event, 'generate', '.', '<<QuickSave>>')
278
392
  app.update
279
393
 
280
394
  app.after(50) do
@@ -313,8 +427,6 @@ class TestMGBAPlayer < Minitest::Test
313
427
  # E2E: open Settings via menu, navigate to Save States tab,
314
428
  # change quick save slot from 1 → 10, click Save, verify persisted.
315
429
  def test_settings_change_quick_slot_and_save
316
- skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
317
-
318
430
  code = <<~RUBY
319
431
  require "gemba"
320
432
  require "tmpdir"
@@ -325,7 +437,7 @@ class TestMGBAPlayer < Minitest::Test
325
437
  config_dir = Dir.mktmpdir("gemba-settings-test")
326
438
  config_path = File.join(config_dir, "settings.json")
327
439
 
328
- player = Gemba::Player.new("#{TEST_ROM}")
440
+ player = Gemba::AppController.new("#{TEST_ROM}")
329
441
  app = player.app
330
442
  config = player.config
331
443
 
@@ -397,11 +509,11 @@ class TestMGBAPlayer < Minitest::Test
397
509
  # -- Audio fade ramp (pure function, no Tk/SDL2 needed) --------------------
398
510
 
399
511
  def test_fade_ramp_attenuates_first_samples
400
- require "gemba/player"
512
+ require "gemba/headless"
401
513
  # 10 stereo frames of max-amplitude int16
402
514
  pcm = ([32767, 32767] * 10).pack('s*')
403
515
  total = 10
404
- result, remaining = Gemba::Player.apply_fade_ramp(pcm, total, total)
516
+ result, remaining = Gemba::EmulatorFrame.apply_fade_ramp(pcm, total, total)
405
517
  samples = result.unpack('s*')
406
518
 
407
519
  # First stereo pair: gain = 1 - 10/10 = 0.0 → should be 0
@@ -416,17 +528,17 @@ class TestMGBAPlayer < Minitest::Test
416
528
  end
417
529
 
418
530
  def test_fade_ramp_returns_remaining_when_pcm_shorter_than_fade
419
- require "gemba/player"
531
+ require "gemba/headless"
420
532
  # Only 2 stereo frames but fade wants 10
421
533
  pcm = ([20000, 20000] * 2).pack('s*')
422
- _result, remaining = Gemba::Player.apply_fade_ramp(pcm, 10, 10)
534
+ _result, remaining = Gemba::EmulatorFrame.apply_fade_ramp(pcm, 10, 10)
423
535
  assert_equal 8, remaining, "should have 8 fade samples remaining"
424
536
  end
425
537
 
426
538
  def test_fade_ramp_noop_when_remaining_zero
427
- require "gemba/player"
539
+ require "gemba/headless"
428
540
  pcm = ([10000, -10000] * 4).pack('s*')
429
- result, remaining = Gemba::Player.apply_fade_ramp(pcm, 0, 10)
541
+ result, remaining = Gemba::EmulatorFrame.apply_fade_ramp(pcm, 0, 10)
430
542
  assert_equal pcm, result, "should not modify samples when remaining is 0"
431
543
  assert_equal 0, remaining
432
544
  end
@@ -436,8 +548,6 @@ class TestMGBAPlayer < Minitest::Test
436
548
  # closes Settings, opens picker via F6, tries Settings menu (blocked),
437
549
  # closes picker. Checks window visibility via `wm state`.
438
550
  def test_modal_child_blocks_concurrent_windows
439
- skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
440
-
441
551
  code = <<~RUBY
442
552
  require "gemba"
443
553
  require "support/player_helpers"
@@ -445,11 +555,11 @@ class TestMGBAPlayer < Minitest::Test
445
555
  sw_top = Gemba::SettingsWindow::TOP
446
556
  sp_top = Gemba::SaveStatePicker::TOP
447
557
 
448
- player = Gemba::Player.new("#{TEST_ROM}")
558
+ player = Gemba::AppController.new("#{TEST_ROM}")
449
559
  app = player.app
450
560
 
451
561
  poll_until_ready(player) do
452
- vp = player.viewport
562
+ vp = player.frame.viewport
453
563
  frame = vp.frame.path
454
564
 
455
565
  # 1. Open Settings via menu (Settings > Video = index 0)
@@ -539,13 +649,11 @@ class TestMGBAPlayer < Minitest::Test
539
649
  # -- File drop (DND) --------------------------------------------------------
540
650
 
541
651
  def test_drop_rom_file_loads_game
542
- skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
543
-
544
652
  code = <<~RUBY
545
653
  require "gemba"
546
654
  require "support/player_helpers"
547
655
 
548
- player = Gemba::Player.new
656
+ player = Gemba::AppController.new
549
657
  app = player.app
550
658
 
551
659
  # Stub tk_messageBox so it never blocks
@@ -557,7 +665,7 @@ class TestMGBAPlayer < Minitest::Test
557
665
  app.update
558
666
 
559
667
  app.after(50) do
560
- core = player.core
668
+ core = player.frame.core
561
669
  if core && !core.destroyed?
562
670
  $stdout.puts "TITLE=\#{core.title}"
563
671
  else
@@ -581,13 +689,11 @@ class TestMGBAPlayer < Minitest::Test
581
689
  end
582
690
 
583
691
  def test_drop_unsupported_file_shows_error
584
- skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
585
-
586
692
  code = <<~RUBY
587
693
  require "gemba"
588
694
  require "support/player_helpers"
589
695
 
590
- player = Gemba::Player.new
696
+ player = Gemba::AppController.new
591
697
  app = player.app
592
698
 
593
699
  # Capture tk_messageBox calls instead of blocking
@@ -624,13 +730,11 @@ class TestMGBAPlayer < Minitest::Test
624
730
  end
625
731
 
626
732
  def test_drop_multiple_files_shows_error
627
- skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
628
-
629
733
  code = <<~RUBY
630
734
  require "gemba"
631
735
  require "support/player_helpers"
632
736
 
633
- player = Gemba::Player.new
737
+ player = Gemba::AppController.new
634
738
  app = player.app
635
739
 
636
740
  # Capture tk_messageBox calls instead of blocking
@@ -669,8 +773,6 @@ class TestMGBAPlayer < Minitest::Test
669
773
  # E2E: press F10 to start recording, run a few frames with the red dot
670
774
  # indicator rendering, press F10 to stop, verify .grec file was created.
671
775
  def test_recording_toggle_creates_trec_file
672
- skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
673
-
674
776
  code = <<~RUBY
675
777
  require "gemba"
676
778
  require "tmpdir"
@@ -680,31 +782,26 @@ class TestMGBAPlayer < Minitest::Test
680
782
  rec_dir = Dir.mktmpdir("gemba-rec-test")
681
783
 
682
784
  begin
683
- player = Gemba::Player.new("#{TEST_ROM}")
785
+ player = Gemba::AppController.new("#{TEST_ROM}")
684
786
  app = player.app
685
787
  config = player.config
686
788
  config.recordings_dir = rec_dir
687
789
 
688
790
  poll_until_ready(player) do
689
- vp = player.viewport
690
- frame = vp.frame.path
691
-
692
- # Press F10 → start recording
693
- app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'F10')
791
+ # Start recording via virtual event (no focus needed)
792
+ app.command(:event, 'generate', '.', '<<RecordToggle>>')
694
793
  app.update
695
794
 
696
795
  # Let a few frames render with the recording indicator (red dot)
697
796
  app.after(50) do
698
- unless player.recording?
797
+ unless player.frame.recording?
699
798
  puts "FAIL: recording never started"
700
799
  player.running = false
701
800
  next
702
801
  end
703
802
 
704
- # Refocus needed under xvfb after timer callbacks
705
- app.tcl_eval("focus -force \#{frame}")
706
- # Press F10 → stop recording
707
- app.command(:event, 'generate', frame, '<KeyPress>', keysym: 'F10')
803
+ # Stop recording via virtual event
804
+ app.command(:event, 'generate', '.', '<<RecordToggle>>')
708
805
  app.update
709
806
 
710
807
  app.after(50) do
@@ -738,33 +835,76 @@ class TestMGBAPlayer < Minitest::Test
738
835
  assert_includes stdout, "PASS", "Expected .grec file to be created\n#{output.join("\n")}"
739
836
  end
740
837
 
838
+ # -- Frame stack show/hide ---------------------------------------------------
839
+
840
+ # Loads a ROM, verifies the emulator viewport is visible (packed),
841
+ # then hides it and confirms it's gone. Catches missing show/hide on frames.
842
+ def test_emulator_frame_show_hide_round_trip
843
+ code = <<~RUBY
844
+ require "gemba"
845
+ require "support/player_helpers"
846
+
847
+ player = Gemba::AppController.new("#{TEST_ROM}")
848
+ app = player.app
849
+
850
+ poll_until_ready(player) do
851
+ vp_path = player.frame.viewport.frame.path
852
+
853
+ # Viewport should be visible after ROM load
854
+ info = app.tcl_eval("pack info \#{vp_path}") rescue ""
855
+ abort "FAIL: viewport not packed after load" if info.empty?
856
+
857
+ # Hide the frame, viewport should disappear
858
+ player.frame.hide
859
+ info_after = app.tcl_eval("pack info \#{vp_path}") rescue ""
860
+ abort "FAIL: viewport still packed after hide" unless info_after.empty?
861
+
862
+ # Show it again
863
+ player.frame.show
864
+ info_restored = app.tcl_eval("pack info \#{vp_path}") rescue ""
865
+ abort "FAIL: viewport not packed after show" if info_restored.empty?
866
+
867
+ puts "PASS"
868
+ player.running = false
869
+ end
870
+
871
+ player.run
872
+ RUBY
873
+
874
+ success, stdout, stderr, _status = tk_subprocess(code)
875
+
876
+ output = []
877
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
878
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
879
+
880
+ assert success, "Frame show/hide round-trip failed\n#{output.join("\n")}"
881
+ assert_includes stdout, "PASS"
882
+ end
883
+
741
884
  # -- Pause CPU optimization (thread_timer_ms) --------------------------------
742
885
 
743
886
  def test_event_loop_constants
744
- require "gemba/player"
745
- assert_equal 1, Gemba::Player::EVENT_LOOP_FAST_MS, "fast loop should be 1ms"
746
- assert_equal 50, Gemba::Player::EVENT_LOOP_IDLE_MS, "idle loop should be 50ms"
887
+ require "gemba/headless"
888
+ assert_equal 1, Gemba::AppController::EVENT_LOOP_FAST_MS, "fast loop should be 1ms"
889
+ assert_equal 50, Gemba::AppController::EVENT_LOOP_IDLE_MS, "idle loop should be 50ms"
747
890
  end
748
891
 
749
892
  # E2E: verify thread_timer_ms switches between idle (50ms) and fast (1ms)
750
893
  # when pausing and unpausing the emulator.
751
894
  def test_pause_switches_event_loop_speed
752
- skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
753
- # Flaky under xvfb: SDL2 INPUT_FOCUS is unreliable, causing focus_poll_tick
754
- # to auto-pause and interfere with manual pause/unpause assertions.
755
- skip "Flaky under xvfb (SDL2 INPUT_FOCUS unreliable)" if ENV['CI']
895
+ skip "Flaky under xvfb: SDL2 INPUT_FOCUS is unreliable"
756
896
 
757
897
  code = <<~RUBY
758
898
  require "gemba"
759
899
  require "support/player_helpers"
760
900
 
761
- player = Gemba::Player.new("#{TEST_ROM}")
901
+ player = Gemba::AppController.new("#{TEST_ROM}")
762
902
  app = player.app
763
903
 
764
904
  poll_until_ready(player) do
765
905
  # Wait for focus so focus_poll_tick won't interfere with pause/unpause
766
906
  poll_until_focused(player) do
767
- vp = player.viewport
907
+ vp = player.frame.viewport
768
908
  frame = vp.frame.path
769
909
 
770
910
  # Before pause: should be fast (1ms) since ROM is running
@@ -795,8 +935,8 @@ class TestMGBAPlayer < Minitest::Test
795
935
  unless ms_resumed == 1
796
936
  xvfb_screenshot("pause_resume_fail")
797
937
  $stderr.puts "FAIL: expected thread_timer_ms=1 after resume, got \#{ms_resumed}"
798
- $stderr.puts "input_focus?=\#{player.viewport.renderer.input_focus?}"
799
- $stderr.puts "paused=\#{player.instance_variable_get(:@paused)}"
938
+ $stderr.puts "input_focus?=\#{player.frame.viewport.renderer.input_focus?}"
939
+ $stderr.puts "paused=\#{player.frame.paused?}"
800
940
  exit 1
801
941
  end
802
942
 
@@ -825,20 +965,17 @@ class TestMGBAPlayer < Minitest::Test
825
965
  # E2E: verify focus loss pauses emulation and focus regain resumes it.
826
966
  # Uses thread_timer_ms as a proxy for paused state (50=idle/paused, 1=fast/running).
827
967
  def test_pause_on_focus_loss
828
- skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
829
- # Flaky under xvfb: SDL2 INPUT_FOCUS is unreliable, so the window may
830
- # never report having focus — making focus-loss detection untestable.
831
- skip "Flaky under xvfb (SDL2 INPUT_FOCUS unreliable)" if ENV['CI']
968
+ skip "Flaky under xvfb: SDL2 INPUT_FOCUS is unreliable"
832
969
 
833
970
  code = <<~RUBY
834
971
  require "gemba"
835
972
  require "support/player_helpers"
836
973
 
837
- player = Gemba::Player.new("#{TEST_ROM}")
974
+ player = Gemba::AppController.new("#{TEST_ROM}")
838
975
  app = player.app
839
976
 
840
977
  poll_until_ready(player) do
841
- renderer = player.viewport.renderer
978
+ renderer = player.frame.viewport.renderer
842
979
 
843
980
  # Ensure the window has focus before testing focus *loss*
844
981
  poll_until_focused(player) do
@@ -871,8 +1008,8 @@ class TestMGBAPlayer < Minitest::Test
871
1008
  # production (Tk's mainloop pumps Cocoa) but is untested in CI.
872
1009
  renderer.show_window
873
1010
  renderer.raise_window
874
- app.command(:event, 'generate', player.viewport.frame.path, '<KeyPress>', keysym: 'p')
875
- app.command(:event, 'generate', player.viewport.frame.path, '<KeyRelease>', keysym: 'p')
1011
+ app.command(:event, 'generate', player.frame.viewport.frame.path, '<KeyPress>', keysym: 'p')
1012
+ app.command(:event, 'generate', player.frame.viewport.frame.path, '<KeyRelease>', keysym: 'p')
876
1013
 
877
1014
  app.after(100) do
878
1015
  ms_regained = app.interp.thread_timer_ms
@@ -900,4 +1037,135 @@ class TestMGBAPlayer < Minitest::Test
900
1037
  assert success, "Pause on focus loss test failed\n#{output.join("\n")}"
901
1038
  assert_includes stdout, "PASS", "Expected PASS in output\n#{output.join("\n")}"
902
1039
  end
1040
+
1041
+ # Regression: on Linux/Windows the window may not have focus at startup,
1042
+ # causing focus_poll_tick to immediately pause the emulator. Loading a ROM
1043
+ # should always start in a running (unpaused) state regardless of focus.
1044
+ def test_rom_does_not_start_paused
1045
+ code = <<~RUBY
1046
+ require "gemba"
1047
+ require "support/player_helpers"
1048
+
1049
+ player = Gemba::AppController.new("#{TEST_ROM}")
1050
+ app = player.app
1051
+
1052
+ poll_until_ready(player) do
1053
+ # Give focus poll a chance to fire (polls every 200ms)
1054
+ app.after(400) do
1055
+ ms = app.interp.thread_timer_ms
1056
+ paused = player.frame.paused?
1057
+ if paused
1058
+ $stderr.puts "FAIL: ROM started paused (thread_timer_ms=\#{ms})"
1059
+ exit 1
1060
+ end
1061
+ $stdout.puts "PASS"
1062
+ player.running = false
1063
+ end
1064
+ end
1065
+
1066
+ player.run
1067
+ RUBY
1068
+
1069
+ success, stdout, stderr, _status = tk_subprocess(code)
1070
+
1071
+ output = []
1072
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
1073
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
1074
+
1075
+ assert success, "ROM started paused\n#{output.join("\n")}"
1076
+ assert_includes stdout, "PASS", "Expected PASS in output\n#{output.join("\n")}"
1077
+ end
1078
+
1079
+ def test_question_hotkey_shows_help_window
1080
+ code = <<~RUBY
1081
+ require "gemba"
1082
+
1083
+ player = Gemba::AppController.new
1084
+ app = player.app
1085
+
1086
+ app.after(100) do
1087
+ app.command(:event, 'generate', '.', '<<ToggleHelpWindow>>')
1088
+ app.update
1089
+ state = app.tcl_eval("wm state .help_window")
1090
+ puts state == 'normal' ? "PASS" : "FAIL: help window not visible (state=\#{state})"
1091
+ player.running = false
1092
+ end
1093
+
1094
+ player.run
1095
+ RUBY
1096
+
1097
+ success, stdout, stderr, _status = tk_subprocess(code)
1098
+
1099
+ output = []
1100
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
1101
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
1102
+
1103
+ assert success, "? hotkey should show help window\n#{output.join("\n")}"
1104
+ assert_includes stdout, "PASS", "Expected PASS in output\n#{output.join("\n")}"
1105
+ end
1106
+
1107
+ def test_question_hotkey_toggles_help_window
1108
+ code = <<~RUBY
1109
+ require "gemba"
1110
+
1111
+ player = Gemba::AppController.new
1112
+ app = player.app
1113
+
1114
+ app.after(100) do
1115
+ # First press — show
1116
+ app.command(:event, 'generate', '.', '<<ToggleHelpWindow>>')
1117
+ app.update
1118
+ # Second press — hide
1119
+ app.command(:event, 'generate', '.', '<<ToggleHelpWindow>>')
1120
+ app.update
1121
+ state = app.tcl_eval("wm state .help_window")
1122
+ puts state == 'withdrawn' ? "PASS" : "FAIL: help window still visible after second ? press (state=\#{state})"
1123
+ player.running = false
1124
+ end
1125
+
1126
+ player.run
1127
+ RUBY
1128
+
1129
+ success, stdout, stderr, _status = tk_subprocess(code)
1130
+
1131
+ output = []
1132
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
1133
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
1134
+
1135
+ assert success, "second ? press should hide help window\n#{output.join("\n")}"
1136
+ assert_includes stdout, "PASS", "Expected PASS in output\n#{output.join("\n")}"
1137
+ end
1138
+
1139
+ def test_help_window_hidden_in_fullscreen
1140
+ code = <<~RUBY
1141
+ require "gemba"
1142
+
1143
+ player = Gemba::AppController.new
1144
+ app = player.app
1145
+
1146
+ app.after(100) do
1147
+ # Go fullscreen via bus
1148
+ Gemba.bus.emit(:request_fullscreen)
1149
+ app.update
1150
+ # Toggle help — should be suppressed in fullscreen
1151
+ app.command(:event, 'generate', '.', '<<ToggleHelpWindow>>')
1152
+ app.update
1153
+ exists = app.tcl_eval("winfo exists .help_window")
1154
+ state = exists == '1' ? app.tcl_eval("wm state .help_window") : 'withdrawn'
1155
+ puts state != 'normal' ? "PASS" : "FAIL: help window visible in fullscreen"
1156
+ player.running = false
1157
+ end
1158
+
1159
+ player.run
1160
+ RUBY
1161
+
1162
+ success, stdout, stderr, _status = tk_subprocess(code)
1163
+
1164
+ output = []
1165
+ output << "STDOUT:\n#{stdout}" unless stdout.empty?
1166
+ output << "STDERR:\n#{stderr}" unless stderr.empty?
1167
+
1168
+ assert success, "? hotkey should be suppressed in fullscreen\n#{output.join("\n")}"
1169
+ assert_includes stdout, "PASS", "Expected PASS in output\n#{output.join("\n")}"
1170
+ end
903
1171
  end