Skip to content

Commit 9bea12a

Browse files
miss-islingtonserhiy-storchakaclaude
authored
[3.15] gh-69134: Harden tkinter GUI tests that depend on a mapped widget (GH-152499) (GH-152516)
Add wait_until_mapped() and AbstractTkTest.require_mapped() to test_tkinter.support and use them to guard the assertions that need a widget to be actually mapped (winfo_width(), identify(), coords(), ...). This avoids intermittent failures under window managers that do not map the widget promptly, without skipping the unrelated checks. (cherry picked from commit 0fff6bd) Co-authored-by: Serhiy Storchaka <storchaka@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2a4ffa6 commit 9bea12a

4 files changed

Lines changed: 85 additions & 27 deletions

File tree

‎Lib/test/test_tkinter/support.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
importfunctools
2+
importtime
23
importtkinter
34
importunittest
45
fromtestimportsupport
@@ -45,6 +46,20 @@ def tearDown(self):
4546
w.destroy()
4647
self.root.withdraw()
4748

49+
defrequire_mapped(self, widget, timeout=None):
50+
"""Realize *widget*, or skip the test if the window manager will
51+
not map it (e.g. a tiling WM or a headless/contended display).
52+
53+
Use this instead of a bare update() before querying realized
54+
geometry (winfo_width(), identify(), coords(), place_info(), ...).
55+
See gh-69134, gh-74941 and bpo-40722.
56+
"""
57+
iftimeoutisNone:
58+
timeout=support.LOOPBACK_TIMEOUT
59+
ifnotwait_until_mapped(widget, timeout):
60+
self.skipTest('widget was not mapped by the window manager '
61+
f'(timed out after {timeout:g}s)')
62+
4863

4964
classAbstractDefaultRootTest:
5065

@@ -78,6 +93,32 @@ def destroy_default_root():
7893
tkinter._default_root.destroy()
7994
tkinter._default_root=None
8095

96+
defwait_until_mapped(widget, timeout=None):
97+
"""Wait until *widget* is actually mapped and laid out by the window
98+
manager, so that realized-geometry queries (winfo_width(), identify(),
99+
coords(), ...) return meaningful values.
100+
101+
Return True once the widget is mapped with a non-trivial size, or False
102+
if that has not happened within *timeout* seconds (default:
103+
``support.LOOPBACK_TIMEOUT``). Unlike Misc.wait_visibility(), this
104+
never blocks indefinitely, so it is safe under a window manager that
105+
never maps the window (see gh-69134, gh-74941, bpo-40722).
106+
"""
107+
iftimeoutisNone:
108+
timeout=support.LOOPBACK_TIMEOUT
109+
deadline=time.monotonic() +timeout
110+
widget.update_idletasks()
111+
whileTrue:
112+
widget.update() # drain pending Map/Configure events
113+
if (widget.winfo_ismapped()
114+
andwidget.winfo_width() >1
115+
andwidget.winfo_height() >1):
116+
returnTrue
117+
iftime.monotonic() >=deadline:
118+
returnFalse
119+
time.sleep(0.01)
120+
121+
81122
defsimulate_mouse_click(widget, x, y):
82123
"""Generate proper events to click at the x, y position (tries to act
83124
like an X server)."""

‎Lib/test/test_tkinter/test_widgets.py‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
fromtest.test_tkinter.supportimportsetUpModule# noqa: F401
88
fromtest.test_tkinter.supportimport (requires_tk, tk_version,
99
get_tk_patchlevel, widget_eq,
10+
wait_until_mapped,
1011
AbstractDefaultRootTest)
1112

