From 349abd9e37dfdc077bc21f19e6ed2292c767f0e8 Mon Sep 17 00:00:00 2001
From: terryjreedy <tjreedy@udel.edu>
Date: Fri, 7 Jul 2017 16:00:57 -0400
Subject: bpo-30779: IDLE -- Factor ConfigChanges class from configdialog, put
 in config; test. (#2612)

* In config, put dump test code in a function; run it and unittest in 'if __name__ == '__main__'.
* Add class config.ConfigChanges based on changes_class_v4.py on bpo issue.
* Add class test_config.ChangesTest, partly based on configdialog_tests_v1.py on bpo issue.
* Revise configdialog to use ConfigChanges, mostly as specified in tracker msg297804.
* Revise test_configdialog to match configdialog changes.  All tests pass in both files.
* Remove configdialog functions unused or moved to ConfigChanges.
Cheryl Sabella contributed parts of the patch.
---
 Lib/idlelib/config.py                      | 140 ++++++++++++++++++++----
 Lib/idlelib/configdialog.py                | 169 +++++++++--------------------
 Lib/idlelib/idle_test/test_config.py       |  86 ++++++++++++++-
 Lib/idlelib/idle_test/test_configdialog.py |  53 ++++-----
 4 files changed, 277 insertions(+), 171 deletions(-)

diff --git a/Lib/idlelib/config.py b/Lib/idlelib/config.py
index 6d683e2..621e0bf 100644
--- a/Lib/idlelib/config.py
+++ b/Lib/idlelib/config.py
@@ -44,7 +44,7 @@ class IdleConfParser(ConfigParser):
         """
         cfgFile - string, fully specified configuration file name
         """
-        self.file = cfgFile
+        self.file = cfgFile  # This is currently '' when testing.
         ConfigParser.__init__(self, defaults=cfgDefaults, strict=False)
 
     def Get(self, section, option, type=None, default=None, raw=False):
@@ -73,7 +73,8 @@ class IdleConfParser(ConfigParser):
 
     def Load(self):
         "Load the configuration file from disk."
-        self.read(self.file)
+        if self.file:
+            self.read(self.file)
 
 class IdleUserConfParser(IdleConfParser):
     """
@@ -130,21 +131,22 @@ class IdleUserConfParser(IdleConfParser):
     def Save(self):
         """Update user configuration file.
 
-        Remove empty sections. If resulting config isn't empty, write the file
-        to disk. If config is empty, remove the file from disk if it exists.
+        If self not empty after removing empty sections, write the file
+        to disk. Otherwise, remove the file from disk if it exists.
 
         """
-        if not self.IsEmpty():
-            fname = self.file
-            try:
-                cfgFile = open(fname, 'w')
-            except OSError:
-                os.unlink(fname)
-                cfgFile = open(fname, 'w')
-            with cfgFile:
-                self.write(cfgFile)
-        else:
-            self.RemoveFile()
+        fname = self.file
+        if fname:
+            if not self.IsEmpty():
+                try:
+                    cfgFile = open(fname, 'w')
+                except OSError:
+                    os.unlink(fname)
+                    cfgFile = open(fname, 'w')
+                with cfgFile:
+                    self.write(cfgFile)
+            else:
+                self.RemoveFile()
 
 class IdleConf:
     """Hold config parsers for all idle config files in singleton instance.
@@ -158,7 +160,7 @@ class IdleConf:
         (user home dir)/.idlerc/config-{config-type}.cfg
     """
     def __init__(self):
-        self.config_types = ('main', 'extensions', 'highlight', 'keys')
+        self.config_types = ('main', 'highlight', 'keys', 'extensions')
         self.defaultCfg = {}
         self.userCfg = {}
         self.cfg = {}  # TODO use to select userCfg vs defaultCfg
@@ -766,7 +768,6 @@ class IdleConf:
 
 idleConf = IdleConf()
 
-
 _warned = set()
 def _warn(msg, *key):
     key = (msg,) + key
@@ -778,9 +779,100 @@ def _warn(msg, *key):
         _warned.add(key)
 
 
+class ConfigChanges(dict):
+    """Manage a user's proposed configuration option changes.
+
+    Names used across multiple methods:
+        page -- one of the 4 top-level dicts representing a
+                .idlerc/config-x.cfg file.
+        config_type -- name of a page.
+        section -- a section within a page/file.
+        option -- name of an option within a section.
+        value -- value for the option.
+
+    Methods
+        add_option: Add option and value to changes.
+        save_option: Save option and value to config parser.
+        save_all: Save all the changes to the config parser and file.
+        delete_section: Delete section if it exists.
+        clear: Clear all changes by clearing each page.
+    """
+    def __init__(self):
+        "Create a page for each configuration file"
+        self.pages = []  # List of unhashable dicts.
+        for config_type in idleConf.config_types:
+            self[config_type] = {}
+            self.pages.append(self[config_type])
+
+    def add_option(self, config_type, section, item, value):
+        "Add item/value pair for config_type and section."
+        page = self[config_type]
+        value = str(value)  # Make sure we use a string.
+        if section not in page:
+            page[section] = {}
+        page[section][item] = value
+
+    @staticmethod
+    def save_option(config_type, section, item, value):
+        """Return True if the configuration value was added or changed.
+
+        Helper for save_all.
+        """
+        if idleConf.defaultCfg[config_type].has_option(section, item):
+            if idleConf.defaultCfg[config_type].Get(section, item) == value:
+                # The setting equals a default setting, remove it from user cfg.
+                return idleConf.userCfg[config_type].RemoveOption(section, item)
+        # If we got here, set the option.
+        return idleConf.userCfg[config_type].SetOption(section, item, value)
+
+    def save_all(self):
+        """Save configuration changes to the user config file.
+
+        Then clear self in preparation for additional changes.
+        """
+        idleConf.userCfg['main'].Save()
+        for config_type in self:
+            cfg_type_changed = False
+            page = self[config_type]
+            for section in page:
+                if section == 'HelpFiles':  # Remove it for replacement.
+                    idleConf.userCfg['main'].remove_section('HelpFiles')
+                    cfg_type_changed = True
+                for item, value in page[section].items():
+                    if self.save_option(config_type, section, item, value):
+                        cfg_type_changed = True
+            if cfg_type_changed:
+                idleConf.userCfg[config_type].Save()
+        for config_type in ['keys', 'highlight']:
+            # Save these even if unchanged!
+            idleConf.userCfg[config_type].Save()
+        self.clear()
+        # ConfigDialog caller must add the following call
+        # self.save_all_changed_extensions()  # Uses a different mechanism.
+
+    def delete_section(self, config_type, section):
+        """Delete a section from self, userCfg, and file.
+
+        Used to delete custom themes and keysets.
+        """
+        if section in self[config_type]:
+            del self[config_type][section]
+        configpage = idleConf.userCfg[config_type]
+        configpage.remove_section(section)
+        configpage.Save()
+
+    def clear(self):
+        """Clear all 4 pages.
+
+        Called in save_all after saving to idleConf.
+        XXX Mark window *title* when there are changes; unmark here.
+        """
+        for page in self.pages:
+            page.clear()
+
+
 # TODO Revise test output, write expanded unittest
-#
-if __name__ == '__main__':
+def _dump():  # htest # (not really, but ignore in coverage)
     from zlib import crc32
     line, crc = 0, 0
 
@@ -790,10 +882,10 @@ if __name__ == '__main__':
         line += 1
         crc = crc32(txt.encode(encoding='utf-8'), crc)
         print(txt)
-        #print('***', line, crc, '***')  # uncomment for diagnosis
+        #print('***', line, crc, '***')  # Uncomment for diagnosis.
 
     def dumpCfg(cfg):
-        print('\n', cfg, '\n')  # has variable '0xnnnnnnnn' addresses
+        print('\n', cfg, '\n')  # Cfg has variable '0xnnnnnnnn' address.
         for key in sorted(cfg.keys()):
             sections = cfg[key].sections()
             sprint(key)
@@ -808,3 +900,9 @@ if __name__ == '__main__':
     dumpCfg(idleConf.defaultCfg)
     dumpCfg(idleConf.userCfg)
     print('\nlines = ', line, ', crc = ', crc, sep='')
+
+if __name__ == '__main__':
+    import unittest
+    unittest.main('idlelib.idle_test.test_config',
+                  verbosity=2, exit=False)
+    #_dump()
diff --git a/Lib/idlelib/configdialog.py b/Lib/idlelib/configdialog.py
index 8409227..ade6710 100644
--- a/Lib/idlelib/configdialog.py
+++ b/Lib/idlelib/configdialog.py
@@ -20,7 +20,7 @@ import tkinter.colorchooser as tkColorChooser
 import tkinter.font as tkFont
 import tkinter.messagebox as tkMessageBox
 
-from idlelib.config import idleConf
+from idlelib.config import idleConf, ConfigChanges
 from idlelib.config_key import GetKeysDialog
 from idlelib.dynoption import DynOptionMenu
 from idlelib import macosx
@@ -28,6 +28,8 @@ from idlelib.query import SectionName, HelpSource
 from idlelib.tabbedpages import TabbedPageSet
 from idlelib.textview import view_text
 
+changes = ConfigChanges()
+
 
 class ConfigDialog(Toplevel):
     """Config dialog for IDLE.
@@ -71,7 +73,6 @@ class ConfigDialog(Toplevel):
             'Shell Stdout Text': ('stdout', '12'),
             'Shell Stderr Text': ('stderr', '13'),
             }
