From ca34830e643fd358c1c64459ea725b70dc86f07b Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 23 Aug 2026 09:56:40 +0300 Subject: [PATCH 1/4] Restore the placeholder import in idlelib/idle_test/template.py It was removed as an unused import in GH-151478, but template.py is a skeleton for creating new IDLE test files, and idle_test/README.txt instructs the user to replace 'zzdummy' with the name of the module under test. Add a Ruff per-file ignore to keep it. Co-Authored-By: Claude Opus 5 (1M context) --- Lib/.ruff.toml | 1 + Lib/idlelib/idle_test/template.py | 1 + 2 files changed, 2 insertions(+) diff --git a/Lib/.ruff.toml b/Lib/.ruff.toml index c9ae9b95641b31..b651f0b6e6b770 100644 --- a/Lib/.ruff.toml +++ b/Lib/.ruff.toml @@ -12,6 +12,7 @@ select = [ "ctypes/__init__.py" = ["F401"] # Re-exports from _ctypes "ensurepip/__init__.py" = ["F401"] # `import zlib` availability check "idlelib/idle_test/htest.py" = ["F401"] # Import for Windows DPI side effect +"idlelib/idle_test/template.py" = ["F401"] # Placeholder import, see README.txt "idlelib/idle_test/test_iomenu.py" = ["F401"] # Imports checked for existence "importlib/_abc.py" = ["F401"] # Bootstrap-sensitive _bootstrap import "importlib/machinery.py" = ["F401"] # NamespacePath re-export diff --git a/Lib/idlelib/idle_test/template.py b/Lib/idlelib/idle_test/template.py index 0a4bd8b8e981fc..69a2af22efa149 100644 --- a/Lib/idlelib/idle_test/template.py +++ b/Lib/idlelib/idle_test/template.py @@ -1,5 +1,6 @@ "Test , coverage %." +from idlelib import zzdummy import unittest from test.support import requires from tkinter import Tk From 6acbb16903e5f3ae6f23355ee13a6ccc84f4a33c Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 23 Aug 2026 10:17:20 +0300 Subject: [PATCH 2/4] gh-155648: Write the empty IDLE tests instead of commenting them out test_editor.RMenuTest was added in GH-18951, which fixed right-clicking inside a selection, with the note that an automated test should follow. Use the DummyRMenu class left there to test right_menu_event(), and test the rmenu_check_*() methods that supply the menu entry states. test_configdialog.ConfigDialogTest was left with two empty stubs in GH-3592, named after the two ConfigDialog methods which the button tests only check to be called. Test them with a fake parent whose instance dictionary contains an autospecced EditorWindow. Co-Authored-By: Claude Opus 5 (1M context) --- Lib/idlelib/idle_test/test_configdialog.py | 24 +++++++++-- Lib/idlelib/idle_test/test_editor.py | 49 ++++++++++++++++++++-- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/Lib/idlelib/idle_test/test_configdialog.py b/Lib/idlelib/idle_test/test_configdialog.py index 696a3b2f8f1bc2..14953d7fa8e611 100644 --- a/Lib/idlelib/idle_test/test_configdialog.py +++ b/Lib/idlelib/idle_test/test_configdialog.py @@ -3,6 +3,7 @@ Half the class creates dialog, half works with user customizations. """ from idlelib import configdialog +from idlelib.editor import EditorWindow from test.support import requires requires('gui') import unittest @@ -51,12 +52,27 @@ def tearDownModule(): class ConfigDialogTest(unittest.TestCase): + # The methods tested here are mocked out in the tests below. - def test_deactivate_current_config(self): - pass + def setUp(self): + self.parent = dialog.parent + self.instance = mock.create_autospec(EditorWindow, instance=True) + dialog.parent = mock.Mock(instance_dict={self.instance: []}) - def activate_config_changes(self): - pass + def tearDown(self): + dialog.parent = self.parent + + def test_deactivate_current_config(self): + dialog.deactivate_current_config() + self.instance.RemoveKeybindings.assert_called_once_with() + + def test_activate_config_changes(self): + dialog.activate_config_changes() + for name in ('ResetColorizer', 'ResetFont', 'set_notabs_indentwidth', + 'ApplyKeybindings', 'reset_help_menu_entries', + 'update_cursor_blink'): + with self.subTest(name=name): + getattr(self.instance, name).assert_called_once_with() class ButtonTest(unittest.TestCase): diff --git a/Lib/idlelib/idle_test/test_editor.py b/Lib/idlelib/idle_test/test_editor.py index e28ee549f180aa..cd9e82072e16b4 100644 --- a/Lib/idlelib/idle_test/test_editor.py +++ b/Lib/idlelib/idle_test/test_editor.py @@ -219,11 +219,13 @@ def setUpClass(cls): cls.root = Tk() cls.root.withdraw() cls.window = Editor(root=cls.root) + cls.text = cls.window.text + cls.window.rmenu = cls.DummyRMenu @classmethod def tearDownClass(cls): cls.window._close() - del cls.window + del cls.window, cls.text cls.root.update_idletasks() for id in cls.root.after_info(): cls.root.after_cancel(id) @@ -233,8 +235,49 @@ def tearDownClass(cls): class DummyRMenu: def tk_popup(x, y): pass - def test_rclick(self): - pass + def click(self, index): + "Simulate a right click at the start of index; return the event." + x, y = self.text.bbox(index)[:2] + Event = namedtuple('Event', ['x', 'y', 'x_root', 'y_root']) + event = Event(x, y, x_root=0, y_root=0) + self.assertEqual(self.window.right_menu_event(event), 'break') + return event + + def test_rclick_no_selection(self): + text = self.text + insert(text, 'one two three') + self.click('1.8') + self.assertEqual(text.tag_ranges('sel'), ()) + self.assertEqual(text.index('insert'), '1.8') + + def test_rclick_outside_selection(self): + text = self.text + insert(text, 'one two three') + text.tag_add('sel', '1.0', '1.3') + text.mark_set('insert', '1.3') + self.click('1.8') + self.assertEqual(text.tag_ranges('sel'), ()) + self.assertEqual(text.index('insert'), '1.8') + + def test_rclick_inside_selection(self): + text = self.text + insert(text, 'one two three') + text.tag_add('sel', '1.4', '1.7') + text.mark_set('insert', '1.7') + self.click('1.5') + self.assertEqual(text.index('sel.first'), '1.4') + self.assertEqual(text.index('sel.last'), '1.7') + self.assertEqual(text.index('insert'), '1.7') + + def test_rmenu_check_copy(self): + text = self.text + insert(text, 'one two three') + eq = self.assertEqual + eq(self.window.rmenu_check_copy(), 'disabled') + eq(self.window.rmenu_check_cut(), 'disabled') + text.tag_add('sel', '1.0', '1.3') + eq(self.window.rmenu_check_copy(), 'normal') + eq(self.window.rmenu_check_cut(), 'normal') if __name__ == '__main__': From 200a88f6ad7031948498af3f96ffb1aeae3caf46 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 23 Aug 2026 10:17:20 +0300 Subject: [PATCH 3/4] Write the placeholder tests in the IDLE test suite * test_configdialog.ExtPageTest was added empty, with a commented-out "Nothing here yet TODO" skip, when ExtPage was factored out of ConfigDialog in GH-26618. Test load_extensions(), extension_selected(), set_extension_value() and save_all_changed_extensions(). * test_grep.Default_commandTest was left empty in 2013 because GrepDialog.default_command() imports OutputWindow when called, and the import cannot be moved to the top of the module due to an import loop. Replace the imported class with a mock instead of moving the import. * test_config.ChangesTest.test_save_default never called save_all(), so it tested nothing. Add the missing assertions, and add the test for the Save() calls that the following TODO comment asked for. * test_config.IdleConfTest.test_get_current_keyset only tested the non-darwin branch, because the default key sets no longer contain Alt keys. Add an extension binding with an Alt key, so that its replacement with Option can be tested. Remove the stale commented-out test in test_get_extension_keys, which used the ZoomHeight extension. Co-Authored-By: Claude Opus 5 (1M context) --- Lib/idlelib/idle_test/test_config.py | 60 ++++++++--- Lib/idlelib/idle_test/test_configdialog.py | 116 ++++++++++++++++++++- Lib/idlelib/idle_test/test_grep.py | 57 ++++++++-- 3 files changed, 207 insertions(+), 26 deletions(-) diff --git a/Lib/idlelib/idle_test/test_config.py b/Lib/idlelib/idle_test/test_config.py index 6d75cf7aa67dcc..56779702082871 100644 --- a/Lib/idlelib/idle_test/test_config.py +++ b/Lib/idlelib/idle_test/test_config.py @@ -454,9 +454,6 @@ def test_get_extension_keys(self): self.assertEqual(idleConf.GetExtensionKeys('ZzDummy'), {'<>': ['']}) userextn.remove_section('ZzDummy') -# need option key test -## key = [''] if sys.platform == 'darwin' else [''] -## eq(conf.GetExtensionKeys('ZoomHeight'), {'<>': key}) def test_get_extension_bindings(self): userextn.read_string(''' @@ -491,19 +488,27 @@ def test_get_keybinding(self): def test_get_current_keyset(self): current_platform = sys.platform conf = self.mock_config() - - # Ensure that platform isn't darwin - sys.platform = 'some-linux' - self.assertEqual(conf.GetCurrentKeySet(), conf.GetKeySet(conf.CurrentKeys())) - - # This should not be the same, since replace ') + self.assertEqual(conf.GetKeySet(conf.CurrentKeys())['<>'], + ['']) + sys.platform = 'darwin' + self.assertEqual(conf.GetCurrentKeySet()['<>'], + ['']) + finally: + # Restore platform + sys.platform = current_platform def test_get_keyset(self): conf = self.mock_config() @@ -762,8 +767,29 @@ def test_save_default(self): # Cover 2nd and 3rd false branches. changes = self.changes changes.add_option('main', 'Indent', 'use-spaces', '1') # save_option returns False; cfg_type_changed remains False. + self.assertFalse(changes.save_all()) + self.assertFalse(usermain.has_option('Indent', 'use-spaces')) + self.assertEqual(changes, self.empty) - # TODO: test that save_all calls usercfg Saves. + def test_save_all_saves_files(self): + eq = self.assertEqual + changes = self.changes + for parser in testcfg.values(): + parser.Save = Func() + try: + # 'main', 'highlight' and 'keys' are saved even if unchanged. + self.assertFalse(changes.save_all()) + eq([testcfg[cfgtype].Save.called + for cfgtype in ('main', 'highlight', 'keys', 'extensions')], + [1, 1, 1, 0]) + # A changed configuration type is saved too. + changes.add_option('extensions', 'Esec', 'eitem', 'eval') + self.assertTrue(changes.save_all()) + eq(testcfg['extensions'].Save.called, 1) + finally: + for parser in testcfg.values(): + del parser.Save + userextn.remove_section('Esec') def test_delete_section(self): changes = self.load() diff --git a/Lib/idlelib/idle_test/test_configdialog.py b/Lib/idlelib/idle_test/test_configdialog.py index 14953d7fa8e611..a63fdfeec2f723 100644 --- a/Lib/idlelib/idle_test/test_configdialog.py +++ b/Lib/idlelib/idle_test/test_configdialog.py @@ -1311,13 +1311,123 @@ def test_context(self): self.assertEqual(extpage, {'CodeContext': {'maxlines': '1'}}) -#unittest.skip("Nothing here yet TODO") class ExtPageTest(unittest.TestCase): - """Test that the help source list works correctly.""" + """Test that the extension page works correctly. + + The page loads the options of each extension from the default and + user config files, displays those of the selected extension, and + saves the changed ones to the user config file. ZzDummy is the + only extension shipped with IDLE. + """ @classmethod def setUpClass(cls): - page = dialog.extpage + page = cls.page = dialog.extpage dialog.note.select(page) + page.update() + + def setUp(self): + # Restore the option vars changed by the previous test. + self.page.load_extensions() + + def tearDown(self): + self.page.ext_userCfg.remove_section('ZzDummy') + + def test_load_extensions(self): + eq = self.assertEqual + extensions = self.page.extensions + eq(list(extensions), sorted(idleConf.GetExtensions(active_only=False))) + opts = extensions['ZzDummy'] + # The 'enable' options come first, the others follow, both sorted. + eq([opt['name'] for opt in opts], + ['enable', 'enable_editor', 'enable_shell', 'z-text']) + eq([opt['type'] for opt in opts], ['bool', 'bool', 'bool', None]) + eq([opt['default'] for opt in opts], ['False', 'True', 'False', 'Z']) + eq([opt['value'] for opt in opts], [False, True, False, 'Z']) + for opt in opts: + with self.subTest(name=opt['name']): + eq(opt['var'].get(), str(opt['value'])) + + def test_load_extensions_enable_first(self): + # The 'enable' options come first even if they sort last. + page = self.page + page.ext_userCfg.SetOption('ZzDummy', 'a-text', 'A') + page.load_extensions() + self.assertEqual([opt['name'] for opt in page.extensions['ZzDummy']], + ['enable', 'enable_editor', 'enable_shell', + 'a-text', 'z-text']) + + def test_load_extensions_user_value(self): + # A user option overrides the default value, but not the default. + page = self.page + page.ext_userCfg.SetOption('ZzDummy', 'z-text', 'user') + page.load_extensions() + opt = page.extensions['ZzDummy'][-1] + self.assertEqual(opt['name'], 'z-text') + self.assertEqual(opt['default'], 'Z') + self.assertEqual(opt['value'], 'user') + self.assertEqual(opt['var'].get(), 'user') + + def test_extension_selected(self): + eq = self.assertEqual + page = self.page + frame = page.config_frame['ZzDummy'] + # Deselecting hides the options of the current extension. + page.extension_list.selection_clear(0, 'end') + page.extension_selected(None) + eq(page.current_extension, None) + eq(page.details_frame.cget('text'), '') + eq(frame.winfo_manager(), '') + # Selecting shows the options of the selected extension. + page.extension_list.selection_set(0) + page.extension_selected(None) + eq(page.current_extension, 'ZzDummy') + eq(page.details_frame.cget('text'), 'ZzDummy') + eq(frame.winfo_manager(), 'grid') + + def test_set_extension_value_changed(self): + page = self.page + opt = page.extensions['ZzDummy'][-1] # z-text, default 'Z'. + opt['var'].set('user') + self.assertTrue(page.set_extension_value('ZzDummy', opt)) + self.assertEqual(page.ext_userCfg.Get('ZzDummy', 'z-text'), 'user') + # Saving the same value again is not a change. + self.assertFalse(page.set_extension_value('ZzDummy', opt)) + + def test_set_extension_value_default(self): + page = self.page + opt = page.extensions['ZzDummy'][-1] + # The default value is not saved in the user config file. + opt['var'].set('Z') + self.assertFalse(page.set_extension_value('ZzDummy', opt)) + self.assertFalse(page.ext_userCfg.has_option('ZzDummy', 'z-text')) + # Setting it back to the default removes the user option. + page.ext_userCfg.SetOption('ZzDummy', 'z-text', 'user') + self.assertTrue(page.set_extension_value('ZzDummy', opt)) + self.assertFalse(page.ext_userCfg.has_option('ZzDummy', 'z-text')) + + def test_set_extension_value_empty(self): + # An empty value is replaced with the default. + page = self.page + opt = page.extensions['ZzDummy'][-1] + opt['var'].set(' ') + self.assertFalse(page.set_extension_value('ZzDummy', opt)) + self.assertEqual(opt['var'].get(), 'Z') + + def test_save_all_changed_extensions(self): + page = self.page + page.ext_userCfg.Save = Func() + try: + # Nothing is saved if nothing is changed. + page.save_all_changed_extensions() + self.assertEqual(page.ext_userCfg.Save.called, 0) + page.extensions['ZzDummy'][0]['var'].set('True') + page.extensions['ZzDummy'][-1]['var'].set('user') + page.save_all_changed_extensions() + self.assertEqual(page.ext_userCfg.Save.called, 1) + self.assertEqual(page.ext_userCfg.Get('ZzDummy', 'enable'), 'True') + self.assertEqual(page.ext_userCfg.Get('ZzDummy', 'z-text'), 'user') + finally: + del page.ext_userCfg.Save class HelpSourceTest(unittest.TestCase): diff --git a/Lib/idlelib/idle_test/test_grep.py b/Lib/idlelib/idle_test/test_grep.py index 94f217effb656e..08c07b1f0b01c4 100644 --- a/Lib/idlelib/idle_test/test_grep.py +++ b/Lib/idlelib/idle_test/test_grep.py @@ -1,16 +1,19 @@ """ !Changing this line will break Test_findfile.test_found! Non-gui unit tests for grep.GrepDialog methods. -dummy_command calls grep_it calls findfiles. +default_command calls grep_it calls findfiles. An exception raised in one method will fail callers. Otherwise, tests are mostly independent. -Currently only test grep_it, coverage 51%. +Currently test default_command and grep_it, coverage 51%. """ from idlelib import grep import unittest +from unittest import mock from test.support import captured_stdout from idlelib.idle_test.mock_tk import Var +import io import os import re +import sys class Dummy_searchengine: @@ -21,12 +24,15 @@ class Dummy_searchengine: def getpat(self): return self._pat + def getprog(self): + return self._prog + searchengine = Dummy_searchengine() class Dummy_grep: # Methods tested - #default_command = GrepDialog.default_command + default_command = grep.GrepDialog.default_command grep_it = grep.GrepDialog.grep_it # Other stuff needed recvar = Var(False) @@ -166,9 +172,48 @@ def test_found(self): class Default_commandTest(unittest.TestCase): - # To write this, move outwin import to top of GrepDialog - # so it can be replaced by captured_stdout in class setup/teardown. - pass + # default_command searches with grep_it, tested above, writing to an + # OutputWindow, which is replaced here by a text buffer. + + def setUp(self): + self.dialog = Dummy_grep() + self.dialog.top = mock.Mock() # For top.bell(). + self.dialog.flist = 'flist' + self.dialog.globvar = Var(value=__file__) + searchengine._pat = pat = 'xyz*' * 7 # Not in this file. + searchengine._prog = re.compile(pat) + + def default_command(self): + "Return the mock OutputWindow class and what was written to it." + save = sys.stdout + with mock.patch('idlelib.outwin.OutputWindow') as OutputWindow: + OutputWindow.return_value = out = io.StringIO() + self.dialog.default_command() + self.assertIs(sys.stdout, save) # Restored even when not replaced. + return OutputWindow, out.getvalue() + + def test_no_pattern(self): + # An invalid pattern is reported by getprog, not here. + searchengine._prog = None + OutputWindow, output = self.default_command() + OutputWindow.assert_not_called() + self.assertEqual(output, '') + self.dialog.top.bell.assert_not_called() + + def test_no_path(self): + self.dialog.globvar = Var(value='') + OutputWindow, output = self.default_command() + self.dialog.top.bell.assert_called_once() + OutputWindow.assert_not_called() + self.assertEqual(output, '') + + def test_search(self): + OutputWindow, output = self.default_command() + OutputWindow.assert_called_once_with('flist') # The flist argument. + lines = output.split('\n') + self.assertIn(searchengine._pat, lines[0]) + self.assertEqual(lines[1], 'No hits.') + self.dialog.top.bell.assert_not_called() if __name__ == '__main__': From 7789adb70c9c8026e4bce6aff19bd1ab2f40b2cc Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 23 Aug 2026 11:27:05 +0300 Subject: [PATCH 4/4] Do not depend on the text geometry in RMenuTest Text.bbox() returns None while the window is not mapped, as on Windows, where the root window of the test is withdrawn. Ask the widget for the index of the clicked character instead of computing the coordinates of a known index. Co-Authored-By: Claude Opus 5 (1M context) --- Lib/idlelib/idle_test/test_editor.py | 40 +++++++++++++++++----------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/Lib/idlelib/idle_test/test_editor.py b/Lib/idlelib/idle_test/test_editor.py index cd9e82072e16b4..b8eddb0e8cf954 100644 --- a/Lib/idlelib/idle_test/test_editor.py +++ b/Lib/idlelib/idle_test/test_editor.py @@ -235,39 +235,47 @@ def tearDownClass(cls): class DummyRMenu: def tk_popup(x, y): pass - def click(self, index): - "Simulate a right click at the start of index; return the event." - x, y = self.text.bbox(index)[:2] + def click(self, x=0, y=0): + """Simulate a right click at the (x, y) pixel of the text. + + Return the index of the clicked character, as computed by the + widget itself. It cannot be computed here, because the geometry + of the text is unknown while its window is not mapped. + """ + index = self.text.index(f'@{x},{y}') Event = namedtuple('Event', ['x', 'y', 'x_root', 'y_root']) event = Event(x, y, x_root=0, y_root=0) self.assertEqual(self.window.right_menu_event(event), 'break') - return event + return index def test_rclick_no_selection(self): text = self.text insert(text, 'one two three') - self.click('1.8') + index = self.click() self.assertEqual(text.tag_ranges('sel'), ()) - self.assertEqual(text.index('insert'), '1.8') + self.assertEqual(text.index('insert'), index) def test_rclick_outside_selection(self): text = self.text insert(text, 'one two three') - text.tag_add('sel', '1.0', '1.3') - text.mark_set('insert', '1.3') - self.click('1.8') + # The selection does not contain the clicked character. + text.tag_add('sel', '1.5', '1.8') + text.mark_set('insert', '1.8') + index = self.click() self.assertEqual(text.tag_ranges('sel'), ()) - self.assertEqual(text.index('insert'), '1.8') + self.assertEqual(text.index('insert'), index) def test_rclick_inside_selection(self): text = self.text insert(text, 'one two three') - text.tag_add('sel', '1.4', '1.7') - text.mark_set('insert', '1.7') - self.click('1.5') - self.assertEqual(text.index('sel.first'), '1.4') - self.assertEqual(text.index('sel.last'), '1.7') - self.assertEqual(text.index('insert'), '1.7') + # The selection contains the clicked character. + index = text.index('@0,0') + text.tag_add('sel', index, f'{index}+3c') + text.mark_set('insert', 'end-1c') + self.click() + self.assertEqual(text.index('sel.first'), index) + self.assertEqual(text.index('sel.last'), text.index(f'{index}+3c')) + self.assertEqual(text.index('insert'), text.index('end-1c')) def test_rmenu_check_copy(self): text = self.text