Repository: biolab/orange3

Issue:
Merge data remember Row matching
Merge data should remember Row matching feature names even when input data changes. For example, when inserting/removing preprocessing widgets or changing preprocessing applied by Single cell preprocessing before merging the Row matching features are forgotten. Same happens when data comes from selection on plots and an empty selection is made by mistake between selections. This can be very frustrating if there are hundreds of features which need to be scrolled through to reach the desired one - maybe a search box could be added as well.

Furthermore, sometimes extra data may come from different sources (e.g. clustering or tSNE) and has same feature names which could be used for merging. Thus an instance of Merge widget could remember what was last set for Row matching and apply it automatically if new data has features with such names. 


Discussion:
Search kind of works if you start typing the name of the variable. Doesn't it?
Yes it does. I did not notice it as there was no box to type in.

Resolving patch:
diff --git a/Orange/widgets/data/owmergedata.py b/Orange/widgets/data/owmergedata.py
--- a/Orange/widgets/data/owmergedata.py
+++ b/Orange/widgets/data/owmergedata.py
@@ -11,7 +11,8 @@
 from Orange.data import StringVariable, ContinuousVariable, Variable
 from Orange.data.util import hstack, get_unique_names_duplicates
 from Orange.widgets import widget, gui
-from Orange.widgets.settings import Setting
+from Orange.widgets.settings import Setting, ContextHandler, ContextSetting
+from Orange.widgets.utils import vartype
 from Orange.widgets.utils.itemmodels import DomainModel
 from Orange.widgets.utils.sql import check_sql_input
 from Orange.widgets.utils.widgetpreview import WidgetPreview
@@ -168,6 +169,72 @@ def data(self, index, role=Qt.DisplayRole):
         return super().data(index, role)
 
 
+class MergeDataContextHandler(ContextHandler):
+    # `widget` is used as an argument in most methods
+    # pylint: disable=redefined-outer-name
+    # context handlers override methods using different signatures
+    # pylint: disable=arguments-differ
+
+    def new_context(self, variables1, variables2):
+        context = super().new_context()
+        context.variables1 = variables1
+        context.variables2 = variables2
+        return context
+
+    def open_context(self, widget, domain1, domain2):
+        if domain1 is not None and domain2 is not None:
+            super().open_context(widget,
+                                 self._encode_domain(domain1),
+                                 self._encode_domain(domain2))
+
+    @staticmethod
+    def encode_variables(variables):
+        return [(v.name, 100 + vartype(v))
+                if isinstance(v, Variable) else (v, 100)
+                for v in variables]
+
+    @staticmethod
+    def decode_pair(widget, pair):
+        left_domain = widget.data and widget.data.domain
+        right_domain = widget.extra_data and widget.extra_data.domain
+        return tuple(var[0] if var[1] == 100 else domain[var[0]]
+                     for domain, var in zip((left_domain, right_domain), pair))
+
+    def _encode_domain(self, domain):
+        if domain is None:
+            return {}
+        all_vars = chain(domain.variables, domain.metas)
+        return dict(self.encode_variables(all_vars))
+
+    def settings_from_widget(self, widget, *_args):
+        context = widget.current_context
+        if context is None:
+            return
+        context.values["attr_pairs"] = [self.encode_variables(row)
+                                        for row in widget.attr_pairs]
+
+    def settings_to_widget(self, widget, *_args):
+        context = widget.current_context
+        if context is None:
+            return
+        pairs = context.values.get("attr_pairs", [])
+        widget.attr_pairs = [self.decode_pair(widget, pair) for pair in pairs]
+
+    def match(self, context, variables1, variables2):
+        def matches(part, variables):
+            return all(var[1] == 100 or variables.get(var[0], -1) == var[1]
+                       for var in part)
+
+        if (variables1, variables2) == (context.variables1, context.variables2):
+            return self.PERFECT_MATCH
+
+        left, right = zip(*context.values["attr_pairs"])
+        if matches(left, variables1) and matches(right, variables2):
+            return 0.5
+
+        return self.NO_MATCH
+
+
 class OWMergeData(widget.OWWidget):
     name = "Merge Data"
     description = "Merge datasets based on the values of selected features."
@@ -208,10 +275,11 @@ class Outputs:
             "Confused about merging options?\nSee the tooltips!",
             "merging_types")]
 
