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.
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Startup frame showing all library ROMs as a sortable treeview list.
5
+ #
6
+ # Alternative to GamePickerFrame (no boxart). Columns: Title, Last Played.
7
+ # Clicking a column header sorts; the active column shows a ▲/▼ indicator.
8
+ # Double-clicking a row emits :rom_selected. Right-clicking shows a context
9
+ # menu identical to the GamePickerFrame card menu.
10
+ # Pure Tk — no SDL2.
11
+ class ListPickerFrame
12
+ include BusEmitter
13
+ include Locale::Translatable
14
+
15
+ LIST_DEFAULT_W = 480
16
+ LIST_DEFAULT_H = 600
17
+ LIST_MIN_W = 320
18
+ LIST_MIN_H = 400
19
+
20
+ SORT_ASC = ' ▲'
21
+ SORT_DESC = ' ▼'
22
+
23
+ def default_geometry = [LIST_DEFAULT_W, LIST_DEFAULT_H]
24
+ def min_geometry = [LIST_MIN_W, LIST_MIN_H]
25
+
26
+ def initialize(app:, rom_library:, rom_overrides: nil)
27
+ @app = app
28
+ @rom_library = rom_library
29
+ @overrides = rom_overrides
30
+ @built = false
31
+ @sort_col = 'last_played'
32
+ @sort_asc = false # most-recent first by default
33
+ @row_data = {} # treeview item id => RomInfo
34
+ end
35
+
36
+ def show
37
+ build_ui unless @built
38
+ refresh
39
+ @app.command(:pack, @outer, fill: :both, expand: 1)
40
+ end
41
+
42
+ def hide
43
+ @app.command(:pack, :forget, @outer) rescue nil
44
+ end
45
+
46
+ def cleanup; end
47
+
48
+ def receive(event, **_args)
49
+ case event
50
+ when :refresh then refresh
51
+ end
52
+ end
53
+
54
+ def aspect_ratio = nil
55
+ def rom_loaded? = false
56
+ def sdl2_ready? = false
57
+ def paused? = false
58
+
59
+ private
60
+
61
+ def build_ui
62
+ @outer = '.list_picker'
63
+ @app.command('ttk::frame', @outer, padding: 8)
64
+
65
+ # Treeview + scrollbar
66
+ @tree = "#{@outer}.tree"
67
+ @scrollbar = "#{@outer}.scroll"
68
+
69
+ @app.command('ttk::treeview', @tree,
70
+ columns: Teek.make_list('title', 'last_played'),
71
+ show: :headings,
72
+ selectmode: :browse)
73
+
74
+ @app.command('ttk::scrollbar', @scrollbar, orient: :vertical,
75
+ command: "#{@tree} yview")
76
+ @app.command(@tree, :configure, yscrollcommand: "#{@scrollbar} set")
77
+
78
+ build_columns
79
+ bind_events
80
+
81
+ @app.command(:grid, @tree, row: 0, column: 0, sticky: :nsew)
82
+ @app.command(:grid, @scrollbar, row: 0, column: 1, sticky: :ns)
83
+ @app.command(:grid, :columnconfigure, @outer, 0, weight: 1)
84
+ @app.command(:grid, :rowconfigure, @outer, 0, weight: 1)
85
+
86
+ build_toolbar
87
+
88
+ @built = true
89
+ end
90
+
91
+ def build_columns
92
+ @app.command(@tree, :heading, 'title',
93
+ text: translate('list_picker.columns.title') + (@sort_col == 'title' ? sort_indicator : ''),
94
+ anchor: :w,
95
+ command: proc { sort_by('title') })
96
+ @app.command(@tree, :heading, 'last_played',
97
+ text: translate('list_picker.columns.last_played') + (@sort_col == 'last_played' ? sort_indicator : ''),
98
+ anchor: :w,
99
+ command: proc { sort_by('last_played') })
100
+ @app.command(@tree, :column, 'title', width: 280, stretch: 1)
101
+ @app.command(@tree, :column, 'last_played', width: 120, stretch: 0)
102
+ end
103
+
104
+ def bind_events
105
+ # Physical double-click fires virtual event so tests can trigger it
106
+ # directly without needing event generate <Double-Button-1> (forbidden in Tk 9).
107
+ @app.command(:bind, @tree, '<Double-Button-1>', proc {
108
+ @app.tcl_eval("event generate #{@tree} <<DoubleClick>>")
109
+ })
110
+ @app.command(:bind, @tree, '<<DoubleClick>>', proc {
111
+ iid = @app.tcl_eval("#{@tree} focus")
112
+ next if iid.to_s.empty?
113
+ rom_info = @row_data[iid]
114
+ emit(:rom_selected, rom_info.path) if rom_info
115
+ })
116
+
117
+ # Physical right-click: use %x/%y (widget-relative event coords) to
118
+ # identify the row, select and focus it, then fire the virtual event so
119
+ # tests can trigger the same code path without real pointer coordinates.
120
+ @app.tcl_eval(<<~TCL)
121
+ bind #{@tree} <Button-3> {+
122
+ set _iid [#{@tree} identify row %x %y]
123
+ if {$_iid ne {}} {
124
+ #{@tree} selection set $_iid
125
+ #{@tree} focus $_iid
126
+ event generate #{@tree} <<RightClick>>
127
+ }
128
+ }
129
+ TCL
130
+
131
+ # Virtual event reads the currently focused item. Decoupled from pointer
132
+ # position so tests can trigger it directly after setting focus.
133
+ @app.command(:bind, @tree, '<<RightClick>>', proc {
134
+ iid = @app.tcl_eval("#{@tree} focus")
135
+ rom_info = @row_data[iid.to_s]
136
+ post_row_menu(rom_info) if rom_info
137
+ })
138
+ end
139
+
140
+ def build_toolbar
141
+ sep = "#{@outer}.sep"
142
+ @app.command('ttk::separator', sep, orient: :horizontal)
143
+ @app.command(:grid, sep, row: 1, column: 0, columnspan: 2, sticky: :ew, pady: [4, 0])
144
+
145
+ toolbar = "#{@outer}.toolbar"
146
+ @app.command('ttk::frame', toolbar, padding: [4, 2])
147
+ @app.command(:grid, toolbar, row: 2, column: 0, columnspan: 2, sticky: :ew)
148
+
149
+ gear_btn = "#{toolbar}.gear"
150
+ gear_menu = "#{toolbar}.gearmenu"
151
+ @app.command('ttk::button', gear_btn, text: "\u2699", width: 1,
152
+ command: proc { post_view_menu(gear_menu, gear_btn) })
153
+ @app.command(:pack, gear_btn, side: :right)
154
+ end
155
+
156
+ def refresh
157
+ @app.tcl_eval("#{@tree} delete [#{@tree} children {}]")
158
+ @row_data.clear
159
+
160
+ roms = sorted(@rom_library.all)
161
+ roms.each do |rom|
162
+ rom_info = RomInfo.from_rom(rom, overrides: @overrides)
163
+ lp = format_last_played(rom['last_played'])
164
+ iid = @app.tcl_eval(
165
+ "#{@tree} insert {} end -values [list #{Teek.make_list(rom_info.title, lp)}]"
166
+ )
167
+ @row_data[iid] = rom_info
168
+ end
169
+ end
170
+
171
+ def sorted(roms)
172
+ sorted = roms.sort_by do |r|
173
+ case @sort_col
174
+ when 'title' then r['title'].to_s.downcase
175
+ when 'last_played' then r['last_played'] || r['added_at'] || ''
176
+ end
177
+ end
178
+ @sort_asc ? sorted : sorted.reverse
179
+ end
180
+
181
+ def sort_by(col)
182
+ if @sort_col == col
183
+ @sort_asc = !@sort_asc
184
+ else
185
+ @sort_col = col
186
+ @sort_asc = (col == 'title') # title: asc first; date: newest first
187
+ end
188
+ update_headings
189
+ refresh
190
+ end
191
+
192
+ def update_headings
193
+ ['title', 'last_played'].each do |col|
194
+ label_key = col == 'title' ? 'list_picker.columns.title' : 'list_picker.columns.last_played'
195
+ indicator = @sort_col == col ? sort_indicator : ''
196
+ @app.command(@tree, :heading, col, text: translate(label_key) + indicator)
197
+ end
198
+ end
199
+
200
+ def sort_indicator
201
+ @sort_asc ? SORT_ASC : SORT_DESC
202
+ end
203
+
204
+ def format_last_played(iso)
205
+ return translate('list_picker.never_played') if iso.to_s.empty?
206
+ require 'time'
207
+ Time.parse(iso).localtime.strftime('%b %-d, %Y')
208
+ rescue
209
+ iso.to_s
210
+ end
211
+
212
+ def post_view_menu(menu, btn)
213
+ @app.command(:menu, menu, tearoff: 0) unless @app.tcl_eval("winfo exists #{menu}") == '1'
214
+ @app.command(menu, :delete, 0, :end)
215
+ current = Gemba.user_config.picker_view
216
+ @app.command(menu, :add, :command,
217
+ label: "#{current == 'grid' ? "\u2713 " : ' '}#{translate('picker.toolbar.boxart_view')}",
218
+ command: proc { emit(:picker_view_changed, view: 'grid') })
219
+ @app.command(menu, :add, :command,
220
+ label: "#{current == 'list' ? "\u2713 " : ' '}#{translate('picker.toolbar.list_view')}",
221
+ command: proc { emit(:picker_view_changed, view: 'list') })
222
+ x = @app.tcl_eval("winfo rootx #{btn}").to_i
223
+ y = @app.tcl_eval("winfo rooty #{btn}").to_i
224
+ h = @app.tcl_eval("winfo height #{btn}").to_i
225
+ @app.tcl_eval("tk_popup #{menu} #{x} #{y + h}")
226
+ end
227
+
228
+ def post_row_menu(rom_info)
229
+ menu = "#{@tree}.ctx"
230
+ @app.command(:menu, menu, tearoff: 0) unless @app.tcl_eval("winfo exists #{menu}") == '1'
231
+ @app.command(menu, :delete, 0, :end)
232
+ @app.command(menu, :add, :command,
233
+ label: translate('game_picker.menu.play'),
234
+ command: proc { emit(:rom_selected, rom_info.path) })
235
+ qs_slot = Gemba.user_config.quick_save_slot
236
+ qs_state = quick_save_exists?(rom_info, qs_slot)
237
+ @app.command(menu, :add, :command,
238
+ label: translate('game_picker.menu.quick_load'),
239
+ state: qs_state ? :normal : :disabled,
240
+ command: proc { emit(:rom_quick_load, path: rom_info.path, slot: qs_slot) })
241
+ @app.command(menu, :add, :command,
242
+ label: translate('game_picker.menu.set_boxart'),
243
+ command: proc { pick_custom_boxart(rom_info) })
244
+ @app.command(menu, :add, :separator)
245
+ @app.command(menu, :add, :command,
246
+ label: translate('game_picker.menu.remove'),
247
+ command: proc { remove_rom(rom_info) })
248
+ @app.tcl_eval("tk_popup #{menu} [winfo pointerx .] [winfo pointery .]")
249
+ end
250
+
251
+ def quick_save_exists?(rom_info, slot)
252
+ return false unless rom_info.rom_id
253
+ state_file = File.join(Gemba.user_config.states_dir, rom_info.rom_id, "state#{slot}.ss")
254
+ File.exist?(state_file)
255
+ end
256
+
257
+ def remove_rom(rom_info)
258
+ @rom_library.remove(rom_info.rom_id)
259
+ @rom_library.save!
260
+ refresh
261
+ end
262
+
263
+ def pick_custom_boxart(rom_info)
264
+ return unless @overrides
265
+ filetypes = '{{PNG Images} {.png}}'
266
+ path = @app.tcl_eval("tk_getOpenFile -filetypes {#{filetypes}}")
267
+ return if path.to_s.strip.empty?
268
+ @overrides.set_custom_boxart(rom_info.rom_id, path)
269
+ end
270
+ end
271
+ end
@@ -142,6 +142,7 @@ settings:
142
142
  ra_username_placeholder: "Username:"
