[3.6] bpo-30870: IDLE: Add configdialog fontlist selection unittest (GH-2666) (#2701)

Initial patch by Louie Lu.
(cherry picked from commit 9b622fb)
diff --git a/Lib/idlelib/configdialog.py b/Lib/idlelib/configdialog.py
index a2cfaab..0d68b80 100644
--- a/Lib/idlelib/configdialog.py
+++ b/Lib/idlelib/configdialog.py
@@ -47,7 +47,8 @@
         self.parent = parent
         if _htest:
             parent.instance_dict = {}
-        self.withdraw()
+        if not _utest:
+            self.withdraw()
 
         self.configure(borderwidth=5)
         self.title(title or 'IDLE Preferences')
@@ -76,7 +77,6 @@
         self.create_widgets()
         self.resizable(height=FALSE, width=FALSE)
         self.transient(parent)
-        self.grab_set()
         self.protocol("WM_DELETE_WINDOW", self.cancel)
         self.fontlist.focus_set()
         # XXX Decide whether to keep or delete these key bindings.
@@ -88,6 +88,7 @@
         self.attach_var_callbacks()  # Avoid callbacks during load_configs.
 
         if not _utest:
+            self.grab_set()
             self.wm_deiconify()
             self.wait_window()
 
diff --git a/Lib/idlelib/idle_test/mock_idle.py b/Lib/idlelib/idle_test/mock_idle.py
index c7b49ef..8f3147b 100644
--- a/Lib/idlelib/idle_test/mock_idle.py
+++ b/Lib/idlelib/idle_test/mock_idle.py
@@ -6,24 +6,25 @@
 from idlelib.idle_test.mock_tk import Text
 
 class Func:
-    '''Mock function captures args and returns result set by test.
+    '''Record call, capture args, return/raise result set by test.
 
-    Attributes:
-    self.called - records call even if no args, kwds passed.
-    self.result - set by init, returned by call.
-    self.args - captures positional arguments.
-    self.kwds - captures keyword arguments.
+    When mock function is called, set or use attributes:
+    self.called - increment call number even if no args, kwds passed.
+    self.args - capture positional arguments.
+    self.kwds - capture keyword arguments.
+    self.result - return or raise value set in __init__.
 
-    Most common use will probably be to mock methods.
+    Most common use will probably be to mock instance methods.
+    Given class instance, can set and delete as instance attribute.
     Mock_tk.Var and Mbox_func are special variants of this.
     '''
     def __init__(self, result=None):
-        self.called = False
+        self.called = 0
         self.result = result
         self.args = None
         self.kwds = None
     def __call__(self, *args, **kwds):
-        self.called = True
+        self.called += 1
         self.args = args
         self.kwds = kwds
         if isinstance(self.result, BaseException):
diff --git a/Lib/idlelib/idle_test/test_configdialog.py b/Lib/idlelib/idle_test/test_configdialog.py
index 26b045d..4f1f9af 100644
--- a/Lib/idlelib/idle_test/test_configdialog.py
+++ b/Lib/idlelib/idle_test/test_configdialog.py
@@ -1,7 +1,7 @@
 """Test idlelib.configdialog.
 
 Half the class creates dialog, half works with user customizations.
-Coverage: 46% just by creating dialog, 56% with current tests.
+Coverage: 46% just by creating dialog, 60% with current tests.
 """
 from idlelib.configdialog import ConfigDialog, idleConf, changes
 from test.support import requires
@@ -9,6 +9,7 @@
 from tkinter import Tk
 import unittest
 import idlelib.config as config
+from idlelib.idle_test.mock_idle import Func
 
 # Tests should not depend on fortuitous user configurations.
 # They must not affect actual user .cfg files.
@@ -22,27 +23,29 @@
 }
 
 root = None
-configure = None
+dialog = None
 mainpage = changes['main']
 highpage = changes['highlight']
 keyspage = changes['keys']
 
-class TestDialog(ConfigDialog): pass  # Delete?
+
+class TestDialog(ConfigDialog):
+    pass  # Delete?
 
 
 def setUpModule():
-    global root, configure
+    global root, dialog
     idleConf.userCfg = testcfg
     root = Tk()
-    root.withdraw()
-    configure = TestDialog(root, 'Test', _utest=True)
+    # root.withdraw()    # Comment out, see issue 30870
+    dialog = TestDialog(root, 'Test', _utest=True)
 
 
 def tearDownModule():
-    global root, configure
+    global root, dialog
     idleConf.userCfg = usercfg
-    configure.remove_var_callbacks()
-    del configure
+    dialog.remove_var_callbacks()
+    del dialog
     root.update_idletasks()
     root.destroy()
     del root
@@ -58,31 +61,105 @@
         default_font = idleConf.GetFont(root, 'main', 'EditorWindow')
         default_size = str(default_font[1])
         default_bold = default_font[2] == 'bold'
-        configure.font_name.set('Test Font')
+        dialog.font_name.set('Test Font')
         expected = {'EditorWindow': {'font': 'Test Font',
                                      'font-size': default_size,
                                      'font-bold': str(default_bold)}}
         self.assertEqual(mainpage, expected)
         changes.clear()
-        configure.font_size.set(20)
+        dialog.font_size.set(20)
         expected = {'EditorWindow': {'font': 'Test Font',
                                      'font-size': '20',
                                      'font-bold': str(default_bold)}}
         self.assertEqual(mainpage, expected)
         changes.clear()
-        configure.font_bold.set(not default_bold)
+        dialog.font_bold.set(not default_bold)
         expected = {'EditorWindow': {'font': 'Test Font',
                                      'font-size': '20',
                                      'font-bold': str(not default_bold)}}
         self.assertEqual(mainpage, expected)
 
-    #def test_sample(self): pass  # TODO
+    def test_set_sample(self):
+        # Set_font_sample also sets highlight_sample.
+        pass
 
     def test_tabspace(self):
-        configure.space_num.set(6)
+        dialog.space_num.set(6)
         self.assertEqual(mainpage, {'Indent': {'num-spaces': '6'}})
 
 
+class FontSelectTest(unittest.TestCase):
+    # These two functions test that selecting a new font in the
+    # list of fonts changes font_name and calls set_font_sample.
+    # The fontlist widget and on_fontlist_select event handler
+    # are tested here together.
+
+    @classmethod
+    def setUpClass(cls):
+        if dialog.fontlist.size() < 2:
+            cls.skipTest('need at least 2 fonts')
+        dialog.set_font_sample = Func()  # Mask instance method.
+
+    @classmethod
+    def tearDownClass(cls):
+        del dialog.set_font_sample  # Unmask instance method.
+
+    def setUp(self):
+        dialog.set_font_sample.called = 0
+        changes.clear()
+
+    def test_select_font_key(self):
+        # Up and Down keys should select a new font.
+
+        fontlist = dialog.fontlist
+        fontlist.activate(0)
+        font = dialog.fontlist.get('active')
+
+        # Test Down key.
+        fontlist.focus_force()
+        fontlist.update()
+        fontlist.event_generate('<Key-Down>')
+        fontlist.event_generate('<KeyRelease-Down>')
+
+        down_font = fontlist.get('active')
+        self.assertNotEqual(down_font, font)
+        self.assertIn(dialog.font_name.get(), down_font.lower())
+        self.assertEqual(dialog.set_font_sample.called, 1)
+
+        # Test Up key.
+        fontlist.focus_force()
+        fontlist.update()
+        fontlist.event_generate('<Key-Up>')
+        fontlist.event_generate('<KeyRelease-Up>')
+
+        up_font = fontlist.get('active')
+        self.assertEqual(up_font, font)
+        self.assertIn(dialog.font_name.get(), up_font.lower())
+        self.assertEqual(dialog.set_font_sample.called, 2)
+
+    def test_select_font_mouse(self):
+        # Click on item should select that item.
+
+        fontlist = dialog.fontlist
+        fontlist.activate(0)
+
+        # Select next item in listbox
+        fontlist.focus_force()
+        fontlist.see(1)
+        fontlist.update()
+        x, y, dx, dy = fontlist.bbox(1)
+        x += dx // 2
+        y += dy // 2
+        fontlist.event_generate('<Button-1>', x=x, y=y)
+        fontlist.event_generate('<ButtonRelease-1>', x=x, y=y)
+
+        font1 = fontlist.get(1)
+        select_font = fontlist.get('anchor')
+        self.assertEqual(select_font, font1)
+        self.assertIn(dialog.font_name.get(), font1.lower())
+        self.assertEqual(dialog.set_font_sample.called, 1)
+
+
 class HighlightTest(unittest.TestCase):
 
     def setUp(self):
@@ -103,19 +180,19 @@
         changes.clear()
 
     def test_startup(self):
-        configure.radio_startup_edit.invoke()
+        dialog.radio_startup_edit.invoke()
         self.assertEqual(mainpage,
                          {'General': {'editor-on-startup': '1'}})
 
     def test_autosave(self):
-        configure.radio_save_auto.invoke()
+        dialog.radio_save_auto.invoke()
         self.assertEqual(mainpage, {'General': {'autosave': '1'}})
 
     def test_editor_size(self):
-        configure.entry_win_height.insert(0, '1')
+        dialog.entry_win_height.insert(0, '1')
         self.assertEqual(mainpage, {'EditorWindow': {'height': '140'}})
         changes.clear()
-        configure.entry_win_width.insert(0, '1')
+        dialog.entry_win_width.insert(0, '1')
         self.assertEqual(mainpage, {'EditorWindow': {'width': '180'}})
 
     #def test_help_sources(self): pass  # TODO