-    attr_pairs = Setting(None, schema_only=True)
+    settingsHandler = MergeDataContextHandler()
+    attr_pairs = ContextSetting(None, schema_only=True)
     merging = Setting(LeftJoin)
     auto_apply = Setting(True)
-    settings_version = 1
+    settings_version = 2
 
     want_main_area = False
     resizing_enabled = False
@@ -248,10 +316,9 @@ def __init__(self):
         self.extra_model = DomainModelWithTooltips(content)
 
         box = gui.hBox(self.controlArea, box=None)
-        self.infoBoxData = gui.label(
-            box, self, self.dataInfoText(None), box="Data")
-        self.infoBoxExtraData = gui.label(
-            box, self, self.dataInfoText(None), box="Extra Data")
+        no_info = self.data_info(None)
+        self.info_box_data = gui.label(box, self, no_info, box="Data")
+        self.info_box_extra_data = gui.label(box, self, no_info, box="Extra Data")
 
         grp = gui.radioButtons(
             self.controlArea, self, "merging", box="Merging",
@@ -268,109 +335,57 @@ def __init__(self):
         gui.auto_apply(self.controlArea, self, box=False)
         # connect after wrapping self.commit with gui.auto_commit!
         self.attr_boxes.vars_changed.connect(self.commit)
+        self.attr_boxes.vars_changed.connect(self.store_combo_state)
         self.settingsAboutToBePacked.connect(self.store_combo_state)
 
     def change_merging(self):
         self.commit()
 
-    @staticmethod
-    def _try_set_combo(combo, var):
-        if var in combo.model():
-            combo.setCurrentIndex(combo.model().indexOf(var))
-        else:
-            combo.setCurrentIndex(0)
-
-    def _find_best_match(self):
-        def get_unique_str_metas_names(model_):
-            return [m for m in model_ if isinstance(m, StringVariable)]
-
-        def best_match(model, extra_model):
-            attr, extra_attr, n_max_intersect = None, None, 0
-            str_metas = get_unique_str_metas_names(model)
-            extra_str_metas = get_unique_str_metas_names(extra_model)
-            for m_a, m_b in product(str_metas, extra_str_metas):
-                col = self.data[:, m_a].metas
-                extra_col = self.extra_data[:, m_b].metas
-                if col.size and extra_col.size \
-                        and isinstance(col[0][0], str) \
-                        and isinstance(extra_col[0][0], str):
-                    n_inter = len(np.intersect1d(col, extra_col))
-                    if n_inter > n_max_intersect:
-                        n_max_intersect, attr, extra_attr = n_inter, m_a, m_b
-            return attr, extra_attr
-
-        if self.data and self.extra_data:
-            box = self.attr_boxes
-            state = box.current_state()
-            if len(state) == 1 \
-                    and not any(isinstance(v, Variable) for v in state[0]):
-                l_var, r_var = best_match(box.model_left, box.model_right)
-                if l_var is not None:
-                    self._try_set_combo(box.rows[0].left_combo, l_var)
-                    self._try_set_combo(box.rows[0].right_combo, r_var)
-
     @Inputs.data
     @check_sql_input
-    def setData(self, data):
+    def set_data(self, data):
         self.data = data
-        prev_settings = self.attr_boxes.current_state()
         self.model.set_domain(data and data.domain)
-        self._restore_combo_current_items(0, prev_settings)
-        self.infoBoxData.setText(self.dataInfoText(data))
+        self.info_box_data.setText(self.data_info(data))
 
     @Inputs.extra_data
     @check_sql_input
-    def setExtraData(self, data):
+    def set_extra_data(self, data):
         self.extra_data = data
-        prev_settings = self.attr_boxes.current_state()
         self.extra_model.set_domain(data and data.domain)
-        self._restore_combo_current_items(1, prev_settings)
-        self.infoBoxExtraData.setText(self.dataInfoText(data))
-
-    def _restore_combo_current_items(self, side, prev_settings):
-        for row, pair in zip(self.attr_boxes.rows, prev_settings):
-            self._try_set_combo(
-                [row.left_combo, row.right_combo][side], pair[side])
+        self.info_box_extra_data.setText(self.data_info(data))
 
     def store_combo_state(self):
-        self.attr_pairs = (
-            self.data is not None,
-            self.extra_data is not None,
-            [[[INDEX, INSTANCEID].index(x) if isinstance(x, str) else x.name
-              for x in row]
-             for row in self.attr_boxes.current_state()])
+        self.attr_pairs = self.attr_boxes.current_state()
 
     def handleNewSignals(self):
-        if self.attr_pairs \
-                and self.attr_pairs[:2] == (self.data is not None,
-                                            self.extra_data is not None):
-            def unpack(x, data):
-                if isinstance(x, int):
-                    return [INDEX, INSTANCEID][x]
-                if data is not None and x in data.domain:
-                    return data.domain[x]
-                else:
-                    return INDEX
-
-            state = [
-                [unpack(row[0], self.data), unpack(row[1], self.extra_data)]
-                for row in self.attr_pairs[2]]
-            self.attr_boxes.set_state(state)
-            # This is schema-only setting, so it should be single-shot
-            # More complicated alternative: make it a context setting
-            self.attr_pairs = None
-        self._find_best_match()
-        box = self.attr_boxes
-
-        state = box.current_state()
-        for i in range(len(box.rows) - 1, 0, -1):
-            if state[i] in state[:i]:
-                box.remove_row(box.rows[i])
-
+        self.closeContext()
+        self.attr_pairs = [self._find_best_match()]
+        self.openContext(self.data and self.data.domain,
+                         self.extra_data and self.extra_data.domain)
+        self.attr_boxes.set_state(self.attr_pairs)
         self.unconditional_commit()
 
+    def _find_best_match(self):
+        def get_unique_str_metas_names(model_):
+            return [m for m in model_ if isinstance(m, StringVariable)]
+
+        attr, extra_attr, n_max_intersect = INDEX, INDEX, 0
+        str_metas = get_unique_str_metas_names(self.model)
+        extra_str_metas = get_unique_str_metas_names(self.extra_model)
+        for m_a, m_b in product(str_metas, extra_str_metas):
+            col = self.data[:, m_a].metas
+            extra_col = self.extra_data[:, m_b].metas
+            if col.size and extra_col.size \
+                    and isinstance(col[0][0], str) \
+                    and isinstance(extra_col[0][0], str):
+                n_inter = len(np.intersect1d(col, extra_col))
+                if n_inter > n_max_intersect:
+                    n_max_intersect, attr, extra_attr = n_inter, m_a, m_b
+        return attr, extra_attr
+
     @staticmethod
-    def dataInfoText(data):
+    def data_info(data):
         if data is None:
             return "No data."
         else:
@@ -381,7 +396,7 @@ def dataInfoText(data):
 
     def commit(self):
         self.clear_messages()
-        merged = None if not self.data or not self.extra_data else self.merge()
+        merged = self.merge() if self.data and self.extra_data else None
         self.Outputs.data.send(merged)
 
     def send_report(self):
@@ -655,15 +670,27 @@ def mig_value(x):
             operations = ("augment", "merge", "combine")
             oper = operations[settings["merging"]]
             settings["attr_pairs"] = (
-                True, True,
                 [(mig_value(settings[f"attr_{oper}_data"]),
                   mig_value(settings[f"attr_{oper}_extra"]))])
             for oper in operations:
                 del settings[f"attr_{oper}_data"]
                 del settings[f"attr_{oper}_extra"]
 
+        if not version or version < 2 and "attr_pairs" in settings:
+            attr_pairs = settings.pop("attr_pairs")
+            attr_pairs = [tuple((var, 100) if isinstance(var, str) else var
+                                for var in pair)
+                          for pair in attr_pairs]
+            context = ContextHandler().new_context()
+            context.variables1 = \
+                dict(var for var, _ in attr_pairs if var[1] > 100)
+            context.variables2 = \
+                dict(var for _, var in attr_pairs if var[1] > 100)
+            context.values["attr_pairs"] = attr_pairs
+            settings["context_settings"] = [context]
+
 
 if __name__ == "__main__":  # pragma: no cover
     WidgetPreview(OWMergeData).run(
-        setData=Orange.data.Table("tests/data-gender-region"),
-        setExtraData=Orange.data.Table("tests/data-regions"))
+        set_data=Orange.data.Table("tests/data-gender-region"),
+        set_extra_data=Orange.data.Table("tests/data-regions"))


