From cbaaac9e3d48314c16874cd57f50d7835b5829b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Primo=C5=BE=20Godec?= Date: Sun, 1 Mar 2020 11:20:12 +0100 Subject: [PATCH] OWDomain: Merge less frequent values --- Orange/widgets/data/oweditdomain.py | 319 ++++++++++++++---- .../widgets/data/tests/test_oweditdomain.py | 136 ++++++-- 2 files changed, 360 insertions(+), 95 deletions(-) diff --git a/Orange/widgets/data/oweditdomain.py b/Orange/widgets/data/oweditdomain.py index c13a350752c..09fe5d71646 100644 --- a/Orange/widgets/data/oweditdomain.py +++ b/Orange/widgets/data/oweditdomain.py @@ -11,28 +11,25 @@ from contextlib import contextmanager from collections import namedtuple, Counter from functools import singledispatch, partial - from typing import ( Tuple, List, Any, Optional, Union, Dict, Sequence, Iterable, NamedTuple, FrozenSet, Type, Callable, TypeVar, Mapping, Hashable ) + +import numpy as np +import pandas as pd from AnyQt.QtWidgets import ( QWidget, QListView, QTreeView, QVBoxLayout, QHBoxLayout, QFormLayout, QToolButton, QLineEdit, QAction, QActionGroup, QGroupBox, QStyledItemDelegate, QStyleOptionViewItem, QStyle, QSizePolicy, QToolTip, - QDialogButtonBox, QPushButton, QCheckBox, QComboBox, QShortcut, - QStackedLayout -) + QDialogButtonBox, QPushButton, QCheckBox, QComboBox, QStackedLayout, + QDialog, QRadioButton, QGridLayout, QLabel, QSpinBox, QDoubleSpinBox) from AnyQt.QtGui import QStandardItemModel, QStandardItem, QKeySequence, QIcon from AnyQt.QtCore import ( - Qt, QEvent, QSize, QModelIndex, QAbstractItemModel, QPersistentModelIndex, - QRect, + Qt, QEvent, QSize, QModelIndex, QAbstractItemModel, QPersistentModelIndex ) from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot -import numpy as np -import pandas as pd - import Orange.data from Orange.preprocess.transformation import Transformation, Identity, Lookup @@ -73,6 +70,20 @@ def __ne__(self, other): def __hash__(self): return hash((type(self), super().__hash__())) + def name_type(self): + """ + Returns a tuple with name and type of the variable. + It is used since it is forbidden to use names of variables in settings. + """ + type_number = { + "Categorical": 0, + "Ordered": 1, + "Real": 2, + "Time": 3, + "String": 4 + } + return self.name, type_number[type(self).__name__] + #: An ordered sequence of key, value pairs (variable annotations) AnnotationsType = Tuple[Tuple[str, str], ...] @@ -662,6 +673,164 @@ def on_label_selection_changed(self): self.remove_label_action.setEnabled(bool(len(selected))) +class GroupItemsDialog(QDialog): + """ + A dialog for group less frequent values. + """ + DEFAULT_LABEL = "other" + + def __init__( + self, variable: Categorical, data: Union[np.ndarray, List], + selected_attributes: List[str], parent: QWidget = None, + flags: Qt.WindowFlags = Qt.Dialog, **kwargs + ) -> None: + super().__init__(parent, flags, **kwargs) + self.variable = variable + self.data = data + self.selected_attributes = selected_attributes + + # grouping strategy + self.selected_radio = radio1 = QRadioButton("Group selected values") + self.frequent_abs_radio = radio2 = QRadioButton( + "Group values with less than" + ) + self.frequent_rel_radio = radio3 = QRadioButton( + "Group values with less than" + ) + self.n_values_radio = radio4 = QRadioButton( + "Group all except" + ) + + # if selected attributes available check the first radio button, + # othervise disable it + if selected_attributes: + radio1.setChecked(True) + else: + radio1.setEnabled(False) + radio2.setChecked(True) + + label2 = QLabel("occurrences") + label3 = QLabel("occurrences") + label4 = QLabel("most frequent values") + + self.frequent_abs_spin = spin2 = QSpinBox() + max_val = len(data) + spin2.setMinimum(1) + spin2.setMaximum(max_val) + spin2.setValue(10) + spin2.setMinimumWidth( + self.fontMetrics().width("X") * (len(str(max_val)) + 1) + 20 + ) + spin2.valueChanged.connect(self._frequent_abs_spin_changed) + + self.frequent_rel_spin = spin3 = QDoubleSpinBox() + spin3.setMinimum(0) + spin3.setDecimals(1) + spin3.setSingleStep(0.1) + spin3.setMaximum(100) + spin3.setValue(10) + spin3.setMinimumWidth(self.fontMetrics().width("X") * (2 + 1) + 20) + spin3.setSuffix(" %") + spin3.valueChanged.connect(self._frequent_rel_spin_changed) + + self.n_values_spin = spin4 = QSpinBox() + max_val = min(10, len(variable.categories)) + spin4.setMinimum(0) + spin4.setMaximum(len(variable.categories)) + spin4.setValue(max_val) + spin4.setMinimumWidth( + self.fontMetrics().width("X") * (len(str(max_val)) + 1) + 20 + ) + spin4.valueChanged.connect(self._n_values_spin_spin_changed) + + grid_layout = QGridLayout() + # first row + grid_layout.addWidget(radio1, 0, 0, 1, 2) + # second row + grid_layout.addWidget(radio2, 1, 0, 1, 2) + grid_layout.addWidget(spin2, 1, 2) + grid_layout.addWidget(label2, 1, 3) + # third row + grid_layout.addWidget(radio3, 2, 0, 1, 2) + grid_layout.addWidget(spin3, 2, 2) + grid_layout.addWidget(label3, 2, 3) + # fourth row + grid_layout.addWidget(radio4, 3, 0) + grid_layout.addWidget(spin4, 3, 1) + grid_layout.addWidget(label4, 3, 2, 1, 2) + + group_box = QGroupBox() + group_box.setLayout(grid_layout) + + # grouped variable name + new_name_label = QLabel("New value name: ") + self.new_name_line_edit = n_line_edit = QLineEdit(self.DEFAULT_LABEL) + # it is shown gray when user removes the text and let user know that + # word others is default one + n_line_edit.setPlaceholderText(self.DEFAULT_LABEL) + name_hlayout = QHBoxLayout() + name_hlayout.addWidget(new_name_label) + name_hlayout.addWidget(n_line_edit) + + # confirm_button = QPushButton("Apply") + # cancel_button = QPushButton("Cancel") + buttons = QDialogButtonBox( + orientation=Qt.Horizontal, + standardButtons=(QDialogButtonBox.Ok | QDialogButtonBox.Cancel), + objectName="dialog-button-box", + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + # join components + self.setLayout(QVBoxLayout()) + self.layout().addWidget(group_box) + self.layout().addLayout(name_hlayout) + self.layout().addWidget(buttons) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + def _frequent_abs_spin_changed(self) -> None: + self.frequent_abs_radio.setChecked(True) + + def _n_values_spin_spin_changed(self) -> None: + self.n_values_radio.setChecked(True) + + def _frequent_rel_spin_changed(self) -> None: + self.frequent_rel_radio.setChecked(True) + + def get_merge_attributes(self) -> List[str]: + """ + Returns attributes that will be merged + + Returns + ------- + List of attributes' to be merged names + """ + counts = Counter(self.data) + if self.selected_radio.isChecked(): + return self.selected_attributes + elif self.n_values_radio.isChecked(): + keep_values = self.n_values_spin.value() + values = counts.most_common()[keep_values:] + indices = [i for i, _ in values] + elif self.frequent_abs_radio.isChecked(): + indices = [v for v, c in counts.most_common() + if c < self.frequent_abs_spin.value()] + else: # self.frequent_rel_radio.isChecked(): + n_all = sum(counts.values()) + indices = [v for v, c in counts.most_common() + if c / n_all * 100 < self.frequent_rel_spin.value()] + return np.array(self.variable.categories)[indices].tolist() + + def get_merged_value_name(self) -> str: + """ + Returns + ------- + New label of merged values + """ + return self.new_name_line_edit.text() or self.DEFAULT_LABEL + + @contextmanager def disconnected(signal, slot, connection_type=Qt.AutoConnection): signal.disconnect(slot) @@ -847,6 +1016,9 @@ class DiscreteVariableEditor(VariableEditor): """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + + self._values = None + form = self.layout().itemAt(0) assert isinstance(form, QFormLayout) self.ordered_cb = QCheckBox( @@ -916,8 +1088,7 @@ def __init__(self, *args, **kwargs): objectName="action-merge-item", toolTip="Merge selected items.", shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_Equal), - shortcutContext=Qt.WidgetShortcut, - enabled=False, + shortcutContext=Qt.WidgetShortcut ) self.add_new_item.triggered.connect(self._add_category) @@ -945,8 +1116,7 @@ def __init__(self, *args, **kwargs): accessibleName="Merge", ) self.values_edit.addActions([self.move_value_up, self.move_value_down, - self.add_new_item, self.remove_item, - self.merge_items]) + self.add_new_item, self.remove_item]) hlayout.addWidget(button1) hlayout.addWidget(button2) hlayout.addSpacing(3) @@ -969,12 +1139,18 @@ def __init__(self, *args, **kwargs): QWidget.setTabOrder(button3, button4) def set_data(self, var, transform=()): - # type: (Optional[Categorical], Sequence[Transform]) -> None + raise NotImplementedError + + def set_data_categorical(self, var, values, transform=()): + # type: (Optional[Categorical], Optional[Sequence[float]], Sequence[Transform]) -> None """ Set the variable to edit. + + `values` is needed for categorical features to perform grouping. """ # pylint: disable=too-many-branches - super().set_data(var, transform) + super().set_data(var, transform=transform) + self._values = values tr = None # type: Optional[CategoriesMapping] ordered = None # type: Optional[ChangeOrdered] for tr_ in transform: @@ -1118,7 +1294,6 @@ def on_value_selection_changed(self): len(rows) > 1 and \ not any(index.data(EditStateRole) != ItemEditState.NoState for index in rows) - self.merge_items.setEnabled(enable_merge) if len(rows) == 1: i = rows[0].row() @@ -1182,65 +1357,63 @@ def _add_category(self): view.edit(index) self.on_values_changed() - def _merge_categories(self): + def _reset_name_merge(self) -> None: + """ + This function resets renamed and merged variables in the model. """ - Merge selected categories into one. + view = self.values_edit + model = view.model() # type: QAbstractItemModel + prows = [ + QPersistentModelIndex(model.index(i, 0)) + for i in range(model.rowCount()) + ] + with disconnected(model.dataChanged, self.on_values_changed): + for prow in prows: + if prow.isValid(): + model.setData( + QModelIndex(prow), prow.data(SourceNameRole), + Qt.EditRole + ) + self.variable_changed.emit() - Popup an editable combo box for selection/edit of a new value. + def _merge_categories(self) -> None: + """ + Merge less common categories into one with the dialog for merge + selection. """ view = self.values_edit model = view.model() # type: QAbstractItemModel - rows = view.selectedIndexes() # type: List[QModelIndex] - if not len(rows) >= 2: - return # pragma: no cover - first_row = rows[0] - def mapRectTo(widget, parent, rect): - # type: (QWidget, QWidget, QRect) -> QRect - return QRect( - widget.mapTo(parent, rect.topLeft()), - rect.size(), - ) + selected_attributes = [ind.data() for ind in view.selectedIndexes()] - def mapRectToGlobal(widget, rect): - # type: (QWidget, QRect) -> QRect - return QRect( - widget.mapToGlobal(rect.topLeft()), - rect.size(), - ) - view.scrollTo(first_row) - vport = view.viewport() - vrect = view.visualRect(first_row) - vrect = mapRectTo(vport, view, vrect) - vrect = vrect.intersected(vport.geometry()) - vrect = mapRectToGlobal(vport, vrect) - - cb = QComboBox(editable=True, insertPolicy=QComboBox.InsertAtBottom) - cb.setAttribute(Qt.WA_DeleteOnClose) - sh = QShortcut(QKeySequence(QKeySequence.Cancel), cb) - sh.activated.connect(cb.close) - cb.setParent(self, Qt.Popup) - cb.move(vrect.topLeft()) - - cb.addItems( - list(unique(str(row.data(Qt.EditRole)) for row in rows))) - prows = [QPersistentModelIndex(row) for row in rows] - - def complete_merge(text): + dlg = GroupItemsDialog( + self.var, self._values, selected_attributes, self, + windowTitle="Import Options", + sizeGripEnabled=True, + ) + dlg.setWindowModality(Qt.WindowModal) + status = dlg.exec_() + dlg.deleteLater() + + prows = [ + QPersistentModelIndex(model.index(i, 0)) + for i in range(model.rowCount()) + ] + + def complete_merge(text, merge_attributes): # write the new text for edit role in all rows + self._reset_name_merge() with disconnected(model.dataChanged, self.on_values_changed): for prow in prows: - if prow.isValid(): + if (prow.isValid() + and prow.data(SourceNameRole) in merge_attributes): model.setData(QModelIndex(prow), text, Qt.EditRole) - cb.close() self.variable_changed.emit() - cb.activated[str].connect(complete_merge) - size = cb.sizeHint().expandedTo(vrect.size()) - cb.resize(size) - cb.show() - cb.raise_() - cb.setFocus(Qt.PopupFocusReason) + if status == QDialog.Accepted: + complete_merge( + dlg.get_merged_value_name(), dlg.get_merge_attributes() + ) def _set_ordered(self, ordered): self.ordered_cb.setChecked(ordered) @@ -1438,7 +1611,10 @@ def set_data(self, data, transform=()): # pylint: disable=arguments-differ if index != -1: w = self.layout().currentWidget() assert isinstance(w, VariableEditor) - w.set_data(var, transform) + if isinstance(var, Categorical): + w.set_data_categorical(var, data.data(), transform=transform) + else: + w.set_data(var, transform=transform) self.__history[var] = tuple(transform) cb = w.findChild(QComboBox, "type-combo") cb.setCurrentIndex(index) @@ -1494,6 +1670,7 @@ def __reinterpret_activated(self, index): var = self.var self.__transform = transform + data = None if transform is not None and self.__data is not None: data = transform(self.__data) var = data.vtype @@ -1510,7 +1687,11 @@ def __reinterpret_activated(self, index): w.variable_changed, self.variable_changed, Qt.UniqueConnection ): - w.set_data(var, tr) + if isinstance(w, DiscreteVariableEditor): + data = data or self.__data + w.set_data_categorical(var, data.data(), transform=tr) + else: + w.set_data(var, transform=tr) self.variable_changed.emit() @@ -1534,7 +1715,7 @@ class Error(widget.OWWidget.Error): settings_version = 2 _domain_change_store = settings.ContextSetting({}) - _selected_item = settings.ContextSetting(None) # type: Optional[str] + _selected_item = settings.ContextSetting(None) # type: Optional[Tuple[str, int]] want_control_area = False @@ -1652,7 +1833,7 @@ def reset_selected(self): with disconnected(editor.variable_changed, self._on_variable_changed): model.setData(midx, [], TransformRole) - editor.set_data(var, []) + editor.set_data(var, transform=[]) self._invalidate() def reset_all(self): @@ -1711,7 +1892,7 @@ def _restore(self, ): i = -1 if self._selected_item is not None: for i, vec in enumerate(model): - if vec.vtype.name == self._selected_item: + if vec.vtype.name_type() == self._selected_item: break if i == -1 and model.rowCount(): i = 0 @@ -1722,7 +1903,7 @@ def _restore(self, ): def _on_selection_changed(self): self.selected_index = self.selected_var_index() if self.selected_index != -1: - self._selected_item = self.variables_model[self.selected_index].vtype.name + self._selected_item = self.variables_model[self.selected_index].vtype.name_type() else: self._selected_item = None self.open_editor(self.selected_index) @@ -1739,7 +1920,7 @@ def open_editor(self, index): if tr is None: tr = [] editor = self._editor - editor.set_data(vector, tr) + editor.set_data(vector, transform=tr) editor.variable_changed.connect( self._on_variable_changed, Qt.UniqueConnection ) diff --git a/Orange/widgets/data/tests/test_oweditdomain.py b/Orange/widgets/data/tests/test_oweditdomain.py index 51ff26c2af0..245989bf3d4 100644 --- a/Orange/widgets/data/tests/test_oweditdomain.py +++ b/Orange/widgets/data/tests/test_oweditdomain.py @@ -1,15 +1,17 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=all import pickle +import unittest from itertools import product from unittest import TestCase -from unittest.mock import Mock +from unittest.mock import Mock, patch import numpy as np from numpy.testing import assert_array_equal from AnyQt.QtCore import QItemSelectionModel, Qt, QItemSelection -from AnyQt.QtWidgets import QAction, QComboBox, QLineEdit, QStyleOptionViewItem +from AnyQt.QtWidgets import QAction, QComboBox, QLineEdit, \ + QStyleOptionViewItem, QDialog from AnyQt.QtTest import QTest, QSignalSpy from Orange.widgets.utils import colorpalettes @@ -31,8 +33,8 @@ table_column_data, ReinterpretVariableEditor, CategoricalVector, VariableEditDelegate, TransformRole, RealVector, TimeVector, StringVector, make_dict_mapper, DictMissingConst, - LookupMappingTransform, as_float_or_nan, column_str_repr -) + LookupMappingTransform, as_float_or_nan, column_str_repr, + GroupItemsDialog) from Orange.widgets.data.owcolor import OWColor, ColorRole from Orange.widgets.tests.base import WidgetTest, GuiTest from Orange.tests import test_filename, assert_array_nanequal @@ -332,13 +334,14 @@ def test_discrete_editor(self): self.assertEqual(w.get_data(), (None, [])) v = Categorical("C", ("a", "b", "c"), (("A", "1"), ("B", "b"))) - w.set_data(v) + values = [0, 0, 0, 1, 1, 2] + w.set_data_categorical(v, values) self.assertEqual(w.name_edit.text(), v.name) self.assertFalse(w.ordered_cb.isChecked()) self.assertEqual(w.labels_model.get_dict(), dict(v.annotations)) self.assertEqual(w.get_data(), (v, [])) - w.set_data(None) + w.set_data_categorical(None, None) self.assertEqual(w.name_edit.text(), "") self.assertEqual(w.labels_model.get_dict(), {}) self.assertEqual(w.get_data(), (None, [])) @@ -348,17 +351,19 @@ def test_discrete_editor(self): ("b", None), (None, "b") ] - w.set_data(v, [CategoriesMapping(mapping)]) + w.set_data_categorical(v, values, [CategoriesMapping(mapping)]) w.grab() # run delegate paint method self.assertEqual(w.get_data(), (v, [CategoriesMapping(mapping)])) - w.set_data(v, [CategoriesMapping(mapping), ChangeOrdered(True)]) + w.set_data_categorical( + v, values, [CategoriesMapping(mapping), ChangeOrdered(True)] + ) self.assertTrue(w.ordered_cb.isChecked()) self.assertEqual( w.get_data()[1], [CategoriesMapping(mapping), ChangeOrdered(True)] ) # test selection/deselection in the view - w.set_data(v) + w.set_data_categorical(v, values) view = w.values_edit model = view.model() assert model.rowCount() @@ -373,7 +378,7 @@ def test_discrete_editor(self): ("b", "b"), ("c", "b") ] - w.set_data(v, [CategoriesMapping(mapping)]) + w.set_data_categorical(v, values, [CategoriesMapping(mapping)]) self.assertEqual(w.get_data()[1], [CategoriesMapping(mapping)]) self.assertEqual(model.data(model.index(0, 0), MultiplicityRole), 1) self.assertEqual(model.data(model.index(1, 0), MultiplicityRole), 2) @@ -389,7 +394,8 @@ def test_discrete_editor_add_remove_action(self): w = DiscreteVariableEditor() v = Categorical("C", ("a", "b", "c"), (("A", "1"), ("B", "b"))) - w.set_data(v) + values = [0, 0, 0, 1, 1, 2] + w.set_data_categorical(v, values) action_add = w.add_new_item action_remove = w.remove_item view = w.values_edit @@ -423,13 +429,21 @@ def test_discrete_editor_add_remove_action(self): self.assertGreaterEqual(len(changedspy), 1, "Did not change data") w.grab() + # mocking exec_ make dialog never show - dialog blocks running until closed + @patch( + "Orange.widgets.data.oweditdomain.GroupLessFrequentItemsDialog.exec_", + Mock(side_effect=lambda: QDialog.Accepted) + ) def test_discrete_editor_merge_action(self): + """ + This function check whether results of dialog have effect on + merging the attributes. The dialog itself is tested separately. + """ w = DiscreteVariableEditor() v = Categorical("C", ("a", "b", "c"), (("A", "1"), ("B", "b"))) - w.set_data(v) - action = w.merge_items - self.assertFalse(action.isEnabled()) + w.set_data_categorical(v, [0, 0, 0, 1, 1, 2]) + view = w.values_edit model = view.model() selmodel = view.selectionModel() # type: QItemSelectionModel @@ -437,20 +451,12 @@ def test_discrete_editor_merge_action(self): QItemSelection(model.index(0, 0), model.index(1, 0)), QItemSelectionModel.ClearAndSelect ) - self.assertTrue(action.isEnabled()) + # trigger the action, then find the active popup, and simulate entry - spy = QSignalSpy(w.variable_changed) w.merge_items.trigger() - cb = w.findChild(QComboBox) - cb.setCurrentText("BA") - cb.activated[str].emit("BA") - cb.close() - self.assertEqual(model.index(0, 0).data(Qt.EditRole), "BA") - self.assertEqual(model.index(1, 0).data(Qt.EditRole), "BA") - - self.assertSequenceEqual( - list(spy), [[]], 'variable_changed should emit exactly once' - ) + + self.assertEqual(model.index(0, 0).data(Qt.EditRole), "Others") + self.assertEqual(model.index(1, 0).data(Qt.EditRole), "Others") def test_time_editor(self): w = TimeVariableEditor() @@ -884,3 +890,81 @@ def test_pickle(self): assert_array_equal(r, [np.nan, 0, 1, np.nan]) r_ = lookup_.transform(c) assert_array_equal(r_, [np.nan, 0, 1, np.nan]) + + +class TestGroupLessFrequentItemsDialog(GuiTest): + def setUp(self) -> None: + self.v = Categorical("C", ("a", "b", "c"), + (("A", "1"), ("B", "b"))) + self.data = [0, 0, 0, 1, 1, 2] + + def test_dialog_open(self): + dialog = GroupItemsDialog(self.v, self.data, ["a", "b"]) + self.assertTrue(dialog.selected_radio.isChecked()) + self.assertFalse(dialog.frequent_abs_radio.isChecked()) + self.assertFalse(dialog.frequent_rel_radio.isChecked()) + self.assertFalse(dialog.n_values_radio.isChecked()) + + dialog = GroupItemsDialog(self.v, self.data, []) + self.assertFalse(dialog.selected_radio.isChecked()) + self.assertTrue(dialog.frequent_abs_radio.isChecked()) + self.assertFalse(dialog.frequent_rel_radio.isChecked()) + self.assertFalse(dialog.n_values_radio.isChecked()) + + def test_group_selected(self): + dialog = GroupItemsDialog(self.v, self.data, ["a", "b"]) + dialog.selected_radio.setChecked(True) + dialog.new_name_line_edit.setText("BA") + + self.assertListEqual(dialog.get_merge_attributes(), ["a", "b"]) + self.assertEqual(dialog.get_merged_value_name(), "BA") + + def test_group_less_frequent_abs(self): + dialog = GroupItemsDialog(self.v, self.data, ["a", "b"]) + dialog.frequent_abs_radio.setChecked(True) + dialog.frequent_abs_spin.setValue(3) + dialog.new_name_line_edit.setText("BA") + + self.assertListEqual(dialog.get_merge_attributes(), ["b", "c"]) + self.assertEqual(dialog.get_merged_value_name(), "BA") + + dialog.frequent_abs_spin.setValue(2) + self.assertListEqual(dialog.get_merge_attributes(), ["c"]) + + dialog.frequent_abs_spin.setValue(1) + self.assertListEqual(dialog.get_merge_attributes(), []) + + def test_group_less_frequent_rel(self): + dialog = GroupItemsDialog(self.v, self.data, ["a", "b"]) + dialog.frequent_rel_radio.setChecked(True) + dialog.frequent_rel_spin.setValue(50) + dialog.new_name_line_edit.setText("BA") + + self.assertListEqual(dialog.get_merge_attributes(), ["b", "c"]) + self.assertEqual(dialog.get_merged_value_name(), "BA") + + dialog.frequent_rel_spin.setValue(20) + self.assertListEqual(dialog.get_merge_attributes(), ["c"]) + + dialog.frequent_rel_spin.setValue(15) + self.assertListEqual(dialog.get_merge_attributes(), []) + + def test_group_keep_n(self): + dialog = GroupItemsDialog(self.v, self.data, ["a", "b"]) + dialog.n_values_radio.setChecked(True) + dialog.n_values_spin.setValue(1) + dialog.new_name_line_edit.setText("BA") + + self.assertListEqual(dialog.get_merge_attributes(), ["b", "c"]) + self.assertEqual(dialog.get_merged_value_name(), "BA") + + dialog.n_values_spin.setValue(2) + self.assertListEqual(dialog.get_merge_attributes(), ["c"]) + + dialog.n_values_spin.setValue(3) + self.assertListEqual(dialog.get_merge_attributes(), []) + + +if __name__ == '__main__': + unittest.main() +