Repository: biolab/orange3

Issue:
OWScatterPlot sometimes doesn't call handleNewSignals
#3450 is a temporary patch and has to be replaced with one that sets `__invalidated` to `True` instead. See the comment in #3450.


Discussion:

Resolving patch:
diff --git a/Orange/widgets/visualize/owscatterplot.py b/Orange/widgets/visualize/owscatterplot.py
--- a/Orange/widgets/visualize/owscatterplot.py
+++ b/Orange/widgets/visualize/owscatterplot.py
@@ -105,13 +105,17 @@ def clear(self):
         super().clear()
         self.reg_line_item = None
 
-    def set_axis_title(self, axis, title):
-        self.plot_widget.setLabel(axis=axis, text=title)
-
     def update_coordinates(self):
         super().update_coordinates()
+        self.update_axes()
         self.update_regression_line()
 
+    def update_axes(self):
+        for axis, title in self.master.get_axes().items():
+            self.plot_widget.setLabel(axis=axis, text=title or "")
+            if title is None:
+                self.plot_widget.hideAxis(axis)
+
     def update_regression_line(self):
         if self.reg_line_item is not None:
             self.plot_widget.removeItem(self.reg_line_item)
@@ -371,16 +375,17 @@ def handleNewSignals(self):
                 all(attr in self.data.domain for attr
                         in self.attribute_selection_list):
             self.attr_x, self.attr_y = self.attribute_selection_list[:2]
-            self.attr_changed()
             self.attribute_selection_list = None
-        else:
-            super().handleNewSignals()
+        super().handleNewSignals()
         self._vizrank_color_change()
 
     @Inputs.features
     def set_shown_attributes(self, attributes):
         if attributes and len(attributes) >= 2:
             self.attribute_selection_list = attributes[:2]
+            self._invalidated = self._invalidated \
+                or self.attr_x != attributes[0] \
+                or self.attr_y != attributes[1]
         else:
             self.attribute_selection_list = None
 
@@ -393,10 +398,8 @@ def attr_changed(self):
         self.setup_plot()
         self.commit()
 
-    def setup_plot(self):
-        super().setup_plot()
-        self.graph.set_axis_title("bottom", self.attr_x)
-        self.graph.set_axis_title("left", self.attr_y)
+    def get_axes(self):
+        return {"bottom": self.attr_x, "left": self.attr_y}
 
     def colors_changed(self):
         super().colors_changed()
diff --git a/Orange/widgets/visualize/utils/widget.py b/Orange/widgets/visualize/utils/widget.py
--- a/Orange/widgets/visualize/utils/widget.py
+++ b/Orange/widgets/visualize/utils/widget.py
@@ -374,7 +374,7 @@ def __init__(self):
         self.subset_data = None
         self.subset_indices = None
         self.__pending_selection = self.selection
-        self.__invalidated = True
+        self._invalidated = True
         self.setup_gui()
 
     # GUI
@@ -424,12 +424,12 @@ def set_data(self, data):
             self.init_attr_values()
         self.openContext(self.data)
         self.use_context()