Test patch:
diff --git a/Orange/widgets/data/tests/test_owmergedata.py b/Orange/widgets/data/tests/test_owmergedata.py
--- a/Orange/widgets/data/tests/test_owmergedata.py
+++ b/Orange/widgets/data/tests/test_owmergedata.py
@@ -4,6 +4,7 @@
 # pylint: disable=too-many-lines,too-many-public-methods
 from itertools import chain
 import unittest
+from unittest.mock import Mock
 
 import numpy as np
 import scipy.sparse as sp
@@ -12,9 +13,11 @@
 
 from Orange.data import Table, Domain, DiscreteVariable, StringVariable, \
     ContinuousVariable
-from Orange.widgets.data.owmergedata import OWMergeData, INSTANCEID, INDEX
+from Orange.widgets.data.owmergedata import OWMergeData, INSTANCEID, INDEX, \
+    MergeDataContextHandler
 from Orange.widgets.tests.base import WidgetTest
 from Orange.tests import test_filename
+from orangewidget.settings import VERSION_KEY
 
 
 class TestOWMergeData(WidgetTest):
@@ -288,122 +291,73 @@ def test_retrieve_settings(self):
             widget2.attr_boxes.current_state(),
             [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (var0, var1)])
 
-    def test_retrieve_settings_with_missing_var(self):
+    def test_match_settings(self):
         widget = self.widget
         boxes = widget.attr_boxes
