Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/reference/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Unreleased changes

### CI/CD

- Improve the unit test suite for the `ridgeplot._hist` module ({gh-pr}`365`)
- Review the coverage configuration in light of `covdefaults`, adopting its `assert_never` exclusion and `skip_covered` report setting, and raising all package coverage gates to 100% ({gh-pr}`390`)
- Adopt pytest's strict mode, following the recommendations from pytest's "Good Integration Practices" guide ({gh-pr}`387`)
- Make the e2e path sanity check independent of the checkout directory's name, so tests can run from git worktrees ({gh-pr}`391`)
Expand Down
225 changes: 161 additions & 64 deletions tests/unit/test_hist.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np
import pytest

Expand All @@ -8,97 +10,192 @@
bin_trace_samples,
)

# Example data

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)
if TYPE_CHECKING:
from collections.abc import Callable
from typing import Any

WEIGHTS = [1, 1, 1, 1, 9]
NON_FINITE_VALUES = [np.inf, -np.inf, np.nan]

# ==============================================================
# --- estimate_density_trace()
# --- bin_trace_samples()
# ==============================================================


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
# --- Basic functionality ---


@pytest.mark.parametrize(
("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 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)]),
],
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


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((7 / 30, 0.5, 23 / 30))
assert y_vals == (1.0, 1.0, 1.0)


@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("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(
"non_finite_value",
[np.inf, np.nan, float("inf"), float("nan")],
ids=["np-inf", "np-nan", "float-inf", "float-nan"],
"input_type",
[list, tuple, np.asarray],
ids=["list", "tuple", "ndarray"],
)
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_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
# 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 {type(value) for xy_pair in result for value in xy_pair} == {float}


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_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_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")],
ids=["np-inf", "np-nan", "float-inf", "float-nan"],
("samples", "weights", "nbins", "expected_counts"),
[
# 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]),
# Fractional weights
([1, 2], [0.5, 1.5], 2, [0.5, 1.5]),
],
ids=["heavy_first", "zero_weight", "fractional"],
)
def test_bin_trace_samples_weights_fails_for_non_finite_values(
non_finite_value: float,
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", 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)
Comment on lines +132 to +135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove stale tests that reference deleted constants

The new non-finite-value cases duplicate rather than replace the existing tests at lines 71–74 and 172–180, while this patch deletes SAMPLES_IN, NBINS, and WEIGHTS. Consequently, collecting this file runs eight cases that raise NameError before reaching bin_trace_samples; the targeted pytest suite reports 8 failures. Delete those stale tests or make them self-contained so the unit suite passes.

AGENTS.md reference: AGENTS.md:L163-L163

Useful? React with 👍 / 👎.



@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])


@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:
samples = [1, 2, 2, 3, 4]
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=[[SAMPLES_IN], [SAMPLES_IN]],
nbins=NBINS,
samples=[[trace_a], [trace_b]],
nbins=2,
sample_weights=[weights_a, None],
)
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 densities == [
[bin_trace_samples(trace_a, nbins=2, weights=weights_a)],
[bin_trace_samples(trace_b, nbins=2)],
]
Loading