143
143
  ra_token_placeholder: "Password:"
144
144
  ra_rich_presence: "Rich Presence (per-game)"
145
+ ra_screenshot_on_unlock: "Screenshot on achievement unlock (per-game)"
145
146
  ra_hardcore: "Hardcore mode (disables save states and rewind)"
146
147
  tip_ra_password: "Your password is only used to fetch an API token and is never saved."
147
148
  ra_login: "Login"
@@ -185,6 +186,17 @@ game_picker:
185
186
  set_boxart: "Set Boxart"
186
187
  remove: "Remove from Library"
187
188
 
189
+ picker:
190
+ toolbar:
191
+ boxart_view: "Box art view"
192
+ list_view: "List view"
193
+
194
+ list_picker:
195
+ columns:
196
+ title: "Title"
197
+ last_played: "Last Played"
198
+ never_played: "Never"
199
+
188
200
  rom_info:
189
201
  title: "ROM Info"
190
202
  field_title: "Title:"
@@ -142,6 +142,7 @@ settings:
142
142
  ra_username_placeholder: "ユーザー名:"
143
143
  ra_token_placeholder: "パスワード:"
144
144
  ra_rich_presence: "リッチプレゼンス(ゲームごと)"
145
+ ra_screenshot_on_unlock: "実績解除時にスクリーンショット(ゲームごと)"
145
146
  ra_hardcore: "ハードコアモード(セーブステートと巻き戻し無効)"
146
147
  tip_ra_password: "パスワードはAPIトークン取得のみに使用され、保存されません。"
147
148
  ra_login: "ログイン"
@@ -185,6 +186,17 @@ game_picker:
185
186
  set_boxart: "カスタム画像を設定"
186
187
  remove: "ライブラリから削除"
187
188
 
189
+ picker:
190
+ toolbar:
191
+ boxart_view: "ボックスアート表示"
192
+ list_view: "リスト表示"
193
+
194
+ list_picker:
195
+ columns:
196
+ title: "タイトル"
197
+ last_played: "最終プレイ日"
198
+ never_played: "未プレイ"
199
+
188
200
  rom_info:
189
201
  title: "ROM情報"
190
202
  field_title: "タイトル:"
@@ -106,9 +106,10 @@ module Gemba
106
106
  byte = io.read(1)
107
107
  raise "Truncated UPS patch (varint read past end)" if byte.nil?
108
108
  b = byte.getbyte(0)
109
- value |= (b & 0x7f) << shift
109
+ value += (b & 0x7f) << shift
110
110
  break if (b & 0x80) != 0
111
111
  shift += 7
112
+ value += 1 << shift
112
113
  end
113
114
  value
114
115
  end
@@ -25,6 +25,7 @@ module Gemba
25
25
  RA_FEEDBACK_LABEL = "#{FRAME}.ra_feedback"