-        var0, var1 = self.dataA.domain.attributes[:2]
+        domainA = self.dataA.domain
+        domainB = self.dataB.domain
 
-        self.send_signal(self.widget.Inputs.data, self.dataA)
-        self.send_signal(self.widget.Inputs.extra_data, self.dataA)
+        self.send_signal(widget.Inputs.data, self.dataA)
+        self.send_signal(widget.Inputs.extra_data, self.dataA)
+        attr_pairs = [(INDEX, INDEX), (INSTANCEID, INSTANCEID),
+                      (domainA[0], domainA[1]), (domainA[1], domainA[0])]
+        boxes.set_state(attr_pairs)
+        boxes.emit_list()
+        self.assertEqual(widget.attr_pairs, attr_pairs)
 
-        boxes.set_state(
-            [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (var0, var1)])
+        self.send_signal(widget.Inputs.data, None)
+        self.assertEqual(widget.attr_pairs, [(INDEX, INDEX)])
 
-        domain = self.dataA.domain
+        self.send_signal(widget.Inputs.data, self.dataA)
+        self.assertEqual(widget.attr_pairs, attr_pairs)
 
-        # The left combo in the last row must change to INDEX due to missing var
-        data2 = self.dataA.transform(
-            Domain(domain.attributes[1:], domain.class_var, domain.metas))
-        settings = widget.settingsHandler.pack_data(widget)
-        widget2 = self.create_widget(OWMergeData, stored_settings=settings)
-        widget2.attr_boxes.set_state([(INDEX, INDEX)])
-        self.send_signals(
-            [(widget2.Inputs.data, data2),
-             (widget2.Inputs.extra_data, data2)],
-            widget=widget2)
-        self.assertEqual(
-            widget2.attr_boxes.current_state(),
-            [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (INDEX, var1)])
+        self.send_signal(widget.Inputs.extra_data, self.dataB)
+        attr_pairs2 = [(domainA[0], domainB[0]), (domainA[1], domainB[1])]
+        boxes.set_state(attr_pairs2)
+        boxes.emit_list()
+        self.assertEqual(widget.attr_pairs, attr_pairs2)
 
-        # The last row is changed to INDEX, INDEX and removed as duplicate
-        data2 = self.dataA.transform(
-            Domain(domain.attributes[2:], domain.class_var, domain.metas))
-        settings = widget.settingsHandler.pack_data(widget)
-        widget2 = self.create_widget(OWMergeData, stored_settings=settings)
-        widget2.attr_boxes.set_state([(INDEX, INDEX)])
-        self.send_signals(
-            [(widget2.Inputs.data, data2),
-             (widget2.Inputs.extra_data, data2)],
-            widget=widget2)
-        self.assertEqual(
-            widget2.attr_boxes.current_state(),
-            [(INDEX, INDEX), (INSTANCEID, INSTANCEID)])
+        self.send_signal(widget.Inputs.extra_data, self.dataA)
+        self.assertEqual(widget.attr_pairs, attr_pairs)
 
     def test_migrate_settings(self):
