diff --git a/orangecontrib/network/widgets/OWNxFromDistances.py b/orangecontrib/network/widgets/OWNxFromDistances.py index 64aa64d..55cb821 100644 --- a/orangecontrib/network/widgets/OWNxFromDistances.py +++ b/orangecontrib/network/widgets/OWNxFromDistances.py @@ -1,11 +1,14 @@ +from typing import Optional + import numpy as np import scipy.sparse as sp import pyqtgraph as pg from AnyQt.QtCore import QLineF, QSize, Qt, Signal, QEvent from AnyQt.QtGui import QDoubleValidator, QIntValidator, QPalette +from AnyQt.QtWidgets import QApplication, QLineEdit -from Orange.data import Domain, StringVariable, Table +from Orange.data import Table from Orange.distance import Euclidean from Orange.misc import DistMatrix from Orange.widgets import gui, widget, settings @@ -30,7 +33,7 @@ def fixup(self, text): class OWNxFromDistances(widget.OWWidget): name = "Network From Distances" description = ('Constructs a network by connecting nodes with distances ' - 'below somethreshold.') + 'below some threshold.') icon = "icons/NetworkFromDistances.svg" priority = 6440 @@ -46,7 +49,7 @@ class Outputs: # The widget stores `density` as setting, because it is more transferable # than `threshold`. Internally, the widget uses `threshold` because it is # more accurate. - density = settings.Setting(20) + density = settings.Setting(20, schemaOnly=True) class Warning(widget.OWWidget.Warning): large_number_of_nodes = \ @@ -57,10 +60,21 @@ class Error(widget.OWWidget.Error): def __init__(self): super().__init__() - self.matrix = None + # Matrix from the input, unmodified + self.matrix: Optional[np.ndarray] = None + # True, if the matrix is symmetric self.symmetric = False + # All relevant thresholds, that is, all unique values in the distance + # matrix outside of diagonal. Zero is not explicitly prepended, but + # will be included if it appears in the distance matrix. + self.thresholds: Optional[np.ndarray] = None + # Cumulative frequencies of the thresholds. No prepended zero. + self.cumfreqs: Optional[np.ndarray] = None + # Current threshold. Whatever the user sets (threshold, edges, density) + # will be converted to threshold and stored here. self.threshold = 0 - self.graph = None + # Number of nodes and edges; use in reports and to set line edits + self.graph_stat: Optional[tuple[float, float]] = None box = gui.vBox(self.mainArea, box=True) self.histogram = Histogram(self) @@ -71,35 +85,31 @@ def __init__(self): hbox = gui.hBox(box) gui.rubber(hbox) - _edit_args = dict( - orientation=Qt.Horizontal, alignment=Qt.AlignRight, controlWidth=50) - self.labels = [] - self.threshold_label = gui.widgetLabel(hbox, "Threshold:") - self.labels.append(self.threshold_label) - self.threshold_edit = gui.lineEdit( - hbox, self, '', - validator=QDoubleValidator(), callback=self._on_threshold_edit, - **_edit_args) + _edit_args = dict(alignment=Qt.AlignRight, maximumWidth=50) + gui.widgetLabel(hbox, "Threshold:") + self.threshold_edit = QLineEdit(**_edit_args) + self.threshold_edit.setValidator(QDoubleValidator()) + self.threshold_edit.editingFinished.connect(self._on_threshold_edit) + hbox.layout().addWidget(self.threshold_edit) gui.rubber(hbox) - self.edges_label = gui.widgetLabel(hbox, "Number of edges:") - self.edges_edit = gui.lineEdit( - hbox, self, '', - validator=QIntValidatorWithFixup(), callback=self._on_edges_edit, - **_edit_args) - self.labels.append(self.edges_label) + gui.widgetLabel(hbox, "Number of edges:") + self.edges_edit = QLineEdit(**_edit_args) + self.edges_edit.setValidator(QIntValidatorWithFixup()) + self.edges_edit.editingFinished.connect(self._on_edges_edit) + hbox.layout().addWidget(self.edges_edit) gui.rubber(hbox) - self.density_label = gui.widgetLabel(hbox, "Density (%):") - self.density_edit = gui.lineEdit( - hbox, self, 'density', - validator=QIntValidatorWithFixup(0, 100), - callback=self._on_density_edit, - **_edit_args) - self.labels.append(self.density_label) + gui.widgetLabel(hbox, "Density (%):") + self.density_edit = QLineEdit(**_edit_args) + self.density_edit.setValidator(QIntValidatorWithFixup(0, 100)) + self.density_edit.editingFinished.connect(self._on_density_edit) + hbox.layout().addWidget(self.density_edit) gui.rubber(hbox) - def sizeHint(self): + self._set_controls_enabled(False) + + def sizeHint(self): # pragma: no cover return QSize(600, 500) @property @@ -109,96 +119,120 @@ def eff_distances(self): def _on_threshold_dragged(self, threshold): self.threshold = threshold - self.update_edits(self.histogram) + self.update_edits(from_dragging=True) def _on_threshold_drag_finished(self): self.generate_network() def _on_threshold_edit(self): self.threshold = float(self.threshold_edit.text()) - self.update_edits(self.threshold_label) self.generate_network() + self.update_histogram_lines() + self.threshold_edit.selectAll() def _on_density_edit(self): self.density = int(self.density_edit.text()) self.set_threshold_from_density() - self.update_edits(self.density_label) self.generate_network() + self.update_histogram_lines() + self.density_edit.selectAll() def _on_edges_edit(self): edges = int(self.edges_edit.text()) self.set_threshold_from_edges(edges) - self.update_edits(self.edges_label) self.generate_network() + self.update_histogram_lines() + self.edges_edit.selectAll() def set_threshold_from_density(self): - self.set_threshold_from_edges(self.density * self.eff_distances / 100) + self.set_threshold_from_edges( + int(np.ceil(self.density * self.eff_distances / 100))) def set_threshold_from_edges(self, edges): # Set the threshold that will give at least the given number of edges if edges == 0: self.threshold = 0 else: - self.threshold = self.edges[np.searchsorted(self.cumfreqs, edges)] + matrix = self.matrix + if not self.symmetric: + matrix = self.__no_diagonal(matrix) + thresholds = np.sort(matrix.flat) + edges = min(edges, len(thresholds)) or 1 + self.threshold = thresholds[edges - 1] def edges_from_threshold(self): - idx = np.searchsorted(self.edges, self.threshold, side='right') + """ + Fast, histogram-based estimate of the number of edges below + the current threshold. + """ + idx = np.searchsorted(self.thresholds, self.threshold, side='right') return self.cumfreqs[idx - 1] if idx else 0 - def update_edits(self, reference): - if reference is not self.threshold_label: - self.threshold_edit.setText(f"{self.threshold:.2f}") - if reference is not self.edges_label: + def update_edits(self, from_dragging=False): + n_decimals = max(0, -int(np.floor(np.log10(np.max(self.thresholds)))) + 2) + if from_dragging or self.graph_stat is None: edges = self.edges_from_threshold() - self.edges_edit.setText(str(edges)) - if reference is not self.density_label: - self.density = \ - int(round(100 * self.edges_from_threshold() / self.eff_distances)) - self.density_edit.setText(str(self.density)) - if reference is not self.histogram: - self.histogram.update_region(self.threshold, True, True, - density=self.density) - - for label in self.labels: - font = label.font() - font.setBold(label is reference) - label.setFont(font) + else: + _, edges = self.graph_stat + self.density = int(round(100 * edges / self.eff_distances)) + + appx = "~" if from_dragging else "" + self.threshold_edit.setText(f"{self.threshold:.{n_decimals}f}") + self.edges_edit.setText(appx + str(edges)) + self.density_edit.setText(appx + str(self.density)) + + def update_histogram_lines(self): + if self.graph_stat is None: + return + _, edges = self.graph_stat + self.histogram.update_region(self.threshold, True, True, edges=edges) + + def _set_controls_enabled(self, enabled): + for edit in (self.threshold_edit, self.edges_edit, self.density_edit): + edit.setEnabled(enabled) + + # This can be removed when DistMatrix.flat is fixed to include this code + @staticmethod + def __no_diagonal(matrix): + return np.lib.stride_tricks.as_strided( + matrix.reshape(matrix.size, -1)[1:], + shape=(matrix.shape[0] - 1, matrix.shape[1]), + strides=(matrix.strides[0] + matrix.strides[1], + matrix.strides[1]), + writeable=False + ) @Inputs.distances def set_matrix(self, matrix: DistMatrix): - if matrix is not None and not matrix.size: + if matrix is not None and matrix.size <= 1: matrix = None self.matrix = matrix if matrix is None: + self.thresholds = None self.symmetric = True - self.histogram.set_values([], []) + self._set_controls_enabled(False) + self.histogram.clear_graph() self.generate_network() return + self._set_controls_enabled(True) self.symmetric = matrix.is_symmetric() - # This can be removed when DistMatrix.flat is fixed to include this code if not self.symmetric: - matrix = np.lib.stride_tricks.as_strided( - matrix.reshape(matrix.size, -1)[1:], - shape=(matrix.shape[0] - 1, matrix.shape[1]), - strides=(matrix.strides[0] + matrix.strides[1], - matrix.strides[1]), - writeable=False - ) + matrix = self.__no_diagonal(matrix) if self.eff_distances < 1000: - self.edges, freq = np.unique(matrix.flat, return_counts=True) + self.thresholds, freq = np.unique(matrix.flat, return_counts=True) else: freq, edges = np.histogram(matrix.flat, bins=1000) - self.edges = edges[:-1] + self.thresholds = edges[1:] self.cumfreqs = np.cumsum(freq) - self.histogram.set_values(self.edges, self.cumfreqs) + self.histogram.set_graph(self.thresholds, self.cumfreqs) self.edges_edit.validator().setRange(0, self.eff_distances) self.set_threshold_from_density() - self.update_edits(self.density_label) self.generate_network() + self.update_histogram_lines() def generate_network(self): self.Error.clear() @@ -206,19 +240,18 @@ def generate_network(self): matrix = self.matrix if matrix is None: - self.graph = None + self.graph_stat = None self.Outputs.network.send(None) return - threshold = float(self.threshold_edit.text()) nedges = self.edges_from_threshold() if nedges > 200000: self.Error.number_of_edges(nedges) - self.graph = None + self.graph_stat = None self.Outputs.network.send(None) return - mask = np.array(matrix <= threshold) + mask = np.array(matrix <= self.threshold) if self.symmetric: mask &= np.tri(*matrix.shape, k=-1, dtype=bool) else: @@ -243,28 +276,30 @@ def generate_network(self): else: edges = sp.csr_matrix(matrix.shape) edge_type = UndirectedEdges if self.symmetric else DirectedEdges - self.graph = Network(items_from_distmatrix(self.matrix), edge_type(edges)) - self.Warning.large_number_of_nodes( - shown=self.graph.number_of_nodes() > 3000 - or self.graph.number_of_edges() > 10000) - - self.Outputs.network.send(self.graph) + graph = Network(items_from_distmatrix(self.matrix), edge_type(edges)) + nodes, edges = graph.number_of_nodes(), graph.number_of_edges() + self.graph_stat = nodes, edges + self.Warning.large_number_of_nodes(shown=nodes > 3000 or edges > 10000) + self.Outputs.network.send(graph) + self.update_edits() def send_report(self): + # We take the threshold from the edit box to have the same number of + # decimals (the user may have entered a value with more decimals than + # we'd set within update_edits + if self.graph_stat is None: + return + self.report_items("Settings", [ - ("Threshold", self.threshold), - ("Density", self.density), - ("Edges", self.edges_from_threshold()), + ("Threshold", self.threshold_edit.text()), + ("Density", self.density_edit.text()), + ("Edges", self.edges_edit.text()), ]) - if self.graph is None: - return - self.report_name("Histogram") self.report_plot(self.histogram) - nodes = self.graph.number_of_nodes() - edges = self.graph.number_of_edges() + nodes, edges = self.graph_stat self.report_items( "Output network", [("Vertices", len(self.matrix)), @@ -290,6 +325,14 @@ def paint(self, p, *args): p.drawLine(line) p.restore() + def hoverEvent(self, ev): # pragma: no cover + if ev.isEnter(): + QApplication.setOverrideCursor( + Qt.SizeVerCursor if self.angle == 0 else Qt.SizeHorCursor) + elif ev.isExit(): + QApplication.restoreOverrideCursor() + return super().hoverEvent(ev) + # Patched so that the Histogram's LinearRegionItem works on MacOS pg.InfiniteLine = InfiniteLine pg.graphicsItems.LinearRegionItem.InfiniteLine = InfiniteLine @@ -339,16 +382,18 @@ def __init__(self, parent, **kwargs): self.vline.sigDragged.connect(self._vline_dragged) self.vline.sigPositionChangeFinished.connect(self.draggingFinished) - def setScene(self, scene): + self.clear_graph() + + def setScene(self, scene): # pragma: no cover super().setScene(scene) self.__updateScenePalette() - def __updateScenePalette(self): + def __updateScenePalette(self): # pragma: no cover scene = self.scene() if scene is not None: scene.setPalette(self.palette()) - def changeEvent(self, event): + def changeEvent(self, event): # pragma: no cover if event.type() == QEvent.PaletteChange: self.__updateScenePalette() self.resetCachedContent() @@ -356,20 +401,25 @@ def changeEvent(self, event): def update_region(self, thresh, set_hline=False, set_vline=False, - density=None): - high = np.searchsorted(self.xData, thresh, side='right') - self.fill_curve.setData(self.xData[:high], self.yData[:high]) + edges=None): + xData, yData = self.curve.xData, self.curve.yData + high = np.searchsorted(xData, thresh, side='right') + self.fill_curve.setData(xData[:high], yData[:high]) if set_hline: - if density is None: - density = self.yData[min(high, len(self.yData) - 1)] - self.hline.setPos(density) + if edges is None: + if high == len(yData): + edges = yData[-1] + else: + edges = yData[high - (xData[high] > thresh)] + self.hline.setPos(edges) if set_vline: self.vline.setPos(thresh) def _hline_dragged(self): + xData, yData = self.curve.xData, self.curve.yData pos = self.hline.value() - idx = np.searchsorted(self.yData, pos, side='right') - thresh = self.xData[min(idx, len(self.xData) - 1)] + idx = np.searchsorted(yData, pos, side='left') + thresh = xData[min(idx, len(xData) - 1)] self.update_region(thresh, set_vline=True) self.thresholdChanged.emit(thresh) @@ -378,28 +428,25 @@ def _vline_dragged(self): self.update_region(thresh, set_hline=True) self.thresholdChanged.emit(thresh) - def set_values(self, edges, cumfreqs): - self.fill_curve.setData([0, 1], [0, 0]) - if not len(edges): - self.curve.setData([0], [1]) - self.fill_curve.setData([0], [1]) - return + def _elements(self): + return self.curve, self.fill_curve, self.hline, self.vline + + def clear_graph(self): + for el in self._elements(): + el.hide() + self.prop_axis.setScale(1) + + def set_graph(self, edges, cumfreqs): self.curve.setData(np.hstack(([0], edges)), np.hstack(([0], cumfreqs))) self.getAxis('left').setRange(0, cumfreqs[-1]) self.hline.setBounds([0, cumfreqs[-1]]) self.prop_axis.setScale(1 / cumfreqs[-1] * 100) - self.getAxis('bottom').setRange(edges[0], edges[-1]) - self.vline.setBounds([edges[0], edges[-1]]) + self.getAxis('bottom').setRange(0, edges[-1]) + self.vline.setBounds([0, edges[-1]]) self.update_region(edges[0], set_hline=True, set_vline=True) - - @property - def xData(self): - return self.curve.xData - - @property - def yData(self): - return self.curve.yData + for el in self._elements(): + el.show() if __name__ == "__main__": diff --git a/orangecontrib/network/widgets/tests/test_OWNxFromDistances.py b/orangecontrib/network/widgets/tests/test_OWNxFromDistances.py index bdbaa35..bd4356e 100644 --- a/orangecontrib/network/widgets/tests/test_OWNxFromDistances.py +++ b/orangecontrib/network/widgets/tests/test_OWNxFromDistances.py @@ -3,17 +3,24 @@ import numpy as np +from AnyQt.QtTest import QSignalSpy +from AnyQt.QtWidgets import QLineEdit +from AnyQt.QtCore import QEvent, Qt +from AnyQt.QtGui import QKeyEvent + +from orangewidget.tests.base import GuiTest from Orange.misc import DistMatrix from Orange.widgets.tests.base import WidgetTest -from orangecontrib.network.widgets.OWNxFromDistances import OWNxFromDistances +from orangecontrib.network.widgets.OWNxFromDistances import OWNxFromDistances, \ + Histogram, QIntValidatorWithFixup class TestOWNxFromDistances(WidgetTest): def setUp(self): self.widget: OWNxFromDistances = self.create_widget(OWNxFromDistances) - # Put non-zero elements in the diagonal to test if the widget ignores them + # Put non-zero elements in the diagonal to check that the widget ignores them self.distances = DistMatrix(np.array([[0., 1, 2, 5, 10], [1, -1, 5, 5, 13], [2, 5, 2, 6, 13], @@ -28,39 +35,52 @@ def set_edit(edit, value): edit.returnPressed.emit() edit.editingFinished.emit() + def _assert_controls_enabled(self, enabled): + widget = self.widget + self.assertEqual(widget.threshold_edit.isEnabled(), enabled) + self.assertEqual(widget.edges_edit.isEnabled(), enabled) + self.assertEqual(widget.density_edit.isEnabled(), enabled) + def test_set_weird_matrix(self): widget = self.widget self.send_signal(widget.Inputs.distances, self.distances) self.assertEqual(widget.eff_distances, 10) self.assertIsNotNone(self.get_output(widget.Outputs.network)) + self._assert_controls_enabled(True) self.send_signal(widget.Inputs.distances, None) self.assertIsNone(self.get_output(widget.Outputs.network)) + self._assert_controls_enabled(False) self.send_signal(widget.Inputs.distances, self.distances) self.assertIsNotNone(self.get_output(widget.Outputs.network)) + self._assert_controls_enabled(True) self.send_signal(widget.Inputs.distances, DistMatrix(np.zeros((0, 0)))) self.assertIsNone(self.get_output(widget.Outputs.network)) + self._assert_controls_enabled(False) self.send_signal(widget.Inputs.distances, DistMatrix(np.array([[1]]))) - self.assertIsNotNone(self.get_output(widget.Outputs.network)) + self.assertIsNone(self.get_output(widget.Outputs.network)) + self._assert_controls_enabled(False) self.send_signal(widget.Inputs.distances, None) self.assertIsNone(self.get_output(widget.Outputs.network)) + self._assert_controls_enabled(False) self.send_signal(widget.Inputs.distances, DistMatrix(np.array([[0, 1], [1, 0]]))) self.assertIsNotNone(self.get_output(widget.Outputs.network)) + self._assert_controls_enabled(True) - @patch("orangecontrib.network.widgets.OWNxFromDistances.Histogram.set_values") - def test_compute_histogram_symmetric(self, set_values): + @patch("orangecontrib.network.widgets.OWNxFromDistances.Histogram.set_graph") + def test_compute_histogram_symmetric(self, set_graph): widget = self.widget self.send_signal(widget.Inputs.distances, self.distances) - np.testing.assert_almost_equal(widget.edges, [1, 2, 5, 6, 10, 13, 15]) + np.testing.assert_almost_equal(widget.thresholds, [1, 2, 5, 6, 10, 13, 15]) np.testing.assert_almost_equal(widget.cumfreqs, [1, 2, 5, 6, 7, 9, 10]) - set_values.assert_called_with(widget.edges, widget.cumfreqs) + set_graph.assert_called_with(widget.thresholds, widget.cumfreqs) # The matrix is symmetric, so the number of bins is below 1000 -> # the histogram is computed exactly @@ -69,8 +89,8 @@ def test_compute_histogram_symmetric(self, set_values): distances[:i, i] = distances[i, :i] = np.arange(i) distances = DistMatrix(distances) self.send_signal(widget.Inputs.distances, distances) - np.testing.assert_almost_equal(widget.edges, np.arange(39)) - set_values.assert_called_with(widget.edges, widget.cumfreqs) + np.testing.assert_almost_equal(widget.thresholds, np.arange(39)) + set_graph.assert_called_with(widget.thresholds, widget.cumfreqs) # Even though the matrix is symmetric, the number of bins is above 1000 distances = np.zeros((50, 50)) @@ -79,27 +99,27 @@ def test_compute_histogram_symmetric(self, set_values): distances = DistMatrix(distances) self.send_signal(widget.Inputs.distances, distances) np.testing.assert_almost_equal( - widget.edges[:5], [0. , 0.048, 0.096, 0.144, 0.192]) - set_values.assert_called_with(widget.edges, widget.cumfreqs) + widget.thresholds[:5], [0.048, 0.096, 0.144, 0.192, 0.24]) + set_graph.assert_called_with(widget.thresholds, widget.cumfreqs) - @patch("orangecontrib.network.widgets.OWNxFromDistances.Histogram.set_values") - def test_compute_histogram_asymmetric(self, set_values): + @patch("orangecontrib.network.widgets.OWNxFromDistances.Histogram.set_graph") + def test_compute_histogram_asymmetric(self, set_graph): widget = self.widget self.distances[0, 1] = 1.5 self.send_signal(widget.Inputs.distances, self.distances) - np.testing.assert_almost_equal(widget.edges, [1, 1.5, 2, 5, 6, 10, 13, 15]) + np.testing.assert_almost_equal(widget.thresholds, [1, 1.5, 2, 5, 6, 10, 13, 15]) np.testing.assert_almost_equal(widget.cumfreqs, [1, 2, 4, 10, 12, 14, 18, 20]) - set_values.assert_called_with(widget.edges, widget.cumfreqs) + set_graph.assert_called_with(widget.thresholds, widget.cumfreqs) distances = DistMatrix(np.array(np.arange(40 * 40).reshape((40, 40)))) self.send_signal(widget.Inputs.distances, distances) np.testing.assert_almost_equal( - widget.edges[:5], [1, 2.597, 4.194, 5.791, 7.388]) + widget.thresholds[:5], [2.597, 4.194, 5.791, 7.388, 8.985]) np.testing.assert_almost_equal( widget.cumfreqs[:5], [2, 4, 5, 7, 8]) self.assertEqual(widget.cumfreqs[-1], 40 * 39) - set_values.assert_called_with(widget.edges, widget.cumfreqs) + set_graph.assert_called_with(widget.thresholds, widget.cumfreqs) def test_set_symmetric(self): widget = self.widget @@ -170,8 +190,7 @@ def test_edges_from_threshold_asymmetric(self): self.assertEqual(float(widget.edges_edit.text()), edges, msg=f"at threshold={threshold}") - @patch("orangecontrib.network.widgets.OWNxFromDistances.OWNxFromDistances.generate_network") - def test_set_edges(self, generate_network): + def test_set_edges(self): widget = self.widget self.send_signal(widget.Inputs.distances, self.distances) @@ -179,16 +198,20 @@ def test_set_edges(self, generate_network): (3, 5, 50), (4, 5, 50), (5, 5, 50), (6, 6, 60), (7, 10, 70), (8, 13, 90), (9, 13, 90), (10, 15, 100)]: - generate_network.reset_mock() self.set_edit(widget.edges_edit, edges) - generate_network.assert_called_once() self.assertEqual(widget.threshold, threshold, msg=f"at edges={edges}") + self.assertEqual(widget.edges_edit.text(), str(density // 10), + msg=f"at edges={edges}") self.assertEqual(widget.density, density, msg=f"at edges={edges}") + self.assertEqual(widget.histogram.hline.value(), density // 10, + msg=f"at edges={edges}") + self.assertEqual(widget.histogram.vline.value(), + min(threshold, np.max(self.distances)), + msg=f"at edges={edges}") - @patch("orangecontrib.network.widgets.OWNxFromDistances.OWNxFromDistances.generate_network") - def test_set_density(self, generate_network): + def test_set_density(self): widget = self.widget self.send_signal(widget.Inputs.distances, self.distances) @@ -196,16 +219,19 @@ def test_set_density(self, generate_network): (30, 5, 5), (40, 5, 5), (50, 5, 5), (60, 6, 6), (70, 10, 7), (80, 13, 9), (90, 13, 9), (100, 15, 10)]: - generate_network.reset_mock() self.set_edit(widget.density_edit, density) - generate_network.assert_called_once() self.assertEqual(widget.threshold, threshold, msg=f"at density={density}") self.assertEqual(int(widget.edges_edit.text()), edges, msg=f"at density={density}") + self.assertEqual(widget.density, edges * 10) + self.assertEqual(widget.histogram.hline.value(), edges, + msg=f"at threshold={threshold}") + self.assertEqual(widget.histogram.vline.value(), + min(threshold, np.max(self.distances)), + msg=f"at threshold={threshold}") - @patch("orangecontrib.network.widgets.OWNxFromDistances.OWNxFromDistances.generate_network") - def test_set_threshold(self, generate_network): + def test_set_threshold(self): widget = self.widget self.send_signal(widget.Inputs.distances, self.distances) @@ -215,13 +241,16 @@ def test_set_threshold(self, generate_network): (12.9, 70, 7), (13, 90, 9), (14.9, 90, 9), (15, 100, 10), (16, 100, 10)]: - generate_network.reset_mock() self.set_edit(widget.threshold_edit, threshold) - generate_network.assert_called_once() self.assertEqual(widget.density, density, msg=f"at threshold={threshold}") self.assertEqual(int(widget.edges_edit.text()), edges, msg=f"at threshold={threshold}") + self.assertEqual(widget.histogram.hline.value(), edges, + msg=f"at threshold={threshold}") + self.assertEqual(widget.histogram.vline.value(), + min(threshold, np.max(self.distances)), + msg=f"at threshold={threshold}") @patch("orangecontrib.network.widgets.OWNxFromDistances.OWNxFromDistances.generate_network") def test_set_threshold_from_histogram(self, generate_network): @@ -233,25 +262,25 @@ def test_set_threshold_from_histogram(self, generate_network): generate_network.assert_not_called() self.assertEqual(widget.threshold, 5) self.assertEqual(widget.density, 50) - self.assertEqual(int(widget.edges_edit.text()), 5) + self.assertEqual(widget.edges_edit.text(), "~5") widget.histogram.thresholdChanged.emit(11) generate_network.assert_not_called() self.assertEqual(widget.threshold, 11) self.assertEqual(widget.density, 70) - self.assertEqual(int(widget.edges_edit.text()), 7) + self.assertEqual(widget.edges_edit.text(), "~7") widget.histogram.thresholdChanged.emit(15) generate_network.assert_not_called() self.assertEqual(widget.threshold, 15) self.assertEqual(widget.density, 100) - self.assertEqual(int(widget.edges_edit.text()), 10) + self.assertEqual(widget.edges_edit.text(), "~10") widget.histogram.thresholdChanged.emit(16) generate_network.assert_not_called() self.assertEqual(widget.threshold, 16) self.assertEqual(widget.density, 100) - self.assertEqual(int(widget.edges_edit.text()), 10) + self.assertEqual(widget.edges_edit.text(), "~10") widget.histogram.draggingFinished.emit() generate_network.assert_called_once() @@ -302,6 +331,215 @@ def test_generate_network(self): np.testing.assert_equal(coo.row, [1, 2, 2, 3, 3, 4]) np.testing.assert_equal(coo.col, [0, 0, 1, 0, 1, 1]) + def test_threshold_decimals(self): + widget = self.widget + self.send_signal(widget.Inputs.distances, self.distances) + + widget.threshold = 1.23412 + widget.update_edits(widget.edges_edit) + self.assertEqual(widget.threshold_edit.text(), "1.2") + + self.send_signal(widget.Inputs.distances, self.distances / 100) + widget.threshold = 1.23412 + widget.update_edits(widget.edges_edit) + self.assertEqual(widget.threshold_edit.text(), "1.234") + + def test_too_many_edges(self): + widget = self.widget + self.send_signal(widget.Inputs.distances, self.distances) + + with patch("orangecontrib.network.widgets.OWNxFromDistances." + "OWNxFromDistances.edges_from_threshold", + return_value=1_000_000): + widget.generate_network() + self.assertTrue(widget.Error.number_of_edges.is_shown()) + + widget.generate_network() + self.assertFalse(widget.Error.number_of_edges.is_shown()) + + def test_report(self): + widget = self.widget + widget.send_report() + + self.send_signal(widget.Inputs.distances, self.distances) + widget.send_report() + + +class TestHistogram(GuiTest): + def setUp(self): + self.histogram = Histogram(None) + assert set(self.histogram._elements()) == \ + {self.histogram.hline, self.histogram.vline, + self.histogram.curve, self.histogram.fill_curve} + + def test_show_hide_elements(self): + hist = self.histogram + + self.assertFalse(any(x.isVisible() for x in hist._elements())) + hist.set_graph(np.array([1, 2, 5, 6, 10, 13, 15]), + np.array([1, 2, 5, 6, 7, 9, 10])) + self.assertTrue(all(x.isVisible() for x in hist._elements())) + + hist.clear_graph() + self.assertFalse(any(x.isVisible() for x in hist._elements())) + + def test_ranges(self): + hist = self.histogram + + hist.set_graph(np.array([1, 2, 5, 6, 10, 13, 15]), + np.array([1, 2, 5, 6, 7, 9, 10])) + self.assertEqual(hist.hline.bounds(), [0, 10]) + self.assertEqual(hist.vline.bounds(), [0, 15]) + self.assertEqual(hist.getAxis("left").range, [0, 10]) + self.assertEqual(hist.getAxis("bottom").range, [0, 15]) + self.assertAlmostEqual(hist.prop_axis.scale, 1 / 10 * 100) + + def _assert_fill_curve(self, hist, data): + for exp, act in zip(data, hist.fill_curve.getData()): + np.testing.assert_almost_equal(exp, act) + + def test_drag_hline(self): + hist = self.histogram + spy = QSignalSpy(hist.thresholdChanged) + + hist.set_graph(np.array([1, 2, 5, 6, 10, 13, 15]), + np.array([1, 2, 5, 6, 7, 9, 10])) + + hist.hline.setPos(1) + hist.hline.sigDragged.emit(hist.hline) + self.assertEqual(hist.vline.value(), 1) + self.assertEqual(list(spy), [[1]]) + self._assert_fill_curve(hist, ([0, 1], [0, 1])) + + hist.hline.setPos(1.5) + hist.hline.sigDragged.emit(hist.hline) + self.assertEqual(hist.vline.value(), 2) + self.assertEqual(list(spy), [[1], [2]]) + self._assert_fill_curve(hist, ([0, 1, 2], [0, 1, 2])) + + hist.hline.setPos(6.8) + hist.hline.sigDragged.emit(hist.hline) + self.assertEqual(hist.vline.value(), 10) + self.assertEqual(list(spy), [[1], [2], [10]]) + self._assert_fill_curve(hist, + ([0, 1, 2, 5, 6, 10], + [0, 1, 2, 5, 6, 7])) + + hist.hline.setPos(7) + hist.hline.sigDragged.emit(hist.hline) + self.assertEqual(hist.vline.value(), 10) + self.assertEqual(list(spy), [[1], [2], [10], [10]]) + self._assert_fill_curve(hist, + ([0, 1, 2, 5, 6, 10], + [0, 1, 2, 5, 6, 7])) + + hist.hline.setPos(7.2) + hist.hline.sigDragged.emit(hist.hline) + self.assertEqual(hist.vline.value(), 13) + self.assertEqual(list(spy), [[1], [2], [10], [10], [13]]) + self._assert_fill_curve(hist, + ([0, 1, 2, 5, 6, 10, 13], + [0, 1, 2, 5, 6, 7, 9])) + + hist.hline.setPos(10) + hist.hline.sigDragged.emit(hist.hline) + self.assertEqual(hist.vline.value(), 15) + self.assertEqual(list(spy), [[1], [2], [10], [10], [13], [15]]) + self._assert_fill_curve(hist, + ([0, 1, 2, 5, 6, 10, 13, 15], + [0, 1, 2, 5, 6, 7, 9, 10])) + + hist.hline.setPos(0) + hist.hline.sigDragged.emit(hist.hline) + self.assertEqual(hist.vline.value(), 0) + self.assertEqual(list(spy), [[1], [2], [10], [10], [13], [15], [0]]) + self._assert_fill_curve(hist, + ([0], + [0])) + + def test_drag_vline(self): + hist = self.histogram + spy = QSignalSpy(hist.thresholdChanged) + + hist.set_graph(np.array([1, 2, 5, 6, 10, 13, 15]), + np.array([1, 2, 5, 6, 7, 9, 10])) + + hist.vline.setPos(1) + hist.vline.sigDragged.emit(hist.vline) + self.assertEqual(hist.hline.value(), 1) + self.assertEqual(list(spy), [[1]]) + self._assert_fill_curve(hist, ([0, 1], [0, 1])) + + hist.vline.setPos(1.5) + hist.vline.sigDragged.emit(hist.vline) + self.assertEqual(hist.hline.value(), 1) + self.assertEqual(list(spy), [[1], [1.5]]) + self._assert_fill_curve(hist, ([0, 1], [0, 1])) + + hist.vline.setPos(2) + hist.vline.sigDragged.emit(hist.vline) + self.assertEqual(hist.hline.value(), 2) + self.assertEqual(list(spy), [[1], [1.5], [2]]) + self._assert_fill_curve(hist, ([0, 1, 2], [0, 1, 2])) + + hist.vline.setPos(9) + hist.vline.sigDragged.emit(hist.vline) + self.assertEqual(hist.hline.value(), 6) + self.assertEqual(list(spy), [[1], [1.5], [2], [9]]) + self._assert_fill_curve(hist, + ([0, 1, 2, 5, 6], + [0, 1, 2, 5, 6])) + + hist.vline.setPos(10) + hist.vline.sigDragged.emit(hist.vline) + self.assertEqual(hist.hline.value(), 7) + self.assertEqual(list(spy), [[1], [1.5], [2], [9], [10]]) + self._assert_fill_curve(hist, + ([0, 1, 2, 5, 6, 10], + [0, 1, 2, 5, 6, 7])) + + hist.vline.setPos(10.5) + hist.vline.sigDragged.emit(hist.vline) + self.assertEqual(hist.hline.value(), 7) + self.assertEqual(list(spy), [[1], [1.5], [2], [9], [10], [10.5]]) + self._assert_fill_curve(hist, + ([0, 1, 2, 5, 6, 10], + [0, 1, 2, 5, 6, 7])) + + hist.vline.setPos(13) + hist.vline.sigDragged.emit(hist.vline) + self.assertEqual(hist.hline.value(), 9) + self.assertEqual(list(spy), [[1], [1.5], [2], [9], [10], [10.5], [13]]) + self._assert_fill_curve(hist, + ([0, 1, 2, 5, 6, 10, 13], + [0, 1, 2, 5, 6, 7, 9])) + + hist.vline.setPos(0) + hist.vline.sigDragged.emit(hist.vline) + self.assertEqual(hist.hline.value(), 0) + self.assertEqual(list(spy), [[1], [1.5], [2], [9], [10], [10.5], [13], [0]]) + self._assert_fill_curve(hist, + ([0], + [0])) + + +class TestQIntValidatorWithFixup(GuiTest): + def test_validator(self): + def enter_text(t): + e.setText(t) + e.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Enter, Qt.NoModifier)) + + e = QLineEdit() + e.setValidator(QIntValidatorWithFixup(0, 100, e)) + enter_text("") + self.assertEqual(e.text(), "") + enter_text("1") + self.assertEqual(e.text(), "1") + enter_text("100") + self.assertEqual(e.text(), "100") + enter_text("101") + self.assertEqual(e.text(), "100") + if __name__ == "__main__": unittest.main()