26
26
  RA_HARDCORE_CHECK = "#{FRAME}.ra_hardcore_row.check"
27
27
  RA_RICH_PRESENCE_CHECK = "#{FRAME}.ra_rich_presence_row.check"
28
+ RA_SCREENSHOT_CHECK = "#{FRAME}.ra_screenshot_row.check"
28
29
 
29
30
  VAR_BIOS_PATH = '::gemba_bios_path'
30
31
  VAR_SKIP_BIOS = '::gemba_skip_bios'
@@ -34,7 +35,8 @@ module Gemba
34
35
  VAR_RA_HARDCORE = '::gemba_ra_hardcore'
35
36
  VAR_RA_UNOFFICIAL = '::gemba_ra_unofficial'
36
37
  VAR_RA_PASSWORD = '::gemba_ra_password'
37
- VAR_RA_RICH_PRESENCE = '::gemba_ra_rich_presence'
38
+ VAR_RA_RICH_PRESENCE = '::gemba_ra_rich_presence'
39
+ VAR_RA_SCREENSHOT = '::gemba_ra_screenshot_on_unlock'
38
40
 
39
41
  RA_UNOFFICIAL_CHECK = "#{FRAME}.ra_unofficial_row.check"
40
42
 
@@ -69,7 +71,8 @@ module Gemba
69
71
  @app.set_variable(VAR_RA_USERNAME, config.ra_username.to_s)
70
72
  @app.set_variable(VAR_RA_HARDCORE, config.ra_hardcore? ? '1' : '0')
71
73
  @app.set_variable(VAR_RA_UNOFFICIAL, config.ra_unofficial? ? '1' : '0')
72
- @app.set_variable(VAR_RA_RICH_PRESENCE, config.ra_rich_presence? ? '1' : '0')
74
+ @app.set_variable(VAR_RA_RICH_PRESENCE, config.ra_rich_presence? ? '1' : '0')
75
+ @app.set_variable(VAR_RA_SCREENSHOT, config.ra_screenshot_on_unlock? ? '1' : '0')
73
76
  @app.set_variable(VAR_RA_PASSWORD, '')
74
77
 
75
78
  @presenter&.dispose
@@ -85,7 +88,8 @@ module Gemba
85
88
  config.ra_username = @presenter ? @presenter.username : @app.get_variable(VAR_RA_USERNAME).to_s.strip
