Repository: biolab/orange3

Issue:
Distributions outputs wrong data
**What's wrong**

The widget's output does not match the selection.

**How can we reproduce the problem?** 

Load Zoo and pass it to Distributions. Show "type" and check "Sort categories by frequency".

When selecting the n-th column, the widget outputs data referring to the n-th value of the variable in the original, unsorted order.

It is pretty amazing that nobody noticed this so far.

**When fixing this** consider that the user can click "Sort categories by frequency" while something is selected. This shouldn't affect which values are selected (e.g. if one selects mammals and insects, they must still be selected).

**Don't forget** selection using keyboard, including, e.g. Shift-Right.

**What's your environment?**
- Operating system: macOS
- Orange version: latest master
- How you installed Orange: pip



Discussion:
@VesnaT would say that the original author of the current implementation of this widget hasn't written proper tests. The said author would hate that she'd be right.
@markotoplak, I assigned a milestone 3.36 to this. I hope the release can wait for a few more days. It's a part of an example in the book for teachers, and it's pretty embarrassing.

I can look at this tomorrow or on Sunday; it's probably not that difficult, but needs to be carefully fixed in several places.
Khm, I believe 3.36.0 was released one week ago, but we still did not publish final installers. @PrimozGodec, this looks like we'll go 3.36.1.
Hehe. I just wanted to start signing installers. Will wait until this is merged, and the we can make 3.36.1

