From 3dcef0e867c3e786b3e963303dd875c120f6b016 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 9 Sep 2026 03:20:25 +0300 Subject: [PATCH] [3.13] gh-155648: Write the empty and placeholder IDLE tests (GH-156260) 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. 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. 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) Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Co-authored-by: TerryJReedy (cherry picked from commit f8f8c30ed4e20208e8badbc9e2fc3822e8db8e49) Co-authored-by: Serhiy Storchaka --- Lib/idlelib/idle_test/README.txt | 2 +- Lib/idlelib/idle_test/template.py | 1 + Lib/idlelib/idle_test/test_config.py | 60 ++++++--- Lib/idlelib/idle_test/test_configdialog.py | 139 +++++++++++++++++++-- Lib/idlelib/idle_test/test_editor.py | 58 ++++++++- Lib/idlelib/idle_test/test_grep.py | 69 ++++++++-- 6 files changed, 290 insertions(+), 39 deletions(-) diff --git a/Lib/idlelib/idle_test/README.txt b/Lib/idlelib/idle_test/README.txt index 242de2225248178..bb9aa1fb495fd4a 100644 --- a/Lib/idlelib/idle_test/README.txt +++ b/Lib/idlelib/idle_test/README.txt @@ -18,7 +18,7 @@ The idle directory, idlelib, has over 60 xyz.py files. The idle_test subdirectory contains test_xyz.py for each implementation file xyz.py. To add a test for abc.py, open idle_test/template.py and immediately Save As test_abc.py. Insert 'abc' on the first line, and replace -'zzdummy' with 'abc. +'zzdummy' with 'abc'. Remove the imports of requires and tkinter if not needed. Otherwise, add to the tkinter imports as needed. diff --git a/Lib/idlelib/idle_test/template.py b/Lib/idlelib/idle_test/template.py index 7c3df6ba8fbce38..6c3e830769324ec 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 # Replace with the module to test. import unittest from test.support import requires from tkinter import Tk diff --git a/Lib/idlelib/idle_test/test_config.py b/Lib/idlelib/idle_test/test_config.py index 6d75cf7aa67dcce..028d9f9dbd613ce 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 config in testcfg.values(): + config.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 config in testcfg.values(): + del config.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 7bf3c07a4d717fd..21bdf36b52f451f 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') from test.support.testcase import ExtraAssertions @@ -51,14 +52,28 @@ def tearDownModule(): root = dialog = None -@unittest.skip('Empty tests') class ConfigDialogTest(unittest.TestCase): + # The methods tested here are mocked out in the tests below. + + def setUp(self): + self.parent = dialog.parent + self.instance = mock.create_autospec(EditorWindow, instance=True) + dialog.parent = mock.Mock(instance_dict={self.instance: []}) + + def tearDown(self): + dialog.parent = self.parent def test_deactivate_current_config(self): - pass + dialog.deactivate_current_config() + self.instance.RemoveKeybindings.assert_called_once_with() - def activate_config_changes(self): - pass + 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, ExtraAssertions): @@ -1297,13 +1312,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_editor.py b/Lib/idlelib/idle_test/test_editor.py index 1fcea4f1eb0d6a0..873637f67defa37 100644 --- a/Lib/idlelib/idle_test/test_editor.py +++ b/Lib/idlelib/idle_test/test_editor.py @@ -211,8 +211,10 @@ def test_searcher(self): self.assertEqual(actual_pair, expected_pair) -@unittest.skip('Empty test') class RMenuTest(unittest.TestCase): + # Test selection-rclick interaction in right_click_event and + # rmenu_check_copy(cut) status settings. These are part of the rmenu + # functions common to all text windows with context windows. @classmethod def setUpClass(cls): @@ -220,11 +222,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) @@ -234,8 +238,54 @@ def tearDownClass(cls): class DummyRMenu: def tk_popup(x, y): pass - def test_rclick(self): - pass + def click(self): + """Simulate a right click at(text pixel 0,0). + """ + Event = namedtuple('Event', ['x', 'y', 'x_root', 'y_root']) + event = Event(0, 0, 0, 0) + self.assertEqual(self.window.right_menu_event(event), 'break') + # This assertion should be moved to a new method that also + # text that dummy rmenu.tk_popup is called. + + def test_rclick_not_in_a_selection(self): + # Like left click, 'insert' moves to click and any selection is deleted. + eq = self.assertEqual + text = self.text + insert(text, 'one two three') + # Selection exists but not clicked. + text.tag_add('sel', '1.4', '1.7') # 'two' selected. + text.mark_set('insert', '1.7') # Outside of selection. + self.click() + eq(text.tag_ranges('sel'), ()) + eq(text.index('insert'), '1.0') + # No selection to click, same result. + text.mark_set('insert', '1.8') + index = self.click() + eq(text.tag_ranges('sel'), ()) + eq(text.index('insert'), '1.0') + + def test_rclick_inside_selection(self): + # Unlike left click, selection is not deleted. + eq = self.assertEqual + text = self.text + insert(text, 'one two three') # 'insert' at 1.13. + # The selection contains the clicked character. + text.tag_add('sel', '1.0', '1.3') # Select 'one'. + text.mark_set('insert', '1.3') # If select rightward, 'insert' at 1.3. + self.click() + eq((text.index('sel.first'), text.index('sel.last')), ('1.0', '1.3')) + eq(text.index('insert'), '1.3') + + def test_rmenu_check_copy(self): + # copy and cut only valid for click inside selection. + eq = self.assertEqual + text = self.text + insert(text, 'one two three') + eq(self.window.rmenu_check_copy(), 'disabled') + eq(self.window.rmenu_check_cut(), 'disabled') + text.tag_add('sel', '1.0', '1.3') # Includes '1.0' click. + eq(self.window.rmenu_check_copy(), 'normal') + eq(self.window.rmenu_check_cut(), 'normal') if __name__ == '__main__': diff --git a/Lib/idlelib/idle_test/test_grep.py b/Lib/idlelib/idle_test/test_grep.py index 45d906b9f2d044c..70a6976e0f91aa1 100644 --- a/Lib/idlelib/idle_test/test_grep.py +++ b/Lib/idlelib/idle_test/test_grep.py @@ -1,17 +1,20 @@ """ !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 test.support.testcase import ExtraAssertions from idlelib.idle_test.mock_tk import Var +import io import os import re +import sys class Dummy_searchengine: @@ -22,16 +25,23 @@ 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 + # Simplifications: 1. Don't initialize superclass SearchEngineBase + # with searchengine. 2. Use directly set class vars instead of + # instance vars set from the GUI. + globvar = Var('') # File name. + recvar = Var(False) # Recurse down directories? + engine = searchengine # Pattern and flags. + # Methods tested. grep_it = grep.GrepDialog.grep_it - # Other stuff needed - recvar = Var(False) - engine = searchengine + default_command = grep.GrepDialog.default_command # Uses grep_it. + def close(self): # gui method pass @@ -167,9 +177,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__':