86
89
  config.ra_token = @presenter ? @presenter.token : ''
87
90
  config.ra_hardcore = @app.get_variable(VAR_RA_HARDCORE) == '1'
88
- config.ra_rich_presence = @app.get_variable(VAR_RA_RICH_PRESENCE) == '1'
91
+ config.ra_rich_presence = @app.get_variable(VAR_RA_RICH_PRESENCE) == '1'
92
+ config.ra_screenshot_on_unlock = @app.get_variable(VAR_RA_SCREENSHOT) == '1'
89
93
  # Password is never persisted — ephemeral field only
90
94
  end
91
95
 
@@ -252,6 +256,17 @@ module Gemba
252
256
  command: proc { @mark_dirty.call })
253
257
  @app.command(:pack, RA_RICH_PRESENCE_CHECK, side: :left)
254
258
 
259
+ # Screenshot on achievement unlock (per-game)
260
+ ss_row = "#{FRAME}.ra_screenshot_row"
261
+ @app.command('ttk::frame', ss_row)
262
+ @app.command(:pack, ss_row, fill: :x, padx: 10, pady: [0, 4])
263
+ @app.set_variable(VAR_RA_SCREENSHOT, '1')
264
+ @app.command('ttk::checkbutton', RA_SCREENSHOT_CHECK,
265
+ text: translate('settings.ra_screenshot_on_unlock'),
266
+ variable: VAR_RA_SCREENSHOT,
267
+ command: proc { @mark_dirty.call })
268
+ @app.command(:pack, RA_SCREENSHOT_CHECK, side: :left)
269
+
255
270
  # TODO: hardcore mode — not yet wired up, hidden until ready
256
271
  # hardcore_row = "#{FRAME}.ra_hardcore_row"
257
272
  # @app.command('ttk::frame', hardcore_row)
data/lib/gemba/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gemba
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.1"
5
5
  end
@@ -123,6 +123,7 @@ module TeekTestHelper
123
123
  env['VISUAL'] = '1' if ENV['VISUAL']
124
124
  env['COVERAGE'] = '1' if ENV['COVERAGE']
125
125
  env['GEMBA_BGERROR_LOG'] = bgerror_path
126
+ env['GEMBA_CONFIG_DIR'] = Dir.mktmpdir('gemba-test-config')
126
127
 
127
128
  # -rbundler/setup activates Bundler in the subprocess so path: gems
128
129
  # (e.g. teek, teek-sdl2 from sibling repos) are on the load path.
@@ -37,22 +37,32 @@ class FakeRequester
37
37
  attr_reader :requests
38
38
 
39
39
  def initialize
40
- @stubs = {} # r_string => [json_or_nil, ok_bool]
40
+ @stubs = {} # r_string => Array<[json_or_nil, ok_bool]>
41
41
  @requests = [] # all params hashes, in call order
42
42
  end
43
43
 
44
44
  # Register a canned response for a given r= value.
45
+ # Overwrites any previous stub — the response is reused for every call
46
+ # until replaced. Use stub_queue for ordered sequential responses.
45
47
  def stub(r:, body: nil, ok: true)
46
- @stubs[r.to_s] = [body, ok]
48
+ @stubs[r.to_s] = [[body, ok]]
49
+ end
50
+
51
+ # Enqueue an additional response for a given r= value.
52
+ # Each queued entry is consumed once; the last entry is reused once exhausted.
53
+ def stub_queue(r:, body: nil, ok: true)
54
+ (@stubs[r.to_s] ||= []) << [body, ok]
47
55
  end
48
56
 
49
57
  # Called by ra_request with the same signature as Teek::BackgroundWork.new.
50
58
  # Ignores the block (which contains real Net::HTTP code) and returns a
51
59
  # Result that fires on_progress synchronously with the canned response.
52
- def call(_app, params, mode: nil, **_opts, &_block)
60
+ def call(_app, params, mode: nil, worker: nil, **_opts, &_block)
53
61
  @requests << params.dup
54
62
  r = (params[:r] || params["r"]).to_s
55
- result = @stubs.fetch(r, [nil, false])
63
+ queue = @stubs.fetch(r, [[nil, false]])
64
+ result = queue.size > 1 ? queue.shift : queue.first
65
+ result = [result[1] ? true : false, params[:a].to_s] if worker
56
66
  Result.new(result)
57
67
  end
58
68
 
