gemba 0.1.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 (65) hide show
  1. checksums.yaml +7 -0
  2. data/THIRD_PARTY_NOTICES +113 -0
  3. data/assets/JetBrainsMonoNL-Regular.ttf +0 -0
  4. data/assets/ark-pixel-12px-monospaced-ja.ttf +0 -0
  5. data/bin/gemba +14 -0
  6. data/ext/gemba/extconf.rb +185 -0
  7. data/ext/gemba/gemba_ext.c +1051 -0
  8. data/ext/gemba/gemba_ext.h +15 -0
  9. data/gemba.gemspec +38 -0
  10. data/lib/gemba/child_window.rb +62 -0
  11. data/lib/gemba/cli.rb +384 -0
  12. data/lib/gemba/config.rb +621 -0
  13. data/lib/gemba/core.rb +121 -0
  14. data/lib/gemba/headless.rb +12 -0
  15. data/lib/gemba/headless_player.rb +206 -0
  16. data/lib/gemba/hotkey_map.rb +202 -0
  17. data/lib/gemba/input_mappings.rb +214 -0
  18. data/lib/gemba/locale.rb +92 -0
  19. data/lib/gemba/locales/en.yml +157 -0
  20. data/lib/gemba/locales/ja.yml +157 -0
  21. data/lib/gemba/method_coverage_service.rb +265 -0
  22. data/lib/gemba/overlay_renderer.rb +109 -0
  23. data/lib/gemba/player.rb +1515 -0
  24. data/lib/gemba/recorder.rb +156 -0
  25. data/lib/gemba/recorder_decoder.rb +325 -0
  26. data/lib/gemba/rom_info_window.rb +346 -0
  27. data/lib/gemba/rom_loader.rb +100 -0
  28. data/lib/gemba/runtime.rb +39 -0
  29. data/lib/gemba/save_state_manager.rb +155 -0
  30. data/lib/gemba/save_state_picker.rb +199 -0
  31. data/lib/gemba/settings_window.rb +1173 -0
  32. data/lib/gemba/tip_service.rb +133 -0
  33. data/lib/gemba/toast_overlay.rb +128 -0
  34. data/lib/gemba/version.rb +5 -0
  35. data/lib/gemba.rb +17 -0
  36. data/test/fixtures/test.gba +0 -0
  37. data/test/fixtures/test.sav +0 -0
  38. data/test/shared/screenshot_helper.rb +113 -0
  39. data/test/shared/simplecov_config.rb +59 -0
  40. data/test/shared/teek_test_worker.rb +388 -0
  41. data/test/shared/tk_test_helper.rb +354 -0
  42. data/test/support/input_mocks.rb +61 -0
  43. data/test/support/player_helpers.rb +77 -0
  44. data/test/test_cli.rb +281 -0
  45. data/test/test_config.rb +897 -0
  46. data/test/test_core.rb +401 -0
  47. data/test/test_gamepad_map.rb +116 -0
  48. data/test/test_headless_player.rb +205 -0
  49. data/test/test_helper.rb +19 -0
  50. data/test/test_hotkey_map.rb +396 -0
  51. data/test/test_keyboard_map.rb +108 -0
  52. data/test/test_locale.rb +159 -0
  53. data/test/test_mgba.rb +26 -0
  54. data/test/test_overlay_renderer.rb +199 -0
  55. data/test/test_player.rb +903 -0
  56. data/test/test_recorder.rb +180 -0
  57. data/test/test_rom_loader.rb +149 -0
  58. data/test/test_save_state_manager.rb +289 -0
  59. data/test/test_settings_hotkeys.rb +434 -0
  60. data/test/test_settings_window.rb +1039 -0
  61. data/test/test_tip_service.rb +138 -0
  62. data/test/test_toast_overlay.rb +216 -0
  63. data/test/test_virtual_keyboard.rb +39 -0
  64. data/test/test_xor_delta.rb +61 -0
  65. metadata +234 -0