-        self.reset_changed_items()  # Initialize changed_items dict.
         self.create_widgets()
         self.resizable(height=FALSE, width=FALSE)
         self.transient(parent)
@@ -559,16 +560,16 @@ class ConfigDialog(Toplevel):
         overriding the default font, we need to write out everything.
         """
         value = self.font_name.get()
-        self.add_changed_item('main', 'EditorWindow', 'font', value)
+        changes.add_option('main', 'EditorWindow', 'font', value)
         value = self.font_size.get()
-        self.add_changed_item('main', 'EditorWindow', 'font-size', value)
+        changes.add_option('main', 'EditorWindow', 'font-size', value)
         value = self.font_bold.get()
-        self.add_changed_item('main', 'EditorWindow', 'font-bold', value)
+        changes.add_option('main', 'EditorWindow', 'font-bold', value)
 
     def var_changed_space_num(self, *params):
         "Store change to indentation size."
         value = self.space_num.get()
-        self.add_changed_item('main', 'Indent', 'num-spaces', value)
+        changes.add_option('main', 'Indent', 'num-spaces', value)
 
     def var_changed_colour(self, *params):
         "Process change to color choice."
@@ -584,13 +585,13 @@ class ConfigDialog(Toplevel):
         value = self.builtin_theme.get()
         if value not in old_themes:
             if idleConf.GetOption('main', 'Theme', 'name') not in old_themes:
-                self.add_changed_item('main', 'Theme', 'name', old_themes[0])
-            self.add_changed_item('main', 'Theme', 'name2', value)
+                changes.add_option('main', 'Theme', 'name', old_themes[0])
+            changes.add_option('main', 'Theme', 'name2', value)
             self.new_custom_theme.config(text='New theme, see Help',
                                          fg='#500000')
         else:
-            self.add_changed_item('main', 'Theme', 'name', value)
-            self.add_changed_item('main', 'Theme', 'name2', '')
+            changes.add_option('main', 'Theme', 'name', value)
+            changes.add_option('main', 'Theme', 'name2', '')
             self.new_custom_theme.config(text='', fg='black')
         self.paint_theme_sample()
 
@@ -602,7 +603,7 @@ class ConfigDialog(Toplevel):
         """
         value = self.custom_theme.get()
         if value != '- no custom themes -':
