Repository: biolab/orange3

Issue:
Predictions does not support selecting rows
#### Workaround

To select a row (or a subset), the user must connect Predictions to Table. Annoying, especially in a classroom.


Discussion:

Resolving patch:
diff --git a/Orange/widgets/evaluate/owpredictions.py b/Orange/widgets/evaluate/owpredictions.py
--- a/Orange/widgets/evaluate/owpredictions.py
+++ b/Orange/widgets/evaluate/owpredictions.py
@@ -1,7 +1,9 @@
 from collections import namedtuple
+from contextlib import contextmanager
 from functools import partial
 from operator import itemgetter
 from itertools import chain
+from typing import Set, List, Sequence, Union
 
 import numpy
 from AnyQt.QtWidgets import (
@@ -10,9 +12,11 @@
 from AnyQt.QtGui import QPainter, QStandardItem, QPen, QColor
 from AnyQt.QtCore import (
     Qt, QSize, QRect, QRectF, QPoint, QLocale,
-    QModelIndex, QAbstractTableModel, QSortFilterProxyModel, pyqtSignal, QTimer)
+    QModelIndex, QAbstractTableModel, QSortFilterProxyModel, pyqtSignal, QTimer,
+    QItemSelectionModel, QItemSelection)
 
 from orangewidget.report import plural
+from Orange.widgets.utils.state_summary import format_summary_details
 
 import Orange
 from Orange.evaluation import Results
@@ -68,6 +72,7 @@ class Error(OWWidget.Error):
 
     #: List of selected class value indices in the `class_values` list
     selected_classes = settings.ContextSetting([])
+    selection = settings.Setting([], schema_only=True)
 
     def __init__(self):
         super().__init__()
@@ -77,6 +82,8 @@ def __init__(self):
         self.class_values = []  # type: List[str]
         self._delegates = []
         self.left_width = 10