-        self.__invalidated = not (
+        self._invalidated = not (
             data_existed and self.data is not None and
             effective_data.X.shape == self.effective_data.X.shape and
             np.allclose(effective_data.X,
                         self.effective_data.X, equal_nan=True))
-        if self.__invalidated:
+        if self._invalidated:
             self.clear()
         self.cb_class_density.setEnabled(self.can_draw_density())
 
@@ -449,8 +449,8 @@ def set_subset_data(self, subset):
         self.controls.graph.alpha_value.setEnabled(subset is None)
 
     def handleNewSignals(self):
-        if self.__invalidated:
-            self.__invalidated = False
+        if self._invalidated:
+            self._invalidated = False
             self.setup_plot()
         else:
             self.graph.update_point_props()


Test patch:
diff --git a/Orange/widgets/tests/base.py b/Orange/widgets/tests/base.py
--- a/Orange/widgets/tests/base.py
+++ b/Orange/widgets/tests/base.py
@@ -223,8 +223,32 @@ def send_signal(self, input, value, *args, widget=None, wait=-1):
         wait : int
             The amount of time to wait for the widget to complete.
         """
+        return self.send_signals([(input, value)], *args,
+                                 widget=widget, wait=wait)
+
+    def send_signals(self, signals, *args, widget=None, wait=-1):
+        """ Send signals to widget by calling appropriate triggers.
+        After all the signals are send, widget's handleNewSignals() in invoked.
+
+        Parameters
+        ----------
+        signals : list of (str, Object)
+        widget : Optional[OWWidget]
+            widget to send signals to. If not set, self.widget is used
+        wait : int
+            The amount of time to wait for the widget to complete.
+        """
         if widget is None:
             widget = self.widget
+        for input, value in signals:
+            self._send_signal(widget, input, value, *args)
+        widget.handleNewSignals()
+        if wait >= 0 and widget.isBlocking():
+            spy = QSignalSpy(widget.blockingStateChanged)
+            self.assertTrue(spy.wait(timeout=wait))
+
+    @staticmethod
+    def _send_signal(widget, input, value, *args):
         if isinstance(input, str):
             for input_signal in widget.get_signals("inputs"):
                 if input_signal.name == input:
@@ -243,10 +267,6 @@ def send_signal(self, input, value, *args, widget=None, wait=-1):
             '{} should be {}'.format(value.__class__.__mro__, input.type)
 
         handler(value, *args)
-        widget.handleNewSignals()
-        if wait >= 0 and widget.isBlocking():
-            spy = QSignalSpy(widget.blockingStateChanged)
-            self.assertTrue(spy.wait(timeout=wait))
 
     def wait_until_stop_blocking(self, widget=None, wait=DEFAULT_TIMEOUT):
         """Wait until the widget stops blocking i.e. finishes computation.
diff --git a/Orange/widgets/visualize/tests/test_owscatterplot.py b/Orange/widgets/visualize/tests/test_owscatterplot.py
--- a/Orange/widgets/visualize/tests/test_owscatterplot.py
+++ b/Orange/widgets/visualize/tests/test_owscatterplot.py
@@ -1,6 +1,6 @@
 # Test methods with long descriptive names can omit docstrings
 # pylint: disable=missing-docstring
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock, patch, Mock
 import numpy as np
 
 from AnyQt.QtCore import QRectF, Qt
@@ -589,6 +589,108 @@ def test_change_data(self):
         self.assertIsNotNone(
             self.get_output(self.widget.Outputs.annotated_data))
 
+    def test_invalidated_same_features(self):
+        self.widget.setup_plot = Mock()
+        # send data and set default features
+        self.send_signal(self.widget.Inputs.data, self.data)
+        self.widget.setup_plot.assert_called_once()
+        self.assertListEqual(self.widget.effective_variables,
+                             list(self.data.domain.attributes[:2]))
+
+        # send the same features as already set
+        self.widget.setup_plot.reset_mock()
+        self.send_signal(self.widget.Inputs.features,
+                         AttributeList(self.data.domain.attributes[:2]))
+        self.widget.setup_plot.assert_not_called()
+        self.assertListEqual(self.widget.effective_variables,
+                             list(self.data.domain.attributes[:2]))
+
+    def test_invalidated_same_time(self):
+        self.widget.setup_plot = Mock()
+        # send data and features at the same time (data first)
+        features = self.data.domain.attributes[:2]
+        signals = [(self.widget.Inputs.data, self.data),
+                   (self.widget.Inputs.features, AttributeList(features))]
+        self.send_signals(signals)
+        self.widget.setup_plot.assert_called_once()
+        self.assertListEqual(self.widget.effective_variables, list(features))
+
+    def test_invalidated_features_first(self):
+        self.widget.setup_plot = Mock()
+        # send features (same as default ones)
+        self.send_signal(self.widget.Inputs.features,
+                         AttributeList(self.data.domain.attributes[:2]))
+        self.assertListEqual(self.widget.effective_variables, [None, None])
+        self.widget.setup_plot.assert_called_once()
+
+        # send data
+        self.widget.setup_plot.reset_mock()
+        self.send_signal(self.widget.Inputs.data, self.data)
+        self.widget.setup_plot.assert_called()
+        self.assertListEqual(self.widget.effective_variables,
+                             list(self.data.domain.attributes[:2]))
+
+    def test_invalidated_same_time_features_first(self):
+        self.widget.setup_plot = Mock()
+        # send features and data at the same time (features first)
+        features = self.data.domain.attributes[:2]
+        signals = [(self.widget.Inputs.features, AttributeList(features)),
+                   (self.widget.Inputs.data, self.data)]
+        self.send_signals(signals)
+        self.widget.setup_plot.assert_called_once()
+        self.assertListEqual(self.widget.effective_variables, list(features))
+
+    def test_invalidated_diff_features(self):
+        self.widget.setup_plot = Mock()
+        # send data and set default features
+        self.send_signal(self.widget.Inputs.data, self.data)
+        self.widget.setup_plot.assert_called_once()
+        self.assertListEqual(self.widget.effective_variables,
+                             list(self.data.domain.attributes[:2]))
+
+        # send different features
+        self.widget.setup_plot.reset_mock()
+        self.send_signal(self.widget.Inputs.features,
+                         AttributeList(self.data.domain.attributes[2:4]))
+        self.widget.setup_plot.assert_called_once()
+        self.assertListEqual(self.widget.effective_variables,
+                             list(self.data.domain.attributes[2:4]))
+
+    def test_invalidated_diff_features_same_time(self):
+        self.widget.setup_plot = Mock()
+        # send data and different features at the same time (data first)
+        features = self.data.domain.attributes[2:4]
+        signals = [(self.widget.Inputs.data, self.data),
+                   (self.widget.Inputs.features, AttributeList(features))]
+        self.send_signals(signals)
+        self.widget.setup_plot.assert_called_once()
+        self.assertListEqual(self.widget.effective_variables, list(features))
+
+    def test_invalidated_diff_features_features_first(self):
+        self.widget.setup_plot = Mock()
+        # send features (not the same as defaults)
+        self.send_signal(self.widget.Inputs.features,
+                         AttributeList(self.data.domain.attributes[2:4]))
+        self.assertListEqual(self.widget.effective_variables, [None, None])
+        self.widget.setup_plot.assert_called_once()
+
+        # send data
+        self.widget.setup_plot.reset_mock()
+        self.send_signal(self.widget.Inputs.data, self.data)
+        self.widget.setup_plot.assert_called_once()
+        self.assertListEqual(self.widget.effective_variables,
+                             list(self.data.domain.attributes[2:4]))
+
+    def test_invalidated_diff_features_same_time_features_first(self):
+        self.widget.setup_plot = Mock()
+        # send data and different features at the same time (features first)
+        features = self.data.domain.attributes[2:4]
+        signals = [(self.widget.Inputs.features, AttributeList(features)),
+                   (self.widget.Inputs.data, self.data)]
+        self.send_signals(signals)
+        self.widget.setup_plot.assert_called_once()
+        self.assertListEqual(self.widget.effective_variables, list(features))
+
 
 if __name__ == "__main__":
     import unittest