diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..5f78666 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,37 @@ +name: Unit Tests + +# Runs the Python unit tests in tests/ (pytest). This is intentionally +# separate from build-and-test.yml, whose "test" jobs are container/k8s +# deployment smoke tests rather than pytest. + +on: + push: + branches: [develop] + pull_request: + workflow_dispatch: + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + # Match the runtime built in the Dockerfile (python=3.11). + python-version: "3.11" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + # Pinned runtime deps (pyopenms is needed so ParameterManager imports + # cleanly at collection time) plus test-only deps. fakeredis backs the + # QueueManager/WorkflowManager tests, which pytest.importorskip it. + pip install -r requirements.txt + pip install pytest fakeredis + + - name: Run unit tests + run: pytest tests/ -v diff --git a/src/render/compression.py b/src/render/compression.py index 316e5d2..f42576b 100644 --- a/src/render/compression.py +++ b/src/render/compression.py @@ -51,6 +51,13 @@ def downsample_heatmap(data, max_datapoints=20000, rt_bins=400, mz_bins=50, logg # We need to collect here because scipy requires numpy arrays sorted_data = sorted_data.collect() + + # scipy's binned_statistic_2d reduces over the inputs to derive bin edges + # and raises "zero-size array to reduction operation minimum" on empty + # input. With no peaks there is nothing to downsample, so return the input + # unchanged (same schema, zero rows). + if sorted_data.is_empty(): + return data # Count peaks total_count = sorted_data.select(pl.count()).item() diff --git a/tests/test_render_compression.py b/tests/test_render_compression.py new file mode 100644 index 0000000..f7e3593 --- /dev/null +++ b/tests/test_render_compression.py @@ -0,0 +1,50 @@ +""" +Tests for downsample_heatmap, the heatmap point-reduction helper. + +The deconvolved-heatmap panels are built per MS level, and a level with no +peaks (e.g. an MS1-only run that still shows an MS2 panel) yields an empty +frame. That empty frame used to reach scipy's binned_statistic_2d, which +raises "zero-size array to reduction operation minimum which has no identity" +while computing bin edges. downsample_heatmap must short-circuit on empty +input and return an empty, schema-preserving frame instead of crashing. + +The helper is pure polars/numpy/scipy (no Streamlit), so it is unit-testable +without booting the app. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import polars as pl + +from src.render.compression import downsample_heatmap + + +HEATMAP_SCHEMA = {"mass": pl.Float64, "rt": pl.Float64, "intensity": pl.Float64} + + +def test_empty_dataframe_returns_empty_with_schema(): + result = downsample_heatmap(pl.DataFrame(schema=HEATMAP_SCHEMA)).collect() + assert result.is_empty() + assert set(result.columns) >= {"mass", "rt", "intensity"} + + +def test_empty_lazyframe_returns_empty_with_schema(): + result = downsample_heatmap(pl.LazyFrame(schema=HEATMAP_SCHEMA)).collect() + assert result.is_empty() + assert set(result.columns) >= {"mass", "rt", "intensity"} + + +def test_nonempty_input_passes_through_binning(): + df = pl.DataFrame( + { + "mass": [100.0, 200.0, 300.0], + "rt": [1.0, 2.0, 3.0], + "intensity": [10.0, 20.0, 30.0], + } + ) + result = downsample_heatmap(df).collect() + assert set(result.columns) >= {"mass", "rt", "intensity"} + assert result.height <= df.height