-            self.add_changed_item('main', 'Theme', 'name', value)
+            changes.add_option('main', 'Theme', 'name', value)
             self.paint_theme_sample()
 
     def var_changed_is_builtin_theme(self, *params):
@@ -612,7 +613,7 @@ class ConfigDialog(Toplevel):
         selected theme type.
         """
         value = self.is_builtin_theme.get()
-        self.add_changed_item('main', 'Theme', 'default', value)
+        changes.add_option('main', 'Theme', 'default', value)
         if value:
             self.var_changed_builtin_theme()
         else:
@@ -628,11 +629,11 @@ class ConfigDialog(Toplevel):
         key_set = self.custom_keys.get()
         event = self.list_bindings.get(ANCHOR).split()[0]
         if idleConf.IsCoreBinding(event):
-            self.add_changed_item('keys', key_set, event, value)
+            changes.add_option('keys', key_set, event, value)
         else:  # Event is an extension binding.
             ext_name = idleConf.GetExtnNameForEvent(event)
             ext_keybind_section = ext_name + '_cfgBindings'
-            self.add_changed_item('extensions', ext_keybind_section, event, value)
+            changes.add_option('extensions', ext_keybind_section, event, value)
 
     def var_changed_builtin_keys(self, *params):
         "Process selection of builtin key set."
@@ -645,13 +646,13 @@ class ConfigDialog(Toplevel):
         value = self.builtin_keys.get()
         if value not in old_keys:
             if idleConf.GetOption('main', 'Keys', 'name') not in old_keys:
-                self.add_changed_item('main', 'Keys', 'name', old_keys[0])
-            self.add_changed_item('main', 'Keys', 'name2', value)
+                changes.add_option('main', 'Keys', 'name', old_keys[0])
+            changes.add_option('main', 'Keys', 'name2', value)
             self.new_custom_keys.config(text='New key set, see Help',
                                         fg='#500000')
         else:
-            self.add_changed_item('main', 'Keys', 'name', value)
-            self.add_changed_item('main', 'Keys', 'name2', '')
+            changes.add_option('main', 'Keys', 'name', value)
+            changes.add_option('main', 'Keys', 'name2', '')
             self.new_custom_keys.config(text='', fg='black')
         self.load_keys_list(value)
 
@@ -659,13 +660,13 @@ class ConfigDialog(Toplevel):
         "Process selection of custom key set."
         value = self.custom_keys.get()
         if value != '- no custom keys -':
-            self.add_changed_item('main', 'Keys', 'name', value)
+            changes.add_option('main', 'Keys', 'name', value)
             self.load_keys_list(value)
 
     def var_changed_are_keys_builtin(self, *params):
         "Process toggle between builtin key set and custom key set."
         value = self.are_keys_builtin.get()
-        self.add_changed_item('main', 'Keys', 'default', value)
+        changes.add_option('main', 'Keys', 'default', value)
         if value:
             self.var_changed_builtin_keys()
         else:
@@ -674,59 +675,27 @@ class ConfigDialog(Toplevel):
     def var_changed_win_width(self, *params):
         "Store change to window width."
         value = self.win_width.get()
-        self.add_changed_item('main', 'EditorWindow', 'width', value)
+        changes.add_option('main', 'EditorWindow', 'width', value)
 
     def var_changed_win_height(self, *params):
         "Store change to window height."
         value = self.win_height.get()
-        self.add_changed_item('main', 'EditorWindow', 'height', value)
+        changes.add_option('main', 'EditorWindow', 'height', value)
 
     def var_changed_startup_edit(self, *params):
         "Store change to toggle for starting IDLE in the editor or shell."
         value = self.startup_edit.get()
-        self.add_changed_item('main', 'General', 'editor-on-startup', value)
+        changes.add_option('main', 'General', 'editor-on-startup', value)
 
     def var_changed_autosave(self, *params):
         "Store change to autosave."
         value = self.autosave.get()
-        self.add_changed_item('main', 'General', 'autosave', value)
+        changes.add_option('main', 'General', 'autosave', value)
 
     def var_changed_encoding(self, *params):
         "Store change to encoding."
         value = self.encoding.get()
-        self.add_changed_item('main', 'EditorWindow', 'encoding', value)
-
-    def reset_changed_items(self):
-        """Reset dictionary containing the items changed on each tab.
-
-        When any config item is changed in this dialog, an entry
-        should be made in the relevant section (config type) of this
-        dictionary. The key should be the config file section name and the
-        value a dictionary, whose key:value pairs are item=value pairs for
-        that config file section.
-        """
-        self.changed_items = {'main':{}, 'highlight':{}, 'keys':{},
-                             'extensions':{}}
-
-    def add_changed_item(self, typ, section, item, value):
-        "Add item/value pair to changed items dictionary for typ and section."
-        value = str(value)  # Make sure we use a string.
-        if section not in self.changed_items[typ]:
-            self.changed_items[typ][section] = {}
-        self.changed_items[typ][section][item] = value
-
-    def GetDefaultItems(self):
-        "Return dictionary of default configuration settings."
-        d_items={'main':{}, 'highlight':{}, 'keys':{}, 'extensions':{}}
-        for config_type in d_items:
-            sections = idleConf.GetSectionList('default', config_type)
-            for section in sections:
-                d_items[config_type][section] = {}
-                options = idleConf.defaultCfg[config_type].GetOptionList(section)
-                for option in options:
-                    d_items[config_type][section][option] = (
-                            idleConf.defaultCfg[config_type].Get(section, option))
-        return d_items
+        changes.add_option('main', 'EditorWindow', 'encoding', value)
 
     def set_theme_type(self):
         "Set available screen options based on builtin or custom theme."
@@ -769,8 +738,8 @@ class ConfigDialog(Toplevel):
         else:
             current_key_set_name = self.custom_keys.get()
         current_bindings = idleConf.GetCurrentKeySet()
-        if current_key_set_name in self.changed_items['keys']:  # unsaved changes
-            key_set_changes = self.changed_items['keys'][current_key_set_name]
+        if current_key_set_name in changes['keys']:  # unsaved changes
+            key_set_changes = changes['keys'][current_key_set_name]
             for event in key_set_changes:
                 current_bindings[event] = key_set_changes[event].split()
         current_key_sequences = list(current_bindings.values())
@@ -832,8 +801,8 @@ class ConfigDialog(Toplevel):
             binding = ' '.join(prev_keys[event])
             new_keys[event_name] = binding
         # Handle any unsaved changes to prev key set.
-        if prev_key_set_name in self.changed_items['keys']:
-            key_set_changes = self.changed_items['keys'][prev_key_set_name]
+        if prev_key_set_name in changes['keys']:
+            key_set_changes = changes['keys'][prev_key_set_name]
             for event in key_set_changes:
                 new_keys[event] = key_set_changes[event]
         # Save the new key set.
@@ -863,10 +832,10 @@ class ConfigDialog(Toplevel):
         for bind_name in bind_names:
             key = ' '.join(keyset[bind_name])
             bind_name = bind_name[2:-2]  # Trim off the angle brackets.
-            if keyset_name in self.changed_items['keys']:
+            if keyset_name in changes['keys']:
                 # Handle any unsaved changes to this key set.
-                if bind_name in self.changed_items['keys'][keyset_name]:
-                    key = self.changed_items['keys'][keyset_name][bind_name]
+                if bind_name in changes['keys'][keyset_name]:
+                    key = changes['keys'][keyset_name][bind_name]
             self.list_bindings.insert(END, bind_name+' - '+key)
         if reselect:
             self.list_bindings.see(list_index)
@@ -886,12 +855,8 @@ class ConfigDialog(Toplevel):
                 'Delete Key Set',  delmsg % keyset_name, parent=self):
             return
         self.deactivate_current_config()
-        # Remove key set from config.
-        idleConf.userCfg['keys'].remove_section(keyset_name)
-        if keyset_name in self.changed_items['keys']:
-            del(self.changed_items['keys'][keyset_name])
-        # Write changes.
-        idleConf.userCfg['keys'].Save()
+        # Remove key set from changes, config, and file.
+        changes.remove(keyset_name)
         # Reload user key set list.
         item_list = idleConf.GetSectionList('user', 'keys')
         item_list.sort()
@@ -906,7 +871,8 @@ class ConfigDialog(Toplevel):
         self.builtin_keys.set(idleConf.defaultCfg['main'].Get('Keys', 'name')
                              or idleConf.default_keys())
         # User can't back out of these changes, they must be applied now.
-        self.save_all_changed_configs()
+        changes.save_all()
+        self.save_all_changed_extensions()
         self.activate_config_changes()
         self.set_keys_type()
 
@@ -923,12 +889,8 @@ class ConfigDialog(Toplevel):
                 'Delete Theme',  delmsg % theme_name, parent=self):
             return
         self.deactivate_current_config()
-        # Remove theme from config.
-        idleConf.userCfg['highlight'].remove_section(theme_name)
-        if theme_name in self.changed_items['highlight']:
-            del(self.changed_items['highlight'][theme_name])
-        # Write changes.
-        idleConf.userCfg['highlight'].Save()
+        # Remove theme from changes, config, and file.
+        changes.delete_section('highlight')
         # Reload user theme list.
         item_list = idleConf.GetSectionList('user', 'highlight')
         item_list.sort()
@@ -941,7 +903,8 @@ class ConfigDialog(Toplevel):
         self.is_builtin_theme.set(idleConf.defaultCfg['main'].Get('Theme', 'default'))
         self.builtin_theme.set(idleConf.defaultCfg['main'].Get('Theme', 'name'))
         # User can't back out of these changes, they must be applied now.
-        self.save_all_changed_configs()
+        changes.save_all()
+        self.save_all_changed_extensions()
         self.activate_config_changes()
         self.set_theme_type()
 
@@ -979,7 +942,7 @@ class ConfigDialog(Toplevel):
         self.text_highlight_sample.tag_config(sample_element, **{plane:new_colour})
         theme = self.custom_theme.get()
         theme_element = sample_element + '-' + plane
-        self.add_changed_item('highlight', theme, theme_element, new_colour)
+        changes.add_option('highlight', theme, theme_element, new_colour)
 
     def get_new_theme_name(self, message):
         "Return name of new theme from query popup."
@@ -1010,8 +973,8 @@ class ConfigDialog(Toplevel):
             theme_name = self.custom_theme.get()
         new_theme = idleConf.GetThemeDict(theme_type, theme_name)
         # Apply any of the old theme's unsaved changes to the new theme.
-        if theme_name in self.changed_items['highlight']:
-            theme_changes = self.changed_items['highlight'][theme_name]
+        if theme_name in changes['highlight']:
+            theme_changes = changes['highlight'][theme_name]
             for element in theme_changes:
                 new_theme[element] = theme_changes[element]
         # Save the new theme.
@@ -1078,8 +1041,8 @@ class ConfigDialog(Toplevel):
                 colours['background'] = idleConf.GetHighlight(
                         theme, 'normal', fgBg='bg')
             # Handle any unsaved changes to this theme.
-            if theme in self.changed_items['highlight']:
-                theme_dict = self.changed_items['highlight'][theme]
+            if theme in changes['highlight']:
+                theme_dict = changes['highlight'][theme]
                 if element + '-foreground' in theme_dict:
                     colours['foreground'] = theme_dict[element + '-foreground']
                 if element + '-background' in theme_dict:
@@ -1150,10 +1113,10 @@ class ConfigDialog(Toplevel):
         self.set_helplist_button_states()
 
     def update_user_help_changed_items(self):
-        "Clear and rebuild the HelpFiles section in self.changed_items"
-        self.changed_items['main']['HelpFiles'] = {}
+        "Clear and rebuild the HelpFiles section in changes"
+        changes['main']['HelpFiles'] = {}
         for num in range(1, len(self.user_helplist) + 1):
-            self.add_changed_item(
+            changes.add_option(
                     'main', 'HelpFiles', str(num),
                     ';'.join(self.user_helplist[num-1][:2]))
 
@@ -1317,37 +1280,6 @@ class ConfigDialog(Toplevel):
             value = theme[element]
             idleConf.userCfg['highlight'].SetOption(theme_name, element, value)
 
-    def set_user_value(self, config_type, section, item, value):
-        "Return True if the configuration value was added or changed."
-        if idleConf.defaultCfg[config_type].has_option(section, item):
-            if idleConf.defaultCfg[config_type].Get(section, item) == value:
-                # The setting equals a default setting, remove it from user cfg.
-                return idleConf.userCfg[config_type].RemoveOption(section, item)
-        # If we got here, set the option.
-        return idleConf.userCfg[config_type].SetOption(section, item, value)
-
-    def save_all_changed_configs(self):
-        "Save all configuration changes to the user config file."
-        idleConf.userCfg['main'].Save()
-        for config_type in self.changed_items:
-            cfg_type_changed = False
-            for section in self.changed_items[config_type]:
-                if section == 'HelpFiles':
-                    # This section gets completely replaced.
-                    idleConf.userCfg['main'].remove_section('HelpFiles')
-                    cfg_type_changed = True
-                for item in self.changed_items[config_type][section]:
-                    value = self.changed_items[config_type][section][item]
-                    if self.set_user_value(config_type, section, item, value):
-                        cfg_type_changed = True
-            if cfg_type_changed:
-                idleConf.userCfg[config_type].Save()
-        for config_type in ['keys', 'highlight']:
-            # Save these even if unchanged!
-            idleConf.userCfg[config_type].Save()
-        self.reset_changed_items()  # Clear the changed items dict.
-        self.save_all_changed_extensions()  # Uses a different mechanism.
-
     def deactivate_current_config(self):
         "Remove current key bindings."
         # Before a config is saved, some cleanup of current
@@ -1378,7 +1310,8 @@ class ConfigDialog(Toplevel):
     def apply(self):
         "Apply config changes and leave dialog open."
         self.deactivate_current_config()
-        self.save_all_changed_configs()
+        changes.save_all()
+        self.save_all_changed_extensions()
         self.activate_config_changes()
 
     def help(self):
diff --git a/Lib/idlelib/idle_test/test_config.py b/Lib/idlelib/idle_test/test_config.py
index e678cc6..e157bbb 100644
--- a/Lib/idlelib/idle_test/test_config.py
+++ b/Lib/idlelib/idle_test/test_config.py
@@ -9,12 +9,13 @@ from idlelib import config
 
 # Tests should not depend on fortuitous user configurations.
 # They must not affect actual user .cfg files.
-# Replace user parsers with empty parsers that cannot be saved.
+# Replace user parsers with empty parsers that cannot be saved
+# due to getting '' as the filename when created.
 
 idleConf = config.idleConf
 usercfg = idleConf.userCfg
 testcfg = {}
-usermain = testcfg['main'] = config.IdleUserConfParser('')  # filename
+usermain = testcfg['main'] = config.IdleUserConfParser('')
 userhigh = testcfg['highlight'] = config.IdleUserConfParser('')
 userkeys = testcfg['keys'] = config.IdleUserConfParser('')
 
@@ -136,6 +137,87 @@ class CurrentColorKeysTest(unittest.TestCase):
         userkeys.remove_section('Custom Keys')
 
 
+class ChangesTest(unittest.TestCase):
+
+    empty = {'main':{}, 'highlight':{}, 'keys':{}, 'extensions':{}}
+
+    def load(self):  # Test_add_option verifies that this works.
+        changes = self.changes
+        changes.add_option('main', 'Msec', 'mitem', 'mval')
+        changes.add_option('highlight', 'Hsec', 'hitem', 'hval')
+        changes.add_option('keys', 'Ksec', 'kitem', 'kval')
+        return changes
+
+    loaded = {'main': {'Msec': {'mitem': 'mval'}},
+              'highlight': {'Hsec': {'hitem': 'hval'}},
+              'keys': {'Ksec': {'kitem':'kval'}},
+              'extensions': {}}
+
+    def setUp(self):
+        self.changes = config.ConfigChanges()
+
+    def test_init(self):
+        self.assertEqual(self.changes, self.empty)
+
+    def test_add_option(self):
+        changes = self.load()
+        self.assertEqual(changes, self.loaded)
+        changes.add_option('main', 'Msec', 'mitem', 'mval')
+        self.assertEqual(changes, self.loaded)
+
+    def test_save_option(self):  # Static function does not touch changes.
+        save_option = self.changes.save_option
+        self.assertTrue(save_option('main', 'Indent', 'what', '0'))
+        self.assertFalse(save_option('main', 'Indent', 'what', '0'))
+        self.assertEqual(usermain['Indent']['what'], '0')
+
+        self.assertTrue(save_option('main', 'Indent', 'use-spaces', '0'))
+        self.assertEqual(usermain['Indent']['use-spaces'], '0')
+        self.assertTrue(save_option('main', 'Indent', 'use-spaces', '1'))
+        self.assertFalse(usermain.has_option('Indent', 'use-spaces'))
+        usermain.remove_section('Indent')
+
+    def test_save_added(self):
+        changes = self.load()
+        changes.save_all()
+        self.assertEqual(usermain['Msec']['mitem'], 'mval')
+        self.assertEqual(userhigh['Hsec']['hitem'], 'hval')
+        self.assertEqual(userkeys['Ksec']['kitem'], 'kval')
+        usermain.remove_section('Msec')
+        userhigh.remove_section('Hsec')
+        userkeys.remove_section('Ksec')
+
+    def test_save_help(self):
+        changes = self.changes
+        changes.save_option('main', 'HelpFiles', 'IDLE', 'idledoc')
+        changes.add_option('main', 'HelpFiles', 'ELDI', 'codeldi')
+        changes.save_all()
+        self.assertFalse(usermain.has_option('HelpFiles', 'IDLE'))
+        self.assertTrue(usermain.has_option('HelpFiles', 'ELDI'))
+
+    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.
+
+    # TODO: test that save_all calls usercfg Saves.
+
+    def test_delete_section(self):
+        changes = self.load()
+        changes.delete_section('main', 'fake')  # Test no exception.
+        self.assertEqual(changes, self.loaded)  # Test nothing deleted.
+        for cfgtype, section in (('main', 'Msec'), ('keys', 'Ksec')):
+            changes.delete_section(cfgtype, section)
+            with self.assertRaises(KeyError):
+                changes[cfgtype][section]  # Test section gone.
+        # TODO Test change to userkeys and maybe save call.
+
+    def test_clear(self):
+        changes = self.load()
+        changes.clear()
+        self.assertEqual(changes, self.empty)
+
+
 class WarningTest(unittest.TestCase):
 
     def test_warn(self):
diff --git a/Lib/idlelib/idle_test/test_configdialog.py b/Lib/idlelib/idle_test/test_configdialog.py
index ffc8a72..26b045d 100644
--- a/Lib/idlelib/idle_test/test_configdialog.py
+++ b/Lib/idlelib/idle_test/test_configdialog.py
@@ -3,7 +3,7 @@
 Half the class creates dialog, half works with user customizations.
 Coverage: 46% just by creating dialog, 56% with current tests.
 """