1213
fromtest.test_tkinter.widget_testsimport (
@@ -754,10 +755,11 @@ def test_invoke(self):
754755
deftest_identify(self):
755756
widget=self.create()
756757
widget.pack()
757-
widget.update_idletasks()
758-
# The empty string is returned for a point over no element.
759-
self.assertIn(widget.identify(5, 5),
760-
('entry', 'buttonup', 'buttondown', 'none', ''))
758+
# Identifying the element under a point requires the widget to be
759+
# mapped with a real size.
760+
ifwait_until_mapped(widget):
761+
self.assertIn(widget.identify(5, 5),
762+
('entry', 'buttonup', 'buttondown', 'none'))
761763
self.assertRaises(TclError, widget.identify, 'a', 'b')
762764

763765
deftest_scan(self):
@@ -2096,9 +2098,11 @@ def test_delta(self):
20962098
deftest_identify(self):
20972099
sb=self.create()
20982100
sb.pack(fill='y', expand=True)
2099-
sb.update_idletasks()
2100-
self.assertIn(sb.identify(5, 5),
2101-
('arrow1', 'arrow2', 'slider', 'trough1', 'trough2', ''))
2101+
# Identifying the element under a point requires the widget to be
2102+
# mapped with a real size.
2103+
ifwait_until_mapped(sb):
2104+
self.assertIn(sb.identify(5, 5),
2105+
('arrow1', 'arrow2', 'slider', 'trough1', 'trough2'))
21022106
self.assertRaises(TclError, sb.identify, 'a', 'b')
21032107

21042108

@@ -2218,10 +2222,12 @@ def test_identify(self):
22182222
p, b, c=self.create2()
22192223
p.configure(width=200, height=50)
22202224
p.pack()
2221-
p.update()
2222-
x, y=p.sash_coord(0)
2223-
# A point over the sash reports the sash.
2224-
self.assertIn('sash', p.identify(x+1, y+5))
2225+
# Locating the sash requires the widget to be mapped with a real
2226+
# size; the rest of the checks do not.
2227+
ifwait_until_mapped(p):
2228+
x, y=p.sash_coord(0)
2229+
# A point over the sash reports the sash.
2230+
self.assertIn('sash', p.identify(x+1, y+5))
22252231
# A point over a pane reports nothing.
22262232
self.assertFalse(p.identify(2, 2))
22272233
self.assertRaises(TclError, p.identify, 'a', 'b')

‎Lib/test/test_ttk/test_extensions.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def check_positions(scale, scale_pos, label, label_pos):
109109
deftest_horizontal_range(self):
110110
lscale=ttk.LabeledScale(self.root, from_=0, to=10)
111111
lscale.pack()
112-
lscale.update()
112+
self.require_mapped(lscale)
113113

114114
linfo_1=lscale.label.place_info()
115115
prev_xcoord=lscale.scale.coords()[0]
@@ -138,7 +138,7 @@ def test_horizontal_range(self):
138138
deftest_variable_change(self):
139139
x=ttk.LabeledScale(self.root)
140140
x.pack()
141-
x.update()
141+
self.require_mapped(x)
142142

143143
curr_xcoord=x.scale.coords()[0]
144144
newval=x.value+1
@@ -181,7 +181,7 @@ def test_resize(self):
181181
x=ttk.LabeledScale(self.root)
182182
x.pack(expand=True, fill='both')
183183
gc_collect() # For PyPy or other GCs.
184-
x.update()
184+
self.require_mapped(x)
185185

186186
width, height=x.master.winfo_width(), x.master.winfo_height()
187187
width_new, height_new=width*2, height*2

‎Lib/test/test_ttk/test_widgets.py‎

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
fromtest.test_tkinter.supportimportsetUpModule# noqa: F401
99
fromtest.test_tkinter.supportimport (
1010
AbstractTkTest, requires_tk, tk_version, get_tk_patchlevel,
11-
simulate_mouse_click, AbstractDefaultRootTest)
11+
simulate_mouse_click, wait_until_mapped, AbstractDefaultRootTest)
1212
fromtest.test_tkinter.widget_testsimport (add_configure_tests,
1313
AbstractWidgetTest, StandardOptionsTests, IntegerSizeTests, PixelSizeTests)
1414

@@ -78,11 +78,13 @@ def setUp(self):
7878
self.widget.pack()
7979