data/test/test_config.rb CHANGED
@@ -746,7 +746,8 @@ class TestMGBAConfig < Minitest::Test
746
746
 
747
747
  def test_per_game_settings_constant_keys
748
748
  expected = %w[scale pixel_filter integer_scale color_correction frame_blending
749
- volume muted turbo_speed quick_save_slot save_state_backup ra_rich_presence]
749
+ volume muted turbo_speed quick_save_slot save_state_backup ra_rich_presence
750
+ ra_screenshot_on_unlock]
750
751
  assert_equal expected.sort, Gemba::Config::PER_GAME_SETTINGS.keys.sort
751
752
  end
752
753
 
@@ -22,13 +22,13 @@ class TestGamePickerFrame < Minitest::Test
22
22
  picker.show
23
23
 
24
24
  16.times do |i|
25
- title = app.command(".game_picker.card#{i}.title", :cget, '-text')
25
+ title = app.command(".game_picker.cards.card#{i}.title", :cget, '-text')
26
26
  assert_equal '', title, "Card #{i} title should be empty"
27
27
 
28
- img = app.command(".game_picker.card#{i}.img", :cget, '-image')
28
+ img = app.command(".game_picker.cards.card#{i}.img", :cget, '-image')
29
29
  assert_equal 'boxart_placeholder', img, "Card #{i} should show placeholder image"
30
30
 
31
- bg = app.command(".game_picker.card#{i}", :cget, '-bg')
31
+ bg = app.command(".game_picker.cards.card#{i}", :cget, '-bg')
32
32
  refute_equal '#2a2a2a', bg, "Card #{i} should not have populated background"
33
33
  end
34
34
 
@@ -47,8 +47,8 @@ class TestGamePickerFrame < Minitest::Test
47
47
  picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib)
48
48
  picker.show
49
49
 
50
- assert_equal 'Pokemon - Ruby Version (USA, Europe)', app.command('.game_picker.card0.title', :cget, '-text')
51
- assert_equal 'GBA', app.command('.game_picker.card0.plat', :cget, '-text')
50
+ assert_equal 'Pokemon - Ruby Version (USA, Europe)', app.command('.game_picker.cards.card0.title', :cget, '-text')
51
+ assert_equal 'GBA', app.command('.game_picker.cards.card0.plat', :cget, '-text')
52
52
 
53
53
  picker.cleanup
54
54
  app.command(:destroy, '.game_picker') rescue nil
@@ -64,7 +64,7 @@ class TestGamePickerFrame < Minitest::Test
64
64
  picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib)
65
65
  picker.show
66
66
 
67
- assert_equal 'GBC', app.command('.game_picker.card0.plat', :cget, '-text')
67
+ assert_equal 'GBC', app.command('.game_picker.cards.card0.plat', :cget, '-text')
68
68
 
69
69
  picker.cleanup
70
70
  app.command(:destroy, '.game_picker') rescue nil
@@ -80,7 +80,7 @@ class TestGamePickerFrame < Minitest::Test
80
80
  picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib)
81
81
  picker.show
82
82
 
83
- assert_equal 'MY-ROM', app.command('.game_picker.card0.title', :cget, '-text')
83
+ assert_equal 'MY-ROM', app.command('.game_picker.cards.card0.title', :cget, '-text')
84
84
 
85
85
  picker.cleanup
86
86
  app.command(:destroy, '.game_picker') rescue nil
@@ -96,8 +96,8 @@ class TestGamePickerFrame < Minitest::Test
96
96
  picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib)
97
97
  picker.show
98
98
 
99
- pop_bg = app.command('.game_picker.card0', :cget, '-bg')
100
- hollow_bg = app.command('.game_picker.card1', :cget, '-bg')
99
+ pop_bg = app.command('.game_picker.cards.card0', :cget, '-bg')
100
+ hollow_bg = app.command('.game_picker.cards.card1', :cget, '-bg')
101
101
 
102
102
  assert_equal '#2a2a2a', pop_bg, "Populated card background"
103
103
  refute_equal pop_bg, hollow_bg, "Hollow card should differ from populated"
@@ -119,11 +119,11 @@ class TestGamePickerFrame < Minitest::Test
119
119
  picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib)
120
120
  picker.show
121
121
 
