gemba 0.2.0 → 0.2.1
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 +4 -4
- data/lib/gemba/achievements/backend.rb +4 -0
- data/lib/gemba/achievements/retro_achievements/backend.rb +69 -11
- data/lib/gemba/achievements/retro_achievements/ping_worker.rb +1 -0
- data/lib/gemba/achievements/retro_achievements/unlock_retry_worker.rb +35 -0
- data/lib/gemba/app_controller.rb +28 -7
- data/lib/gemba/config.rb +22 -2
- data/lib/gemba/emulator_frame.rb +24 -0
- data/lib/gemba/frame_stack.rb +10 -0
- data/lib/gemba/game_picker_frame.rb +49 -8
- data/lib/gemba/list_picker_frame.rb +271 -0
- data/lib/gemba/locales/en.yml +12 -0
- data/lib/gemba/locales/ja.yml +12 -0
- data/lib/gemba/rom_patcher/ups.rb +2 -1
- data/lib/gemba/settings/system_tab.rb +18 -3
- data/lib/gemba/version.rb +1 -1
- data/test/shared/tk_test_helper.rb +1 -0
- data/test/support/fake_requester.rb +14 -4
- data/test/test_config.rb +2 -1
- data/test/test_game_picker_frame.rb +22 -22
- data/test/test_list_picker_frame.rb +391 -0
- data/test/test_ra_backend_unlock_retry.rb +123 -0
- data/test/test_rom_patcher.rb +2 -1
- data/test/test_virtual_events.rb +65 -0
- metadata +6 -2
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require_relative "shared/tk_test_helper"
|
|
5
|
+
|
|
6
|
+
class TestListPickerFrame < Minitest::Test
|
|
7
|
+
include TeekTestHelper
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── Population ─────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
def test_empty_library_shows_no_rows
|
|
14
|
+
assert_tk_app("empty library produces zero treeview rows") do
|
|
15
|
+
require "gemba/headless"
|
|
16
|
+
|
|
17
|
+
lib = Struct.new(:roms) { def all = roms }.new([])
|
|
18
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
19
|
+
picker.show
|
|
20
|
+
app.update
|
|
21
|
+
|
|
22
|
+
items = app.tcl_eval(".list_picker.tree children {}").split
|
|
23
|
+
assert_empty items, "no rows expected for empty library"
|
|
24
|
+
|
|
25
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def test_roms_populate_title_column
|
|
30
|
+
assert_tk_app("ROM titles appear in the title column") do
|
|
31
|
+
require "gemba/headless"
|
|
32
|
+
|
|
33
|
+
roms = [
|
|
34
|
+
{ 'title' => 'Alpha', 'platform' => 'gba', 'path' => '/a.gba',
|
|
35
|
+
'last_played' => '2026-02-20T10:00:00Z' },
|
|
36
|
+
{ 'title' => 'Beta', 'platform' => 'gbc', 'path' => '/b.gbc',
|
|
37
|
+
'last_played' => '2026-02-19T10:00:00Z' },
|
|
38
|
+
]
|
|
39
|
+
lib = Struct.new(:roms) { def all = roms }.new(roms)
|
|
40
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
41
|
+
picker.show
|
|
42
|
+
app.update
|
|
43
|
+
|
|
44
|
+
items = app.tcl_eval(".list_picker.tree children {}").split
|
|
45
|
+
titles = items.map { |id| app.tcl_eval(".list_picker.tree set #{id} title") }
|
|
46
|
+
assert_equal 2, titles.size
|
|
47
|
+
assert_includes titles, 'Alpha'
|
|
48
|
+
assert_includes titles, 'Beta'
|
|
49
|
+
|
|
50
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_last_played_formatted_as_month_day_year
|
|
55
|
+
assert_tk_app("last_played ISO string is formatted for display") do
|
|
56
|
+
require "gemba/headless"
|
|
57
|
+
|
|
58
|
+
roms = [{ 'title' => 'Game', 'platform' => 'gba', 'path' => '/g.gba',
|
|
59
|
+
'last_played' => '2026-02-22T15:30:00Z' }]
|
|
60
|
+
lib = Struct.new(:roms) { def all = roms }.new(roms)
|
|
61
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
62
|
+
picker.show
|
|
63
|
+
app.update
|
|
64
|
+
|
|
65
|
+
iid = app.tcl_eval(".list_picker.tree children {}").split.first
|
|
66
|
+
lp = app.tcl_eval(".list_picker.tree set #{iid} last_played")
|
|
67
|
+
assert_match(/Feb\s+22,\s+2026/, lp)
|
|
68
|
+
|
|
69
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def test_nil_last_played_shows_never_placeholder
|
|
74
|
+
assert_tk_app("ROM with no last_played shows never-played text, not a date") do
|
|
75
|
+
require "gemba/headless"
|
|
76
|
+
|
|
77
|
+
roms = [{ 'title' => 'New Game', 'platform' => 'gba', 'path' => '/n.gba' }]
|
|
78
|
+
lib = Struct.new(:roms) { def all = roms }.new(roms)
|
|
79
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
80
|
+
picker.show
|
|
81
|
+
app.update
|
|
82
|
+
|
|
83
|
+
iid = app.tcl_eval(".list_picker.tree children {}").split.first
|
|
84
|
+
lp = app.tcl_eval(".list_picker.tree set #{iid} last_played")
|
|
85
|
+
refute_empty lp
|
|
86
|
+
refute_match(/\d{4}/, lp, "should not look like a date")
|
|
87
|
+
|
|
88
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def test_all_roms_shown_without_cap
|
|
93
|
+
assert_tk_app("all 20 library ROMs appear with no cap") do
|
|
94
|
+
require "gemba/headless"
|
|
95
|
+
|
|
96
|
+
roms = 20.times.map { |i| { 'title' => "Game #{i}", 'platform' => 'gba', 'path' => "/g#{i}.gba" } }
|
|
97
|
+
lib = Struct.new(:roms) { def all = roms }.new(roms)
|
|
98
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
99
|
+
picker.show
|
|
100
|
+
app.update
|
|
101
|
+
|
|
102
|
+
count = app.tcl_eval(".list_picker.tree children {}").split.size
|
|
103
|
+
assert_equal 20, count
|
|
104
|
+
|
|
105
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# ── Default sort ────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def test_default_sort_newest_last_played_first
|
|
112
|
+
assert_tk_app("default sort shows most-recently-played ROM first") do
|
|
113
|
+
require "gemba/headless"
|
|
114
|
+
|
|
115
|
+
roms = [
|
|
116
|
+
{ 'title' => 'Old', 'platform' => 'gba', 'path' => '/o.gba',
|
|
117
|
+
'last_played' => '2024-01-01T00:00:00Z' },
|
|
118
|
+
{ 'title' => 'Recent', 'platform' => 'gba', 'path' => '/r.gba',
|
|
119
|
+
'last_played' => '2026-02-22T00:00:00Z' },
|
|
120
|
+
{ 'title' => 'Middle', 'platform' => 'gba', 'path' => '/m.gba',
|
|
121
|
+
'last_played' => '2025-06-15T00:00:00Z' },
|
|
122
|
+
]
|
|
123
|
+
lib = Struct.new(:roms) { def all = roms }.new(roms)
|
|
124
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
125
|
+
picker.show
|
|
126
|
+
app.update
|
|
127
|
+
|
|
128
|
+
items = app.tcl_eval(".list_picker.tree children {}").split
|
|
129
|
+
titles = items.map { |id| app.tcl_eval(".list_picker.tree set #{id} title") }
|
|
130
|
+
assert_equal 'Recent', titles[0], "most recent should be first"
|
|
131
|
+
assert_equal 'Middle', titles[1]
|
|
132
|
+
assert_equal 'Old', titles[2]
|
|
133
|
+
|
|
134
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# ── Sorting ─────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def test_click_title_heading_sorts_a_to_z_with_indicator
|
|
141
|
+
assert_tk_app("clicking title heading sorts A→Z and shows ▲") do
|
|
142
|
+
require "gemba/headless"
|
|
143
|
+
|
|
144
|
+
roms = [
|
|
145
|
+
{ 'title' => 'Zelda', 'platform' => 'gba', 'path' => '/z.gba' },
|
|
146
|
+
{ 'title' => 'Metroid', 'platform' => 'gba', 'path' => '/m.gba' },
|
|
147
|
+
{ 'title' => 'Castlevania', 'platform' => 'gba', 'path' => '/c.gba' },
|
|
148
|
+
]
|
|
149
|
+
lib = Struct.new(:roms) { def all = roms }.new(roms)
|
|
150
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
151
|
+
picker.show
|
|
152
|
+
app.update
|
|
153
|
+
|
|
154
|
+
cmd = app.tcl_eval(".list_picker.tree heading title -command")
|
|
155
|
+
app.tcl_eval("uplevel #0 {#{cmd}}")
|
|
156
|
+
app.update
|
|
157
|
+
|
|
158
|
+
items = app.tcl_eval(".list_picker.tree children {}").split
|
|
159
|
+
titles = items.map { |id| app.tcl_eval(".list_picker.tree set #{id} title") }
|
|
160
|
+
assert_equal %w[Castlevania Metroid Zelda], titles
|
|
161
|
+
assert_includes app.tcl_eval(".list_picker.tree heading title -text"), '▲'
|
|
162
|
+
|
|
163
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def test_click_title_heading_twice_reverses_to_z_to_a
|
|
168
|
+
assert_tk_app("clicking title heading twice reverses to Z→A and shows ▼") do
|
|
169
|
+
require "gemba/headless"
|
|
170
|
+
|
|
171
|
+
roms = [
|
|
172
|
+
{ 'title' => 'Zelda', 'platform' => 'gba', 'path' => '/z.gba' },
|
|
173
|
+
{ 'title' => 'Metroid', 'platform' => 'gba', 'path' => '/m.gba' },
|
|
174
|
+
]
|
|
175
|
+
lib = Struct.new(:roms) { def all = roms }.new(roms)
|
|
176
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
177
|
+
picker.show
|
|
178
|
+
app.update
|
|
179
|
+
|
|
180
|
+
cmd = app.tcl_eval(".list_picker.tree heading title -command")
|
|
181
|
+
app.tcl_eval("uplevel #0 {#{cmd}}") # asc
|
|
182
|
+
app.tcl_eval("uplevel #0 {#{cmd}}") # desc
|
|
183
|
+
app.update
|
|
184
|
+
|
|
185
|
+
items = app.tcl_eval(".list_picker.tree children {}").split
|
|
186
|
+
titles = items.map { |id| app.tcl_eval(".list_picker.tree set #{id} title") }
|
|
187
|
+
assert_equal %w[Zelda Metroid], titles
|
|
188
|
+
assert_includes app.tcl_eval(".list_picker.tree heading title -text"), '▼'
|
|
189
|
+
|
|
190
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def test_click_last_played_heading_sorts_oldest_first
|
|
195
|
+
assert_tk_app("clicking last_played heading (toggle from default desc) shows oldest first") do
|
|
196
|
+
require "gemba/headless"
|
|
197
|
+
|
|
198
|
+
roms = [
|
|
199
|
+
{ 'title' => 'Old', 'platform' => 'gba', 'path' => '/o.gba',
|
|
200
|
+
'last_played' => '2024-01-01T00:00:00Z' },
|
|
201
|
+
{ 'title' => 'Recent', 'platform' => 'gba', 'path' => '/r.gba',
|
|
202
|
+
'last_played' => '2026-02-22T00:00:00Z' },
|
|
203
|
+
]
|
|
204
|
+
lib = Struct.new(:roms) { def all = roms }.new(roms)
|
|
205
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
206
|
+
picker.show
|
|
207
|
+
app.update
|
|
208
|
+
|
|
209
|
+
# Default is last_played desc — clicking once switches to asc (oldest first)
|
|
210
|
+
cmd = app.tcl_eval(".list_picker.tree heading last_played -command")
|
|
211
|
+
app.tcl_eval("uplevel #0 {#{cmd}}")
|
|
212
|
+
app.update
|
|
213
|
+
|
|
214
|
+
items = app.tcl_eval(".list_picker.tree children {}").split
|
|
215
|
+
titles = items.map { |id| app.tcl_eval(".list_picker.tree set #{id} title") }
|
|
216
|
+
assert_equal 'Old', titles[0], "ascending: oldest first"
|
|
217
|
+
assert_includes app.tcl_eval(".list_picker.tree heading last_played -text"), '▲'
|
|
218
|
+
|
|
219
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def test_switching_sort_column_clears_old_indicator
|
|
224
|
+
assert_tk_app("switching sort column removes ▲/▼ from the old column") do
|
|
225
|
+
require "gemba/headless"
|
|
226
|
+
|
|
227
|
+
roms = [{ 'title' => 'Solo', 'platform' => 'gba', 'path' => '/s.gba' }]
|
|
228
|
+
lib = Struct.new(:roms) { def all = roms }.new(roms)
|
|
229
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
230
|
+
picker.show
|
|
231
|
+
app.update
|
|
232
|
+
|
|
233
|
+
title_cmd = app.tcl_eval(".list_picker.tree heading title -command")
|
|
234
|
+
lp_cmd = app.tcl_eval(".list_picker.tree heading last_played -command")
|
|
235
|
+
|
|
236
|
+
app.tcl_eval("uplevel #0 {#{title_cmd}}") # sort by title (adds ▲)
|
|
237
|
+
app.tcl_eval("uplevel #0 {#{lp_cmd}}") # switch to last_played
|
|
238
|
+
app.update
|
|
239
|
+
|
|
240
|
+
title_text = app.tcl_eval(".list_picker.tree heading title -text")
|
|
241
|
+
refute_includes title_text, '▲', "title heading should lose its indicator"
|
|
242
|
+
refute_includes title_text, '▼', "title heading should lose its indicator"
|
|
243
|
+
|
|
244
|
+
lp_text = app.tcl_eval(".list_picker.tree heading last_played -text")
|
|
245
|
+
assert(lp_text.include?('▲') || lp_text.include?('▼'),
|
|
246
|
+
"last_played heading should now show an indicator")
|
|
247
|
+
|
|
248
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# ── Interaction ─────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
def test_double_click_row_emits_rom_selected
|
|
255
|
+
assert_tk_app("double-clicking a row emits :rom_selected with the ROM path") do
|
|
256
|
+
require "gemba/headless"
|
|
257
|
+
|
|
258
|
+
rom_path = '/games/fire_red.gba'
|
|
259
|
+
roms = [{ 'title' => 'Fire Red', 'platform' => 'gba', 'path' => rom_path }]
|
|
260
|
+
lib = Struct.new(:roms) { def all = roms }.new(roms)
|
|
261
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
262
|
+
|
|
263
|
+
received = nil
|
|
264
|
+
Gemba.bus.on(:rom_selected) { |path| received = path }
|
|
265
|
+
|
|
266
|
+
picker.show
|
|
267
|
+
app.show
|
|
268
|
+
app.update
|
|
269
|
+
|
|
270
|
+
iid = app.tcl_eval(".list_picker.tree children {}").split.first
|
|
271
|
+
app.tcl_eval(".list_picker.tree focus #{iid}")
|
|
272
|
+
app.tcl_eval(".list_picker.tree selection set #{iid}")
|
|
273
|
+
app.tcl_eval("event generate .list_picker.tree <<DoubleClick>>")
|
|
274
|
+
app.update
|
|
275
|
+
|
|
276
|
+
assert_equal rom_path, received
|
|
277
|
+
|
|
278
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def test_right_click_quick_load_disabled_when_no_save_state
|
|
283
|
+
assert_tk_app("<<RightClick>> quick load entry is disabled with no save file") do
|
|
284
|
+
require "gemba/headless"
|
|
285
|
+
require "tmpdir"
|
|
286
|
+
|
|
287
|
+
Dir.mktmpdir("list_picker_qs_test") do |tmpdir|
|
|
288
|
+
rom_id = "AGB-TEST-DEADBEEF"
|
|
289
|
+
roms = [{ 'title' => 'Test', 'platform' => 'gba',
|
|
290
|
+
'rom_id' => rom_id, 'game_code' => 'AGB-TEST',
|
|
291
|
+
'path' => '/games/test.gba', 'last_played' => '2026-01-01T00:00:00Z' }]
|
|
292
|
+
|
|
293
|
+
Gemba.user_config.states_dir = tmpdir
|
|
294
|
+
lib = Struct.new(:roms) { def all = roms }.new(roms)
|
|
295
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
296
|
+
picker.show
|
|
297
|
+
app.show
|
|
298
|
+
app.update
|
|
299
|
+
|
|
300
|
+
iid = app.tcl_eval(".list_picker.tree children {}").split.first
|
|
301
|
+
app.tcl_eval(".list_picker.tree focus #{iid}")
|
|
302
|
+
app.tcl_eval(".list_picker.tree selection set #{iid}")
|
|
303
|
+
|
|
304
|
+
override_tk_popup do
|
|
305
|
+
app.tcl_eval("event generate .list_picker.tree <<RightClick>>")
|
|
306
|
+
app.update
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
state = app.tcl_eval(".list_picker.tree.ctx entrycget 1 -state")
|
|
310
|
+
assert_equal 'disabled', state
|
|
311
|
+
|
|
312
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def test_right_click_quick_load_enabled_when_save_state_exists
|
|
318
|
+
assert_tk_app("<<RightClick>> quick load entry is enabled when save file exists") do
|
|
319
|
+
require "gemba/headless"
|
|
320
|
+
require "tmpdir"
|
|
321
|
+
require "fileutils"
|
|
322
|
+
|
|
323
|
+
fixture = File.expand_path("test/fixtures/test_quicksave.ss")
|
|
324
|
+
|
|
325
|
+
Dir.mktmpdir("list_picker_qs_test") do |tmpdir|
|
|
326
|
+
rom_id = "AGB-TEST-DEADBEEF"
|
|
327
|
+
slot = Gemba.user_config.quick_save_slot
|
|
328
|
+
state_dir = File.join(tmpdir, rom_id)
|
|
329
|
+
FileUtils.mkdir_p(state_dir)
|
|
330
|
+
FileUtils.cp(fixture, File.join(state_dir, "state#{slot}.ss"))
|
|
331
|
+
|
|
332
|
+
roms = [{ 'title' => 'Test', 'platform' => 'gba',
|
|
333
|
+
'rom_id' => rom_id, 'game_code' => 'AGB-TEST',
|
|
334
|
+
'path' => '/games/test.gba', 'last_played' => '2026-01-01T00:00:00Z' }]
|
|
335
|
+
|
|
336
|
+
Gemba.user_config.states_dir = tmpdir
|
|
337
|
+
lib = Struct.new(:roms) { def all = roms }.new(roms)
|
|
338
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
339
|
+
picker.show
|
|
340
|
+
app.show
|
|
341
|
+
app.update
|
|
342
|
+
|
|
343
|
+
iid = app.tcl_eval(".list_picker.tree children {}").split.first
|
|
344
|
+
app.tcl_eval(".list_picker.tree focus #{iid}")
|
|
345
|
+
app.tcl_eval(".list_picker.tree selection set #{iid}")
|
|
346
|
+
|
|
347
|
+
override_tk_popup do
|
|
348
|
+
app.tcl_eval("event generate .list_picker.tree <<RightClick>>")
|
|
349
|
+
app.update
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
state = app.tcl_eval(".list_picker.tree.ctx entrycget 1 -state")
|
|
353
|
+
assert_equal 'normal', state
|
|
354
|
+
|
|
355
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def test_refresh_repopulates_rows
|
|
361
|
+
assert_tk_app("receive(:refresh) updates rows from the current library state") do
|
|
362
|
+
require "gemba/headless"
|
|
363
|
+
|
|
364
|
+
roms = [{ 'title' => 'Alpha', 'platform' => 'gba', 'path' => '/a.gba' }]
|
|
365
|
+
lib = Struct.new(:roms) { def all = roms }.new(roms)
|
|
366
|
+
|
|
367
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
368
|
+
picker.show
|
|
369
|
+
app.update
|
|
370
|
+
|
|
371
|
+
items = app.tcl_eval(".list_picker.tree children {}").split
|
|
372
|
+
titles = items.map { |id| app.tcl_eval(".list_picker.tree set #{id} title") }
|
|
373
|
+
assert_equal ['Alpha'], titles
|
|
374
|
+
|
|
375
|
+
lib.roms = [
|
|
376
|
+
{ 'title' => 'Alpha', 'platform' => 'gba', 'path' => '/a.gba' },
|
|
377
|
+
{ 'title' => 'Beta', 'platform' => 'gbc', 'path' => '/b.gbc' },
|
|
378
|
+
]
|
|
379
|
+
picker.receive(:refresh)
|
|
380
|
+
app.update
|
|
381
|
+
|
|
382
|
+
items = app.tcl_eval(".list_picker.tree children {}").split
|
|
383
|
+
titles = items.map { |id| app.tcl_eval(".list_picker.tree set #{id} title") }
|
|
384
|
+
assert_equal 2, titles.size
|
|
385
|
+
assert_includes titles, 'Alpha'
|
|
386
|
+
assert_includes titles, 'Beta'
|
|
387
|
+
|
|
388
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "gemba/headless"
|
|
5
|
+
require_relative "support/fake_ra_runtime"
|
|
6
|
+
require_relative "support/fake_requester"
|
|
7
|
+
require_relative "support/fake_core"
|
|
8
|
+
|
|
9
|
+
# Tests for the unlock retry queue in RetroAchievements::Backend.
|
|
10
|
+
#
|
|
11
|
+
# FakeRequester fires on_progress synchronously so no event loop is needed.
|
|
12
|
+
# For worker-style calls (drain_unlock_queue) it yields [ok, id] to match
|
|
13
|
+
# what UnlockRetryWorker produces in production.
|
|
14
|
+
class TestRABackendUnlockRetry < Minitest::Test
|
|
15
|
+
Backend = Gemba::Achievements::RetroAchievements::Backend
|
|
16
|
+
|
|
17
|
+
PATCH = {
|
|
18
|
+
"PatchData" => {
|
|
19
|
+
"RichPresencePatch" => "",
|
|
20
|
+
"Achievements" => [
|
|
21
|
+
{ "ID" => 101, "Title" => "First Blood", "Description" => "Kill",
|
|
22
|
+
"Points" => 5, "MemAddr" => "0=1", "Flags" => 3 },
|
|
23
|
+
{ "ID" => 102, "Title" => "Survivor", "Description" => "Survive",
|
|
24
|
+
"Points" => 10, "MemAddr" => "1=1", "Flags" => 3 },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def setup
|
|
30
|
+
@rt = FakeRARuntime.new
|
|
31
|
+
@req = FakeRequester.new
|
|
32
|
+
@b = Backend.new(app: nil, runtime: @rt, requester: @req)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def login_and_load
|
|
36
|
+
@req.stub(r: "login2", body: { "Success" => true })
|
|
37
|
+
@req.stub(r: "gameid", body: { "GameID" => 42 })
|
|
38
|
+
@req.stub(r: "patch", body: PATCH)
|
|
39
|
+
@req.stub(r: "unlocks", body: { "Success" => true, "UserUnlocks" => [] })
|
|
40
|
+
@b.login_with_token(username: "user", token: "tok")
|
|
41
|
+
Dir.mktmpdir do |dir|
|
|
42
|
+
rom = File.join(dir, "test.gba")
|
|
43
|
+
File.write(rom, "FAKEGBA")
|
|
44
|
+
@b.load_game(nil, rom, "deadbeef" * 4)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# -- Queue builds on initial failure ----------------------------------------
|
|
49
|
+
|
|
50
|
+
def test_failed_unlock_enqueues_for_retry
|
|
51
|
+
login_and_load
|
|
52
|
+
@req.stub(r: "awardachievement", ok: false, body: { "Success" => false })
|
|
53
|
+
@rt.queue_triggers("101")
|
|
54
|
+
@b.do_frame(FakeCore.new)
|
|
55
|
+
assert_equal 1, @b.unlock_queue.size
|
|
56
|
+
assert_equal "101", @b.unlock_queue.first[:id]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_successful_unlock_does_not_enqueue
|
|
60
|
+
login_and_load
|
|
61
|
+
@req.stub(r: "awardachievement", ok: true, body: { "Success" => true })
|
|
62
|
+
@rt.queue_triggers("101")
|
|
63
|
+
@b.do_frame(FakeCore.new)
|
|
64
|
+
assert_empty @b.unlock_queue
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_multiple_failed_unlocks_all_enqueue
|
|
68
|
+
login_and_load
|
|
69
|
+
@req.stub(r: "awardachievement", ok: false, body: { "Success" => false })
|
|
70
|
+
@rt.queue_triggers("101", "102")
|
|
71
|
+
@b.do_frame(FakeCore.new)
|
|
72
|
+
assert_equal 2, @b.unlock_queue.size
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# -- drain_unlock_queue -----------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def test_drain_sends_retry_request_per_entry
|
|
78
|
+
login_and_load
|
|
79
|
+
@req.stub(r: "awardachievement", ok: true, body: { "Success" => true })
|
|
80
|
+
@b.unlock_queue << { id: "101", hardcore: false }
|
|
81
|
+
@b.unlock_queue << { id: "102", hardcore: false }
|
|
82
|
+
@b.drain_unlock_queue
|
|
83
|
+
assert_equal 2, @req.requests_for("awardachievement").size
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def test_drain_clears_queue_on_success
|
|
87
|
+
login_and_load
|
|
88
|
+
@req.stub(r: "awardachievement", ok: true, body: { "Success" => true })
|
|
89
|
+
@b.unlock_queue << { id: "101", hardcore: false }
|
|
90
|
+
@b.drain_unlock_queue
|
|
91
|
+
assert_empty @b.unlock_queue
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def test_drain_keeps_queue_on_failure
|
|
95
|
+
login_and_load
|
|
96
|
+
@req.stub(r: "awardachievement", ok: false, body: { "Success" => false })
|
|
97
|
+
@b.unlock_queue << { id: "101", hardcore: false }
|
|
98
|
+
@b.drain_unlock_queue
|
|
99
|
+
assert_equal 1, @b.unlock_queue.size
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def test_drain_partial_success_removes_only_succeeded
|
|
103
|
+
login_and_load
|
|
104
|
+
@req.stub_queue(r: "awardachievement", ok: true, body: { "Success" => true })
|
|
105
|
+
@req.stub_queue(r: "awardachievement", ok: false, body: { "Success" => false })
|
|
106
|
+
@b.unlock_queue << { id: "101", hardcore: false }
|
|
107
|
+
@b.unlock_queue << { id: "102", hardcore: false }
|
|
108
|
+
@b.drain_unlock_queue
|
|
109
|
+
assert_equal 1, @b.unlock_queue.size
|
|
110
|
+
assert_equal "102", @b.unlock_queue.first[:id]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# -- shutdown ---------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def test_shutdown_logs_pending_and_does_not_raise
|
|
116
|
+
@b.unlock_queue << { id: "101", hardcore: false }
|
|
117
|
+
@b.shutdown
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def test_shutdown_with_empty_queue_does_not_raise
|
|
121
|
+
@b.shutdown
|
|
122
|
+
end
|
|
123
|
+
end
|
data/test/test_rom_patcher.rb
CHANGED
|
@@ -70,7 +70,7 @@ class TestRomPatcher < Minitest::Test
|
|
|
70
70
|
payload + [src_crc, tgt_crc, patch_crc].pack("VVV")
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
# Encode a UPS varint (
|
|
73
|
+
# Encode a UPS varint (additive-shift encoding with continuation bias).
|
|
74
74
|
def ups_varint(n)
|
|
75
75
|
out = "".b
|
|
76
76
|
loop do
|
|
@@ -81,6 +81,7 @@ class TestRomPatcher < Minitest::Test
|
|
|
81
81
|
break
|
|
82
82
|
end
|
|
83
83
|
out << x.chr
|
|
84
|
+
n -= 1
|
|
84
85
|
end
|
|
85
86
|
out
|
|
86
87
|
end
|
data/test/test_virtual_events.rb
CHANGED
|
@@ -87,6 +87,71 @@ class TestVirtualEvents < Minitest::Test
|
|
|
87
87
|
assert_virtual_event_fires('ToggleHelpWindow', with_rom: false)
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
# ── ListPickerFrame <<RightClick>> virtual event ───────────────────────────
|
|
91
|
+
|
|
92
|
+
def test_double_button1_on_list_picker_tree_fires_double_click_virtual_event
|
|
93
|
+
assert_tk_app("<Double-Button-1> on list picker treeview generates <<DoubleClick>>") do
|
|
94
|
+
require "gemba/headless"
|
|
95
|
+
|
|
96
|
+
roms = [{ 'title' => 'Test', 'platform' => 'gba', 'path' => '/t.gba' }]
|
|
97
|
+
lib = Struct.new(:roms) { def all = roms }.new(roms)
|
|
98
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
99
|
+
picker.show
|
|
100
|
+
app.show
|
|
101
|
+
app.update
|
|
102
|
+
|
|
103
|
+
tree = '.list_picker.tree'
|
|
104
|
+
app.tcl_eval("bind #{tree} <<DoubleClick>> {+set ::dclick_fired 1}")
|
|
105
|
+
|
|
106
|
+
iid = app.tcl_eval("#{tree} children {}").split.first
|
|
107
|
+
app.tcl_eval("#{tree} focus #{iid}")
|
|
108
|
+
app.tcl_eval("#{tree} selection set #{iid}")
|
|
109
|
+
# Simulate double-click via the virtual event directly (event generate
|
|
110
|
+
# <Double-Button-1> is forbidden in Tk 9).
|
|
111
|
+
app.tcl_eval("event generate #{tree} <<DoubleClick>>")
|
|
112
|
+
app.update
|
|
113
|
+
|
|
114
|
+
fired = app.tcl_eval("info exists ::dclick_fired") rescue "0"
|
|
115
|
+
assert_equal "1", fired, "<<DoubleClick>> binding did not fire"
|
|
116
|
+
|
|
117
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def test_button3_on_list_picker_tree_fires_right_click_virtual_event
|
|
122
|
+
assert_tk_app("<Button-3> on list picker treeview generates <<RightClick>>") do
|
|
123
|
+
require "gemba/headless"
|
|
124
|
+
|
|
125
|
+
roms = [{ 'title' => 'Test', 'platform' => 'gba', 'path' => '/t.gba' }]
|
|
126
|
+
lib = Struct.new(:roms) { def all = roms }.new(roms)
|
|
127
|
+
picker = Gemba::ListPickerFrame.new(app: app, rom_library: lib)
|
|
128
|
+
picker.show
|
|
129
|
+
app.show
|
|
130
|
+
app.update
|
|
131
|
+
app.tcl_eval("raise .")
|
|
132
|
+
app.tcl_eval("update idletasks")
|
|
133
|
+
|
|
134
|
+
tree = '.list_picker.tree'
|
|
135
|
+
# Append a Tcl side-effect to <<RightClick>> on the tree
|
|
136
|
+
app.tcl_eval("bind #{tree} <<RightClick>> {+set ::rclick_fired 1}")
|
|
137
|
+
|
|
138
|
+
iid = app.tcl_eval("#{tree} children {}").split.first
|
|
139
|
+
bbox = app.tcl_eval("#{tree} bbox #{iid}").split.map(&:to_i)
|
|
140
|
+
bx = bbox[0] + 5
|
|
141
|
+
by = bbox[1] + 5
|
|
142
|
+
|
|
143
|
+
override_tk_popup do
|
|
144
|
+
app.tcl_eval("event generate #{tree} <Button-3> -x #{bx} -y #{by} -warp 1")
|
|
145
|
+
app.update
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
fired = app.tcl_eval("info exists ::rclick_fired") rescue "0"
|
|
149
|
+
assert_equal "1", fired, "<<RightClick>> was not generated by <Button-3>"
|
|
150
|
+
|
|
151
|
+
app.command(:destroy, '.list_picker') rescue nil
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
90
155
|
private
|
|
91
156
|
|
|
92
157
|
# Verifies that generating <<EventName>> on '.' fires the binding.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gemba
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- James Cook
|
|
@@ -194,6 +194,7 @@ files:
|
|
|
194
194
|
- lib/gemba/achievements/retro_achievements/backend.rb
|
|
195
195
|
- lib/gemba/achievements/retro_achievements/cli_sync_requester.rb
|
|
196
196
|
- lib/gemba/achievements/retro_achievements/ping_worker.rb
|
|
197
|
+
- lib/gemba/achievements/retro_achievements/unlock_retry_worker.rb
|
|
197
198
|
- lib/gemba/achievements_window.rb
|
|
198
199
|
- lib/gemba/app_controller.rb
|
|
199
200
|
- lib/gemba/bios.rb
|
|
@@ -232,6 +233,7 @@ files:
|
|
|
232
233
|
- lib/gemba/input_recorder.rb
|
|
233
234
|
- lib/gemba/input_replayer.rb
|
|
234
235
|
- lib/gemba/keyboard_map.rb
|
|
236
|
+
- lib/gemba/list_picker_frame.rb
|
|
235
237
|
- lib/gemba/locale.rb
|
|
236
238
|
- lib/gemba/locales/en.yml
|
|
237
239
|
- lib/gemba/locales/ja.yml
|
|
@@ -331,6 +333,7 @@ files:
|
|
|
331
333
|
- test/test_input_replayer.rb
|
|
332
334
|
- test/test_keyboard_map.rb
|
|
333
335
|
- test/test_libretro_backend.rb
|
|
336
|
+
- test/test_list_picker_frame.rb
|
|
334
337
|
- test/test_locale.rb
|
|
335
338
|
- test/test_logging.rb
|
|
336
339
|
- test/test_mgba.rb
|
|
@@ -340,6 +343,7 @@ files:
|
|
|
340
343
|
- test/test_platform.rb
|
|
341
344
|
- test/test_ra_backend.rb
|
|
342
345
|
- test/test_ra_backend_unlock_gate.rb
|
|
346
|
+
- test/test_ra_backend_unlock_retry.rb
|
|
343
347
|
- test/test_recorder.rb
|
|
344
348
|
- test/test_replay_player.rb
|
|
345
349
|
- test/test_rom_info.rb
|
|
@@ -491,7 +495,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
491
495
|
requirements:
|
|
492
496
|
- libmgba development headers
|
|
493
497
|
- rubyzip gem >= 2.4 (optional, for loading ROMs from .zip files)
|
|
494
|
-
rubygems_version:
|
|
498
|
+
rubygems_version: 4.0.3
|
|
495
499
|
specification_version: 4
|
|
496
500
|
summary: GBA emulator frontend powered by teek and libmgba
|
|
497
501
|
test_files: []
|