-        attr1, attr2, attr3, attr4, attr5 = [object() for _ in range(5)]
-        orig_settings = dict(
-            attr_augment_data=attr1, attr_augment_extra=attr2,
-            attr_merge_data=attr3, attr_merge_extra=attr4,
-            attr_combine_data=attr5, attr_combine_extra='Position (index)')
-
-        widget = self.create_widget(
-            OWMergeData, stored_settings=dict(merging=0, **orig_settings))
-        self.assertEqual(widget.attr_pairs, (True, True, [(attr1, attr2)]))
-
-        widget = self.create_widget(
-            OWMergeData, stored_settings=dict(merging=1, **orig_settings))
-        self.assertEqual(widget.attr_pairs, (True, True, [(attr3, attr4)]))
-
-        widget = self.create_widget(
-            OWMergeData, stored_settings=dict(merging=2, **orig_settings))
-        self.assertEqual(widget.attr_pairs, (True, True, [(attr5, INDEX)]))
+        def create_and_send(settings):
+            widget = self.create_widget(OWMergeData, stored_settings=settings)
+            for signal in (widget.Inputs.data, widget.Inputs.extra_data):
+                self.send_signal(signal, self.dataA)
+            return widget
 
-        orig_settings["attr_combine_extra"] = "Source position (index)"
-        widget = self.create_widget(
-            OWMergeData, stored_settings=dict(merging=2, **orig_settings))
-        self.assertEqual(widget.attr_pairs, (True, True, [(attr5, INSTANCEID)]))
-
-    @unittest.skip("widget doesn't work this way, but could in the future")
-    def test_switch_domain(self):  # pragma: no cover
-        widget = self.widget
-        boxes = widget.attr_boxes
-        var0, var1 = self.dataA.domain.attributes[:2]
-
-        self.send_signal(self.widget.Inputs.data, self.dataA)
-        self.send_signal(self.widget.Inputs.extra_data, self.dataA)
-        boxes.set_state(
-            [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (var0, var1)])
-
-        self.send_signal(self.widget.Inputs.data, self.dataB)
-        self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        self.assertEqual(
-            boxes.current_state(),
-            [(INDEX, INDEX), (INSTANCEID, INSTANCEID)])
+        domainA = self.dataA.domain
+        attr1, attr2, attr3 = domainA.variables
+        attr4, attr5 = domainA.metas
 
-        self.send_signal(self.widget.Inputs.data, self.dataA)
-        self.send_signal(self.widget.Inputs.extra_data, self.dataA)
-        boxes.set_state([(var0, var1), (var1, var0)])
+        # Migration from version == None
+        orig_settings = dict(
+            attr_augment_data=(attr1.name, 101),
+            attr_augment_extra=(attr2.name, 101),
+            attr_merge_data=(attr3.name, 101),
+            attr_merge_extra=(attr4.name, 101),
+            attr_combine_data=(attr5.name, 103),
+            attr_combine_extra='Position (index)')
 
-        self.send_signal(self.widget.Inputs.data, self.dataB)
-        self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        self.assertEqual(boxes.current_state(), [(INDEX, INDEX)])
+        widget = create_and_send(dict(merging=0, **orig_settings))
+        self.assertEqual(widget.attr_pairs, ([(attr1, attr2)]))
 
-        self.send_signal(self.widget.Inputs.data, self.dataA)
-        self.send_signal(self.widget.Inputs.extra_data, self.dataA)
-        boxes.set_state([(var1, var1), (var0, var0)])
+        widget = create_and_send(dict(merging=1, **orig_settings))
+        self.assertEqual(widget.attr_pairs, ([(attr3, attr4)]))
 
-        domain2 = Domain(self.dataA.domain.attributes[:1],
-                         self.dataA.domain.class_var)
-        data2 = self.dataA.transform(domain2)
-        self.send_signal(self.widget.Inputs.data, data2)
-        self.send_signal(self.widget.Inputs.extra_data, data2)
-        self.assertEqual(boxes.current_state(), [(var0, var0)])
+        widget = create_and_send(dict(merging=2, **orig_settings))
+        self.assertEqual(widget.attr_pairs, ([(attr5, INDEX)]))
 
