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.
- checksums.yaml +7 -0
- data/THIRD_PARTY_NOTICES +113 -0
- data/assets/JetBrainsMonoNL-Regular.ttf +0 -0
- data/assets/ark-pixel-12px-monospaced-ja.ttf +0 -0
- data/bin/gemba +14 -0
- data/ext/gemba/extconf.rb +185 -0
- data/ext/gemba/gemba_ext.c +1051 -0
- data/ext/gemba/gemba_ext.h +15 -0
- data/gemba.gemspec +38 -0
- data/lib/gemba/child_window.rb +62 -0
- data/lib/gemba/cli.rb +384 -0
- data/lib/gemba/config.rb +621 -0
- data/lib/gemba/core.rb +121 -0
- data/lib/gemba/headless.rb +12 -0
- data/lib/gemba/headless_player.rb +206 -0
- data/lib/gemba/hotkey_map.rb +202 -0
- data/lib/gemba/input_mappings.rb +214 -0
- data/lib/gemba/locale.rb +92 -0
- data/lib/gemba/locales/en.yml +157 -0
- data/lib/gemba/locales/ja.yml +157 -0
- data/lib/gemba/method_coverage_service.rb +265 -0
- data/lib/gemba/overlay_renderer.rb +109 -0
- data/lib/gemba/player.rb +1515 -0
- data/lib/gemba/recorder.rb +156 -0
- data/lib/gemba/recorder_decoder.rb +325 -0
- data/lib/gemba/rom_info_window.rb +346 -0
- data/lib/gemba/rom_loader.rb +100 -0
- data/lib/gemba/runtime.rb +39 -0
- data/lib/gemba/save_state_manager.rb +155 -0
- data/lib/gemba/save_state_picker.rb +199 -0
- data/lib/gemba/settings_window.rb +1173 -0
- data/lib/gemba/tip_service.rb +133 -0
- data/lib/gemba/toast_overlay.rb +128 -0
- data/lib/gemba/version.rb +5 -0
- data/lib/gemba.rb +17 -0
- data/test/fixtures/test.gba +0 -0
- data/test/fixtures/test.sav +0 -0
- data/test/shared/screenshot_helper.rb +113 -0
- data/test/shared/simplecov_config.rb +59 -0
- data/test/shared/teek_test_worker.rb +388 -0
- data/test/shared/tk_test_helper.rb +354 -0
- data/test/support/input_mocks.rb +61 -0
- data/test/support/player_helpers.rb +77 -0
- data/test/test_cli.rb +281 -0
- data/test/test_config.rb +897 -0
- data/test/test_core.rb +401 -0
- data/test/test_gamepad_map.rb +116 -0
- data/test/test_headless_player.rb +205 -0
- data/test/test_helper.rb +19 -0
- data/test/test_hotkey_map.rb +396 -0
- data/test/test_keyboard_map.rb +108 -0
- data/test/test_locale.rb +159 -0
- data/test/test_mgba.rb +26 -0
- data/test/test_overlay_renderer.rb +199 -0
- data/test/test_player.rb +903 -0
- data/test/test_recorder.rb +180 -0
- data/test/test_rom_loader.rb +149 -0
- data/test/test_save_state_manager.rb +289 -0
- data/test/test_settings_hotkeys.rb +434 -0
- data/test/test_settings_window.rb +1039 -0
- data/test/test_tip_service.rb +138 -0
- data/test/test_toast_overlay.rb +216 -0
- data/test/test_virtual_keyboard.rb +39 -0
- data/test/test_xor_delta.rb +61 -0
- 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
|