diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c1c83bf..3990924 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,13 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - run: pip install "numpy<2" pandas scipy tqdm pytest + # PyQt5 wheels bundle their own Qt libs (including the "offscreen" + # platform plugin conftest.py selects, so no real display/Xvfb is + # needed), but on a minimal Ubuntu image they still dynamically link + # against a few system libraries the base image doesn't ship. + - if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libgl1 libxkbcommon-x11-0 libxcb-cursor0 + - run: pip install "numpy<2" pandas scipy tqdm pytest PyQt5 - run: python -m pytest code/tests -v lint: diff --git a/CLAUDE.md b/CLAUDE.md index 58696fb..3fd1a72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,20 @@ that way. Required deps (gate startup): `epam.indigo`→`indigo`, `UpSetPlot`→ core), `dbsearch.py` (`fulldbsearch()`'s NPAtlas ppm-window matching core). Each corresponding `MainWindow` method is now a thin wrapper: call the module function, then apply the result to widgets/`self`. +- **Runtime widget substitution into a Designer placeholder** is an + established pattern here, not a one-off — `plotting.py` does it for every + matplotlib canvas (inserted into a Designer-created `QFrame`), and + `searchtree.py`'s `SearchTreePanel` does it for the feature-search tab's + tree: it removes `ui_main.py`'s Designer-created `QTreeWidget` from its + layout at runtime and inserts a real `QTreeView` + per-column filter bar + in the same slot. No generated file is edited; re-opening the `.ui` file + in Qt Designer would still show the old `QTreeWidget`, only the running + app uses the substitute. `SearchTreePanel`'s filter-matching rules + (per-column text/numeric-range/multi-select-category) live in the + Qt-free, unit-tested `treefilters.py`; `searchtree.py` is just the Qt + model/proxy/widget plumbing wired to it. `MainWindow.fillfttree()`/ + `on_tree_item_selection_changed()` talk to `self.searchtree` (`set_rows()`/ + `selected_compound()`) instead of `QTreeWidgetItem`s now. - **Canonical peak table** (what MPACT consumes; Progenesis = native): CSV, 3 header rows; row 2 = `Compound,m/z,Retention time (min),`; col0 = `RT_mz` id, col1 = m/z, col2 = RT. Rows 0–1 are overwritten by @@ -86,15 +100,26 @@ import). ## Testing -Headless unit tests in `code/tests/` (pure-logic only — no GUI). Run: +Headless unit tests in `code/tests/` (mostly Qt-free logic, plus standalone +Qt widget/model tests that don't import `main.py` — see below). Run: ``` python -m pytest code/tests -q ``` -Covers `filter`, `stats`, `importdependencies`, `translators`, `groupsets`. Add -tests here for any new Qt-free logic. GUI behaviour must be verified by running -the app (can't be tested headlessly). +Covers `filter`, `stats`, `importdependencies`, `translators`, `groupsets`, +`searchtree`. Add tests here for any new Qt-free logic. + +`conftest.py` sets `QT_QPA_PLATFORM=offscreen` and provides a session-scoped +`qapp` fixture: PyQt5 widgets/models/signals *can* be exercised headlessly via +Qt's offscreen platform plugin, as long as the module under test doesn't +import `main.py` (the documented `main`⇄`ui_functions` circular import is a +separate, unrelated constraint and still applies — `main.py` itself still +can't be imported standalone). `searchtree.py`'s tests are a real example: +they construct an actual `QTreeWidget` in an actual layout and exercise the +full runtime widget swap, not just the Qt-free filter logic underneath it. +Full end-to-end GUI behaviour (anything that requires `main.py`/`MainWindow`) +still must be verified by running the app. ## Groupsets (`groupsets.py`) diff --git a/code/main.py b/code/main.py index 05e9b4c..5cb3bdf 100644 --- a/code/main.py +++ b/code/main.py @@ -40,6 +40,7 @@ from csvcache import cached_read_csv, invalidate as invalidate_csv_cache from biogroups import compute_biological_groups from dbsearch import search_npatlas +from searchtree import SearchTreePanel from plotting import plot_abund, show_spectrum, show_featureplt, plot_heatmap, plot_mzrt, plot_samplecorr, kendrick, plot_volcano, plot_fc3d, plot_dendrogram, plot_PCA, prev_cv, gen_upsetplt, gen_treemap import getfragdb @@ -111,14 +112,6 @@ generated each time if filtering is off but they are always true ''' -class NumericalTreeWidgetItem(QtWidgets.QTreeWidgetItem): - def __lt__(self, other): - column = self.treeWidget().sortColumn() # the second column - try: - return float(self.text(column)) < float(other.text(column)) - except ValueError: # fallback to alphabetical sorting if the text is not a number - return super().__lt__(other) - class query: """Legacy groupset shape, kept only so old ``.mpct`` files (which pickle this class by qualified name ``main.query``) can still be unpickled. @@ -293,7 +286,11 @@ def __init__(self): self.highlight = self._plotslots.highlight self.ui.btn_run.clicked.connect(self.run_analysis) - self.ui.treeWidget.itemSelectionChanged.connect(self.on_tree_item_selection_changed) + # Replaces the Designer-created QTreeWidget with a real QTreeView + + # per-column filter bar (searchtree.py) -- doesn't edit ui_main.py, + # see SearchTreePanel's docstring for how the runtime swap works. + self.searchtree = SearchTreePanel(self.ui.treeWidget) + self.searchtree.view.selectionModel().selectionChanged.connect(self.on_tree_item_selection_changed) def moveWindow(event): @@ -609,35 +606,23 @@ def fillfttree(self): index_col=None ) iondict = iondict[iondict['hits'] >= 0] - self.ui.treeWidget.setSortingEnabled(True) - itemdict = {} - self.ui.treeWidget.clear() - for i, row in iondict.iterrows(): - item = NumericalTreeWidgetItem([ - row['Compound'], - str(round(row['m/z'], 4)), - str(round(row['Retention time (min)'], 3)), - str(int(row['max'])), - str(row['groups']), - str(row['groups']), - str(round(row['fc'], 2)), - str(int(row['hits'])) - ]) - itemdict[i] = item - self.ui.treeWidget.addTopLevelItem(item) - # Optionally expand all items - # self.ui.treeWidget.expandAll() + # Rows in searchtree.COLUMNS order (Compound, m/z, TR, Max, Sets, + # Groups, FC, Hits) -- raw values, not pre-formatted strings; the + # model formats numeric columns for display and keeps the raw value + # available for proper numeric sort/filter (see IonTableModel). + rows = [ + (row['Compound'], row['m/z'], row['Retention time (min)'], row['max'], + row['groups'], row['groups'], row['fc'], row['hits']) + for _, row in iondict.iterrows() + ] + self.searchtree.set_rows(rows) + - def on_tree_item_selection_changed(self): - selected_items = self.ui.treeWidget.selectedItems() - if selected_items: - item = selected_items[0] - # Get the name from the first column - name = item.text(0) - #print(f"Selection changed: {name}") + name = self.searchtree.selected_compound() + if name: # Call the method to highlight the feature self.highlight_feature(name) diff --git a/code/searchtree.py b/code/searchtree.py new file mode 100644 index 0000000..3d58055 --- /dev/null +++ b/code/searchtree.py @@ -0,0 +1,321 @@ +""" +MPACT +Copyright 2022, Robert M. Samples, Sara P. Puckett, and Marcy J. Balunas + +Replaces the feature-search tab's Designer-created QTreeWidget +(``ui_main.py``'s ``self.treeWidget``) with a real QTreeView backed by a +QAbstractTableModel + QSortFilterProxyModel, plus a small per-column filter +bar -- so each column gets its own search/range/checkbox filter instead of +one global search box. + +This does NOT edit any generated UI file. ``SearchTreePanel`` is +constructed once, at runtime, against the already-built Designer widget +(see ``MainWindow.__init__``): it removes the Designer QTreeWidget from its +layout and inserts a container (filter bar + QTreeView) in the same slot -- +the same "swap a Designer placeholder for hand-built content at runtime" +pattern ``plotting.py`` already uses for matplotlib canvases inserted into +Designer-created QFrame placeholders. Re-opening the .ui file in Qt +Designer would still show the old QTreeWidget; only the running app uses +this. + +The actual filter-matching rules live in ``treefilters.py`` (Qt-free, +unit-tested) -- this module is just the Qt plumbing that calls into them. +""" + +from PyQt5 import QtCore, QtGui, QtWidgets + +from treefilters import CategoryFilter, RangeFilter, TextFilter, distinct_category_tokens, row_passes + +COLUMNS = ('Compound', 'm/z', 'TR', 'Max', 'Sets', 'Groups', 'FC', 'Hits') +TEXT_COLUMNS = {0} +NUMERIC_COLUMNS = {1, 2, 3, 6, 7} +CATEGORY_COLUMNS = {4, 5} + +_NUMERIC_FORMATS = { + 1: '{:.4f}', # m/z + 2: '{:.3f}', # TR (retention time) + 3: '{:.0f}', # Max + 6: '{:.2f}', # FC + 7: '{:.0f}', # Hits +} + +# Matches the dark palette ui_main.py already sets on the Designer treeWidget +# (rgb(70,70,70)-family backgrounds, rgb(200,200,200) light-grey text) -- +# these widgets are newly created at runtime, so they don't pick that up +# automatically the way the treeWidget itself did from setupUi(). +_FILTER_BAR_STYLE = """ +QWidget { + background-color: rgba(70,70,70,25); +} +QLineEdit, QToolButton { + background-color: rgb(50,50,50); + color: rgb(200,200,200); + border: 1px solid rgb(70,70,70); + border-radius: 2px; + padding: 2px; +} +QLabel { + color: rgb(200,200,200); + background: transparent; +} +QToolButton::menu-indicator { + image: none; +} +QMenu { + background-color: rgb(50,50,50); + color: rgb(200,200,200); + border: 1px solid rgb(70,70,70); +} +QMenu::item:selected { + background-color: rgb(70,70,70); +} +""" + + +class IonTableModel(QtCore.QAbstractTableModel): + """One row per feature; columns match COLUMNS. Stores raw (unformatted, + correctly typed) values so sorting and filtering compare real numbers, + not the display strings -- ``RAW_ROLE`` exposes them for that purpose. + """ + + RAW_ROLE = QtCore.Qt.UserRole + 1 + + def __init__(self, parent=None): + super().__init__(parent) + self._rows = [] + + def set_rows(self, rows): + """``rows``: list of tuples, one per feature, in COLUMNS order.""" + self.beginResetModel() + self._rows = list(rows) + self.endResetModel() + + def raw_row(self, row): + return self._rows[row] + + def rowCount(self, parent=QtCore.QModelIndex()): + return 0 if parent.isValid() else len(self._rows) + + def columnCount(self, parent=QtCore.QModelIndex()): + return 0 if parent.isValid() else len(COLUMNS) + + def data(self, index, role=QtCore.Qt.DisplayRole): + if not index.isValid(): + return None + value = self._rows[index.row()][index.column()] + if role == self.RAW_ROLE: + return value + if role == QtCore.Qt.DisplayRole: + fmt = _NUMERIC_FORMATS.get(index.column()) + if fmt is not None: + try: + return fmt.format(float(value)) + except (TypeError, ValueError): + return str(value) + return str(value) + return None + + def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): + if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal: + return COLUMNS[section] + return None + + +class IonFilterProxyModel(QtCore.QSortFilterProxyModel): + """Per-column filters (column index -> a TextFilter/RangeFilter/ + CategoryFilter from treefilters.py) combined with AND, evaluated via + treefilters.row_passes(). Sorting compares IonTableModel.RAW_ROLE + values instead of the default display-text comparison, so numeric + columns sort numerically without any string-parsing tricks. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._filters = {} + + def set_filter(self, column, criterion): + if criterion is None: + self._filters.pop(column, None) + else: + self._filters[column] = criterion + self.invalidateFilter() + + def filterAcceptsRow(self, source_row, source_parent): + model = self.sourceModel() + if model is None: + return True + return row_passes(model.raw_row(source_row), self._filters) + + def lessThan(self, left, right): + model = self.sourceModel() + left_value = model.data(left, IonTableModel.RAW_ROLE) + right_value = model.data(right, IonTableModel.RAW_ROLE) + try: + return left_value < right_value + except TypeError: + return str(left_value) < str(right_value) + + +class SearchTreePanel: + """Owns the model/proxy/view/filter-bar trio that replaces a Designer + QTreeWidget. Construct once with the Designer widget to replace; use + ``set_rows()``/``selected_compound()`` afterward in place of the old + ``treeWidget.clear()``/``addTopLevelItem()``/``selectedItems()`` calls. + """ + + def __init__(self, old_tree_widget): + layout = old_tree_widget.parentWidget().layout() + index = layout.indexOf(old_tree_widget) + parent = old_tree_widget.parentWidget() + + # Reuse the Designer-set dark-theme stylesheet rather than guessing + # colours -- the new QTreeView is a genuinely different widget + # instance, so it doesn't inherit what setupUi() applied to the one + # being replaced. + old_stylesheet = old_tree_widget.styleSheet() + + layout.removeWidget(old_tree_widget) + old_tree_widget.setParent(None) + old_tree_widget.deleteLater() + + self.model = IonTableModel(parent) + self.proxy = IonFilterProxyModel(parent) + self.proxy.setSourceModel(self.model) + + self.view = QtWidgets.QTreeView(parent) + self.view.setModel(self.proxy) + self.view.setSortingEnabled(True) + self.view.setRootIsDecorated(False) + # Not the original QTreeWidget's behaviour -- it never set this, and + # the app's underlying QPalette was never themed dark for the + # AlternateBase role (only the QSS stylesheets are dark), so Qt's + # default (light grey) alternate-row colour clashes badly here. + self.view.setAlternatingRowColors(False) + self.view.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.view.setUniformRowHeights(True) + if old_stylesheet: + self.view.setStyleSheet(old_stylesheet) + + container = QtWidgets.QWidget(parent) + outer = QtWidgets.QVBoxLayout(container) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(2) + + # A single-line toggle rather than always showing all 8 filters at + # once -- one text box, five min/max range pairs, and two category + # dropdowns doesn't fit comfortably side by side in a narrow side + # panel. Collapsed by default so it doesn't change anything for + # someone who never needs to filter. + toggle_row = QtWidgets.QWidget(container) + toggle_row.setStyleSheet(_FILTER_BAR_STYLE) + toggle_layout = QtWidgets.QHBoxLayout(toggle_row) + toggle_layout.setContentsMargins(4, 2, 4, 2) + self.toggle_button = QtWidgets.QToolButton(toggle_row) + self.toggle_button.setCheckable(True) + self.toggle_button.setText('Filters ▸') + self.toggle_button.toggled.connect(self._on_toggle_filters) + toggle_layout.addWidget(self.toggle_button) + toggle_layout.addStretch() + + self.filter_panel = QtWidgets.QWidget(container) + self.filter_panel.setStyleSheet(_FILTER_BAR_STYLE) + self._filter_layout = QtWidgets.QFormLayout(self.filter_panel) + self._filter_layout.setContentsMargins(6, 4, 6, 4) + self._filter_layout.setSpacing(4) + self.filter_panel.setVisible(False) + + self._category_buttons = {} + for column in range(len(COLUMNS)): + self._filter_layout.addRow(COLUMNS[column], self._build_filter_widget(column)) + + outer.addWidget(toggle_row) + outer.addWidget(self.filter_panel) + outer.addWidget(self.view) + + layout.insertWidget(index, container) + + def _on_toggle_filters(self, checked): + self.filter_panel.setVisible(checked) + self.toggle_button.setText('Filters ▾' if checked else 'Filters ▸') + + def _build_filter_widget(self, column): + if column in TEXT_COLUMNS: + return self._build_text_filter(column) + if column in NUMERIC_COLUMNS: + return self._build_range_filter(column) + return self._build_category_filter(column) + + def _build_text_filter(self, column): + edit = QtWidgets.QLineEdit() + edit.setPlaceholderText('contains…') + edit.setClearButtonEnabled(True) + edit.textChanged.connect( + lambda text, c=column: self.proxy.set_filter(c, TextFilter(text) if text else None)) + return edit + + def _build_range_filter(self, column): + box = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(box) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + min_edit = QtWidgets.QLineEdit() + min_edit.setPlaceholderText('min') + min_edit.setValidator(QtGui.QDoubleValidator()) + max_edit = QtWidgets.QLineEdit() + max_edit.setPlaceholderText('max') + max_edit.setValidator(QtGui.QDoubleValidator()) + + def update_filter(*_args): + minimum = float(min_edit.text()) if min_edit.text() else None + maximum = float(max_edit.text()) if max_edit.text() else None + criterion = RangeFilter(minimum, maximum) if (minimum is not None or maximum is not None) else None + self.proxy.set_filter(column, criterion) + + min_edit.textChanged.connect(update_filter) + max_edit.textChanged.connect(update_filter) + + layout.addWidget(min_edit) + layout.addWidget(max_edit) + return box + + def _build_category_filter(self, column): + button = QtWidgets.QToolButton() + button.setText('Select…') + button.setPopupMode(QtWidgets.QToolButton.InstantPopup) + menu = QtWidgets.QMenu(button) + button.setMenu(menu) + self._category_buttons[column] = (button, menu, {}) + return button + + def _refresh_category_options(self, column): + button, menu, _old_actions = self._category_buttons[column] + menu.clear() + actions = {} + values = [self.model.raw_row(r)[column] for r in range(self.model.rowCount())] + for token in distinct_category_tokens(values): + action = menu.addAction(token) + action.setCheckable(True) + action.toggled.connect(lambda _checked, c=column: self._update_category_filter(c)) + actions[token] = action + self._category_buttons[column] = (button, menu, actions) + + def _update_category_filter(self, column): + _button, _menu, actions = self._category_buttons[column] + selected = frozenset(token for token, action in actions.items() if action.isChecked()) + self.proxy.set_filter(column, CategoryFilter(selected) if selected else None) + + def set_rows(self, rows): + """``rows``: list of tuples, one per feature, in COLUMNS order + (Compound, m/z, TR, Max, Sets, Groups, FC, Hits).""" + self.model.set_rows(rows) + for column in CATEGORY_COLUMNS: + self._refresh_category_options(column) + + def selected_compound(self): + """Return the Compound id of the currently selected row, or None.""" + indexes = self.view.selectionModel().selectedRows() + if not indexes: + return None + source_index = self.proxy.mapToSource(indexes[0]) + return self.model.raw_row(source_index.row())[0] diff --git a/code/tests/conftest.py b/code/tests/conftest.py index 16196ec..1fa95c4 100644 --- a/code/tests/conftest.py +++ b/code/tests/conftest.py @@ -11,3 +11,28 @@ CODE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if CODE_DIR not in sys.path: sys.path.insert(0, CODE_DIR) + +# PyQt5's "offscreen" platform plugin lets QApplication/QWidget subclasses be +# constructed and exercised with no real display -- this does NOT make +# main.py importable headlessly (the main<->ui_functions circular import +# documented in CLAUDE.md is unrelated and still applies), but it does mean +# standalone Qt-coupled modules that don't import main.py (e.g. searchtree.py) +# can have their actual widget/model/signal behaviour covered by real tests +# instead of relying solely on manual testing. Set before any test imports +# PyQt5, and only if the environment hasn't already chosen a platform. +os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') + +import pytest + + +@pytest.fixture(scope='session') +def qapp(): + """One shared QApplication for the whole test session -- constructing + more than one in the same process crashes, so any test touching real + Qt widgets/models should depend on this fixture rather than creating + its own.""" + from PyQt5.QtWidgets import QApplication + app = QApplication.instance() + if app is None: + app = QApplication([]) + return app diff --git a/code/tests/test_searchtree.py b/code/tests/test_searchtree.py new file mode 100644 index 0000000..8951eec --- /dev/null +++ b/code/tests/test_searchtree.py @@ -0,0 +1,203 @@ +from PyQt5 import QtCore, QtWidgets + +from searchtree import COLUMNS, IonFilterProxyModel, IonTableModel, SearchTreePanel +from treefilters import CategoryFilter, RangeFilter, TextFilter + +SAMPLE_ROWS = [ + ('c1', 101.5, 1.0, 1000, ' Blanks 0um_Ce', ' Blanks 0um_Ce', 1.5, 2), + ('c2', 250.25, 2.0, 2000, ' 250um_Ce', ' 250um_Ce', 3.0, 0), + ('c3', 99.9, 0.5, 500, ' Media', ' Media', 0.5, 5), +] + + +def make_model_and_proxy(): + model = IonTableModel() + model.set_rows(SAMPLE_ROWS) + proxy = IonFilterProxyModel() + proxy.setSourceModel(model) + return model, proxy + + +# --------------------------------------------------------------------------- # +# IonTableModel +# --------------------------------------------------------------------------- # + +def test_model_row_and_column_counts(qapp): + model, _ = make_model_and_proxy() + assert model.rowCount() == 3 + assert model.columnCount() == len(COLUMNS) + + +def test_model_header_labels(qapp): + model, _ = make_model_and_proxy() + for i, name in enumerate(COLUMNS): + assert model.headerData(i, 1) == name # Qt.Horizontal == 1 + + +def test_model_raw_role_returns_unformatted_value(qapp): + from PyQt5.QtCore import QModelIndex + model, _ = make_model_and_proxy() + index = model.index(0, 1) # c1's m/z + assert model.data(index, IonTableModel.RAW_ROLE) == 101.5 + + +def test_model_display_role_formats_numeric_columns(qapp): + model, _ = make_model_and_proxy() + mz_index = model.index(0, 1) + assert model.data(mz_index) == '101.5000' + hits_index = model.index(0, 7) + assert model.data(hits_index) == '2' + + +# --------------------------------------------------------------------------- # +# IonFilterProxyModel +# --------------------------------------------------------------------------- # + +def visible_compounds(proxy): + return [proxy.data(proxy.index(r, 0)) for r in range(proxy.rowCount())] + + +def test_no_filters_shows_every_row(qapp): + _, proxy = make_model_and_proxy() + assert set(visible_compounds(proxy)) == {'c1', 'c2', 'c3'} + + +def test_text_filter_on_compound_column(qapp): + _, proxy = make_model_and_proxy() + proxy.set_filter(0, TextFilter('c2')) + assert visible_compounds(proxy) == ['c2'] + + +def test_range_filter_on_numeric_column(qapp): + _, proxy = make_model_and_proxy() + proxy.set_filter(7, RangeFilter(minimum=1)) # Hits >= 1 + assert set(visible_compounds(proxy)) == {'c1', 'c3'} + + +def test_category_filter_on_groups_column(qapp): + _, proxy = make_model_and_proxy() + proxy.set_filter(4, CategoryFilter(frozenset({'Media'}))) + assert visible_compounds(proxy) == ['c3'] + + +def test_combined_filters_across_columns(qapp): + _, proxy = make_model_and_proxy() + proxy.set_filter(6, RangeFilter(minimum=1, maximum=2)) # FC in [1,2] + proxy.set_filter(4, CategoryFilter(frozenset({'0um_Ce'}))) + assert visible_compounds(proxy) == ['c1'] + + +def test_clearing_a_filter_restores_rows(qapp): + _, proxy = make_model_and_proxy() + proxy.set_filter(0, TextFilter('c2')) + assert len(visible_compounds(proxy)) == 1 + proxy.set_filter(0, None) + assert len(visible_compounds(proxy)) == 3 + + +def test_sorting_numeric_column_is_numeric_not_lexicographic(qapp): + _, proxy = make_model_and_proxy() + proxy.sort(1, 0) # sort by m/z ascending (Qt.AscendingOrder == 0) + mz_values = [proxy.data(proxy.index(r, 1), IonTableModel.RAW_ROLE) for r in range(proxy.rowCount())] + assert mz_values == sorted(mz_values) + + +# --------------------------------------------------------------------------- # +# SearchTreePanel -- the actual Designer-widget-swap, end to end +# --------------------------------------------------------------------------- # + +def make_host_with_tree_widget(): + """Mimic the relevant slice of ui_main.py's setupUi(): a frame containing + a layout with a QTreeWidget in it -- the exact shape SearchTreePanel + expects to replace.""" + host = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(host) + tree_widget = QtWidgets.QTreeWidget(host) + layout.addWidget(tree_widget) + return host, layout, tree_widget + + +def test_panel_removes_old_widget_and_inserts_view_in_same_slot(qapp): + host, layout, tree_widget = make_host_with_tree_widget() + assert layout.indexOf(tree_widget) == 0 + + panel = SearchTreePanel(tree_widget) + + assert layout.indexOf(tree_widget) == -1 # removed + assert layout.count() == 1 # the new container took its place + assert panel.view.parentWidget() is not None + + +def test_panel_copies_the_old_widgets_stylesheet_onto_the_new_view(qapp): + """The new QTreeView is a different widget instance, so it doesn't + automatically inherit whatever Designer's setupUi() applied to the + widget it's replacing -- confirm it's carried across explicitly rather + than defaulting to unstyled (light-themed) widgets in a dark-themed app.""" + host, layout, tree_widget = make_host_with_tree_widget() + tree_widget.setStyleSheet('QTreeView { background-color: rgb(50,50,50); }') + + panel = SearchTreePanel(tree_widget) + + assert panel.view.styleSheet() == 'QTreeView { background-color: rgb(50,50,50); }' + + +def test_panel_set_rows_populates_the_view(qapp): + host, layout, tree_widget = make_host_with_tree_widget() + panel = SearchTreePanel(tree_widget) + panel.set_rows(SAMPLE_ROWS) + assert panel.proxy.rowCount() == 3 + + +def test_panel_selected_compound_maps_through_proxy_to_source(qapp): + host, layout, tree_widget = make_host_with_tree_widget() + panel = SearchTreePanel(tree_widget) + panel.set_rows(SAMPLE_ROWS) + + # Filter down to just c3, select the only visible row, and confirm + # selected_compound() correctly maps the (filtered, possibly reordered) + # proxy row back to the right source row rather than assuming row 0 + # of the proxy lines up with row 0 of the unfiltered data. + panel.proxy.set_filter(0, TextFilter('c3')) + proxy_index = panel.proxy.index(0, 0) + panel.view.selectionModel().select( + proxy_index, + QtCore.QItemSelectionModel.Select | QtCore.QItemSelectionModel.Rows, + ) + assert panel.selected_compound() == 'c3' + + +def test_panel_selected_compound_none_when_nothing_selected(qapp): + host, layout, tree_widget = make_host_with_tree_widget() + panel = SearchTreePanel(tree_widget) + panel.set_rows(SAMPLE_ROWS) + assert panel.selected_compound() is None + + +def test_filter_panel_collapsed_by_default(qapp): + host, layout, tree_widget = make_host_with_tree_widget() + panel = SearchTreePanel(tree_widget) + assert panel.filter_panel.isVisible() is False + assert not panel.toggle_button.isChecked() + + +def test_toggle_button_shows_and_hides_the_filter_panel(qapp): + host, layout, tree_widget = make_host_with_tree_widget() + panel = SearchTreePanel(tree_widget) + host.show() # isVisible() reflects the whole ancestor chain, not just + # the widget's own explicit flag -- without showing host, filter_panel + # would never be considered "visible" regardless of setVisible(True). + + panel.toggle_button.setChecked(True) + assert panel.filter_panel.isVisible() is True + + panel.toggle_button.setChecked(False) + assert panel.filter_panel.isVisible() is False + + +def test_panel_category_filter_options_repopulate_on_set_rows(qapp): + host, layout, tree_widget = make_host_with_tree_widget() + panel = SearchTreePanel(tree_widget) + panel.set_rows(SAMPLE_ROWS) + + _button, _menu, actions = panel._category_buttons[4] + assert set(actions.keys()) == {'Blanks', '0um_Ce', '250um_Ce', 'Media'} diff --git a/code/tests/test_treefilters.py b/code/tests/test_treefilters.py new file mode 100644 index 0000000..01c35f6 --- /dev/null +++ b/code/tests/test_treefilters.py @@ -0,0 +1,121 @@ +import pytest + +from treefilters import ( + CategoryFilter, + RangeFilter, + TextFilter, + distinct_category_tokens, + row_passes, +) + + +# --------------------------------------------------------------------------- # +# TextFilter +# --------------------------------------------------------------------------- # + +def test_text_filter_blank_matches_everything(): + assert TextFilter('').matches('anything') + assert TextFilter('').matches('') + + +def test_text_filter_case_insensitive_substring(): + f = TextFilter('gran') + assert f.matches('Granaticin C') + assert f.matches('GRANATICIN') + assert not f.matches('Coelichelin') + + +# --------------------------------------------------------------------------- # +# RangeFilter +# --------------------------------------------------------------------------- # + +def test_range_filter_no_bounds_matches_everything(): + f = RangeFilter() + assert f.matches(0) + assert f.matches(-999.5) + assert f.matches(1e6) + + +def test_range_filter_inclusive_bounds(): + f = RangeFilter(minimum=1, maximum=5) + assert f.matches(1) + assert f.matches(5) + assert f.matches(3) + assert not f.matches(0.999) + assert not f.matches(5.001) + + +def test_range_filter_one_sided_bounds(): + assert RangeFilter(minimum=10).matches(1000) + assert not RangeFilter(minimum=10).matches(9) + assert RangeFilter(maximum=10).matches(-1000) + assert not RangeFilter(maximum=10).matches(11) + + +def test_range_filter_non_numeric_value_never_matches(): + f = RangeFilter(minimum=0, maximum=10) + assert not f.matches('not a number') + assert not f.matches(None) + + +# --------------------------------------------------------------------------- # +# CategoryFilter +# --------------------------------------------------------------------------- # + +def test_category_filter_no_selection_matches_everything(): + assert CategoryFilter(None).matches(' Blanks 0um_Ce') + assert CategoryFilter(frozenset()).matches(' Blanks 0um_Ce') + + +def test_category_filter_matches_any_overlapping_token(): + f = CategoryFilter(frozenset({'0um_Ce'})) + assert f.matches(' Blanks 0um_Ce') + assert not f.matches(' Blanks Media') + + +def test_category_filter_multiple_selected_tokens_is_an_or(): + f = CategoryFilter(frozenset({'0um_Ce', '250um_Ce'})) + assert f.matches(' 0um_Ce') + assert f.matches(' 250um_Ce') + assert not f.matches(' Media') + + +# --------------------------------------------------------------------------- # +# row_passes +# --------------------------------------------------------------------------- # + +def test_row_passes_with_no_filters(): + assert row_passes(['anything', 1, 2], {}) + + +def test_row_passes_requires_all_active_filters_to_match(): + row = ['Granaticin C', 101.5, ' 0um_Ce'] + filters = { + 0: TextFilter('gran'), + 1: RangeFilter(minimum=100, maximum=200), + 2: CategoryFilter(frozenset({'0um_Ce'})), + } + assert row_passes(row, filters) + + +def test_row_fails_if_any_single_filter_fails(): + row = ['Granaticin C', 101.5, ' 0um_Ce'] + filters = { + 0: TextFilter('gran'), + 1: RangeFilter(minimum=200, maximum=300), # out of range + 2: CategoryFilter(frozenset({'0um_Ce'})), + } + assert not row_passes(row, filters) + + +# --------------------------------------------------------------------------- # +# distinct_category_tokens +# --------------------------------------------------------------------------- # + +def test_distinct_category_tokens_sorted_and_deduplicated(): + values = [' Blanks 0um_Ce', ' 0um_Ce Media', ' Media', ''] + assert distinct_category_tokens(values) == ['0um_Ce', 'Blanks', 'Media'] + + +def test_distinct_category_tokens_empty_input(): + assert distinct_category_tokens([]) == [] diff --git a/code/treefilters.py b/code/treefilters.py new file mode 100644 index 0000000..8569db2 --- /dev/null +++ b/code/treefilters.py @@ -0,0 +1,88 @@ +""" +MPACT +Copyright 2022, Robert M. Samples, Sara P. Puckett, and Marcy J. Balunas + +Qt-free per-column filter criteria and row-matching logic for the feature +search tree (main.py's fillfttree()/treeWidget). Kept separate from the Qt +model/proxy/widget layer (searchtree.py) so the actual matching rules -- +substring search, numeric range, multi-select category -- are testable +without a GUI. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class TextFilter: + """Case-insensitive substring match. Blank text matches every row.""" + text: str = '' + + def matches(self, value): + if not self.text: + return True + return self.text.lower() in str(value).lower() + + +@dataclass(frozen=True) +class RangeFilter: + """Inclusive numeric range. Either bound may be ``None`` (unbounded). + A value that can't be parsed as a number never matches a range filter + (it's not "in range" if it isn't a number at all). + """ + minimum: Optional[float] = None + maximum: Optional[float] = None + + def matches(self, value): + try: + number = float(value) + except (TypeError, ValueError): + return False + if self.minimum is not None and number < self.minimum: + return False + if self.maximum is not None and number > self.maximum: + return False + return True + + +@dataclass(frozen=True) +class CategoryFilter: + """For columns whose value is a space-joined set of category tokens + (e.g. the ``groups`` column: ``' Blanks 0um_Ce'``). Matches if the row + has at least one token in common with ``allowed``. An empty/``None`` + ``allowed`` matches every row -- "nothing selected" means "no filter + applied," matching how a checkbox filter list is normally expected to + behave (vs. "nothing selected" hiding everything). + """ + allowed: Optional[frozenset] = None + + def matches(self, value): + if not self.allowed: + return True + row_tokens = set(str(value).split()) + return bool(row_tokens & self.allowed) + + +def row_passes(row_values, filters): + """``row_values``: a sequence of cell values for one row, indexed by + column position. ``filters``: ``{column_index: criterion}`` where each + criterion has a ``.matches(value)`` method (``TextFilter``/ + ``RangeFilter``/``CategoryFilter``, or any object with that interface). + Columns with no entry in ``filters`` are unfiltered. + + Returns ``True`` iff every active filter's column passes. + """ + for column, criterion in filters.items(): + if not criterion.matches(row_values[column]): + return False + return True + + +def distinct_category_tokens(values): + """Given a column of space-joined category strings (e.g. every row's + ``groups`` value), return the sorted set of distinct individual tokens + -- used to populate a per-column checkbox filter's option list.""" + tokens = set() + for value in values: + tokens.update(str(value).split()) + return sorted(tokens) diff --git a/code/ui_functions.py b/code/ui_functions.py index 7701a64..e0bbf68 100644 --- a/code/ui_functions.py +++ b/code/ui_functions.py @@ -157,7 +157,7 @@ def uiDefinitions(self): self.ftrdialog.setWindowFlag(QtCore.Qt.FramelessWindowHint) self.ftrdialog.setWindowFlag(QtCore.Qt.WindowStaysOnTopHint) self.ftrdialog.ui.treeWidget.hideColumn(5) - self.ui.treeWidget.hideColumn(5) #hide sets column for now + self.searchtree.view.hideColumn(5) #hide sets column for now self.ftrdialog.hide() self.ftrdialog.ui.btn_related.hide()