Resolving patch:
diff --git a/Orange/widgets/visualize/owdistributions.py b/Orange/widgets/visualize/owdistributions.py
--- a/Orange/widgets/visualize/owdistributions.py
+++ b/Orange/widgets/visualize/owdistributions.py
@@ -77,7 +77,6 @@ def __init__(self, x, width, padding, freqs, colors, stacked, expanded,
         self.padding = padding
         self.stacked = stacked
         self.expanded = expanded
-        self.__picture = None
         self.polygon = None
         self.hovered = False
         self._tooltip = tooltip
@@ -271,10 +270,12 @@ class Warning(OWWidget.Warning):
         ignored_nans = Msg("Data instances with missing values are ignored")
 
     settingsHandler = settings.DomainContextHandler()
+    settings_version = 2
+
     var = settings.ContextSetting(None)
     cvar = settings.ContextSetting(None)
-    selection = settings.ContextSetting(set(), schema_only=True)
-    # number_of_bins must be a context setting because selection depends on it
+    selected_bars = settings.ContextSetting(set(), schema_only=True)
+    # number_of_bins must be a context setting because selected_bars depends on it
     number_of_bins = settings.ContextSetting(5, schema_only=True)
 
     fitted_distribution = settings.Setting(0)
@@ -311,6 +312,7 @@ def __init__(self):
         self.curve_items = []
         self.curve_descriptions = None
         self.binnings = []
+        self.ordered_values = []
 
         self.last_click_idx = None
         self.drag_operation = self.DragNone
@@ -495,6 +497,7 @@ def _on_hide_bars_changed(self):
         self.plot.update()
 
     def _set_smoothing_visibility(self):
+        # false positive, pylint: disable=invalid-sequence-index
         self.smoothing_box.setDisabled(
             self.Fitters[self.fitted_distribution][1] is not AshCurve)
 
@@ -563,6 +566,7 @@ def replot(self):
             self._set_axis_names()
             self._update_controls_state()
             self._call_plotting()
+            self._reduce_selection()
             self._display_legend()
         self.show_selection()
 
@@ -571,6 +575,8 @@ def _clear_plot(self):
         self.plot_pdf.clear()
         self.plot_mark.clear()
         self.bar_items = []
+        self.ordered_values = []
+        self.last_click_idx = None
         self.curve_items = []
         self._legend.clear()
         self._legend.hide()
@@ -628,11 +634,11 @@ def _disc_plot(self):
         else:
             order = np.arange(len(dist))
 
-        ordered_values = np.array(var.values)[order]
-        self.ploti.getAxis("bottom").setTicks([list(enumerate(ordered_values))])
+        self.ordered_values = list(np.array(var.values)[order])
+        self.ploti.getAxis("bottom").setTicks([list(enumerate(self.ordered_values))])
 
         colors = [QColor(0, 128, 255)]
-        for i, freq, desc in zip(count(), dist[order], ordered_values):
+        for i, freq, desc in zip(count(), dist[order], self.ordered_values):
             tooltip = \
                 "<p style='white-space:pre;'>" \
                 f"<b>{escape(desc)}</b>: {int(freq)} " \
@@ -650,13 +656,13 @@ def _disc_split_plot(self):
         else:
             order = np.arange(len(conts))
 
-        ordered_values = np.array(var.values)[order]
-        self.ploti.getAxis("bottom").setTicks([list(enumerate(ordered_values))])
+        self.ordered_values = list(np.array(var.values)[order])
+        self.ploti.getAxis("bottom").setTicks([list(enumerate(self.ordered_values))])
 
         gcolors = [QColor(*col) for col in self.cvar.colors]
         gvalues = self.cvar.values
         total = len(self.data)
-        for i, freqs, desc in zip(count(), conts[order], ordered_values):
+        for i, freqs, desc in zip(count(), conts[order], self.ordered_values):
             self._add_bar(
                 i - 0.5, 1, 0.1, freqs, gcolors,
                 stacked=self.stacked_columns, expanded=self.show_probs,
@@ -682,6 +688,7 @@ def _cont_plot(self):
         for i, (x0, x1), freq in zip(count(), zip(x, x[1:]), y):
             tot_freq += freq
             desc = self.str_int(x0, x1, not i, i == lasti, unique)
+            self.ordered_values.append(desc)
             tooltip = \
                 "<p style='white-space:pre;'>" \
                 f"<b>{escape(desc)}</b>: " \
@@ -731,6 +738,7 @@ def _cont_split_plot(self):
             tot_freqs += freqs
             plotfreqs = tot_freqs.copy() if self.cumulative_distr else freqs
             desc = self.str_int(x0, x1, not i, i == lasti, unique)
+            self.ordered_values.append(desc)
             bar_width = width if unique else x1 - x0
             self._add_bar(
                 x0 + xoff, bar_width, 0 if self.stacked_columns else 0.1,
@@ -783,6 +791,7 @@ def str_params():
 
         if not y.size:
             return None, None
+        # false positive, pylint: disable=invalid-sequence-index
         _, dist, names, str_names = self.Fitters[self.fitted_distribution]
         fitted = dist.fit(y)
         params = dict(zip(names, fitted))
@@ -795,6 +804,7 @@ def _plot_approximations(self, x0, x1, fitters, colors, prior_probs):
         for y, (fitter, _) in zip(ys, fitters):
             if fitter is None:
                 continue
+            # false positive, pylint: disable=invalid-sequence-index
             if self.Fitters[self.fitted_distribution][1] is AshCurve:
                 y[:] = fitter(x, sigma=(22 - self.kde_smoothing) / 40)
             else:
@@ -933,28 +943,29 @@ def str_int(self, x0, x1, first, last, unique=False):
     # Selection
 
     def _on_item_clicked(self, item, modifiers, drag):
-        def add_or_remove(idx, add):
+        def add_or_remove(value, add):
             self.drag_operation = [self.DragRemove, self.DragAdd][add]
             if add:
-                self.selection.add(idx)
+                self.selected_bars.add(value)
             else:
-                if idx in self.selection:
+                if value in self.selected_bars:
                     # This can be False when removing with dragging and the
                     # mouse crosses unselected items
-                    self.selection.remove(idx)
+                    self.selected_bars.remove(value)
 
         def add_range(add):
             if self.last_click_idx is None:
                 add = True
-                idx_range = {idx}
+                idx_range = {self.ordered_values[idx]}
             else:
                 from_idx, to_idx = sorted((self.last_click_idx, idx))
-                idx_range = set(range(from_idx, to_idx + 1))
+                idx_range = {self.ordered_values[idx]
+                             for idx in range(from_idx, to_idx + 1)}
             self.drag_operation = [self.DragRemove, self.DragAdd][add]
             if add:
-                self.selection |= idx_range
+                self.selected_bars |= idx_range
             else:
-                self.selection -= idx_range
+                self.selected_bars -= idx_range
 
         self.key_operation = None
         if item is None:
@@ -966,19 +977,20 @@ def add_range(add):
             # Dragging has to add a range, otherwise fast dragging skips bars
             add_range(self.drag_operation == self.DragAdd)
         else:
+            value = self.ordered_values[idx]
             if modifiers & Qt.ShiftModifier:
                 add_range(self.drag_operation == self.DragAdd)
             elif modifiers & Qt.ControlModifier:
-                add_or_remove(idx, add=idx not in self.selection)
+                add_or_remove(value, add=value not in self.selected_bars)
             else:
-                if self.selection == {idx}:
-                    # Clicking on a single selected bar  deselects it,
+                if self.selected_bars == {value}:
+                    # Clicking on a single selected bar deselects it,
                     # but dragging from here will select
-                    add_or_remove(idx, add=False)
+                    add_or_remove(value, add=False)
                     self.drag_operation = self.DragAdd
                 else:
-                    self.selection.clear()
-                    add_or_remove(idx, add=True)
+                    self.selected_bars.clear()
+                    add_or_remove(value, add=True)
         self.last_click_idx = idx
 
         self.show_selection()
@@ -987,7 +999,7 @@ def _on_blank_clicked(self):
         self.reset_select()
 
     def reset_select(self):
-        self.selection.clear()
+        self.selected_bars.clear()
         self.last_click_idx = None
         self.drag_operation = None
         self.key_operation = None
@@ -1047,8 +1059,20 @@ def _padding(i):
 
     def grouped_selection(self):
         return [[g[1] for g in group]
-                for _, group in groupby(enumerate(sorted(self.selection)),
+                for _, group in groupby(enumerate(sorted(map(self.ordered_values.index,
+                                                             self.selected_bars))),
                                         key=lambda x: x[1] - x[0])]
+        # Alternative:
+        # groups = []
+        # last = None
+        # for idx, value in enumerate(self.ordered_values):
+        #     if value in self.selected_bars:
+        #         if last is None:
+        #             groups.append(last := [])
+        #         last.append(idx)
+        #     else:
+        #         last = None
+        # return groups
 
     def keyPressEvent(self, e):
         def on_nothing_selected():
@@ -1056,52 +1080,53 @@ def on_nothing_selected():
                 self.last_click_idx = len(self.bar_items) - 1
             else:
                 self.last_click_idx = 0
-            self.selection.add(self.last_click_idx)
+            self.selected_bars.add(self.ordered_values[self.last_click_idx])
 
         def on_key_left():
             if e.modifiers() & Qt.ShiftModifier:
                 if self.key_operation == Qt.Key_Right and first != last:
-                    self.selection.remove(last)
+                    self.selected_bars.remove(self.ordered_values[last])
                     self.last_click_idx = last - 1
                 elif first:
                     self.key_operation = Qt.Key_Left
-                    self.selection.add(first - 1)
+                    self.selected_bars.add(self.ordered_values[first - 1])
                     self.last_click_idx = first - 1
             else:
-                self.selection.clear()
+                self.selected_bars.clear()
                 self.last_click_idx = max(first - 1, 0)
-                self.selection.add(self.last_click_idx)
+                self.selected_bars.add(self.ordered_values[self.last_click_idx])
 
         def on_key_right():
             if e.modifiers() & Qt.ShiftModifier:
                 if self.key_operation == Qt.Key_Left and first != last:
-                    self.selection.remove(first)
+                    self.selected_bars.remove(self.ordered_values[first])
                     self.last_click_idx = first + 1
                 elif not self._is_last_bar(last):
                     self.key_operation = Qt.Key_Right
-                    self.selection.add(last + 1)
+                    self.selected_bars.add(self.ordered_values[last + 1])
                     self.last_click_idx = last + 1
             else:
-                self.selection.clear()
+                self.selected_bars.clear()
                 self.last_click_idx = min(last + 1, len(self.bar_items) - 1)
-                self.selection.add(self.last_click_idx)
+                self.selected_bars.add(self.ordered_values[self.last_click_idx])
 
         if not self.is_valid or not self.bar_items \
                 or e.key() not in (Qt.Key_Left, Qt.Key_Right):
             super().keyPressEvent(e)
             return
 
-        prev_selection = self.selection.copy()
-        if not self.selection:
+        prev_selection = self.selected_bars.copy()
+        if not self.selected_bars:
             on_nothing_selected()
         else:
-            first, last = min(self.selection), max(self.selection)
+            sel_indices = list(map(self.ordered_values.index, self.selected_bars))
+            first, last = min(sel_indices), max(sel_indices)
             if e.key() == Qt.Key_Left:
                 on_key_left()
             else:
                 on_key_right()
 
-        if self.selection != prev_selection:
+        if self.selected_bars != prev_selection:
             self.drag_operation = self.DragAdd
             self.show_selection()
             self.apply.deferred()
@@ -1111,6 +1136,36 @@ def keyReleaseEvent(self, ev):
             self.key_operation = None
         super().keyReleaseEvent(ev)
 
+    def _reduce_selection(self):
+        """
+        Unselect any bars that no longer appear in the plot; migrate from ints
+
+        This function is called after plotting to remove any bars that have
+        been selected but are no longer plotted. This occurs in particular
+        when the widget receives new data with discrete variables that lack
+        some values.
+
+        This function also migrates from previous settings, which stored ints
+        instead of values. This migration requires bar labels and cannot be
+        (easily) done before plotting.
+        """
+        if self.selected_bars \
+                and isinstance(next(iter(self.selected_bars)), int):
+            self.selected_bars = {
+                self.ordered_values[idx] for idx in self.selected_bars
+                if idx < len(self.ordered_values)}
+        else:
+            self.selected_bars = {value for value in self.selected_bars
+                                  if value in self.ordered_values}
+
+    @classmethod
+    def migrate_context(cls, context, version):
+        # settings_version 2 has `selected_bars: set[str]` instead of
+        # `selection: set[int]`. Actual migration can only be done after
+        # plotting (see `_reduce_selection`), but we need to rename the setting
+        # so that handler assigns it to the widget instance
+        if "selection" in context.values:
+            context.values["selected_bars"] = context.values["selection"]
 
     # -----------------------------
     # Output
@@ -1143,9 +1198,16 @@ def apply(self):
     def _get_output_indices_disc(self):
         group_indices = np.zeros(len(self.data), dtype=np.int32)
         col = self.data.get_column(self.var)
-        for group_idx, val_idx in enumerate(self.selection, start=1):
-            group_indices[col == val_idx] = group_idx
-        values = [self.var.values[i] for i in self.selection]
+        group_idx = 1
+        values = []
+        # self.selected_bars is a set, so its order is random;
+        # we iterate through ordered_value to get the same order as in chart
+        for value in self.ordered_values:
+            if value not in self.selected_bars:
+                continue
+            group_indices[col == self.var.to_val(value)] = group_idx
+            group_idx += 1
+            values.append(value)
         return group_indices, values
 
     def _get_output_indices_cont(self):
@@ -1166,6 +1228,7 @@ def _get_output_indices_cont(self):
         return group_indices, values
 
     def _get_histogram_table(self):
+        # bar is OK; pylint: disable=disallowed-name
         var_bin = DiscreteVariable("Bin", [bar.desc for bar in self.bar_items])
         var_freq = ContinuousVariable("Count")
         X = []


Test patch:
diff --git a/Orange/widgets/visualize/tests/test_owdistributions.py b/Orange/widgets/visualize/tests/test_owdistributions.py
--- a/Orange/widgets/visualize/tests/test_owdistributions.py
+++ b/Orange/widgets/visualize/tests/test_owdistributions.py
@@ -1,11 +1,14 @@
 # Test methods with long descriptive names can omit docstrings
 # pylint: disable=missing-docstring,protected-access
+from functools import partial
+
 import os
 import unittest
 from unittest.mock import Mock
 
 import numpy as np
-from AnyQt.QtCore import QItemSelection, Qt
+from AnyQt.QtCore import QItemSelection, Qt, QEvent
+from AnyQt.QtGui import QKeyEvent
 from AnyQt.QtWidgets import QCheckBox
 
 from orangewidget.utils.combobox import qcombobox_emit_activated
@@ -21,6 +24,7 @@ class TestOWDistributions(WidgetTest):
     def setUp(self):
         self.widget = self.create_widget(OWDistributions)  #: OWDistributions
         self.iris = Table("iris")
+        self.heart = Table("heart_disease")
 
     def _set_cvar(self, cvar):
         combo = self.widget.controls.cvar
@@ -76,7 +80,7 @@ def test_set_data(self):
         self.assertIsNone(self.get_output(widget.Outputs.selected_data))
 
         # Data gone: clean up
-        widget.selection.add(0)
+        widget.selected_bars.add(widget.ordered_values[0])
         widget._clear_plot = Mock()
         self.send_signal(widget.Inputs.data, None)
         self.assertEqual(len(var_model), 0)
@@ -84,7 +88,7 @@ def test_set_data(self):
         self.assertIsNone(widget.var)
         self.assertIsNone(widget.cvar)
 
-        self.assertEqual(widget.selection, set())
+        self.assertEqual(widget.selected_bars, set())
         self.assertIsNone(widget.valid_data)
         self.assertIsNone(widget.valid_group_data)
         self.assertIsNone(self.get_output(widget.Outputs.histogram_data))
@@ -184,7 +188,7 @@ def test_histogram_data(self):
         self._set_var(self.iris.domain["sepal length"])
         self._set_cvar(self.iris.domain["iris"])
         hist = self.get_output(widget.Outputs.histogram_data)
-        self.assertTrue(len(hist)>0 and len(hist)%3==0)
+        self.assertTrue(len(hist) > 0 and len(hist) % 3 == 0)
 
     def test_switch_var(self):
         """Widget reset and recomputes when changing var"""
@@ -193,7 +197,7 @@ def test_switch_var(self):
         self.send_signal(widget.Inputs.data, self.iris)
         binnings = widget.binnings.copy()
         valid_data = widget.valid_data.copy()
-        widget.selection.add(1)
+        widget.selected_bars.add(widget.ordered_values[1])
         widget._clear_plot = Mock()
         widget.apply.now = widget.apply.deferred = Mock()
 
@@ -204,7 +208,7 @@ def test_switch_var(self):
         )
         self.assertFalse(valid_data.shape == widget.valid_data.shape
                          and np.allclose(valid_data, widget.valid_data))
-        self.assertEqual(widget.selection, set())
+        self.assertEqual(widget.selected_bars, set())
         widget._clear_plot.assert_called()
         widget.apply.now.assert_called()
 
@@ -225,7 +229,7 @@ def test_switch_cvar(self):
 
         binnings = widget.binnings
         valid_data = widget.valid_data.copy()
-        widget.selection.add(1)
+        widget.selected_bars.add(widget.ordered_values[1])
         widget._clear_plot = Mock()
         widget.apply.now = widget.apply.deferred = Mock()
 
@@ -235,7 +239,7 @@ def test_switch_cvar(self):
         self.assertIs(binnings, widget.binnings)
         np.testing.assert_equal(valid_data[:120], widget.valid_data)
         self.assertEqual(len(widget.valid_group_data), 120)
-        self.assertEqual(widget.selection, {1})
+        self.assertEqual(widget.selected_bars, {widget.ordered_values[1]})
         widget._clear_plot.assert_called()
         widget.apply.now.assert_called()
         widget._clear_plot.reset_mock()
@@ -245,7 +249,7 @@ def test_switch_cvar(self):
         self.assertIs(binnings, widget.binnings)
         np.testing.assert_equal(valid_data, widget.valid_data)
         self.assertIsNone(widget.valid_group_data)
-        self.assertEqual(widget.selection, {1})
+        self.assertEqual(widget.selected_bars, {widget.ordered_values[1]})
         widget._clear_plot.assert_called()
         widget.apply.now.assert_called()
 
@@ -255,12 +259,12 @@ def test_on_bins_changed(self):
         self.send_signal(widget.Inputs.data, self.iris)
 
         self._set_slider(0)
-        widget.selection.add(1)
+        widget.selected_bars.add(widget.ordered_values[1])
         n_bars = len(widget.bar_items)
         widget.apply.now = widget.apply.deferred = Mock()
 
         self._set_slider(1)
-        self.assertEqual(widget.selection, set())
+        self.assertEqual(widget.selected_bars, set())
         self.assertGreater(n_bars, len(widget.bar_items))
         widget.apply.now.assert_called_once()
 
@@ -463,7 +467,8 @@ def test_selection_grouping(self):
         widget = self.widget
         self.send_signal(widget.Inputs.data, self.iris)
         self._set_slider(0)
-        widget.selection = {1, 2, 3, 5, 6, 9}
+        widget.selected_bars = {widget.ordered_values[x]
+                                for x in [1, 2, 3, 5, 6, 9]}
         widget.plot_mark.addItem = Mock()
         widget.show_selection()
         widget._on_end_selecting()
@@ -526,11 +531,10 @@ def test_report(self):
         widget.send_report()
 
     def test_sort_by_freq_no_split(self):
-        data = Table("heart_disease")
-        domain = data.domain
+        domain = self.heart.domain
         sort_by_freq = self.widget.controls.sort_by_freq
 
-        self.send_signal(self.widget.Inputs.data, data)
+        self.send_signal(self.widget.Inputs.data, self.heart)
         self._set_var(domain["gender"])
         self._set_cvar(None)
 
@@ -549,11 +553,10 @@ def test_sort_by_freq_no_split(self):
         self.assertEqual(out[1][1], 97)
 
     def test_sort_by_freq_split(self):
-        data = Table("heart_disease")
-        domain = data.domain
+        domain = self.heart.domain
         sort_by_freq = self.widget.controls.sort_by_freq
 
-        self.send_signal(self.widget.Inputs.data, data)
+        self.send_signal(self.widget.Inputs.data, self.heart)
         self._set_var(domain["gender"])
         self._set_cvar(domain["rest ECG"])
 
@@ -575,6 +578,124 @@ def test_sort_by_freq_split(self):
         self.assertEqual(out[4][1], "left vent hypertrophy")
         self.assertEqual(out[4][2], 45)
 
+    def test_sort_by_freq_output_selection(self):
+        widget = self.widget
+        sort_by_freq = self.widget.controls.sort_by_freq
+        var = self.heart.domain["chest pain"]
+
+        self.send_signal(self.widget.Inputs.data, self.heart)
+        self._set_var(var)
+
+        sort_by_freq.setChecked(False)
+        assert not widget.sort_by_freq
+
+        # Select value[1]
+        widget._on_item_clicked(widget.bar_items[1], Qt.NoModifier, False)
+        widget._on_end_selecting()
+        cp = self.get_output(widget.Outputs.selected_data).get_column(var)
+        self.assertTrue(np.all(cp == 1))
+
+        sort_by_freq.setChecked(True)
+        assert widget.sort_by_freq
+
+        # Select value[2] (because of reordering)
+        # value[1] remains selected
+        widget._on_item_clicked(widget.bar_items[1], Qt.ControlModifier, False)
+        widget._on_end_selecting()
+        cp = self.get_output(widget.Outputs.selected_data).get_column(var)
+        self.assertFalse(np.any(cp == 0))
+        self.assertTrue(np.any(cp == 1))
+        self.assertTrue(np.any(cp == 2))
+        self.assertFalse(np.any(cp == 3))
+
+        # deselect value[1]
+        widget._on_item_clicked(widget.bar_items[2], Qt.ControlModifier, False)
+        widget._on_end_selecting()
+        cp = self.get_output(widget.Outputs.selected_data).get_column(var)
+        self.assertTrue(np.all(cp == 2))
+
+        # Select value[0] and also value[1] (!), because it's in between
+        # This tests checks that shift-selecting works with ordered values
+        widget._on_item_clicked(widget.bar_items[2], Qt.NoModifier, False)
+        widget._on_item_clicked(widget.bar_items[0], Qt.ShiftModifier, False)
+        widget._on_end_selecting()
+        cp = self.get_output(widget.Outputs.selected_data).get_column(var)
+        self.assertTrue(np.any(cp == 0))
+        self.assertTrue(np.any(cp == 1))
+        self.assertTrue(np.any(cp == 2))
+        self.assertFalse(np.any(cp == 3))
+
+    def test_keyboard_interaction_unsorted(self):
+        press = partial(QKeyEvent, QEvent.KeyPress)
+        left, right = Qt.Key_Left, Qt.Key_Right
+
+        widget = self.widget
+        sort_by_freq = self.widget.controls.sort_by_freq
+        widget.sort_by_freq = False
+        var = self.heart.domain["chest pain"]
+
+        for ordered in [False, True]:
+            with self.subTest(ordered=ordered):
+                sort_by_freq.setChecked(ordered)
+                assert widget.sort_by_freq is ordered
+                assert not ordered or list(widget.ordered_values) != list(var.values)
+
+                values = widget.ordered_values if ordered else var.values
+
+                self.send_signal(self.widget.Inputs.data, self.heart)
+                self._set_var(var)
+
+                # Start selecting by pressing right
+                for i in [0, 1, 2, 3, 3, 3]:
+                    widget.keyPressEvent(press(right, Qt.NoModifier))
+                    self.assertEqual(widget.selected_bars, {values[i]}, f"at i={i}")
+
+                # Going left
+                for i in [2, 1, 0, 0, 0]:
+                    widget.keyPressEvent(press(left, Qt.NoModifier))
+                    self.assertEqual(widget.selected_bars, {values[i]}, f"at i={i}")
+
+                # Deselect first item (= clear selection)
+                widget._on_item_clicked(widget.bar_items[0], Qt.NoModifier, False)
+                assert not widget.selected_bars
+
+                # Going left from clear
+                for i in [3, 2, 1, 0, 0, 0]:
+                    widget.keyPressEvent(press(left, Qt.NoModifier))
+                    self.assertEqual(widget.selected_bars, {values[i]}, f"at i={i}")
+
+                widget.keyPressEvent(press(right, Qt.NoModifier))
+                assert widget.selected_bars == {values[1]}
+
+                # Shift selecting to the right
+                widget.keyPressEvent(press(right, Qt.ShiftModifier))
+                assert widget.selected_bars == {values[1], values[2]}
+
+                widget.keyPressEvent(press(right, Qt.ShiftModifier))
+                assert widget.selected_bars == {values[1], values[2], values[3]}
+
+                widget.keyPressEvent(press(right, Qt.ShiftModifier))
+                assert widget.selected_bars == {values[1], values[2], values[3]}
+
+                # Shift deselecting to the left
+                widget.keyPressEvent(press(left, Qt.ShiftModifier))
+                assert widget.selected_bars == {values[1], values[2]}
+
+                widget.keyPressEvent(press(left, Qt.ShiftModifier))
+                assert widget.selected_bars == {values[1]}
+
+                # Now we have a single item, so going left should select the one to the left
+                widget.keyPressEvent(press(left, Qt.ShiftModifier))
+                assert widget.selected_bars == {values[0], values[1]}
+
+                # Right deselects it
+                widget.keyPressEvent(press(right, Qt.ShiftModifier))
+                assert widget.selected_bars == {values[1]}
+
+                # Right again selects the item to the right of the single itsm
+                widget.keyPressEvent(press(right, Qt.ShiftModifier))
+                assert widget.selected_bars == {values[1], values[2]}
+
 
 if __name__ == "__main__":
     unittest.main()