-    def test_domain_switch_cleanup(self):
-        widget = self.widget
-        boxes = widget.attr_boxes
-        var0, var1 = self.dataA.domain.attributes[:2]
-
-        self.send_signal(self.widget.Inputs.data, self.dataA)
-        self.send_signal(self.widget.Inputs.extra_data, self.dataA)
-        boxes.set_state(
-            [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (var0, var1)])
+        orig_settings["attr_combine_extra"] = "Source position (index)"
+        widget = create_and_send(dict(merging=2, **orig_settings))
+        self.assertEqual(widget.attr_pairs, ([(attr5, INSTANCEID)]))
 
-        self.send_signal(self.widget.Inputs.data, self.dataB)
-        self.send_signal(self.widget.Inputs.extra_data, self.dataB)
-        self.assertEqual(
-            boxes.current_state(),
-            [(INDEX, INDEX), (INSTANCEID, INSTANCEID)])
+        # Migration from version 1
+        settings = {"attr_pairs": [((attr1.name, 101), (attr2.name, 101))],
+                    VERSION_KEY: 1}
+        widget = create_and_send(settings)
+        self.assertEqual(widget.attr_pairs, ([(attr1, attr2)]))
 
     def test_report(self):
         widget = self.widget
@@ -460,11 +414,12 @@ def test_output_merge_by_ids_outer(self):
                        np.array([[1, 1], [2, 2], [np.nan, np.nan], [np.nan, 0]]),
                        np.array([[1.0, "m2"], [np.nan, "m3"],
                                  [0.0, ""], [np.nan, "m1"]]).astype(object))
-        self.widget.attr_boxes.set_state([(INSTANCEID, INSTANCEID)])
         self.widget.merging = 2
         self.widget.controls.merging.buttons[self.widget.OuterJoin].click()
         self.send_signal(self.widget.Inputs.data, self.dataA[1:, [0, "clsA", -1]])
         self.send_signal(self.widget.Inputs.extra_data, self.dataA[:3, [1, "clsA", -2]])
+        self.widget.attr_boxes.set_state([(INSTANCEID, INSTANCEID)])
+        self.widget.attr_boxes.emit_list()
         out = self.get_output(self.widget.Outputs.data)
         self.assertTablesEqual(out, result)
         np.testing.assert_equal(
@@ -786,7 +741,7 @@ def test_best_match(self):
         self.send_signal(widget.Inputs.extra_data, zoo_images)
         for i in range(3):
             self.assertEqual(widget.attr_boxes.current_state(),
-                             [(zoo.domain[-1], zoo_images.domain[-1])],
+                             [(zoo.domain["name"], zoo_images.domain["name"])],
                              f"wrong attributes chosen for merge_type={i}")
 
     def test_sparse(self):
@@ -1005,6 +960,7 @@ def test_keep_non_duplicate_variables_missing_rows(self):
         self.send_signal(self.widget.Inputs.data, data)
         self.send_signal(self.widget.Inputs.extra_data, extra_data)
         self.widget.attr_boxes.set_state([(c, c)])
+        self.widget.attr_boxes.emit_list()
 
         # Only one row is matched; A has different values and it's duplicated,
         # and B has the same values, so we get only one copy
@@ -1033,5 +989,22 @@ def test_keep_non_duplicate_variables_missing_rows(self):
                              ["A", "B", "C"])
 
 
+class MergeDataContextHandlerTest(unittest.TestCase):
+    # These units are too small to test individually, so they are tested
+    # within their function in the widget.
+
+    # The following test only covers obscure cases that seem to appear only
+    # within the context of some tests and can't appear in real world.
+    def test_malformed_contexts(self):
+        widget = Mock()
+        handler = MergeDataContextHandler()
+        # pylint: disable=protected-access
+        self.assertEqual(handler._encode_domain(None), {})
+
+        widget.current_context = None
+        handler.settings_from_widget(widget)  # mustn't crash
+        handler.settings_to_widget(widget)  # mustn't crash
+
+
 if __name__ == "__main__":
     unittest.main()