@@ -0,0 +1,388 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Persistent Tk worker process for fast test execution.
4
+ #
5
+ # Instead of spawning a new Ruby process for each test this class keeps a single Tk interpreter
6
+ # alive and resets state between tests. Trad-off is dealing with tests/code that mutate
7
+ # global state.
8
+ #
9
+ # Uses pipe-based IPC (no threads) to avoid Tk threading issues.
10
+ #
11
+ # Usage:
12
+ # Teek::TestWorker.start
13
+ # result = Teek::TestWorker.run_test("label = TkLabel.new(root); ...")
14
+ # Teek::TestWorker.stop
15
+
16
+ require 'minitest'
17
+ require 'stringio'
18
+ require 'fileutils'
19
+ require 'tmpdir'
20
+ require 'json'
21
+ require 'open3'
22
+
23
+ module Teek; end
24
+ class Teek::TestWorker
25
+ SOCKET_DIR = File.join(Dir.tmpdir, 'teek_test_worker')
26
+ PID_FILE = File.join(SOCKET_DIR, 'worker.pid')
27
+ READY_FILE = File.join(SOCKET_DIR, 'ready')
28
+
29
+ class << self
30
+ def start
31
+ return if running?
32
+
33
+ FileUtils.mkdir_p(SOCKET_DIR)
34
+ cleanup_stale_files
35
+
36
+ # Build load path args
37
+ load_paths = $LOAD_PATH.select { |p| p.include?(File.dirname(File.dirname(__FILE__))) }
38
+ load_path_args = load_paths.flat_map { |p| ["-I", p] }
39
+
40
+ # Spawn worker with pipes
41
+ @stdin_w, @stdout_r, @stderr_r, @wait_thread = Open3.popen3(
42
+ RbConfig.ruby, *load_path_args, __FILE__, 'server'
43
+ )
44
+
45
+ File.write(PID_FILE, @wait_thread.pid.to_s)
46
+
47
+ # Wait for ready signal
48
+ wait_for_ready
49
+ end
50
+
51
+ def stop
52
+ return unless running?
53
+
54
+ begin
55
+ send_command('shutdown')
56
+ rescue
57
+ # Already dead
58
+ end
59
+
60
+ @stdin_w&.close
61
+ @stdout_r&.close
62
+ @stderr_r&.close
63
+ @wait_thread&.value rescue nil
64
+
65
+ @stdin_w = @stdout_r = @stderr_r = @wait_thread = nil
66
+ cleanup_stale_files
67
+ end
68
+
69
+ # Default timeout for test execution (can be overridden via TK_TEST_TIMEOUT env var)
70
+ DEFAULT_TIMEOUT = 60
71
+
72
+ def run_test(code, pipe_capture: false, timeout: nil, source_file: nil, source_line: nil)
73
+ start unless running?
74
+ timeout ||= Integer(ENV['TK_TEST_TIMEOUT'] || DEFAULT_TIMEOUT)
75
+ send_command('run', { code: code, pipe_capture: pipe_capture,
76
+ source_file: source_file, source_line: source_line }, timeout: timeout)
77
+ end
78
+
79
+ def running?
80
+ @wait_thread&.alive? && @stdin_w && !@stdin_w.closed?
81
+ end
82
+
83
+ private
84
+
85
+ def send_command(cmd, data = nil, timeout: nil)
86
+ msg = JSON.generate({ cmd: cmd, data: data })
87
+ @stdin_w.puts(msg)
88
+ @stdin_w.flush
89
+
90
+ # Wait for response with timeout
91
+ if timeout
92
+ ready = IO.select([@stdout_r], nil, nil, timeout)
93
+ unless ready
94
+ # Kill the worker on timeout
95
+ Process.kill('KILL', @wait_thread.pid) rescue nil
96
+ raise "Test timed out after #{timeout}s"
97
+ end
98
+ end
99
+
100
+ response = @stdout_r.gets
101
+ unless response
102
+ # Try to get error info
103
+ err = @stderr_r.read_nonblock(4096) rescue "[no output]"
104
+ err_text = "Worker died: #{err}"
105
+
106
+ raise err_text
107
+ end
108
+
109
+ JSON.parse(response, symbolize_names: true)
110
+ end
111
+
112
+ def wait_for_ready(timeout: 10)
113
+ deadline = Time.now + timeout
114
+
115
+ until File.exist?(READY_FILE)
116
+ raise "Teek::TestWorker failed to start within #{timeout}s" if Time.now > deadline
117
+ sleep 0.05
118
+ end
119
+ end
120
+
121
+ def cleanup_stale_files
122
+ File.unlink(PID_FILE) if File.exist?(PID_FILE)
123
+ File.unlink(READY_FILE) if File.exist?(READY_FILE)
124
+ end
125
+ end
126
+
127
+ # Eval context for test code — provides minitest assertions and test helpers.
128
+ class TestContext
129
+ include Minitest::Assertions
130
+ require_relative 'screenshot_helper'
131
+ include ScreenshotHelper
132
+ attr_accessor :assertions
133
+ attr_reader :app
134
+
135
+ def initialize(app)
136
+ @app = app
137
+ @assertions = 0
138
+ end
139
+
140
+ # Resolve a path relative to the project root.
141
+ # Use for test fixtures that live in known locations.
142
+ #
143
+ # fixture_path("teek-sdl2/assets/test_red_8x8.png")
144
+ #
145
+ def fixture_path(*parts)
146
+ File.expand_path(File.join(*parts), File.expand_path("..", __dir__))
147
+ end
148
+
149
+ # Retry until expected value is returned or timeout.
150
+ def wait_for_display(expected, timeout: 1.0)
151
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
152
+ result = nil
153
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
154
+ @app.update
155
+ result = yield
156
+ break if result.to_s == expected
157
+ sleep 0.02
158
+ end
159
+ result
160
+ end
161
+
162
+ # Wait until block returns truthy or timeout.
163
+ def wait_until(timeout: 1.0)
164
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
165
+ result = nil
166
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
167
+ @app.update
168
+ result = yield
169
+ break if result
170
+ sleep 0.02
171
+ end
172
+ result
173
+ end
174
+ end
175
+
176
+ # Server-side: runs in subprocess
177
+ class Server
178
+ def initialize
179
+ require 'teek'
180
+
181
+ # Test-only extension to reset widget auto-naming counters between tests.
182
+ Teek::App.include(Module.new {
183
+ def reset_widget_counters!
184
+ @widget_counters = Hash.new(0)
185
+ end
186
+ })
187
+
188
+ @app = Teek::App.new
189
+
190
+ # Override Tk's default bgerror dialog with a stderr handler.
191
+ # Without this, any background error (e.g. from `after` callbacks)
192
+ # shows a modal dialog that blocks headless/CI test runs.
193
+ @app.tcl_eval(<<~'TCL')
194
+ proc _teek_test_bgerror {msg opts} {
195
+ puts stderr "bgerror: $msg"
196
+ }
197
+ interp bgerror {} _teek_test_bgerror
198
+ TCL
199
+
200
+ @test_count = 0
201
+ end
202
+
203
+ def run
204
+ # Signal ready
205
+ File.write(READY_FILE, '')
206
+
207
+ # Main loop - read commands from stdin
208
+ while (line = $stdin.gets)
209
+ msg = JSON.parse(line, symbolize_names: true)
210
+
211
+ result = case msg[:cmd]
212
+ when 'run'
213
+ data = msg[:data]
214
+ run_test(data[:code], pipe_capture: data[:pipe_capture],
215
+ source_file: data[:source_file], source_line: data[:source_line])
216
+ when 'shutdown'
217
+ # Write coverage BEFORE responding (parent closes pipes after response)
218
+ # This must happen here, not in at_exit, because the parent process
219
+ # closes pipes immediately after receiving the response.
220
+ SimpleCov.result if ENV['COVERAGE'] && defined?(SimpleCov)
221
+ shutdown
222
+ break
223
+ when 'ping'
224
+ { pong: true }
225
+ else
226
+ { error: "Unknown command: #{msg[:cmd]}" }
227
+ end
228
+
229
+ $stdout.puts(JSON.generate(result))
230
+ $stdout.flush
231
+ end
232
+ end
233
+
234
+ def run_test(code, pipe_capture: false, source_file: nil, source_line: nil)
235
+ @test_count += 1
236
+ # Use instance variable to avoid shadowing by test code's local variables
237
+ @_test_result = { success: true, stdout: "", stderr: "", test_number: @test_count }
238
+
239
+ # Pipe-based capture is needed for Ractor tests (StringIO isn't thread-safe)
240
+ @pipe_capture = pipe_capture
241
+ @source_file = source_file
242
+ @source_line = source_line
243
+
244
+ begin
245
+ # Capture stdout/stderr
246
+ old_stdout, old_stderr = $stdout, $stderr
247
+ if pipe_capture
248
+ @out_r, out_w = IO.pipe
249
+ @err_r, err_w = IO.pipe
250
+ out_w.sync = true
251
+ err_w.sync = true
252
+ $stdout = out_w
253
+ $stderr = err_w
254
+ else
255
+ captured_out = StringIO.new
256
+ captured_err = StringIO.new
257
+ $stdout = captured_out
258
+ $stderr = captured_err
259
+ end
260
+
261
+ # Execute test code with minitest assertions available
262
+ ctx = TestContext.new(@app)
263
+ eval_file = @source_file || "(test)"
264
+ eval_line = @source_line || 1
265
+ ctx.instance_eval(code, eval_file, eval_line)
266
+
267
+ if @pipe_capture
268
+ $stdout.close
269
+ $stderr.close
270
+ @_test_result[:stdout] = @out_r.read
271
+ @_test_result[:stderr] = @err_r.read
272
+ else
273
+ @_test_result[:stdout] = captured_out.string
274
+ @_test_result[:stderr] = captured_err.string
275
+ end
276
+ rescue Exception => e
277
+ @_test_result[:success] = false
278
+ @_test_result[:error_class] = e.class.name
279
+ @_test_result[:error_message] = e.message
280
+ @_test_result[:backtrace] = e.backtrace || []
281
+ if @pipe_capture
282
+ $stdout.close rescue nil
283
+ $stderr.close rescue nil
284
+ @_test_result[:stdout] = @out_r.read rescue ""
285
+ @_test_result[:stderr] = @err_r.read rescue ""
286
+ else
287
+ @_test_result[:stdout] = captured_out.string if captured_out
288
+ @_test_result[:stderr] = captured_err.string if captured_err
289
+ end
290
+
291
+ # Extract code context if error is in eval'd code
292
+ bt_pattern = @source_file ? /#{Regexp.escape(@source_file)}:(\d+)/ : /\(test\):(\d+)/
293
+ if (line_match = e.backtrace&.first&.match(bt_pattern))
294
+ line_num = line_match[1].to_i
295
+ code_lines = code.lines
296
+ # Convert absolute line number back to code array index
297
+ code_index = @source_line ? line_num - @source_line : line_num - 1
298
+ start_idx = [code_index - 2, 0].max
299
+ end_idx = [code_index + 2, code_lines.size - 1].min
300
+ context_lines = (start_idx..end_idx).map do |i|
301
+ abs_line = @source_line ? i + @source_line : i + 1
302
+ prefix = (i == code_index) ? ">>>" : " "
303
+ "#{prefix} #{abs_line}: #{code_lines[i]}"
304
+ end
305
+ @_test_result[:code_context] = context_lines.join
306
+ end
307
+ ensure
308
+ $stdout, $stderr = old_stdout, old_stderr
309
+ if @pipe_capture
310
+ @out_r&.close rescue nil
311
+ @err_r&.close rescue nil
312
+ end
313
+ reset_tk_state!
314
+ end
315
+
316
+ @_test_result
317
+ end
318
+
319
+ def shutdown
320
+ @app.destroy('.') rescue nil
321
+ { shutdown: true }
322
+ end
323
+
324
+ private
325
+
326
+ def reset_tk_state!
327
+ # Destroy all children of root, then withdraw
328
+ children = @app.tcl_eval('winfo children .').split
329
+ children.each do |child|
330
+ @app.destroy(child) rescue nil
331
+ end
332
+ @app.hide
333
+
334
+ # Reset WM properties (these persist between tests)
335
+ @app.tcl_eval('wm minsize . 1 1')
336
+ @app.tcl_eval('wm maxsize . 0 0') rescue nil
337
+ @app.set_window_geometry('')
338
+
339
+ reset_grid_config!
340
+ @app.reset_widget_counters!
341
+ end
342
+
343
+ # Reset grid geometry manager state for a widget.
344
+ # Column/row weights, minsize, pad, uniform settings persist after
345
+ # children are removed, so we must explicitly clear them.
346
+ def reset_grid_config!(path = '.')
347
+ result = @app.tcl_eval("grid size #{path}")
348
+ cols, rows = result.to_s.split.map(&:to_i)
349
+ return if cols == 0 && rows == 0
350
+
351
+ @app.tcl_eval("grid anchor #{path} nw")
352
+ @app.tcl_eval("grid propagate #{path} 1")
353
+
354
+ cols.times do |c|
355
+ @app.tcl_eval("grid columnconfigure #{path} #{c} -weight 0 -minsize 0 -pad 0 -uniform {}")
356
+ end
357
+
358
+ rows.times do |r|
359
+ @app.tcl_eval("grid rowconfigure #{path} #{r} -weight 0 -minsize 0 -pad 0 -uniform {}")
360
+ end
361
+ end
362
+ end
363
+ end
364
+
365
+ # Run as server if executed with 'server' argument
366
+ if ARGV[0] == 'server'
367
+ require 'open3'
368
+
369
+ # Set up SimpleCov for coverage collection in the worker process
370
+ if ENV['COVERAGE']
371
+ require 'simplecov'
372
+ require_relative 'simplecov_config'
373
+
374
+ coverage_name = ENV['COVERAGE_NAME'] || 'default'
375
+ SimpleCov.coverage_dir "#{SimpleCovConfig::PROJECT_ROOT}/coverage/results/#{coverage_name}_worker_#{Process.pid}"
376
+ SimpleCov.command_name "tk_worker:#{coverage_name}:#{Process.pid}"
377
+ SimpleCov.print_error_status = false
378
+ SimpleCov.formatter SimpleCov::Formatter::SimpleFormatter
379
+
380
+ SimpleCov.start do
381
+ SimpleCovConfig.apply_filters(self)
382
+ track_files "#{SimpleCovConfig::PROJECT_ROOT}/lib/**/*.rb"
383
+ end
384
+ end
385
+
386
+ FileUtils.mkdir_p(Teek::TestWorker::SOCKET_DIR)
387
+ Teek::TestWorker::Server.new.run
388
+ end