8080
deftest_identify(self):
81-
self.widget.update()
82-
self.assertEqual(self.widget.identify(
83-
int(self.widget.winfo_width() /2),
84-
int(self.widget.winfo_height() /2)
85-
), "label")
81+
# Identifying the element under a point requires the widget to be
82+
# mapped with a real size; the rest of the checks do not.
83+
ifwait_until_mapped(self.widget):
84+
self.assertEqual(self.widget.identify(
85+
int(self.widget.winfo_width() /2),
86+
int(self.widget.winfo_height() /2)
87+
), "label")
8688
self.assertEqual(self.widget.identify(-1, -1), "")
8789

8890
self.assertRaises(tkinter.TclError, self.widget.identify, None, 5)
@@ -385,9 +387,11 @@ def test_identify(self):
385387
self.skipTest('Test does not work on macOS Tk 9.')
386388
# https://core.tcl-lang.org/tk/tktview/8b49e9cfa6
387389
self.entry.pack()
388-
self.root.update()
389390

390-
self.assertIn(self.entry.identify(5, 5), self.IDENTIFY_AS)
391+
# Identifying the element under a point requires the widget to be
392+
# mapped with a real size; the rest of the checks do not.
393+
ifwait_until_mapped(self.entry):
394+
self.assertIn(self.entry.identify(5, 5), self.IDENTIFY_AS)
391395
self.assertEqual(self.entry.identify(-1, -1), "")
392396

393397
self.assertRaises(tkinter.TclError, self.entry.identify, None, 5)
@@ -506,7 +510,7 @@ def test_virtual_event(self):
506510
self.combo.bind('<<ComboboxSelected>>',
507511
lambdaevt: success.append(True))
508512
self.combo.pack()
509-
self.combo.update()
513+
self.require_mapped(self.combo)
510514

511515
height=self.combo.winfo_height()
512516
self._show_drop_down_listbox()
@@ -525,7 +529,7 @@ def test_configure_postcommand(self):
525529

526530
self.combo['postcommand'] =lambda: success.append(True)
527531
self.combo.pack()
528-
self.combo.update()
532+
self.require_mapped(self.combo)
529533

530534
self._show_drop_down_listbox()
531535
self.assertTrue(success)
@@ -875,8 +879,10 @@ def test_get(self):
875879
else:
876880
conv=float
877881

878-
scale_width=self.scale.winfo_width()
879-
self.assertEqual(self.scale.get(scale_width, 0), self.scale['to'])
882+
# Reading the value at the far edge needs the realized width.
883+
ifwait_until_mapped(self.scale):
884+
scale_width=self.scale.winfo_width()
885+
self.assertEqual(self.scale.get(scale_width, 0), self.scale['to'])
880886

881887
self.assertEqual(conv(self.scale.get(0, 0)), conv(self.scale['from']))
882888
self.assertEqual(self.scale.get(), self.scale['value'])
@@ -918,7 +924,10 @@ def test_set(self):
918924
# nevertheless, note that the max/min values we can get specifying
919925
# x, y coords are the ones according to the current range
920926
self.assertEqual(conv(self.scale.get(0, 0)), min)
921-
self.assertEqual(conv(self.scale.get(self.scale.winfo_width(), 0)), max)
927+
# Reading the value at the far edge needs the realized width.
928+
ifwait_until_mapped(self.scale):
929+
self.assertEqual(
930+
conv(self.scale.get(self.scale.winfo_width(), 0)), max)
922931

923932
self.assertRaises(tkinter.TclError, self.scale.set, None)
924933

@@ -1269,6 +1278,7 @@ def create(self, **kwargs):
12691278
returnttk.Spinbox(self.root, **kwargs)
12701279

12711280
def_click_increment_arrow(self):
1281+
self.require_mapped(self.spin)
12721282
width=self.spin.winfo_width()
12731283
height=self.spin.winfo_height()
12741284
x=width-5
@@ -1279,6 +1289,7 @@ def _click_increment_arrow(self):
12791289
self.spin.update_idletasks()
12801290

12811291
def_click_decrement_arrow(self):
1292+
self.require_mapped(self.spin)
12821293
width=self.spin.winfo_width()
12831294
height=self.spin.winfo_height()
12841295
x=width-5

0 commit comments

Comments
 (0)