-from idlelib.configdialog import ConfigDialog, idleConf  # test import
+from idlelib.configdialog import ConfigDialog, idleConf, changes
 from test.support import requires
 requires('gui')
 from tkinter import Tk
@@ -21,17 +21,13 @@ testcfg = {
     'extensions': config.IdleUserConfParser(''),
 }
 
-# ConfigDialog.changed_items is a 3-level hierarchical dictionary of
-# pending changes that mirrors the multilevel user config dict.
-# For testing, record args in a list for comparison with expected.
-changes = []
 root = None
 configure = None
+mainpage = changes['main']
+highpage = changes['highlight']
+keyspage = changes['keys']
 
-
-class TestDialog(ConfigDialog):
-    def add_changed_item(self, *args):
-        changes.append(args)
+class TestDialog(ConfigDialog): pass  # Delete?
 
 
 def setUpModule():
@@ -63,31 +59,28 @@ class FontTabTest(unittest.TestCase):
         default_size = str(default_font[1])
         default_bold = default_font[2] == 'bold'
         configure.font_name.set('Test Font')
-        expected = [
-            ('main', 'EditorWindow', 'font', 'Test Font'),
-            ('main', 'EditorWindow', 'font-size', default_size),
-            ('main', 'EditorWindow', 'font-bold', default_bold)]
-        self.assertEqual(changes, expected)
+        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)
-        expected = [
-            ('main', 'EditorWindow', 'font', 'Test Font'),
-            ('main', 'EditorWindow', 'font-size', '20'),
-            ('main', 'EditorWindow', 'font-bold', default_bold)]
-        self.assertEqual(changes, expected)
+        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)
-        expected = [
-            ('main', 'EditorWindow', 'font', 'Test Font'),
-            ('main', 'EditorWindow', 'font-size', '20'),
-            ('main', 'EditorWindow', 'font-bold', not default_bold)]
-        self.assertEqual(changes, expected)
+        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_tabspace(self):
         configure.space_num.set(6)