122
- assert_equal 'Alpha', app.command('.game_picker.card0.title', :cget, '-text')
123
- assert_equal 'GBA', app.command('.game_picker.card0.plat', :cget, '-text')
124
- assert_equal 'Beta', app.command('.game_picker.card1.title', :cget, '-text')
125
- assert_equal 'GBC', app.command('.game_picker.card1.plat', :cget, '-text')
126
- assert_equal '', app.command('.game_picker.card2.title', :cget, '-text')
122
+ assert_equal 'Alpha', app.command('.game_picker.cards.card0.title', :cget, '-text')
123
+ assert_equal 'GBA', app.command('.game_picker.cards.card0.plat', :cget, '-text')
124
+ assert_equal 'Beta', app.command('.game_picker.cards.card1.title', :cget, '-text')
125
+ assert_equal 'GBC', app.command('.game_picker.cards.card1.plat', :cget, '-text')
126
+ assert_equal '', app.command('.game_picker.cards.card2.title', :cget, '-text')
127
127
 
128
128
  picker.cleanup
129
129
  app.command(:destroy, '.game_picker') rescue nil
@@ -154,7 +154,7 @@ class TestGamePickerFrame < Minitest::Test
154
154
  picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib, boxart_fetcher: fetcher)
155
155
  picker.show
156
156
 
157
- img_name = app.command('.game_picker.card0.img', :cget, '-image')
157
+ img_name = app.command('.game_picker.cards.card0.img', :cget, '-image')
158
158
  assert_equal "boxart_#{game_code}", img_name,
159
159
  "Card should display cached boxart image, not the placeholder"
160
160
 
@@ -175,7 +175,7 @@ class TestGamePickerFrame < Minitest::Test
175
175
  picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib)
176
176
  picker.show
177
177
 
178
- img_name = app.command('.game_picker.card0.img', :cget, '-image')
178
+ img_name = app.command('.game_picker.cards.card0.img', :cget, '-image')
179
179
  assert_equal 'boxart_placeholder', img_name
180
180
 
181
181
  picker.cleanup
@@ -206,12 +206,12 @@ class TestGamePickerFrame < Minitest::Test
206
206
  # Suppress tk_popup so the right-click binding configures menu entries
207
207
  # without posting the menu (no platform grab → no blocking app.update).
208
208
  override_tk_popup do
209
- app.tcl_eval("event generate .game_picker.card0 <Button-3> -x 10 -y 10")
209
+ app.tcl_eval("event generate .game_picker.cards.card0 <Button-3> -x 10 -y 10")
210
210
  app.update
211
211
  end
212
212
 
213
213
  # index 1 = Quick Load
214
- state = app.tcl_eval(".game_picker.card0.ctx entrycget 1 -state")
214
+ state = app.tcl_eval(".game_picker.cards.card0.ctx entrycget 1 -state")
215
215
  assert_equal 'disabled', state
216
216
 
217
217
  picker.cleanup
@@ -246,12 +246,12 @@ class TestGamePickerFrame < Minitest::Test
246
246
  app.update
247
247
 
248
248
  override_tk_popup do
249
- app.tcl_eval("event generate .game_picker.card0 <Button-3> -x 10 -y 10")
249
+ app.tcl_eval("event generate .game_picker.cards.card0 <Button-3> -x 10 -y 10")
250
250
  app.update
251
251
  end
252
252
 
253
253
  # index 1 = Quick Load
254
- state = app.tcl_eval(".game_picker.card0.ctx entrycget 1 -state")
254
+ state = app.tcl_eval(".game_picker.cards.card0.ctx entrycget 1 -state")
255
255
  assert_equal 'normal', state
256
256
 
257
257
  picker.cleanup
@@ -290,13 +290,13 @@ class TestGamePickerFrame < Minitest::Test
290
290
  app.update
291
291
 
292
292
  override_tk_popup do
293
- app.tcl_eval("event generate .game_picker.card0 <Button-3> -x 10 -y 10")
293
+ app.tcl_eval("event generate .game_picker.cards.card0 <Button-3> -x 10 -y 10")
294
294
  app.update
295
295
  end
296
296
 
297
297
  # Menu was built but not shown — invoke the Quick Load entry directly.
298
298
  # index 1 = Quick Load
299
- app.tcl_eval(".game_picker.card0.ctx invoke 1")
299
+ app.tcl_eval(".game_picker.cards.card0.ctx invoke 1")
300
300
  app.update
301
301
 
302
302
  assert_equal rom_path, received[:path]