From e0583dd2b959928ed89766d25ca6fc684b757cae Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos Date: Fri, 26 Dec 2025 18:42:27 +0800 Subject: [PATCH 1/5] Improve test_hist.py Signed-off-by: Tomas Pereira de Vasconcelos --- tests/unit/test_hist.py | 188 +++++++++++++++++++++++++++------------- 1 file changed, 130 insertions(+), 58 deletions(-) diff --git a/tests/unit/test_hist.py b/tests/unit/test_hist.py index 0008bbbe..2f5fbd60 100644 --- a/tests/unit/test_hist.py +++ b/tests/unit/test_hist.py @@ -8,89 +8,161 @@ bin_trace_samples, ) -# Example data +# ============================================================== +# --- bin_trace_samples() +# ============================================================== -SAMPLES_IN = [1, 2, 2, 3, 4] -NBINS = 4 -# NOTE: The x values in DENSITIES_OUT correspond to the centers of -# equally spaced bins over the range [1, 4]. -# This can be counterintuitive for count data, as the bins -# do not align with the integer sample values. -DENSITIES_OUT = [(1.375, 1), (2.125, 2), (2.875, 1), (3.625, 1)] -X_OUT, Y_OUT = zip(*DENSITIES_OUT, strict=True) -WEIGHTS = [1, 1, 1, 1, 9] +# --- Basic functionality --- + + +@pytest.mark.parametrize( + ("samples", "nbins", "expected"), + [ + # Basic case with repeated values + ([1, 2, 2, 3, 4], 4, [(1.375, 1), (2.125, 2), (2.875, 1), (3.625, 1)]), + # Single bin aggregates all samples + ([1, 2, 3], 1, [(2.0, 3)]), + # Uniform distribution + ([0, 1, 2, 3], 4, [(0.375, 1), (1.125, 1), (1.875, 1), (2.625, 1)]), + # All identical samples go to rightmost bin + ([3, 3, 3], 2, [(2.75, 0), (3.25, 3)]), + # Negative values + ([-2, -1, 0, 1], 2, [(-1.25, 2), (0.25, 2)]), + ], + ids=["basic", "single_bin", "uniform", "identical", "negative"], +) +def test_basic_binning( + samples: list[float], nbins: int, expected: list[tuple[float, float]] +) -> None: + result = bin_trace_samples(samples, nbins=nbins) + assert result == expected -# ============================================================== -# --- estimate_density_trace() -# ============================================================== + +def test_float_samples_binning() -> None: + result = bin_trace_samples([0.1, 0.5, 0.9], nbins=3) + x_vals, y_vals = zip(*result, strict=True) + assert x_vals == pytest.approx((0.233, 0.5, 0.767), rel=1e-2) + assert y_vals == (1.0, 1.0, 1.0) -def test_bin_trace_samples_simple() -> None: - density_trace = bin_trace_samples(trace_samples=SAMPLES_IN, nbins=NBINS) - x, y = zip(*density_trace, strict=True) - assert x == X_OUT - assert y == Y_OUT +@pytest.mark.parametrize("nbins", [1, 2, 5, 10, 50]) +def test_output_length_matches_nbins(nbins: int) -> None: + result = bin_trace_samples([1, 2, 3, 4, 5], nbins=nbins) + assert len(result) == nbins -@pytest.mark.parametrize("nbins", [2, 5, 8, 11]) -def test_bin_trace_samples_nbins(nbins: int) -> None: - density_trace = bin_trace_samples(trace_samples=SAMPLES_IN, nbins=nbins) - assert len(density_trace) == nbins +@pytest.mark.parametrize( + "input_type", + [list, tuple, np.array], + ids=["list", "tuple", "ndarray"], +) +def test_accepts_various_input_types(input_type: type) -> None: + samples = input_type([1, 2, 3]) + result = bin_trace_samples(samples, nbins=2) + assert len(result) == 2 + assert all(isinstance(x, float) and isinstance(y, float) for x, y in result) -@pytest.mark.parametrize("non_finite_value", [np.inf, np.nan, float("inf"), float("nan")]) -def test_bin_trace_samples_fails_for_non_finite_values(non_finite_value: float) -> None: - err_msg = "The samples array should not contain any infs or NaNs." - with pytest.raises(ValueError, match=err_msg): - bin_trace_samples(trace_samples=[*SAMPLES_IN[:-1], non_finite_value], nbins=NBINS) +def test_counts_sum_to_sample_size() -> None: + samples = list(range(100)) + result = bin_trace_samples(samples, nbins=7) + total_count = sum(y for _, y in result) + assert total_count == len(samples) -def test_bin_trace_samples_weights() -> None: - density_trace = bin_trace_samples( - trace_samples=SAMPLES_IN, - nbins=NBINS, - weights=WEIGHTS, - ) - x, y = zip(*density_trace, strict=True) - assert x == X_OUT - assert np.argmax(y) == len(y) - 1 +def test_bin_centers_within_data_range() -> None: + samples = [10, 20, 30, 40, 50] + result = bin_trace_samples(samples, nbins=5) + centers = [x for x, _ in result] + assert all(min(samples) <= c <= max(samples) for c in centers) -def test_bin_trace_samples_weights_not_same_length() -> None: - with pytest.raises( - ValueError, match="The weights array should have the same length as the samples array" - ): - bin_trace_samples(trace_samples=SAMPLES_IN, nbins=NBINS, weights=[1, 1, 1]) +# --- Weights --- -@pytest.mark.parametrize("non_finite_value", [np.inf, np.nan, float("inf"), float("nan")]) -def test_bin_trace_samples_weights_fails_for_non_finite_values( - non_finite_value: float, +@pytest.mark.parametrize( + ("samples", "weights", "nbins", "expected_counts"), + [ + # Weights shift distribution + ([1, 2, 3], [10, 1, 1], 3, [10, 1, 1]), + # Zero weights effectively exclude samples + ([1, 2, 3], [1, 0, 1], 3, [1, 0, 1]), + # Fractional weights + ([1, 2], [0.5, 1.5], 2, [0.5, 1.5]), + ], + ids=["heavy_first", "zero_weight", "fractional"], +) +def test_weights_affect_counts( + samples: list[float], + weights: list[float], + nbins: int, + expected_counts: list[float], ) -> None: - err_msg = "The weights array should not contain any infs or NaNs." - with pytest.raises(ValueError, match=err_msg): - bin_trace_samples( - trace_samples=SAMPLES_IN, - nbins=NBINS, - weights=[*WEIGHTS[:-1], non_finite_value], - ) + result = bin_trace_samples(samples, nbins=nbins, weights=weights) + counts = [y for _, y in result] + assert counts == pytest.approx(expected_counts) + + +def test_weighted_counts_sum_to_weight_sum() -> None: + samples = [1, 2, 3, 4, 5] + weights = [2.0, 3.0, 1.5, 0.5, 4.0] + result = bin_trace_samples(samples, nbins=3, weights=weights) + assert sum(y for _, y in result) == pytest.approx(sum(weights)) + + +# --- Error handling --- + + +@pytest.mark.parametrize( + "non_finite", + [np.inf, -np.inf, np.nan, float("inf"), float("nan")], + ids=["inf", "neg_inf", "nan", "float_inf", "float_nan"], +) +def test_rejects_non_finite_samples(non_finite: float) -> None: + with pytest.raises(ValueError, match="samples array should not contain any infs or NaNs"): + bin_trace_samples([1, 2, non_finite], nbins=2) + + +@pytest.mark.parametrize( + "non_finite", + [np.inf, -np.inf, np.nan, float("inf"), float("nan")], + ids=["inf", "neg_inf", "nan", "float_inf", "float_nan"], +) +def test_rejects_non_finite_weights(non_finite: float) -> None: + with pytest.raises(ValueError, match="weights array should not contain any infs or NaNs"): + bin_trace_samples([1, 2, 3], nbins=2, weights=[1, non_finite, 1]) + + +@pytest.mark.parametrize( + ("samples", "weights"), + [ + ([1, 2, 3], [1, 2]), + ([1, 2], [1, 2, 3]), + ([1], []), + ], + ids=["weights_short", "weights_long", "empty_weights"], +) +def test_rejects_mismatched_weights_length(samples: list[float], weights: list[float]) -> None: + with pytest.raises(ValueError, match="weights array should have the same length"): + bin_trace_samples(samples, nbins=2, weights=weights) # ============================================================== -# --- estimate_densities() +# --- bin_samples() # ============================================================== def test_bin_samples() -> None: - densities = bin_samples( - samples=[[SAMPLES_IN], [SAMPLES_IN]], - nbins=NBINS, - ) + samples = [1, 2, 2, 3, 4] + nbins = 4 + expected = [(1.375, 1), (2.125, 2), (2.875, 1), (3.625, 1)] + x_out, y_out = zip(*expected, strict=True) + densities = bin_samples(samples=[[samples], [samples]], nbins=nbins) assert len(densities) == 2 for densities_row in densities: assert len(densities_row) == 1 density_trace = next(iter(densities_row)) x, y = zip(*density_trace, strict=True) - assert x == X_OUT - assert y == Y_OUT + assert x == x_out + assert y == y_out From e21e017acb961fabdb40d237b8e04c5305c0eb2a Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos Date: Fri, 24 Jul 2026 19:23:09 +0200 Subject: [PATCH 2/5] Extend bin_samples() coverage, tighten assertions, and add changelog entry --- docs/reference/changelog.md | 1 + tests/unit/test_hist.py | 91 +++++++++++++++++++++++++------------ 2 files changed, 63 insertions(+), 29 deletions(-) diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md index e7bf832f..fe2bfd4a 100644 --- a/docs/reference/changelog.md +++ b/docs/reference/changelog.md @@ -7,6 +7,7 @@ Unreleased changes ### CI/CD +- Improve the unit test suite for the `ridgeplot._hist` module ({gh-pr}`365`) - Bump actions/download-artifact from 7 to 8 ({gh-pr}`368`) - Bump actions/upload-artifact from 6 to 7 ({gh-pr}`369`) - Bump sigstore/gh-action-sigstore-python from 3.2.0 to 3.3.0 ({gh-pr}`370`) diff --git a/tests/unit/test_hist.py b/tests/unit/test_hist.py index 2f5fbd60..ea0da41c 100644 --- a/tests/unit/test_hist.py +++ b/tests/unit/test_hist.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import numpy as np import pytest @@ -8,6 +10,12 @@ bin_trace_samples, ) +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any + +NON_FINITE_VALUES = [np.inf, -np.inf, np.nan] + # ============================================================== # --- bin_trace_samples() # ============================================================== @@ -20,12 +28,17 @@ ("samples", "nbins", "expected"), [ # Basic case with repeated values + # NOTE: The expected x values correspond to the centers of + # equally spaced bins over the range [min, max] of the + # samples. This can be counterintuitive for count data, + # as the bins do not align with the integer sample values. ([1, 2, 2, 3, 4], 4, [(1.375, 1), (2.125, 2), (2.875, 1), (3.625, 1)]), # Single bin aggregates all samples ([1, 2, 3], 1, [(2.0, 3)]), # Uniform distribution ([0, 1, 2, 3], 4, [(0.375, 1), (1.125, 1), (1.875, 1), (2.625, 1)]), - # All identical samples go to rightmost bin + # All identical samples fall in the rightmost bin + # (NumPy pads the zero-width range by +/-0.5) ([3, 3, 3], 2, [(2.75, 0), (3.25, 3)]), # Negative values ([-2, -1, 0, 1], 2, [(-1.25, 2), (0.25, 2)]), @@ -42,7 +55,7 @@ def test_basic_binning( def test_float_samples_binning() -> None: result = bin_trace_samples([0.1, 0.5, 0.9], nbins=3) x_vals, y_vals = zip(*result, strict=True) - assert x_vals == pytest.approx((0.233, 0.5, 0.767), rel=1e-2) + assert x_vals == pytest.approx((7 / 30, 0.5, 23 / 30)) assert y_vals == (1.0, 1.0, 1.0) @@ -54,14 +67,16 @@ def test_output_length_matches_nbins(nbins: int) -> None: @pytest.mark.parametrize( "input_type", - [list, tuple, np.array], + [list, tuple, np.asarray], ids=["list", "tuple", "ndarray"], ) -def test_accepts_various_input_types(input_type: type) -> None: - samples = input_type([1, 2, 3]) - result = bin_trace_samples(samples, nbins=2) +def test_accepts_various_input_types(input_type: Callable[[list[int]], Any]) -> None: + result = bin_trace_samples(input_type([1, 2, 3]), nbins=2) assert len(result) == 2 - assert all(isinstance(x, float) and isinstance(y, float) for x, y in result) + # The output should always be normalised to built-in floats + # (note: isinstance() checks wouldn't cut it here since + # np.float64 is also a subclass of the built-in float) + assert all(type(x) is float and type(y) is float for x, y in result) def test_counts_sum_to_sample_size() -> None: @@ -84,7 +99,7 @@ def test_bin_centers_within_data_range() -> None: @pytest.mark.parametrize( ("samples", "weights", "nbins", "expected_counts"), [ - # Weights shift distribution + # Each sample falls in its own bin, so the weights become the counts ([1, 2, 3], [10, 1, 1], 3, [10, 1, 1]), # Zero weights effectively exclude samples ([1, 2, 3], [1, 0, 1], 3, [1, 0, 1]), @@ -114,21 +129,13 @@ def test_weighted_counts_sum_to_weight_sum() -> None: # --- Error handling --- -@pytest.mark.parametrize( - "non_finite", - [np.inf, -np.inf, np.nan, float("inf"), float("nan")], - ids=["inf", "neg_inf", "nan", "float_inf", "float_nan"], -) +@pytest.mark.parametrize("non_finite", NON_FINITE_VALUES) def test_rejects_non_finite_samples(non_finite: float) -> None: with pytest.raises(ValueError, match="samples array should not contain any infs or NaNs"): bin_trace_samples([1, 2, non_finite], nbins=2) -@pytest.mark.parametrize( - "non_finite", - [np.inf, -np.inf, np.nan, float("inf"), float("nan")], - ids=["inf", "neg_inf", "nan", "float_inf", "float_nan"], -) +@pytest.mark.parametrize("non_finite", NON_FINITE_VALUES) def test_rejects_non_finite_weights(non_finite: float) -> None: with pytest.raises(ValueError, match="weights array should not contain any infs or NaNs"): bin_trace_samples([1, 2, 3], nbins=2, weights=[1, non_finite, 1]) @@ -155,14 +162,40 @@ def test_rejects_mismatched_weights_length(samples: list[float], weights: list[f def test_bin_samples() -> None: samples = [1, 2, 2, 3, 4] - nbins = 4 - expected = [(1.375, 1), (2.125, 2), (2.875, 1), (3.625, 1)] - x_out, y_out = zip(*expected, strict=True) - densities = bin_samples(samples=[[samples], [samples]], nbins=nbins) - assert len(densities) == 2 - for densities_row in densities: - assert len(densities_row) == 1 - density_trace = next(iter(densities_row)) - x, y = zip(*density_trace, strict=True) - assert x == x_out - assert y == y_out + expected_trace = [(1.375, 1.0), (2.125, 2.0), (2.875, 1.0), (3.625, 1.0)] + densities = bin_samples(samples=[[samples], [samples]], nbins=4) + assert densities == [[expected_trace], [expected_trace]] + + +def test_bin_samples_preserves_shape() -> None: + densities = bin_samples(samples=[[[0, 1], [2, 3, 4]], [[5, 6, 7, 8]]], nbins=3) + assert [len(row) for row in densities] == [2, 1] + assert all(len(trace) == 3 for row in densities for trace in row) + + +def test_bin_samples_broadcasts_flat_weights() -> None: + """A single flat weights vector should be applied to all traces.""" + trace_a, trace_b = [1, 2, 3], [4, 5, 6] + weights = [1, 2, 3] + densities = bin_samples(samples=[[trace_a, trace_b]], nbins=2, sample_weights=weights) + assert densities == [ + [ + bin_trace_samples(trace_a, nbins=2, weights=weights), + bin_trace_samples(trace_b, nbins=2, weights=weights), + ] + ] + + +def test_bin_samples_per_trace_weights() -> None: + """Per-trace weights (shallow form) should be matched to each trace.""" + trace_a, trace_b = [1, 2, 3], [4, 5, 6, 7] + weights_a = [1, 2, 3] + densities = bin_samples( + samples=[[trace_a], [trace_b]], + nbins=2, + sample_weights=[weights_a, None], + ) + assert densities == [ + [bin_trace_samples(trace_a, nbins=2, weights=weights_a)], + [bin_trace_samples(trace_b, nbins=2)], + ] From f42678ead6ea35a6fe8dd63fe4340522a0c97abe Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos Date: Fri, 24 Jul 2026 19:28:31 +0200 Subject: [PATCH 3/5] Avoid unidiomatic-typecheck pattern flagged by Codacy --- tests/unit/test_hist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_hist.py b/tests/unit/test_hist.py index ea0da41c..09ebcc75 100644 --- a/tests/unit/test_hist.py +++ b/tests/unit/test_hist.py @@ -76,7 +76,7 @@ def test_accepts_various_input_types(input_type: Callable[[list[int]], Any]) -> # The output should always be normalised to built-in floats # (note: isinstance() checks wouldn't cut it here since # np.float64 is also a subclass of the built-in float) - assert all(type(x) is float and type(y) is float for x, y in result) + assert {type(value) for xy_pair in result for value in xy_pair} == {float} def test_counts_sum_to_sample_size() -> None: From 0a8cbba0cb170b46b0c2089357eee26dede8c400 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:53:18 +0000 Subject: [PATCH 4/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/unit/test_hist.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/test_hist.py b/tests/unit/test_hist.py index 6426fda2..dc3ffebb 100644 --- a/tests/unit/test_hist.py +++ b/tests/unit/test_hist.py @@ -63,6 +63,8 @@ def test_float_samples_binning() -> None: def test_output_length_matches_nbins(nbins: int) -> None: result = bin_trace_samples([1, 2, 3, 4, 5], nbins=nbins) assert len(result) == nbins + + @pytest.mark.parametrize( "non_finite_value", [np.inf, np.nan, float("inf"), float("nan")], From 83a226209523ea6f8b991a3fc55d73d9822506f0 Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos Date: Sat, 25 Jul 2026 06:10:20 +0200 Subject: [PATCH 5/5] Remove stale tests reintroduced by merge conflict resolution --- tests/unit/test_hist.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/tests/unit/test_hist.py b/tests/unit/test_hist.py index dc3ffebb..09ebcc75 100644 --- a/tests/unit/test_hist.py +++ b/tests/unit/test_hist.py @@ -65,17 +65,6 @@ def test_output_length_matches_nbins(nbins: int) -> None: assert len(result) == nbins -@pytest.mark.parametrize( - "non_finite_value", - [np.inf, np.nan, float("inf"), float("nan")], - ids=["np-inf", "np-nan", "float-inf", "float-nan"], -) -def test_bin_trace_samples_fails_for_non_finite_values(non_finite_value: float) -> None: - err_msg = "The samples array should not contain any infs or NaNs." - with pytest.raises(ValueError, match=err_msg): - bin_trace_samples(trace_samples=[*SAMPLES_IN[:-1], non_finite_value], nbins=NBINS) - - @pytest.mark.parametrize( "input_type", [list, tuple, np.asarray], @@ -166,23 +155,6 @@ def test_rejects_mismatched_weights_length(samples: list[float], weights: list[f bin_trace_samples(samples, nbins=2, weights=weights) -@pytest.mark.parametrize( - "non_finite_value", - [np.inf, np.nan, float("inf"), float("nan")], - ids=["np-inf", "np-nan", "float-inf", "float-nan"], -) -def test_bin_trace_samples_weights_fails_for_non_finite_values( - non_finite_value: float, -) -> None: - err_msg = "The weights array should not contain any infs or NaNs." - with pytest.raises(ValueError, match=err_msg): - bin_trace_samples( - trace_samples=SAMPLES_IN, - nbins=NBINS, - weights=[*WEIGHTS[:-1], non_finite_value], - ) - - # ============================================================== # --- bin_samples() # ==============================================================