# Test methods with long descriptive names can omit docstrings
# pylint: disable=missing-docstring,too-many-public-methods,protected-access
# pylint: disable=too-many-lines
from unittest.mock import MagicMock, patch, Mock
import numpy as np

from AnyQt.QtCore import QRectF, Qt
from AnyQt.QtWidgets import QToolTip
from AnyQt.QtGui import QColor

from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable
from Orange.widgets.tests.base import (
    WidgetTest, WidgetOutputsTestMixin, datasets, ProjectionWidgetTestMixin
)
from Orange.widgets.tests.utils import simulate
from Orange.widgets.utils.colorpalette import DefaultRGBColors
from Orange.widgets.visualize.owscatterplot import (
    OWScatterPlot, ScatterPlotVizRank, OWScatterPlotGraph)
from Orange.widgets.visualize.utils.widget import MAX_COLORS
from Orange.widgets.widget import AttributeList


class TestOWScatterPlot(WidgetTest, ProjectionWidgetTestMixin,
                        WidgetOutputsTestMixin):
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        WidgetOutputsTestMixin.init(cls)
        cls.same_input_output_domain = False

        cls.signal_name = "Data"
        cls.signal_data = cls.data

    def setUp(self):
        self.widget = self.create_widget(OWScatterPlot)

    def test_set_data(self):
        # Connect iris to scatter plot
        self.send_signal(self.widget.Inputs.data, self.data)

        # First two attribute should be selected as x an y
        self.assertEqual(self.widget.attr_x, self.data.domain[0])
        self.assertEqual(self.widget.attr_y, self.data.domain[1])

        # Class var should be selected as color
        self.assertIs(self.widget.attr_color, self.data.domain.class_var)

        # Change which attributes are displayed
        self.widget.attr_x = self.data.domain[2]
        self.widget.attr_y = self.data.domain[3]

        # Disconnect the data
        self.send_signal(self.widget.Inputs.data, None)

        # removing data should have cleared attributes
        self.assertIsNone(self.widget.attr_x)
        self.assertIsNone(self.widget.attr_y)
        self.assertIsNone(self.widget.attr_color)

        # and remove the legend
        self.assertEqual(len(self.widget.graph.color_legend.items), 0)

        # Connect iris again
        # same attributes that were used last time should be selected
        self.send_signal(self.widget.Inputs.data, self.data)

        self.assertIs(self.widget.attr_x, self.data.domain[2])
        self.assertIs(self.widget.attr_y, self.data.domain[3])

    def test_score_heuristics(self):
        domain = Domain([ContinuousVariable(c) for c in "abcd"],
                        DiscreteVariable("e", values="ab"))
        a = np.arange(10).reshape((10, 1))
        data = Table(domain, np.hstack([a, a, a, a]), a >= 5)
        self.send_signal(self.widget.Inputs.data, data)
        vizrank = ScatterPlotVizRank(self.widget)
        self.assertEqual([x.name for x in vizrank.score_heuristic()],
                         list("abcd"))

    def test_score_heuristics_no_disc(self):
        domain = Domain([ContinuousVariable(c) for c in "abc"] +
                        [DiscreteVariable("d", values="abcdefghij")],
                        DiscreteVariable("e", values="ab"))
        a = np.arange(10).reshape((10, 1))
        data = Table(domain, np.hstack([a, a, a, a]), a >= 5)
        self.send_signal(self.widget.Inputs.data, data)
        vizrank = ScatterPlotVizRank(self.widget)
        self.assertEqual([x.name for x in vizrank.score_heuristic()],
                         list("abc"))

    def test_optional_combos(self):
        domain = self.data.domain
        d1 = Domain(domain.attributes[:2], domain.class_var,
                    [domain.attributes[2]])
        t1 = self.data.transform(d1)
        self.send_signal(self.widget.Inputs.data, t1)
        self.widget.graph.attr_size = domain.attributes[2]

        d2 = Domain(domain.attributes[:2], domain.class_var,
                    [domain.attributes[3]])
        t2 = self.data.transform(d2)
        self.send_signal(self.widget.Inputs.data, t2)

    def test_error_message(self):
        """Check if error message appears and then disappears when
        data is removed from input"""
        data = self.data.copy()
        data.X[:, 0] = np.nan
        self.send_signal(self.widget.Inputs.data, data)
        self.assertTrue(self.widget.Warning.missing_coords.is_shown())
        self.send_signal(self.widget.Inputs.data, None)
        self.assertFalse(self.widget.Warning.missing_coords.is_shown())

    def test_report_on_empty(self):
        self.widget.report_plot = MagicMock()
        self.widget.report_caption = MagicMock()
        self.widget.report_items = MagicMock()
        self.widget.send_report()  # Essentially, don't crash
        self.widget.report_plot.assert_not_called()
        self.widget.report_caption.assert_not_called()
        self.widget.report_items.assert_not_called()

    def test_data_column_nans(self):
        """
        ValueError cannot convert float NaN to integer.
        In case when all column values are NaN then it throws that error.
        GH-2061
        """
        table = datasets.data_one_column_nans()
        self.send_signal(self.widget.Inputs.data, table)
        cb_attr_color = self.widget.controls.attr_color
        simulate.combobox_activate_item(cb_attr_color, "b")
        simulate.combobox_activate_item(self.widget.cb_attr_x, "a")
        simulate.combobox_activate_item(self.widget.cb_attr_y, "a")

        #self.widget.update_graph()
        self.widget.graph.reset_graph()

    def test_data_column_infs(self):
        """
        Scatter Plot should not crash on data with infinity values
        GH-2707
        GH-2684
        """
        table = datasets.data_one_column_infs()
        self.send_signal(self.widget.Inputs.data, table)
        attr_x = self.widget.controls.attr_x
        simulate.combobox_activate_item(attr_x, "b")

    def test_points_combo_boxes(self):
        """Check Point box combo models and values"""
        self.send_signal(self.widget.Inputs.data, self.data)
        controls = self.widget.controls

        # color and label should contain all variables
        # size should contain only continuous variables
        # shape should contain only discrete variables
        for var in self.data.domain.variables + self.data.domain.metas:
            self.assertIn(var, controls.attr_color.model())
            self.assertIn(var, controls.attr_label.model())
            if var.is_continuous:
                self.assertIn(var, controls.attr_size.model())
                self.assertNotIn(var, controls.attr_shape.model())
            if var.is_discrete:
                self.assertNotIn(var, controls.attr_size.model())
                self.assertIn(var, controls.attr_shape.model())

        widget = self.create_widget(OWScatterPlot)
        self.send_signal(self.widget.Inputs.data, self.data, widget=widget)
        self.assertEqual(controls.attr_color.currentText(),
                         self.data.domain.class_var.name)

    def test_group_selections(self):
        self.send_signal(self.widget.Inputs.data, self.data)
        graph = self.widget.graph
        points = graph.scatterplot_item.points()
        sel_column = np.zeros((len(self.data), 1))

        x = self.data.X

        def selectedx():
            return self.get_output(self.widget.Outputs.selected_data).X

        def selected_groups():
            return self.get_output(self.widget.Outputs.selected_data).metas[:, 0]

        def annotated():
            return self.get_output(self.widget.Outputs.annotated_data).metas

        def annotations():
            return self.get_output(self.widget.Outputs.annotated_data).domain.metas[0].values

        # Select 0:5
        graph.select(points[:5])
        np.testing.assert_equal(selectedx(), x[:5])
        np.testing.assert_equal(selected_groups(), np.zeros(5))
        sel_column[:5] = 1
        np.testing.assert_equal(annotated(), sel_column)
        self.assertEqual(annotations(), ["No", "Yes"])

        # Shift-select 5:10; now we have groups 0:5 and 5:10
        with self.modifiers(Qt.ShiftModifier):
            graph.select(points[5:10])
        np.testing.assert_equal(selectedx(), x[:10])
        np.testing.assert_equal(selected_groups(), np.array([0] * 5 + [1] * 5))
        sel_column[:5] = 0
        sel_column[5:10] = 1
        sel_column[10:] = 2
        np.testing.assert_equal(annotated(), sel_column)
        self.assertEqual(len(annotations()), 3)

        # Select: 15:20; we have 15:20
        graph.select(points[15:20])
        sel_column = np.zeros((len(self.data), 1))
        sel_column[15:20] = 1
        np.testing.assert_equal(selectedx(), x[15:20])
        np.testing.assert_equal(selected_groups(), np.zeros(5))
        self.assertEqual(annotations(), ["No", "Yes"])

        # Alt-select (remove) 10:17; we have 17:20
        with self.modifiers(Qt.AltModifier):
            graph.select(points[10:17])
        np.testing.assert_equal(selectedx(), x[17:20])
        np.testing.assert_equal(selected_groups(), np.zeros(3))
        sel_column[15:17] = 0
        np.testing.assert_equal(annotated(), sel_column)
        self.assertEqual(annotations(), ["No", "Yes"])

        # Ctrl-Shift-select (add-to-last) 10:17; we have 17:25
        with self.modifiers(Qt.ShiftModifier | Qt.ControlModifier):
            graph.select(points[20:25])
        np.testing.assert_equal(selectedx(), x[17:25])
        np.testing.assert_equal(selected_groups(), np.zeros(8))
        sel_column[20:25] = 1
        np.testing.assert_equal(annotated(), sel_column)
        self.assertEqual(annotations(), ["No", "Yes"])

        # Shift-select (add) 30:35; we have 17:25, 30:35
        with self.modifiers(Qt.ShiftModifier):
            graph.select(points[30:35])
        # ... then Ctrl-Shift-select (add-to-last) 10:17; we have 17:25, 30:40
        with self.modifiers(Qt.ShiftModifier | Qt.ControlModifier):
            graph.select(points[35:40])
        sel_column[:] = 2
        sel_column[17:25] = 0
        sel_column[30:40] = 1
        np.testing.assert_equal(selected_groups(), np.array([0] * 8 + [1] * 10))
        np.testing.assert_equal(annotated(), sel_column)
        self.assertEqual(len(annotations()), 3)

    def test_saving_selection(self):
        self.send_signal(self.widget.Inputs.data, self.data)  # iris
        self.widget.graph.select_by_rectangle(QRectF(4, 3, 3, 1))
        selected_inds = np.flatnonzero(self.widget.graph.selection)
        settings = self.widget.settingsHandler.pack_data(self.widget)
        np.testing.assert_equal(selected_inds,
                                [i for i, g in settings["selection"]])

    def test_points_selection(self):
        # Opening widget with saved selection should restore it
        self.widget = self.create_widget(
            OWScatterPlot, stored_settings={
                "selection_group": [(i, 1) for i in range(50)]}
        )
        self.send_signal(self.widget.Inputs.data, self.data)  # iris
        selected_data = self.get_output(self.widget.Outputs.selected_data)
        self.assertEqual(len(selected_data), 50)

        # Changing the dataset should clear selection
        heart = Table("heart_disease")
        self.send_signal(self.widget.Inputs.data, heart)
        selected_data = self.get_output(self.widget.Outputs.selected_data)
        self.assertIsNone(selected_data)

    def test_migrate_selection(self):
        settings = dict(selection=list(range(2)))
        OWScatterPlot.migrate_settings(settings, 0)
        self.assertEqual(settings["selection_group"], [(0, 1), (1, 1)])

    def test_invalid_points_selection(self):
        # if selection contains rows that are not present in the current
        # dataset, widget should select what can be selected.
        self.widget = self.create_widget(
            OWScatterPlot, stored_settings={
                "selection_group": [(i, 1) for i in range(50)]}
        )
        data = self.data.copy()[:11]
        data[0, 0] = np.nan
        self.send_signal(self.widget.Inputs.data, data)
        self.assertIsNone(self.get_output(self.widget.Outputs.selected_data))

    def test_set_strings_settings(self):
        """
        Test if settings can be loaded as strings and successfully put
        in new owplotgui combos.
        """
        self.send_signal(self.widget.Inputs.data, self.data)
        settings = self.widget.settingsHandler.pack_data(self.widget)
        plot_settings = settings["context_settings"][0].values
        plot_settings["attr_label"] = ("sepal length", -2)
        plot_settings["attr_color"] = ("sepal width", -2)
        plot_settings["attr_shape"] = ("iris", -2)
        plot_settings["attr_size"] = ("petal width", -2)
        w = self.create_widget(OWScatterPlot, stored_settings=settings)
        self.send_signal(self.widget.Inputs.data, self.data, widget=w)
        self.assertEqual(w.attr_label.name, "sepal length")
        self.assertEqual(w.attr_color.name, "sepal width")
        self.assertEqual(w.attr_shape.name, "iris")
        self.assertEqual(w.attr_size.name, "petal width")

    def test_features_and_no_data(self):
        """
        Prevent crashing when features are sent but no data.
        """
        domain = Table("iris").domain
        self.send_signal(self.widget.Inputs.features,
                         AttributeList(domain.variables))
        self.send_signal(self.widget.Inputs.features, None)

    def test_features_and_data(self):
        self.assertTrue(self.widget.attr_box.isEnabled())
        self.send_signal(self.widget.Inputs.data, self.data)
        x, y = self.widget.graph.scatterplot_item.getData()
        np.testing.assert_array_equal(x, self.data.X[:, 0])
        np.testing.assert_array_equal(y, self.data.X[:, 1])
        self.send_signal(self.widget.Inputs.features,
                         AttributeList(self.data.domain[2:]))
        self.assertIs(self.widget.attr_x, self.data.domain[2])
        self.assertIs(self.widget.attr_y, self.data.domain[3])
        self.assertFalse(self.widget.attr_box.isEnabled())
        self.assertFalse(self.widget.vizrank.isEnabled())
        x, y = self.widget.graph.scatterplot_item.getData()
        np.testing.assert_array_equal(x, self.data.X[:, 2])
        np.testing.assert_array_equal(y, self.data.X[:, 3])

        self.send_signal(self.widget.Inputs.data, None)
        self.send_signal(self.widget.Inputs.data, self.data)
        self.assertIs(self.widget.attr_x, self.data.domain[2])
        self.assertIs(self.widget.attr_y, self.data.domain[3])
        self.assertFalse(self.widget.attr_box.isEnabled())
        self.assertFalse(self.widget.vizrank.isEnabled())

        self.send_signal(self.widget.Inputs.features, None)
        self.assertTrue(self.widget.attr_box.isEnabled())
        self.assertTrue(self.widget.vizrank.isEnabled())

    def test_output_features(self):
        data = Table("iris")
        self.send_signal(self.widget.Inputs.data, data)

        # This doesn't work because combo's callbacks are connected to signal
        # `activated`, which is only triggered by user interaction, and not to
        # `currentIndexChanged`
        # combo_y = self.widget.controls.attr_y
        # combo_y.setCurrentIndex(combo_y.model().indexOf(data.domain[3]))
        # This is a workaround
        self.widget.attr_y = data.domain[3]
        self.widget.attr_changed()

        features = self.get_output(self.widget.Outputs.features)
        self.assertEqual(features, [data.domain[0], data.domain[3]])

    def test_vizrank(self):
        data = Table("iris")
        self.send_signal(self.widget.Inputs.data, data)
        vizrank = ScatterPlotVizRank(self.widget)
        n_states = len(data.domain.attributes)
        n_states = n_states * (n_states - 1) / 2
        states = [state for state in vizrank.iterate_states(None)]
        self.assertEqual(len(states), n_states)
        self.assertEqual(len(set(states)), n_states)
        self.assertIsNotNone(vizrank.compute_score(states[0]))
        self.send_signal(self.widget.Inputs.data, data[:9])
        self.assertIsNone(vizrank.compute_score(states[0]))

        data = Table("housing")[::10]
        self.send_signal(self.widget.Inputs.data, data)
        vizrank = ScatterPlotVizRank(self.widget)
        states = [state for state in vizrank.iterate_states(None)]
        self.assertIsNotNone(vizrank.compute_score(states[0]))

    def test_vizrank_class_nan(self):
        """
        When class values are nan, vizrank should be disabled. It should behave like
        the class column is missing.
        GH-2757
        """
        def assert_vizrank_enabled(data, is_enabled):
            self.send_signal(self.widget.Inputs.data, data)
            self.assertEqual(is_enabled, self.widget.vizrank_button.isEnabled())

        data1 = Table("iris")[::30]
        data2 = Table("iris")[::30]
        data2.Y[:] = np.nan
        domain = Domain(
            attributes=data2.domain.attributes[:4], class_vars=DiscreteVariable("iris", values=[]))
        data2 = Table(domain, data2.X, Y=data2.Y)
        data3 = Table("iris")[::30]
        data3.Y[:] = np.nan

        for data, is_enabled in zip([data1, data2, data1, data3, data1],
                                    [True, False, True, False, True]):
            assert_vizrank_enabled(data, is_enabled)

    def test_vizrank_nonprimitives(self):
        """VizRank does not try to include non primitive attributes"""
        data = Table("brown-selected")
        self.send_signal(self.widget.Inputs.data, data)
        with patch("Orange.widgets.visualize.owscatterplot.ReliefF",
                   new=lambda *_1, **_2: lambda data: np.arange(len(data))):
            self.widget.vizrank.score_heuristic()

    def test_vizrank_enabled(self):
        self.send_signal(self.widget.Inputs.data, self.data)
        self.assertTrue(self.widget.vizrank_button.isEnabled())
        self.assertEqual(self.widget.vizrank_button.toolTip(), "")
        self.assertTrue(self.widget.vizrank.button.isEnabled())
        self.widget.vizrank.button.click()

    def test_vizrank_enabled_no_data(self):
        self.send_signal(self.widget.Inputs.data, None)
        self.assertFalse(self.widget.vizrank_button.isEnabled())
        self.assertEqual(self.widget.vizrank_button.toolTip(), "No data on input")

    def test_vizrank_enabled_sparse_data(self):
        self.send_signal(self.widget.Inputs.data, self.data.to_sparse())
        self.assertFalse(self.widget.vizrank_button.isEnabled())
        self.assertEqual(self.widget.vizrank_button.toolTip(), "Data is sparse")

    def test_vizrank_enabled_constant_data(self):
        domain = Domain([ContinuousVariable("c1"),
                         ContinuousVariable("c2"),
                         ContinuousVariable("c3"),
                         ContinuousVariable("c4")],
                        DiscreteVariable("cls", values=["a", "b"]))
        X = np.zeros((10, 4))
        table = Table(domain, X, np.random.randint(2, size=10))
        self.send_signal(self.widget.Inputs.data, table)
        self.assertEqual(self.widget.vizrank_button.toolTip(), "")
        self.assertTrue(self.widget.vizrank_button.isEnabled())
        self.assertTrue(self.widget.vizrank.button.isEnabled())
        self.widget.vizrank.button.click()

    def test_vizrank_enabled_two_features(self):
        self.send_signal(self.widget.Inputs.data, self.data[:, 2:])
        self.assertFalse(self.widget.vizrank_button.isEnabled())
        self.assertEqual(self.widget.vizrank_button.toolTip(),
                         "Not enough features for ranking")

    def test_vizrank_enabled_no_color_var(self):
        self.send_signal(self.widget.Inputs.data, self.data[:, :3])
        self.assertFalse(self.widget.vizrank_button.isEnabled())
        self.assertEqual(self.widget.vizrank_button.toolTip(),
                         "Color variable is not selected")

    def test_vizrank_enabled_color_var_nans(self):
        domain = Domain([ContinuousVariable("c1"),
                         ContinuousVariable("c2"),
                         ContinuousVariable("c3"),
                         ContinuousVariable("c4")],
                        DiscreteVariable("cls", values=["a", "b"]))
        table = Table(domain, np.random.random((10, 4)), np.full(10, np.nan))
        self.send_signal(self.widget.Inputs.data, table)
        self.assertFalse(self.widget.vizrank_button.isEnabled())
        self.assertEqual(self.widget.vizrank_button.toolTip(),
                         "Color variable has no values")

    def test_auto_send_selection(self):
        """
        Scatter Plot automatically sends selection only when the checkbox Send automatically
        is checked.
        GH-2649
        GH-2646
        """
        data = Table("iris")
        self.send_signal(self.widget.Inputs.data, data)
        self.widget.controls.auto_commit.setChecked(False)
        self.assertFalse(self.widget.controls.auto_commit.isChecked())
        self._select_data()
        self.assertIsNone(self.get_output(self.widget.Outputs.selected_data))
        self.widget.controls.auto_commit.setChecked(True)
        output = self.get_output(self.widget.Outputs.selected_data)
        self.assertIsInstance(output, Table)

    def test_color_is_optional(self):
        heart = Table("heart_disease")
        age, rest_sbp, max_hr, cholesterol, gender, narrowing = \
            [heart.domain[x]
             for x in ["age", "rest SBP", "max HR", "cholesterol", "gender",
                       "diameter narrowing"]]
        attr_x = self.widget.controls.attr_x
        attr_y = self.widget.controls.attr_y
        attr_color = self.widget.controls.attr_color

        # Send dataset, ensure defaults are what we expect them to be
        self.send_signal(self.widget.Inputs.data, heart)
        self.assertEqual(attr_x.currentText(), age.name)
        self.assertEqual(attr_y.currentText(), rest_sbp.name)
        self.assertEqual(attr_color.currentText(), narrowing.name)
        # Select different values
        simulate.combobox_activate_item(attr_x, max_hr.name)
        simulate.combobox_activate_item(attr_y, cholesterol.name)
        simulate.combobox_activate_item(attr_color, gender.name)

        # Send compatible dataset, values should not change
        heart2 = heart[:, (cholesterol, gender, max_hr, narrowing)]
        self.send_signal(self.widget.Inputs.data, heart2)
        simulate.combobox_activate_item(attr_x, max_hr.name)
        simulate.combobox_activate_item(attr_y, cholesterol.name)
        simulate.combobox_activate_item(attr_color, gender.name)

        # Send dataset without color variable
        # x and y should remain, color reset to default
        heart3 = heart[:, (age, max_hr, cholesterol, narrowing)]
        self.send_signal(self.widget.Inputs.data, heart3)
        simulate.combobox_activate_item(attr_x, max_hr.name)
        simulate.combobox_activate_item(attr_y, cholesterol.name)
        self.assertEqual(attr_color.currentText(), narrowing.name)

        # Send dataset without x
        # y and color should be the same as with heart
        heart4 = heart[:, (age, rest_sbp, cholesterol, narrowing)]
        self.send_signal(self.widget.Inputs.data, heart4)
        self.assertEqual(attr_x.currentText(), age.name)
        self.assertEqual(attr_y.currentText(), rest_sbp.name)
        self.assertEqual(attr_color.currentText(), narrowing.name)

        # Send dataset compatible with heart2 and heart3
        # Color should reset to one in heart3, as it was used more
        # recently
        heart5 = heart[:, (age, max_hr, cholesterol, gender, narrowing)]
        self.send_signal(self.widget.Inputs.data, heart5)
        simulate.combobox_activate_item(attr_x, max_hr.name)
        simulate.combobox_activate_item(attr_y, cholesterol.name)
        self.assertEqual(attr_color.currentText(), narrowing.name)

    def test_handle_metas(self):
        """
        Scatter Plot Graph can handle metas
        GH-2699
        """
        w = self.widget
        data = Table("iris")
        domain = Domain(
            attributes=data.domain.attributes[:2],
            class_vars=data.domain.class_vars,
            metas=data.domain.attributes[2:]
        )
        data = data.transform(domain)
        # Sometimes floats in metas are saved as objects
        data.metas = data.metas.astype(object)
        self.send_signal(w.Inputs.data, data)
        simulate.combobox_activate_item(w.cb_attr_x, data.domain.metas[1].name)
        simulate.combobox_activate_item(w.controls.attr_color, data.domain.metas[0].name)
        w.graph.reset_graph()

    def test_subset_data(self):
        """
        Scatter Plot subset data is sent to Scatter Plot Graph
        GH-2773
        """
        data = Table("iris")
        w = self.widget
        self.send_signal(w.Inputs.data, data)
        self.send_signal(w.Inputs.data_subset, data[::30])
        self.assertEqual(len(w.subset_indices), 5)

    def test_metas_zero_column(self):
        """
        Prevent crash when metas column is zero.
        GH-2775
        """
        data = Table("iris")
        domain = data.domain
        domain = Domain(domain.attributes[:3], domain.class_vars, domain.attributes[3:])
        data = data.transform(domain)
        data.metas[:, 0] = 0
        w = self.widget
        self.send_signal(w.Inputs.data, data)
        simulate.combobox_activate_item(w.controls.attr_x, domain.metas[0].name)

    def test_tooltip(self):
        # The test tests presence of some data,
        # but avoids checking the exact format
        data = Table("heart_disease")
        self.send_signal(self.widget.Inputs.data, data)
        widget = self.widget
        graph = widget.graph

        widget.attr_x = data.domain["age"]
        widget.attr_y = data.domain["max HR"]
        scatterplot_item = graph.scatterplot_item
        all_points = scatterplot_item.points()

        event = MagicMock()
        with patch.object(scatterplot_item, "mapFromScene"), \
                patch.object(QToolTip, "showText") as show_text:

            # Single point hovered
            with patch.object(scatterplot_item, "pointsAt",
                              return_value=[all_points[42]]):
                # Show just x and y attribute
                widget.tooltip_shows_all = False
                self.assertTrue(graph.help_event(event))
                (_, text), _ = show_text.call_args
                self.assertIn("age = {}".format(data[42, "age"]), text)
                self.assertIn("max HR = {}".format(data[42, "max HR"]), text)
                self.assertNotIn("gender = {}".format(data[42, "gender"]), text)
                self.assertNotIn("others", text)

                # Show all attributes
                widget.tooltip_shows_all = True
                self.assertTrue(graph.help_event(event))
                (_, text), _ = show_text.call_args
                self.assertIn("age = {}".format(data[42, "age"]), text)
                self.assertIn("gender = {}".format(data[42, "gender"]), text)
                self.assertIn("max HR = {}".format(data[42, "max HR"]), text)
                self.assertIn("... and 4 others", text)

            # Two points hovered
            with patch.object(scatterplot_item, "pointsAt",
                              return_value=[all_points[42], all_points[100]]):
                self.assertTrue(graph.help_event(event))
                (_, text), _ = show_text.call_args
                self.assertIn("age = {}".format(data[42, "age"]), text)
                self.assertIn("gender = {}".format(data[42, "gender"]), text)
                self.assertIn("age = {}".format(data[100, "age"]), text)
                self.assertIn("gender = {}".format(data[100, "gender"]), text)

            # No points hovered
            with patch.object(scatterplot_item, "pointsAt",
                              return_value=[]):
                show_text.reset_mock()
                self.assertFalse(graph.help_event(event))
                self.assertEqual(show_text.call_count, 0)

    def test_many_discrete_values(self):
        """
        Do not show all discrete values if there are too many.
        Also test for values with a nan.
        GH-2804
        """
        def prepare_data():
            data = Table("iris")
            values = list(range(15))
            class_var = DiscreteVariable("iris5", values=[str(v) for v in values])
            data = data.transform(Domain(attributes=data.domain.attributes, class_vars=[class_var]))
            data.Y = np.array(values * 10, dtype=float)
            return data

        def assert_equal(data, max):
            self.send_signal(self.widget.Inputs.data, data)
            pen_data, _ = self.widget.graph.get_colors()
            self.assertEqual(max, len(np.unique([id(p) for p in pen_data])), )

        assert_equal(prepare_data(), MAX_COLORS)
        # data with nan value
        data = prepare_data()
        data.Y[42] = np.nan
        assert_equal(data, MAX_COLORS + 1)

    def test_change_data(self):
        self.send_signal(self.widget.Inputs.data, self.data)
        self.send_signal(self.widget.Inputs.data, Table("titanic"))
        self.assertTrue(self.widget.Warning.no_continuous_vars.is_shown())
        self.assertIsNone(self.widget.data)
        self.assertIsNone(self.get_output(self.widget.Outputs.annotated_data))
        self.send_signal(self.widget.Inputs.data, self.data)
        self.assertFalse(self.widget.Warning.no_continuous_vars.is_shown())
        self.assertIs(self.widget.data, self.data)
        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, [])
        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, [])
        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))

    @patch('Orange.widgets.visualize.owscatterplot.ScatterPlotVizRank.'
           'on_manual_change')
    def test_vizrank_receives_manual_change(self, on_manual_change):
        # Recreate the widget so the patch kicks in
        self.widget = self.create_widget(OWScatterPlot)
        data = Table("iris.tab")
        self.send_signal(self.widget.Inputs.data, data)
        model = self.widget.controls.attr_x.model()
        self.widget.attr_x = model[0]
        self.widget.attr_y = model[1]
        simulate.combobox_activate_index(self.widget.controls.attr_x, 2)
        self.assertIs(self.widget.attr_x, model[2])
        on_manual_change.assert_called_with(model[2], model[1])

    def test_on_manual_change(self):
        data = Table("iris.tab")
        self.send_signal(self.widget.Inputs.data, data)
        vizrank = self.widget.vizrank
        vizrank.toggle()
        self.process_events(until=lambda: not vizrank.keep_running)

        model = vizrank.rank_model
        attrs = model.data(model.index(3, 0), vizrank._AttrRole)
        vizrank.on_manual_change(*attrs)
        selection = vizrank.rank_table.selectedIndexes()
        self.assertEqual(len(selection), 1)
        self.assertEqual(selection[0].row(), 3)

        vizrank.on_manual_change(*attrs[::-1])
        selection = vizrank.rank_table.selectedIndexes()
        self.assertEqual(len(selection), 0)

    def test_regression_lines_appear(self):
        self.widget.graph.controls.show_reg_line.setChecked(True)
        self.assertEqual(len(self.widget.graph.reg_line_items), 0)
        self.send_signal(self.widget.Inputs.data, self.data)
        self.assertEqual(len(self.widget.graph.reg_line_items), 4)
        simulate.combobox_activate_index(self.widget.controls.attr_color, 0)
        self.assertEqual(len(self.widget.graph.reg_line_items), 1)
        data = self.data.copy()
        data[:, 0] = np.nan
        self.send_signal(self.widget.Inputs.data, data)
        self.assertEqual(len(self.widget.graph.reg_line_items), 0)

    def test_regression_line_coeffs(self):
        widget = self.widget
        graph = widget.graph
        xy = np.array([[0, 0], [1, 0], [1, 2], [2, 2],
                       [0, 1], [1, 3], [2, 5]], dtype=np.float)
        colors = np.array([0, 0, 0, 0, 1, 1, 1], dtype=np.float)
        widget.get_coordinates_data = lambda: xy.T
        widget.get_color_data = lambda: colors
        widget.is_continuous_color = lambda: False
        graph.palette = DefaultRGBColors
        graph.controls.show_reg_line.setChecked(True)

        graph.update_regression_line()

        line1 = graph.reg_line_items[1]
        self.assertEqual(line1.pos().x(), 0)
        self.assertEqual(line1.pos().y(), 0)
        self.assertEqual(line1.angle, 45)
        self.assertEqual(line1.pen.color().getRgb()[:3], graph.palette[0])

        line2 = graph.reg_line_items[2]
        self.assertEqual(line2.pos().x(), 0)
        self.assertEqual(line2.pos().y(), 1)
        self.assertAlmostEqual(line2.angle, np.degrees(np.arctan2(2, 1)))
        self.assertEqual(line2.pen.color().getRgb()[:3], graph.palette[1])

        graph.orthonormal_regression = True
        graph.update_regression_line()

        line1 = graph.reg_line_items[1]
        self.assertEqual(line1.pos().x(), 0)
        self.assertAlmostEqual(line1.pos().y(), -0.6180339887498949)
        self.assertEqual(line1.angle, 58.28252558853899)
        self.assertEqual(line1.pen.color().getRgb()[:3], graph.palette[0])

        line2 = graph.reg_line_items[2]
        self.assertEqual(line2.pos().x(), 0)
        self.assertEqual(line2.pos().y(), 1)
        self.assertAlmostEqual(line2.angle, np.degrees(np.arctan2(2, 1)))
        self.assertEqual(line2.pen.color().getRgb()[:3], graph.palette[1])

    def test_orthonormal_line(self):
        color = QColor(1, 2, 3)
        width = 42
        # Normal line
        line = OWScatterPlotGraph._orthonormal_line(
            np.array([0, 1, 1, 2]), np.array([0, 0, 2, 2]), color, width)
        self.assertEqual(line.pos().x(), 0)
        self.assertAlmostEqual(line.pos().y(), -0.6180339887498949)
        self.assertEqual(line.angle, 58.28252558853899)
        self.assertEqual(line.pen.color(), color)
        self.assertEqual(line.pen.width(), width)

        # Normal line, negative slope
        line = OWScatterPlotGraph._orthonormal_line(
            np.array([1, 2, 3]), np.array([3, 2, 1]), color, width)
        self.assertEqual(line.pos().x(), 1)
        self.assertEqual(line.pos().y(), 3)
        self.assertEqual(line.angle % 360, 315)

        # Horizontal line
        line = OWScatterPlotGraph._orthonormal_line(
            np.array([10, 11, 12]), np.array([42, 42, 42]), color, width)
        self.assertEqual(line.pos().x(), 10)
        self.assertEqual(line.pos().y(), 42)
        self.assertEqual(line.angle, 0)

        # Vertical line
        line = OWScatterPlotGraph._orthonormal_line(
            np.array([42, 42, 42]), np.array([10, 11, 12]), color, width)
        self.assertEqual(line.pos().x(), 42)
        self.assertEqual(line.pos().y(), 10)
        self.assertEqual(line.angle, 90)

        # No line because all points coincide
        line = OWScatterPlotGraph._orthonormal_line(
            np.array([1, 1, 1]), np.array([42, 42, 42]), color, width)
        self.assertIsNone(line)

        # No line because the group is symmetric
        line = OWScatterPlotGraph._orthonormal_line(
            np.array([1, 1, 2, 2]), np.array([42, 5, 5, 42]), color, width)
        self.assertIsNone(line)

    def test_regression_line(self):
        color = QColor(1, 2, 3)
        width = 42
        # Normal line
        line = OWScatterPlotGraph._regression_line(
            np.array([0, 1, 1, 2]), np.array([0, 0, 2, 2]), color, width)
        self.assertEqual(line.pos().x(), 0)
        self.assertAlmostEqual(line.pos().y(), 0)
        self.assertEqual(line.angle, 45)
        self.assertEqual(line.pen.color(), color)
        self.assertEqual(line.pen.width(), width)

        # Normal line, negative slope
        line = OWScatterPlotGraph._regression_line(
            np.array([1, 2, 3]), np.array([3, 2, 1]), color, width)
        self.assertEqual(line.pos().x(), 1)
        self.assertEqual(line.pos().y(), 3)
        self.assertEqual(line.angle % 360, 315)

        # Horizontal line
        line = OWScatterPlotGraph._regression_line(
            np.array([10, 11, 12]), np.array([42, 42, 42]), color, width)
        self.assertEqual(line.pos().x(), 10)
        self.assertEqual(line.pos().y(), 42)
        self.assertEqual(line.angle, 0)

        # Vertical line
        line = OWScatterPlotGraph._regression_line(
            np.array([42, 42, 42]), np.array([10, 11, 12]), color, width)
        self.assertIsNone(line)

        # No line because all points coincide
        line = OWScatterPlotGraph._regression_line(
            np.array([1, 1, 1]), np.array([42, 42, 42]), color, width)
        self.assertIsNone(line)

    def test_add_line_calls_proper_regressor(self):
        graph = self.widget.graph
        graph._orthonormal_line = Mock(return_value=None)
        graph._regression_line = Mock(return_value=None)
        x, y, c, w = Mock(), Mock(), Mock(), Mock()

        graph.orthonormal_regression = True
        graph._add_line(x, y, c, w)
        graph._orthonormal_line.assert_called_once_with(x, y, c, w)
        graph._regression_line.assert_not_called()
        graph._orthonormal_line.reset_mock()

        graph.orthonormal_regression = False
        graph._add_line(x, y, c, w)
        graph._regression_line.assert_called_with(x, y, c, w)
        graph._orthonormal_line.assert_not_called()

    def test_no_regression_line(self):
        graph = self.widget.graph
        graph._orthonormal_line = lambda *_: None
        graph.orthonormal_regression = True

        graph.plot_widget.addItem = Mock()

        x, y, c, w = Mock(), Mock(), Mock(), Mock()
        graph._add_line(x, y, c, w)
        graph.plot_widget.addItem.assert_not_called()
        self.assertEqual(graph.reg_line_items, [])

    def test_update_regression_line_calls_add_line(self):
        widget = self.widget
        graph = widget.graph
        x, y = np.array([[0, 0], [1, 0], [1, 2], [2, 2],
                         [0, 1], [1, 3], [2, 5]], dtype=np.float).T
        colors = np.array([0, 0, 0, 0, 1, 1, 1], dtype=np.float)
        widget.get_coordinates_data = lambda: (x, y)
        widget.get_color_data = lambda: colors
        widget.is_continuous_color = lambda: False
        graph.palette = DefaultRGBColors
        graph.controls.show_reg_line.setChecked(True)

        graph._add_line = Mock()

        graph.update_regression_line()
        (args1, _), (args2, _), (args3, _) = graph._add_line.call_args_list
        np.testing.assert_equal(args1[0], x)
        np.testing.assert_equal(args1[1], y)
        self.assertEqual(args1[2], QColor("#505050"))

        np.testing.assert_equal(args2[0], x[:4])
        np.testing.assert_equal(args2[1], y[:4])
        self.assertEqual(args2[2], graph.palette[0])

        np.testing.assert_equal(args3[0], x[4:])
        np.testing.assert_equal(args3[1], y[4:])
        self.assertEqual(args3[2], graph.palette[1])
        graph._add_line.reset_mock()

        # Continuous color - just a single line
        widget.is_continuous_color = lambda: True
        graph.update_regression_line()
        graph._add_line.assert_called_once()
        args1, _ = graph._add_line.call_args_list[0]
        np.testing.assert_equal(args1[0], x)
        np.testing.assert_equal(args1[1], y)
        self.assertEqual(args1[2], QColor("#505050"))
        graph._add_line.reset_mock()
        widget.is_continuous_color = lambda: False

        # No palette - just a single line
        graph.palette = None
        graph.update_regression_line()
        graph._add_line.assert_called_once()
        graph._add_line.reset_mock()
        graph.palette = DefaultRGBColors

        # Regression line is disabled
        graph.show_reg_line = False
        graph.update_regression_line()
        graph._add_line.assert_not_called()
        graph.show_reg_line = True

        # No colors - just one line
        widget.get_color_data = lambda: None
        graph.update_regression_line()
        graph._add_line.assert_called_once()
        graph._add_line.reset_mock()

        # No data
        widget.get_coordinates_data = lambda: (None, None)
        graph.update_regression_line()
        graph._add_line.assert_not_called()
        graph.show_reg_line = True
        widget.get_coordinates_data = lambda: (x, y)

        # One color group contains just one point - skip that line
        widget.get_color_data = lambda: np.array([0] + [1] * (len(x) - 1))

        graph.update_regression_line()
        (args1, _), (args2, _) = graph._add_line.call_args_list
        np.testing.assert_equal(args1[0], x)
        np.testing.assert_equal(args1[1], y)
        self.assertEqual(args1[2], QColor("#505050"))

        np.testing.assert_equal(args2[0], x[1:])
        np.testing.assert_equal(args2[1], y[1:])
        self.assertEqual(args2[2], graph.palette[1])

    def test_update_regression_line_is_called(self):
        widget = self.widget
        graph = widget.graph
        urline = graph.update_regression_line = Mock()

        self.send_signal(widget.Inputs.data, self.data)
        urline.assert_called_once()
        urline.reset_mock()

        self.send_signal(widget.Inputs.data, None)
        urline.assert_called_once()
        urline.reset_mock()

        self.send_signal(widget.Inputs.data, self.data)
        urline.assert_called_once()
        urline.reset_mock()

        simulate.combobox_activate_index(self.widget.controls.attr_color, 0)
        urline.assert_called_once()
        urline.reset_mock()

        simulate.combobox_activate_index(self.widget.controls.attr_color, 2)
        urline.assert_called_once()
        urline.reset_mock()

        simulate.combobox_activate_index(self.widget.controls.attr_x, 3)
        urline.assert_called_once()
        urline.reset_mock()


if __name__ == "__main__":
    import unittest
    unittest.main()