+        self.selection_store = None
+        self.__pending_selection = self.selection
 
         gui.listBox(self.controlArea, self, "selected_classes", "class_values",
                     box="Show probabibilities for",
@@ -92,7 +99,7 @@ def __init__(self):
 
         table_opts = dict(horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOn,
                           horizontalScrollMode=QTableView.ScrollPerPixel,
-                          selectionMode=QTableView.NoSelection,
+                          selectionMode=QTableView.ExtendedSelection,
                           focusPolicy=Qt.StrongFocus)
         self.dataview = TableView(
             sortingEnabled=True,
@@ -114,6 +121,8 @@ def __init__(self):
             lambda index, _, size:
             self.predictionsview.verticalHeader().resizeSection(index, size))
 
+        self.dataview.setItemDelegate(DataItemDelegate(self.dataview))
+
         self.splitter = QSplitter(
             orientation=Qt.Horizontal, childrenCollapsible=False, handleWidth=2)
         self.splitter.splitterMoved.connect(self.splitter_resized)
@@ -125,11 +134,19 @@ def __init__(self):
         self.vsplitter.layout().addWidget(self.splitter)
         self.vsplitter.layout().addWidget(self.score_table.view)
 
+    def get_selection_store(self, proxy):
+        # Both proxies map the same, so it doesn't matter which one is used
+        # to initialize SharedSelectionStore
+        if self.selection_store is None:
+            self.selection_store = SharedSelectionStore(proxy)
+        return self.selection_store
+
     @Inputs.data
     @check_sql_input
     def set_data(self, data):
         self.Warning.empty_data(shown=data is not None and not data)
         self.data = data
+        self.selection_store = None
         if not data:
             self.dataview.setModel(None)
             self.predictionsview.setModel(None)
@@ -140,6 +157,16 @@ def set_data(self, data):
             modelproxy = SortProxyModel()
             modelproxy.setSourceModel(model)
             self.dataview.setModel(modelproxy)
+            sel_model = SharedSelectionModel(
+                self.get_selection_store(modelproxy), modelproxy, self.dataview)
+            self.dataview.setSelectionModel(sel_model)
+            if self.__pending_selection is not None:
+                self.selection = self.__pending_selection
+                self.__pending_selection = None
+                self.selection_store.select_rows(
+                    set(self.selection), QItemSelectionModel.ClearAndSelect)
+            sel_model.selectionChanged.connect(self.commit)
+            sel_model.selectionChanged.connect(self._store_selection)
 
             self.dataview.model().list_sorted.connect(
                 partial(
@@ -148,6 +175,9 @@ def set_data(self, data):
 
         self._invalidate_predictions()
 
+    def _store_selection(self):
+        self.selection = list(self.selection_store.rows)
+
     @property
     def class_var(self):
         return self.data and self.data.domain.class_var
@@ -358,10 +388,19 @@ def _update_predictions_model(self):
         else:
             model = None
 
+        if self.selection_store is not None:
+            self.selection_store.unregister(
+                self.predictionsview.selectionModel())
+
         predmodel = PredictionsSortProxyModel()
         predmodel.setSourceModel(model)
         predmodel.setDynamicSortFilter(True)
         self.predictionsview.setModel(predmodel)
+
+        self.predictionsview.setSelectionModel(
+            SharedSelectionModel(self.get_selection_store(predmodel),
+                                 predmodel, self.predictionsview))
+
         hheader = self.predictionsview.horizontalHeader()
         hheader.setSortIndicatorShown(False)
         # SortFilterProxyModel is slow due to large abstraction overhead
@@ -376,8 +415,7 @@ def _update_predictions_model(self):
 
         self.predictionsview.resizeColumnsToContents()
 
-    @staticmethod
-    def _update_data_sort_order(sort_source_view, sort_dest_view):
+    def _update_data_sort_order(self, sort_source_view, sort_dest_view):
         sort_dest = sort_dest_view.model()
         sort_source = sort_source_view.model()
         sortindicatorshown = False
@@ -399,6 +437,7 @@ def _update_data_sort_order(sort_source_view, sort_dest_view):
             False)
         sort_source_view.horizontalHeader().setSortIndicatorShown(
             sortindicatorshown)
+        self.commit()
 
     def _reset_order(self):
         datamodel = self.dataview.model()
@@ -478,7 +517,8 @@ def _update_prediction_delegate(self):
                 None if target.is_continuous else self.class_values,
                 colors,
                 shown_probs,
-                target.format_str if target.is_continuous else None
+                target.format_str if target.is_continuous else None,
+                parent=self.predictionsview
             )
             # QAbstractItemView does not take ownership of delegates, so we must
             self._delegates.append(delegate)
@@ -533,6 +573,7 @@ def _commit_evaluation_results(self):
     def _commit_predictions(self):
         if not self.data:
             self.Outputs.predictions.send(None)
+            self.info.set_output_summary(self.info.NoOutput)
             return
 
         newmetas = []
@@ -561,8 +602,27 @@ def _commit_predictions(self):
             newcolumns = numpy.hstack(
                 [numpy.atleast_2d(cols) for cols in newcolumns])
             predictions.metas[:, -newcolumns.shape[1]:] = newcolumns
+
+        index = self.dataview.model().index
+        map_to = self.dataview.model().mapToSource
+        assert self.selection_store is not None
+        rows = None
+        if self.selection_store.rows:
+            rows = [ind.row()
+                    for ind in self.dataview.selectionModel().selectedRows(0)]
+            rows.sort()
+        elif self.dataview.model().isSorted() \
+                or self.predictionsview.model().isSorted():
+            rows = list(range(len(self.data)))
+        if rows:
+            source_rows = [map_to(index(row, 0)).row() for row in rows]
+            predictions = predictions[source_rows]
         self.Outputs.predictions.send(predictions)
 
+        summary = str(len(predictions))
+        details = format_summary_details(predictions)
+        self.info.set_output_summary(summary, details)
+
     @staticmethod
     def _add_classification_out_columns(slot, newmetas, newcolumns):
         # Mapped or unmapped predictions?!
@@ -633,6 +693,15 @@ def showEvent(self, event):
         QTimer.singleShot(0, self._update_splitter)
 
 
+class DataItemDelegate(QStyledItemDelegate):
+    def initStyleOption(self, option, index):
+        super().initStyleOption(option, index)
+        if self.parent().selectionModel().isSelected(index):
+            option.state |= QStyle.State_Selected \
+                            | QStyle.State_HasFocus \
+                            | QStyle.State_Active
+
+
 class PredictionsItemDelegate(QStyledItemDelegate):
     """
     A Item Delegate for custom formatting of predictions/probabilities
@@ -685,6 +754,11 @@ def helpEvent(self, event, view, option, index):
 
     def initStyleOption(self, option, index):
         super().initStyleOption(option, index)
+        if self.parent().selectionModel().isSelected(index):
+            option.state |= QStyle.State_Selected \
+                            | QStyle.State_HasFocus \
+                            | QStyle.State_Active
+
         if self.class_values is None:
             option.displayAlignment = \
                 (option.displayAlignment & Qt.AlignVertical_Mask) | \
@@ -914,6 +988,217 @@ def headerData(self, section, orientation, role=Qt.DisplayRole):
         return None
 
 
+class SharedSelectionStore:
+    """
+    An object shared between multiple selection models
+
+    The object assumes that the underlying models are proxies.
+    - Method `select` and emit refer to indices in proxy.
+    - Internally, the object stores indices into source model (as int). Method
+      `select_rows` also uses internal, source-model indices.
+
+    The class implements method `select` with the same signature as
+    QItemSelectionModel.select. Selection models that share this object
+    must call this method. After changing the selection, this method will
+    call `emit_selection_rows_changed` of all selection models, so they
+    can emit the signal selectionChanged.
+    """
+    def __init__(self, proxy):
+        # indices of selected rows in the original model, not in the proxy
+        self._rows: Set[int] = set()
+        self.proxy: SortProxyModel = proxy
+        self._selection_models: List[SharedSelectionModel] = []
+
+    @property
+    def rows(self) -> Set[int]:
+        """Indices of selected rows in the source model"""
+        return self._rows
+
+    def register(self, selection_model):
+        """
+        Add a selection mode to the list of models
+
+        Args:
+            selection_model (SharedSelectionModel): a new model
+        """
+        self._selection_models.append(selection_model)
+
+    def unregister(self, selection_model):
+        """
+        Remove a selection mode to the list of models
+
+        Args:
+            selection_model (SharedSelectionModel): a model to remove
+        """
+        if selection_model in self._selection_models:
+            self._selection_models.remove(selection_model)
+
+    def select(self, selection: Union[QModelIndex, QItemSelection], flags: int):
+        """
+        (De)Select given rows
+
+        Args:
+            selection (QModelIndex or QItemSelection):
+                rows to select; indices refer to the proxy model, not the source
+            flags (QItemSelectionModel.SelectionFlags):
+                flags that tell whether to Clear, Select, Deselect or Toggle
+        """
+        if isinstance(selection, QModelIndex):
+            rows = {selection.model().mapToSource(selection).row()}
+        else:
+            indices = selection.indexes()
+            if indices:
+                selection = indices[0].model().mapSelectionToSource(selection)
+                rows = {index.row() for index in selection.indexes()}
+            else:
+                rows = set()
+        self.select_rows(rows, flags)
+
+    def select_rows(self, rows: Set[int], flags):
+        """
+        (De)Select given rows
+
+        Args:
+            selection (set of int):
+                rows to select; indices refer to the source model.
+            flags (QItemSelectionModel.SelectionFlags):
+                flags that tell whether to Clear, Select, Deselect or Toggle
+        """
+        with self._emit_changed():
+            if flags & QItemSelectionModel.Clear:
+                self._rows.clear()
+            if flags & QItemSelectionModel.Select:
+                self._rows |= rows
+            if flags & QItemSelectionModel.Deselect:
+                self._rows -= rows
+            if flags & QItemSelectionModel.Toggle:
+                self._rows ^= rows
+
+    def clear_selection(self):
+        """Clear selection and emit changeSelection signal to all models"""
+        with self._emit_changed():
+            self._rows.clear()
+
+    def reset(self):
+        """Clear selection without emiting a signal,"""
+        self._rows.clear()
+
+    @contextmanager
+    def _emit_changed(self):
+        """
+        A context manager that calls `emit_selection_rows_changed after
+        changing a selection.
+        """
+        def map_from_source(rows):
+            from_src = self.proxy.mapFromSource
+            index = self.proxy.sourceModel().index
+            return {from_src(index(row, 0)).row() for row in rows}
+
+        old_rows = self._rows.copy()
+        try:
+            yield
+        finally:
+            deselected = map_from_source(old_rows - self._rows)
+            selected = map_from_source(self._rows - old_rows)
+            if selected or deselected:
+                for model in self._selection_models:
+                    model.emit_selection_rows_changed(selected, deselected)
+
+
+class SharedSelectionModel(QItemSelectionModel):
+    """
+    A selection model that shares the selection with its peers.
+
+    It assumes that the underlying model is a proxy.
+    """
+    def __init__(self, shared_store, proxy, parent):
+        super().__init__(proxy, parent)
+        self.store: SharedSelectionStore = shared_store
+        self.store.register(self)
+
+    def select(self, selection, flags):
+        self.store.select(selection, flags)
+
+    def selection_from_rows(self, rows: Sequence[int],
+                            model=None) -> QItemSelection:
+        """
+        Return selection across all columns for given row indices (as ints)
+
+        Args:
+            rows (sequence of int): row indices (in proxy model)
+
+        Returns: QItemSelection
+        """
+        if model is None:
+            model = self.model()
+        index = model.index
+        last_col = model.columnCount() - 1
+        sel = QItemSelection()
+        for row in rows:
+            sel.select(index(row, 0), index(row, last_col))
+        return sel
+
+    def emit_selection_rows_changed(
+            self, selected: Sequence[int], deselected: Sequence[int]):
+        """
+        Given a sequence of indices of selected and deselected rows,
+        emit a selectionChanged signal. Indices refer to proxy model.
+
+        Args:
+            selected (Sequence[int]): indices of selected rows
+            deselected (Sequence[int]): indices of deselected rows
+        """
+        self.selectionChanged.emit(
+            self.selection_from_rows(selected),
+            self.selection_from_rows(deselected))
+
+    def selection(self):
+        src_sel = self.selection_from_rows(self.store.rows,
+                                           model=self.model().sourceModel())
+        return self.model().mapSelectionFromSource(src_sel)
+
+    def hasSelection(self) -> bool:
+        return bool(self.store.rows)
+
+    def isColumnSelected(self, *_) -> bool:
+        return len(self.store.rows) == self.model().rowCount()
+
+    def isRowSelected(self, row, _parent=None) -> bool:
+        return self.isSelected(self.model().index(row, 0))
+
+    rowIntersectsSelection = isRowSelected
+
+    def isSelected(self, index) -> bool:
+        return self.model().mapToSource(index).row() in self.store.rows
+
+    def selectedColumns(self, row: int):
+        if self.isColumnSelected():
+            index = self.model().index
+            return [index(row, col)
+                    for col in range(self.model().columnCount())]
+        else:
+            return []
+
+    def selectedRows(self, col: int):
+        index = self.model().sourceModel().index
+        map_from = self.model().mapFromSource
+        return [map_from(index(row, col)) for row in self.store.rows]
+
+    def selectedIndexes(self):
+        index = self.model().index
+        rows = [index.row() for index in self.selectedRows(0)]
+        return [index(row, col)
+                for col in range(self.model().columnCount())
+                for row in rows]
+
+    def clearSelection(self):
+        self.store.clear_selection()
+
+    def reset(self):
+        self.store.reset()
+        self.clearCurrentIndex()
+
+
 class TableView(QTableView):
     MaxSizeHintSamples = 1000
 


Test patch:
diff --git a/Orange/widgets/evaluate/tests/test_owpredictions.py b/Orange/widgets/evaluate/tests/test_owpredictions.py
--- a/Orange/widgets/evaluate/tests/test_owpredictions.py
+++ b/Orange/widgets/evaluate/tests/test_owpredictions.py
@@ -1,14 +1,19 @@
 """Tests for OWPredictions"""
 import io
+import unittest
 from unittest.mock import Mock
 
 import numpy as np
 
+from AnyQt.QtCore import QItemSelectionModel, QItemSelection
+from AnyQt.QtGui import QStandardItemModel
+
 from Orange.base import Model
 from Orange.classification import LogisticRegressionLearner
 from Orange.data.io import TabReader
 from Orange.widgets.tests.base import WidgetTest
-from Orange.widgets.evaluate.owpredictions import OWPredictions
+from Orange.widgets.evaluate.owpredictions import (
+    OWPredictions, SortProxyModel, SharedSelectionModel, SharedSelectionStore)
 from Orange.widgets.evaluate.owcalibrationplot import OWCalibrationPlot
 from Orange.widgets.evaluate.owconfusionmatrix import OWConfusionMatrix
 from Orange.widgets.evaluate.owliftcurve import OWLiftCurve
@@ -424,7 +429,274 @@ def test_unique_output_domain(self):
         output = self.get_output(self.widget.Outputs.predictions)
         self.assertEqual(output.domain.metas[0].name, 'constant (1)')
 
+    def test_select(self):
+        log_reg_iris = LogisticRegressionLearner()(self.iris)
+        self.send_signal(self.widget.Inputs.predictors, log_reg_iris)
+        self.send_signal(self.widget.Inputs.data, self.iris)
+
+        pred_model = self.widget.predictionsview.model()
+        pred_model.sort(0)
+        self.widget.predictionsview.selectRow(1)
+        sel = {(index.row(), index.column())
+               for index in self.widget.dataview.selectionModel().selectedIndexes()}
+        self.assertEqual(sel, {(1, col) for col in range(5)})
+
+    def test_select_data_first(self):
+        log_reg_iris = LogisticRegressionLearner()(self.iris)
+        self.send_signal(self.widget.Inputs.data, self.iris)
+        self.send_signal(self.widget.Inputs.predictors, log_reg_iris)
+
+        pred_model = self.widget.predictionsview.model()
+        pred_model.sort(0)
+        self.widget.predictionsview.selectRow(1)
+        sel = {(index.row(), index.column())
+               for index in self.widget.dataview.selectionModel().selectedIndexes()}
+        self.assertEqual(sel, {(1, col) for col in range(5)})
+
+    def test_selection_in_setting(self):
+        widget = self.create_widget(OWPredictions,
+                                    stored_settings={"selection": [1, 3, 4]})
+        self.send_signal(widget.Inputs.data, self.iris)
+        log_reg_iris = LogisticRegressionLearner()(self.iris)
+        self.send_signal(widget.Inputs.predictors, log_reg_iris)
+        sel = {(index.row(), index.column())
+               for index in widget.dataview.selectionModel().selectedIndexes()}
+        self.assertEqual(sel, {(row, col)
+                               for row in [1, 3, 4] for col in range(5)})
+        out = self.get_output(widget.Outputs.predictions)
+        exp = self.iris[np.array([1, 3, 4])]
+        np.testing.assert_equal(out.X, exp.X)
+
+    def test_unregister_prediction_model(self):
+        log_reg_iris = LogisticRegressionLearner()(self.iris)
+        self.send_signal(self.widget.Inputs.predictors, log_reg_iris)
+        self.send_signal(self.widget.Inputs.data, self.iris)
+        self.widget.selection_store.unregister = Mock()
+        prev_model = self.widget.predictionsview.model()
+        self.send_signal(self.widget.Inputs.predictors, log_reg_iris)
+        self.widget.selection_store.unregister.called_with(prev_model)
+
+
+class SelectionModelTest(unittest.TestCase):
+    def setUp(self):
+        self.sourceModel1 = QStandardItemModel(5, 2)
+        self.proxyModel1 = SortProxyModel()
+        self.proxyModel1.setSourceModel(self.sourceModel1)
+        self.store = SharedSelectionStore(self.proxyModel1)
+        self.model1 = SharedSelectionModel(self.store, self.proxyModel1, None)
+
+        self.sourceModel2 = QStandardItemModel(5, 3)
+        self.proxyModel2 = SortProxyModel()
+        self.proxyModel2.setSourceModel(self.sourceModel2)
+        self.model2 = SharedSelectionModel(self.store, self.proxyModel2, None)
+
+    def itsel(self, rows):
+        sel = QItemSelection()
+        for row in rows:
+            index = self.store.proxy.index(row, 0)
+            sel.select(index, index)
+        return sel
+
+
+class SharedSelectionStoreTest(SelectionModelTest):
+    def test_registration(self):
+        self.store.unregister(self.model1)
+        self.store.unregister(self.model1)  # should have no effect and no error
+        self.store.unregister(self.model2)
+
+    def test_select_rows(self):
+        emit1 = self.model1.emit_selection_rows_changed = Mock()
+        emit2 = self.model2.emit_selection_rows_changed = Mock()
+
+        store = self.store
+        store.select_rows({1, 2}, QItemSelectionModel.Select)
+        self.assertEqual(store.rows, {1, 2})
+        emit1.assert_called_with({1, 2}, set())
+        emit2.assert_called_with({1, 2}, set())
+
+        store.select_rows({1, 2, 4}, QItemSelectionModel.Select)
+        self.assertEqual(store.rows, {1, 2, 4})
+        emit1.assert_called_with({4}, set())
+        emit2.assert_called_with({4}, set())
+
+        store.select_rows({3, 4}, QItemSelectionModel.Toggle)
+        self.assertEqual(store.rows, {1, 2, 3})
+        emit1.assert_called_with({3}, {4})
+        emit2.assert_called_with({3}, {4})
+
+        store.select_rows({0, 2}, QItemSelectionModel.Deselect)
+        self.assertEqual(store.rows, {1, 3})
+        emit1.assert_called_with(set(), {2})
+        emit2.assert_called_with(set(), {2})
+
+        store.select_rows({2, 3, 4}, QItemSelectionModel.ClearAndSelect)
+        self.assertEqual(store.rows, {2, 3, 4})
+        emit1.assert_called_with({2, 4}, {1})
+        emit2.assert_called_with({2, 4}, {1})
+
+        store.select_rows({2, 3, 4}, QItemSelectionModel.Clear)
+        self.assertEqual(store.rows, set())
+        emit1.assert_called_with(set(), {2, 3, 4})
+        emit2.assert_called_with(set(), {2, 3, 4})
+
+        store.select_rows({2, 3, 4}, QItemSelectionModel.ClearAndSelect)
+        emit1.reset_mock()
+        emit2.reset_mock()
+        store.select_rows({2, 4}, QItemSelectionModel.Select)
+        emit1.assert_not_called()
+        emit2.assert_not_called()
+
+    def test_select_maps_from_proxy(self):
+        store = self.store
+        store.select_rows = Mock()
+
+        # Map QItemSelection
+        store.proxy.setSortIndices([1, 2, 3, 4, 0])
+        store.select(self.itsel([1, 2]), QItemSelectionModel.Select)
+        store.select_rows.assert_called_with({0, 1}, QItemSelectionModel.Select)
+
+        # Map QModelIndex
+        store.proxy.setSortIndices([1, 2, 3, 4, 0])
+        store.select(store.proxy.index(0, 0), QItemSelectionModel.Select)
+        store.select_rows.assert_called_with({4}, QItemSelectionModel.Select)
+
+        # Map empty selection
+        store.select(self.itsel([]), QItemSelectionModel.Select)
+        store.select_rows.assert_called_with(set(), QItemSelectionModel.Select)
+
+    def test_clear(self):
+        store = self.store
+
+        store.select_rows({1, 2, 4}, QItemSelectionModel.Select)
+        self.assertEqual(store.rows, {1, 2, 4})
+
+        emit1 = self.model1.emit_selection_rows_changed = Mock()
+        emit2 = self.model2.emit_selection_rows_changed = Mock()
+
+        store.clear_selection()
+        self.assertEqual(store.rows, set())
+        emit1.assert_called_with(set(), {1, 2, 4})
+        emit2.assert_called_with(set(), {1, 2, 4})
+
+    def test_reset(self):
+        store = self.store
+
+        store.select_rows({1, 2, 4}, QItemSelectionModel.Select)
+        self.assertEqual(store.rows, {1, 2, 4})
+
+        emit1 = self.model1.emit_selection_rows_changed = Mock()
+        emit2 = self.model2.emit_selection_rows_changed = Mock()
+
+        store.reset()
+        self.assertEqual(store.rows, set())
+        emit1.assert_not_called()
+        emit2.assert_not_called()
+
+    def test_emit_changed_maps_to_proxy(self):
+        store = self.store
+        emit1 = self.model1.emit_selection_rows_changed = Mock()
+
+        store.proxy.setSortIndices([1, 2, 3, 4, 0])
+        store.select_rows({3, 4}, QItemSelectionModel.Select)
+        emit1.assert_called_with({4, 0}, set())
+
+        store.proxy.setSortIndices(None)
+        store.select_rows({4}, QItemSelectionModel.Deselect)
+        emit1.assert_called_with(set(), {0})
+
+        store.proxy.setSortIndices([1, 0, 3, 4, 2])
+        store.proxy.setSortIndices(None)
+        self.proxyModel1.sort(-1)
+        store.select_rows({2, 3}, QItemSelectionModel.Deselect)
+        emit1.assert_called_with(set(), {3})
+
+
+class SharedSelectionModelTest(SelectionModelTest):
+    def test_select(self):
+        self.store.select = Mock()
+        sel = self.itsel({1, 2})
+        self.model1.select(sel, QItemSelectionModel.Deselect)
+        self.store.select.assert_called_with(sel, QItemSelectionModel.Deselect)
+
+    def test_selection_from_rows(self):
+        sel = self.model1.selection_from_rows({1, 2})
+        self.assertEqual(len(sel), 2)
+        ind1 = sel[0]
+        self.assertIs(ind1.model(), self.proxyModel1)
+        self.assertEqual(ind1.left(), 0)
+        self.assertEqual(ind1.right(), self.proxyModel1.columnCount() - 1)
+        self.assertEqual(ind1.top(), 1)
+        self.assertEqual(ind1.bottom(), 1)
+
+    def test_emit_selection_rows_changed(self):
+        m1 = Mock()
+        m2 = Mock()
+        self.model1.selectionChanged.connect(m1)
+        self.model2.selectionChanged.connect(m2)
+        self.proxyModel1.setSortIndices([1, 0, 2, 4, 3])
+        self.model1.select(self.itsel({1, 3}), QItemSelectionModel.Select)
+        self.assertEqual(self.store.rows, {0, 4})
+
+        for model, m in zip((self.proxyModel1, self.proxyModel2), (m1, m2)):
+            sel = m.call_args[0][0]
+            self.assertEqual(len(sel), 2)
+            for ind, row in zip(sel, (1, 3)):
+                self.assertIs(ind.model(), model)
+                self.assertEqual(ind.left(), 0)
+                self.assertEqual(ind.right(), model.columnCount() - 1)
+                self.assertEqual(ind.top(), row)
+                self.assertEqual(ind.bottom(), row)
+
+    def test_methods(self):
+        def rowcol(sel):
+            return {(index.row(), index.column()) for index in sel}
+        self.proxyModel1.setSortIndices([1, 0, 2, 4, 3])
+        self.proxyModel2.setSortIndices([1, 0, 2, 4, 3])
+
+        self.assertFalse(self.model1.hasSelection())
+        self.assertFalse(self.model1.isColumnSelected(1))
+        self.assertFalse(self.model1.isRowSelected(1))
+        self.assertEqual(self.model1.selectedColumns(1), [])
+        self.assertEqual(self.model1.selectedRows(1), [])
+        self.assertEqual(self.model1.selectedIndexes(), [])
+
+        self.model1.select(self.itsel({1, 3}), QItemSelectionModel.Select)
+
+        self.assertEqual(rowcol(self.model1.selection().indexes()),
+                         {(1, 0), (1, 1), (3, 0), (3, 1)})
+        self.assertEqual(rowcol(self.model2.selection().indexes()),
+                         {(1, 0), (1, 1), (1, 2), (3, 0), (3, 1), (3, 2)})
+
+        self.assertTrue(self.model1.hasSelection())
+        self.assertFalse(self.model1.isColumnSelected(1))
+        self.assertTrue(self.model1.isRowSelected(1))
+        self.assertFalse(self.model1.isRowSelected(2))
+        self.assertEqual(self.model1.selectedColumns(1), [])
+        self.assertEqual(rowcol(self.model1.selectedRows(1)), {(1, 1), (3, 1)})
+        self.assertEqual(rowcol(self.model1.selectedIndexes()),
+                         {(1, 0), (1, 1), (3, 0), (3, 1)})
+
+        self.model1.select(self.itsel({0, 1, 2, 4}), QItemSelectionModel.Select)
+        self.assertTrue(self.model1.isColumnSelected(1))
+        self.assertEqual(rowcol(self.model1.selectedColumns(1)),
+                         {(1, 0), (1, 1)})
+
+        self.model1.clearSelection()
+        self.assertFalse(self.model1.hasSelection())
+        self.assertFalse(self.model1.isColumnSelected(1))
+        self.assertEqual(self.model1.selectedColumns(1), [])
+        self.assertEqual(self.model1.selectedRows(1), [])
+        self.assertEqual(self.model1.selectedIndexes(), [])
+
+        self.model1.select(self.itsel({0, 1, 2, 3, 4}),
+                           QItemSelectionModel.Select)
+        self.model1.reset()
+        self.assertFalse(self.model1.hasSelection())
+        self.assertFalse(self.model1.isColumnSelected(1))
+        self.assertEqual(self.model1.selectedColumns(1), [])
+        self.assertEqual(self.model1.selectedRows(1), [])
+        self.assertEqual(self.model1.selectedIndexes(), [])
+
 
 if __name__ == "__main__":
-    import unittest
     unittest.main()