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,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Gemba
|
|
7
|
+
# @api private
|
|
8
|
+
# Transforms SimpleCov line coverage data into per-method coverage.
|
|
9
|
+
#
|
|
10
|
+
# Merges coverage data from all test suite result files, uses Prism to
|
|
11
|
+
# parse Ruby source files and find method definitions, then maps
|
|
12
|
+
# SimpleCov line coverage to calculate per-method percentages.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# service = MethodCoverageService.new(
|
|
16
|
+
# coverage_dir: "coverage",
|
|
17
|
+
# source_dirs: ["lib"]
|
|
18
|
+
# )
|
|
19
|
+
# service.call
|
|
20
|
+
# # => writes coverage/method_coverage.json
|
|
21
|
+
#
|
|
22
|
+
class MethodCoverageService
|
|
23
|
+
attr_reader :coverage_dir, :source_dirs, :output_path
|
|
24
|
+
|
|
25
|
+
def initialize(coverage_dir:, source_dirs: ["lib"], output_path: nil)
|
|
26
|
+
@coverage_dir = coverage_dir
|
|
27
|
+
@source_dirs = source_dirs
|
|
28
|
+
@output_path = output_path || File.join(coverage_dir, "method_coverage.json")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
coverage_files = Dir.glob(File.join(coverage_dir, "results", "*", "coverage.json"))
|
|
33
|
+
resultset_files = Dir.glob(File.join(coverage_dir, "results", "*", ".resultset.json"))
|
|
34
|
+
if coverage_files.empty? && resultset_files.empty?
|
|
35
|
+
warn "No coverage files found in #{coverage_dir}/results/"
|
|
36
|
+
return nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
coverage_data = load_and_merge_coverage(coverage_files)
|
|
40
|
+
result = {}
|
|
41
|
+
|
|
42
|
+
# Collect all methods from all files
|
|
43
|
+
all_methods = []
|
|
44
|
+
source_files.each do |file|
|
|
45
|
+
all_methods.concat(extract_methods(file))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Group by class path and calculate coverage
|
|
49
|
+
all_methods.group_by { |m| m[:class_path] }.each do |class_path, methods|
|
|
50
|
+
class_result = { "class_methods" => {}, "instance_methods" => {} }
|
|
51
|
+
total_covered = 0
|
|
52
|
+
total_relevant = 0
|
|
53
|
+
|
|
54
|
+
methods.each do |method|
|
|
55
|
+
file_coverage = coverage_data[method[:file]]
|
|
56
|
+
next unless file_coverage
|
|
57
|
+
|
|
58
|
+
cov = calculate_coverage(file_coverage, method[:start_line], method[:end_line])
|
|
59
|
+
next unless cov
|
|
60
|
+
|
|
61
|
+
# Store [percent, lines_string] - compact format
|
|
62
|
+
method_data = [cov[:percent], cov[:lines]]
|
|
63
|
+
if method[:scope] == :class
|
|
64
|
+
class_result["class_methods"][method[:name]] = method_data
|
|
65
|
+
else
|
|
66
|
+
class_result["instance_methods"][method[:name]] = method_data
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
total_covered += cov[:covered]
|
|
70
|
+
total_relevant += cov[:relevant]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
next if class_result["class_methods"].empty? && class_result["instance_methods"].empty?
|
|
74
|
+
|
|
75
|
+
if total_relevant > 0
|
|
76
|
+
class_result["total"] = (total_covered.to_f / total_relevant * 100).round(1)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
result[class_path] = class_result
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
File.write(output_path, JSON.pretty_generate(result))
|
|
83
|
+
puts "Generated method coverage: #{output_path} (#{result.size} classes/modules)"
|
|
84
|
+
result
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def load_and_merge_coverage(coverage_files)
|
|
90
|
+
merged = {}
|
|
91
|
+
|
|
92
|
+
# Read from coverage.json files
|
|
93
|
+
coverage_files.each do |file|
|
|
94
|
+
data = JSON.parse(File.read(file))
|
|
95
|
+
merge_coverage_data(merged, data["coverage"])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Also read from .resultset.json files (worker/subprocess results)
|
|
99
|
+
resultset_files = Dir.glob(File.join(coverage_dir, "results", "*", ".resultset.json"))
|
|
100
|
+
resultset_files.each do |file|
|
|
101
|
+
data = JSON.parse(File.read(file))
|
|
102
|
+
# Resultsets are nested: { "suite_name" => { "coverage" => { ... } } }
|
|
103
|
+
data.each do |_suite_name, suite_data|
|
|
104
|
+
next unless suite_data.is_a?(Hash) && suite_data["coverage"]
|
|
105
|
+
merge_coverage_data(merged, suite_data["coverage"])
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
merged
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def merge_coverage_data(merged, coverage_hash)
|
|
113
|
+
coverage_hash.each do |path, info|
|
|
114
|
+
# Normalize Docker /app/... paths to local paths
|
|
115
|
+
local_path = path.sub(%r{^/app/}, "")
|
|
116
|
+
lines = info["lines"]
|
|
117
|
+
if merged[local_path]
|
|
118
|
+
merged[local_path] = merge_line_coverage(merged[local_path], lines)
|
|
119
|
+
else
|
|
120
|
+
merged[local_path] = lines
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def merge_line_coverage(lines_a, lines_b)
|
|
126
|
+
max_len = [lines_a.size, lines_b.size].max
|
|
127
|
+
(0...max_len).map do |i|
|
|
128
|
+
a = lines_a[i]
|
|
129
|
+
b = lines_b[i]
|
|
130
|
+
# nil means not relevant (comments, blank lines), "ignored" also not relevant
|
|
131
|
+
# If EITHER run says nil, treat as not relevant - a comment can't become executable
|
|
132
|
+
# This handles cases where different test suites have slightly different coverage metadata
|
|
133
|
+
if a.nil? || a == "ignored" || b.nil? || b == "ignored"
|
|
134
|
+
# Both nil -> nil, one nil -> nil (trust the nil, it means non-executable)
|
|
135
|
+
# Unless both are numeric, in which case take max
|
|
136
|
+
if (a.nil? || a == "ignored") && (b.nil? || b == "ignored")
|
|
137
|
+
nil
|
|
138
|
+
elsif a.nil? || a == "ignored"
|
|
139
|
+
# a is nil, b is numeric - but nil means not relevant, so prefer nil
|
|
140
|
+
# unless b > 0 (was actually executed, so must be real code)
|
|
141
|
+
b.to_i > 0 ? b : nil
|
|
142
|
+
else
|
|
143
|
+
# b is nil, a is numeric
|
|
144
|
+
a.to_i > 0 ? a : nil
|
|
145
|
+
end
|
|
146
|
+
else
|
|
147
|
+
[a.to_i, b.to_i].max
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def source_files
|
|
153
|
+
source_dirs.flat_map { |dir| Dir.glob("#{dir}/**/*.rb") }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def extract_methods(file)
|
|
157
|
+
source = File.read(file)
|
|
158
|
+
result = Prism.parse(source)
|
|
159
|
+
methods = []
|
|
160
|
+
|
|
161
|
+
visitor = MethodVisitor.new(methods, file)
|
|
162
|
+
result.value.accept(visitor)
|
|
163
|
+
|
|
164
|
+
methods
|
|
165
|
+
rescue => e
|
|
166
|
+
warn "Failed to parse #{file}: #{e.message}"
|
|
167
|
+
[]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def calculate_coverage(file_lines, start_line, end_line)
|
|
171
|
+
relevant = 0
|
|
172
|
+
covered = 0
|
|
173
|
+
lines_str = +"" # unfrozen string
|
|
174
|
+
|
|
175
|
+
# Skip first line (def) and last line (end) - only count method body
|
|
176
|
+
body_start = start_line + 1
|
|
177
|
+
body_end = end_line - 1
|
|
178
|
+
|
|
179
|
+
return nil if body_start > body_end # empty method body
|
|
180
|
+
|
|
181
|
+
(body_start..body_end).each do |line_num|
|
|
182
|
+
next if line_num < 1 || line_num > file_lines.size
|
|
183
|
+
line_cov = file_lines[line_num - 1] # array is 0-indexed
|
|
184
|
+
if line_cov.nil? || line_cov == "ignored"
|
|
185
|
+
lines_str << "-" # not relevant
|
|
186
|
+
elsif line_cov.to_i > 0
|
|
187
|
+
lines_str << "1" # covered
|
|
188
|
+
relevant += 1
|
|
189
|
+
covered += 1
|
|
190
|
+
else
|
|
191
|
+
lines_str << "0" # not covered
|
|
192
|
+
relevant += 1
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
return nil if relevant == 0
|
|
197
|
+
{ covered: covered, relevant: relevant, percent: (covered.to_f / relevant * 100).round(1), lines: lines_str }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Prism AST visitor to extract method definitions with class context
|
|
201
|
+
class MethodVisitor < Prism::Visitor
|
|
202
|
+
def initialize(methods, file)
|
|
203
|
+
@methods = methods
|
|
204
|
+
@file = file
|
|
205
|
+
@namespace_stack = [] # track current class/module nesting
|
|
206
|
+
@singleton_depth = 0 # track if we're inside class << self
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def visit_class_node(node)
|
|
210
|
+
name = constant_path_to_string(node.constant_path)
|
|
211
|
+
@namespace_stack.push(name)
|
|
212
|
+
super
|
|
213
|
+
@namespace_stack.pop
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def visit_module_node(node)
|
|
217
|
+
name = constant_path_to_string(node.constant_path)
|
|
218
|
+
@namespace_stack.push(name)
|
|
219
|
+
super
|
|
220
|
+
@namespace_stack.pop
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def visit_singleton_class_node(node)
|
|
224
|
+
@singleton_depth += 1
|
|
225
|
+
super
|
|
226
|
+
@singleton_depth -= 1
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def visit_def_node(node)
|
|
230
|
+
return super if @namespace_stack.empty? # skip top-level methods
|
|
231
|
+
|
|
232
|
+
scope = if node.receiver || @singleton_depth > 0
|
|
233
|
+
:class
|
|
234
|
+
else
|
|
235
|
+
:instance
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
@methods << {
|
|
239
|
+
name: node.name.to_s,
|
|
240
|
+
scope: scope,
|
|
241
|
+
start_line: node.location.start_line,
|
|
242
|
+
end_line: node.location.end_line,
|
|
243
|
+
class_path: @namespace_stack.join("::"),
|
|
244
|
+
file: @file
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
super
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private
|
|
251
|
+
|
|
252
|
+
def constant_path_to_string(node)
|
|
253
|
+
case node
|
|
254
|
+
when Prism::ConstantReadNode
|
|
255
|
+
node.name.to_s
|
|
256
|
+
when Prism::ConstantPathNode
|
|
257
|
+
parent = node.parent ? constant_path_to_string(node.parent) + "::" : ""
|
|
258
|
+
parent + node.name.to_s
|
|
259
|
+
else
|
|
260
|
+
node.to_s
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
# Renders inverse-blend HUD overlays (FPS counter, fast-forward indicator)
|
|
5
|
+
# on top of the game viewport.
|
|
6
|
+
#
|
|
7
|
+
# White source pixels invert the destination, so the text is always
|
|
8
|
+
# readable regardless of the game's background color. Transparent
|
|
9
|
+
# regions pass through unchanged.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# hud = OverlayRenderer.new(font: font, blend_mode: inverse_blend)
|
|
13
|
+
# hud.set_fps("59.7 FPS")
|
|
14
|
+
# hud.set_ff_label(">> 2x")
|
|
15
|
+
# # inside render loop:
|
|
16
|
+
# hud.draw(r, dest_rect)
|
|
17
|
+
class OverlayRenderer
|
|
18
|
+
# @param font [Teek::SDL2::Font] used to render overlay text
|
|
19
|
+
# @param blend_mode [Integer] SDL blend mode (typically inverse blend)
|
|
20
|
+
def initialize(font:, blend_mode:)
|
|
21
|
+
@font = font
|
|
22
|
+
@blend_mode = blend_mode
|
|
23
|
+
@crop_h = compute_crop_h(font)
|
|
24
|
+
@fps_tex = nil
|
|
25
|
+
@ff_tex = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Update the FPS counter text. Pass nil to hide.
|
|
29
|
+
# @param text [String, nil]
|
|
30
|
+
def set_fps(text)
|
|
31
|
+
@fps_tex&.destroy
|
|
32
|
+
@fps_tex = text ? build_tex(text) : nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Update the fast-forward indicator label. Pass nil to hide.
|
|
36
|
+
# @param text [String, nil]
|
|
37
|
+
def set_ff_label(text)
|
|
38
|
+
@ff_tex&.destroy
|
|
39
|
+
@ff_tex = text ? build_tex(text) : nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether the FPS overlay is currently showing.
|
|
43
|
+
# @return [Boolean]
|
|
44
|
+
def fps_visible?
|
|
45
|
+
!!@fps_tex
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Whether the fast-forward label is currently showing.
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
def ff_visible?
|
|
51
|
+
!!@ff_tex
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Draw all active overlays.
|
|
55
|
+
#
|
|
56
|
+
# FPS is positioned top-right; FF label is positioned top-left.
|
|
57
|
+
# Both are inset from the game area defined by +dest+.
|
|
58
|
+
#
|
|
59
|
+
# @param r [Teek::SDL2::Renderer]
|
|
60
|
+
# @param dest [Array(Integer,Integer,Integer,Integer), nil] game area rect
|
|
61
|
+
# @param show_fps [Boolean] whether to draw the FPS counter
|
|
62
|
+
# @param show_ff [Boolean] whether to draw the FF indicator
|
|
63
|
+
def draw(r, dest, show_fps: true, show_ff: false)
|
|
64
|
+
if show_ff && @ff_tex
|
|
65
|
+
ox = dest ? dest[0] : 0
|
|
66
|
+
oy = dest ? dest[1] : 0
|
|
67
|
+
draw_tex(r, @ff_tex, ox + 4, oy + 4)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if show_fps && @fps_tex
|
|
71
|
+
fx = (dest ? dest[0] + dest[2] : r.output_size[0]) - @fps_tex.width - 6
|
|
72
|
+
fy = (dest ? dest[1] : 0) + 4
|
|
73
|
+
draw_tex(r, @fps_tex, fx, fy)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Free all textures.
|
|
78
|
+
def destroy
|
|
79
|
+
@fps_tex&.destroy
|
|
80
|
+
@fps_tex = nil
|
|
81
|
+
@ff_tex&.destroy
|
|
82
|
+
@ff_tex = nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def build_tex(text)
|
|
88
|
+
return nil unless @font
|
|
89
|
+
tex = @font.render_text(text, 255, 255, 255)
|
|
90
|
+
tex.blend_mode = @blend_mode
|
|
91
|
+
tex
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Crop to ascent + partial descender to avoid alpha artifacts
|
|
95
|
+
# visible under inverse blending.
|
|
96
|
+
def draw_tex(r, tex, x, y)
|
|
97
|
+
tw = tex.width
|
|
98
|
+
th = @crop_h || tex.height
|
|
99
|
+
r.copy(tex, [0, 0, tw, th], [x, y, tw, th])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def compute_crop_h(font)
|
|
103
|
+
return nil unless font
|
|
104
|
+
ascent = font.ascent
|
|
105
|
+
full_h = font.measure('p')[1]
|
|
106
|
+
[ascent + (full_h - ascent) / 2, full_h - 1].min
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|