-        self.assertEqual(changes, [('main', 'Indent', 'num-spaces', 6)])
+        self.assertEqual(mainpage, {'Indent': {'num-spaces': '6'}})
 
 
 class HighlightTest(unittest.TestCase):
@@ -111,19 +104,19 @@ class GeneralTest(unittest.TestCase):
 
     def test_startup(self):
         configure.radio_startup_edit.invoke()
-        self.assertEqual(changes,
-                         [('main', 'General', 'editor-on-startup', 1)])
+        self.assertEqual(mainpage,
+                         {'General': {'editor-on-startup': '1'}})
 
     def test_autosave(self):
         configure.radio_save_auto.invoke()
-        self.assertEqual(changes, [('main', 'General', 'autosave', 1)])
+        self.assertEqual(mainpage, {'General': {'autosave': '1'}})
 
     def test_editor_size(self):
         configure.entry_win_height.insert(0, '1')
-        self.assertEqual(changes, [('main', 'EditorWindow', 'height', '140')])
+        self.assertEqual(mainpage, {'EditorWindow': {'height': '140'}})
         changes.clear()
         configure.entry_win_width.insert(0, '1')
-        self.assertEqual(changes, [('main', 'EditorWindow', 'width', '180')])
+        self.assertEqual(mainpage, {'EditorWindow': {'width': '180'}})
 
     #def test_help_sources(self): pass  # TODO
 
-- 
cgit v0.12