Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 29 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),<injections...>`;
col0 = `RT_mz` id, col1 = m/z, col2 = RT. Rows 0–1 are overwritten by
Expand Down Expand Up @@ -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`)

Expand Down
53 changes: 19 additions & 34 deletions code/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading