diff --git a/pixi.toml b/pixi.toml index ab6c977e..f0023fba 100644 --- a/pixi.toml +++ b/pixi.toml @@ -92,7 +92,11 @@ user = { features = ['py-max', 'user'] } # 🧪 Testing Tasks ################## -unit-tests = 'python -m pytest tests/unit/ --color=yes -v' +# The bulk of the unit-test suite still lives at the top level of tests/ +# (pending migration into tests/unit/), so run the whole tree minus the +# functional and integration subtrees -- otherwise CI coverage only sees +# the handful of files under tests/unit/. +unit-tests = 'python -m pytest tests/ --ignore=tests/functional --ignore=tests/integration --color=yes -v' functional-tests = 'python -m pytest tests/functional/ --color=yes -v' # No -n auto: importing easyreflectometry pulls in arviz, and arviz 0.23.4 # (py-311-env) writes a "warn once per day" stamp file on import via a diff --git a/tests/test_bayesian.py b/tests/test_bayesian.py index 15404645..1cb78d4f 100644 --- a/tests/test_bayesian.py +++ b/tests/test_bayesian.py @@ -474,3 +474,409 @@ def test_in_analysis_namespace(self): assert hasattr(analysis, 'plot_distribution') assert 'plot_distribution' in analysis.__all__ + + +# =================================================================== +# Label wrapping helper +# =================================================================== + + +class TestWrapPairLabel: + def test_empty_string_unchanged(self): + from easyreflectometry.analysis.bayesian import _wrap_pair_label + + assert _wrap_pair_label('') == '' + + def test_short_name_unchanged(self): + from easyreflectometry.analysis.bayesian import _wrap_pair_label + + assert _wrap_pair_label('thickness') == 'thickness' + + def test_dotted_name_breaks_on_dots(self): + from easyreflectometry.analysis.bayesian import _wrap_pair_label + + assert _wrap_pair_label('layer1.thickness') == 'layer1.
thickness' + + def test_long_multiword_name_wraps(self): + from easyreflectometry.analysis.bayesian import _wrap_pair_label + + wrapped = _wrap_pair_label('a very long parameter name indeed', max_len=16) + assert '
' in wrapped + assert wrapped.replace('
', ' ') == 'a very long parameter name indeed' + + def test_long_single_word_unchanged(self): + from easyreflectometry.analysis.bayesian import _wrap_pair_label + + name = 'averyveryverylongsingleword' + assert _wrap_pair_label(name, max_len=16) == name + + +# =================================================================== +# Optional-dependency guards +# =================================================================== + + +class TestRequireHelpers: + def test_require_arviz_raises_when_unavailable(self, monkeypatch): + from easyreflectometry.analysis import bayesian as bayesian_mod + + monkeypatch.setattr(bayesian_mod, '_HAS_ARVIZ', False) + with pytest.raises(ImportError, match='arviz'): + bayesian_mod._require_arviz() + + def test_require_plotly_raises_when_unavailable(self, monkeypatch): + import builtins + + from easyreflectometry.analysis.bayesian import _require_plotly + + real_import = builtins.__import__ + + def _fake_import(name, *args, **kwargs): + if name.startswith('plotly'): + raise ImportError('plotly disabled for test') + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, '__import__', _fake_import) + with pytest.raises(ImportError, match='plotly'): + _require_plotly() + + def test_gelman_rubin_warns_and_returns_none_without_arviz(self, sample_draws, monkeypatch): + from easyreflectometry.analysis import bayesian as bayesian_mod + from easyreflectometry.analysis.bayesian import PosteriorResults + + draws, param_names = sample_draws + pr = PosteriorResults(draws, param_names) + monkeypatch.setattr(bayesian_mod, '_HAS_ARVIZ', False) + with pytest.warns(UserWarning, match='arviz'): + result = pr.gelman_rubin() + assert result is None + + +# =================================================================== +# arviz data conversion +# =================================================================== + + +class TestToArvizData: + def test_2d_draws_become_single_chain(self, sample_draws): + pytest.importorskip('arviz') + from easyreflectometry.analysis.bayesian import _to_arviz_data + + draws, param_names = sample_draws + idata = _to_arviz_data(draws, param_names) + posterior = idata.posterior + assert posterior.sizes['chain'] == 1 + assert posterior.sizes['draw'] == draws.shape[0] + for name in param_names: + assert name in posterior + + +# =================================================================== +# Plot construction (plotly available) +# =================================================================== + + +class TestPlotTraceFigure: + def test_returns_figure_for_2d_draws(self, sample_draws): + Figure = pytest.importorskip('plotly.graph_objects').Figure + from easyreflectometry.analysis.bayesian import plot_trace + + draws, param_names = sample_draws + fig = plot_trace(draws, param_names, return_figure=True) + assert isinstance(fig, Figure) + # One line trace and one histogram per parameter for the single chain. + assert len(fig.data) == 2 * len(param_names) + + def test_returns_figure_for_multi_chain_draws(self, sample_draws): + Figure = pytest.importorskip('plotly.graph_objects').Figure + from easyreflectometry.analysis.bayesian import plot_trace + + draws, param_names = sample_draws + multi = np.stack([draws, draws + 1.0], axis=0) # (2, n_draws, n_params) + fig = plot_trace(multi, param_names, return_figure=True) + assert isinstance(fig, Figure) + assert len(fig.data) == 2 * 2 * len(param_names) + + def test_inline_path_delegates_to_arviz(self, sample_draws, monkeypatch): + pytest.importorskip('arviz') + from unittest.mock import MagicMock + + from easyreflectometry.analysis import bayesian as bayesian_mod + + draws, param_names = sample_draws + mock_plot = MagicMock() + monkeypatch.setattr(bayesian_mod._arviz, 'plot_trace', mock_plot) + result = bayesian_mod.plot_trace(draws, param_names) + assert result is None + mock_plot.assert_called_once() + + +class TestPlotDistributionFigure: + def test_returns_none_without_return_figure(self, sample_draws): + from easyreflectometry.analysis.bayesian import plot_distribution + + draws, param_names = sample_draws + assert plot_distribution(draws, param_names) is None + + def test_returns_figure_with_expected_overlays(self, sample_draws): + Figure = pytest.importorskip('plotly.graph_objects').Figure + from easyreflectometry.analysis.bayesian import plot_distribution + + draws, param_names = sample_draws + logp = np.arange(draws.shape[0], dtype=float) + fig = plot_distribution(draws, param_names, logp=logp, return_figure=True) + assert isinstance(fig, Figure) + trace_names = {trace.name for trace in fig.data} + assert 'Posterior histogram' in trace_names + assert '95% credible interval' in trace_names + assert 'Median' in trace_names + # logp was supplied, so the best posterior sample line must be drawn. + assert 'Best posterior sample' in trace_names + + def test_accepts_3d_draws(self, sample_draws): + Figure = pytest.importorskip('plotly.graph_objects').Figure + from easyreflectometry.analysis.bayesian import plot_distribution + + draws, param_names = sample_draws + multi = np.stack([draws, draws], axis=0) # (2, n_draws, n_params) + fig = plot_distribution(multi, param_names, return_figure=True) + assert isinstance(fig, Figure) + + +class TestPosteriorResultsPlotDelegates: + def test_corner_returns_figure(self, sample_draws): + Figure = pytest.importorskip('plotly.graph_objects').Figure + from easyreflectometry.analysis.bayesian import PosteriorResults + + draws, param_names = sample_draws + fig = PosteriorResults(draws, param_names).corner() + assert isinstance(fig, Figure) + + def test_distribution_returns_figure(self, sample_draws): + Figure = pytest.importorskip('plotly.graph_objects').Figure + from easyreflectometry.analysis.bayesian import PosteriorResults + + draws, param_names = sample_draws + fig = PosteriorResults(draws, param_names).distribution() + assert isinstance(fig, Figure) + + def test_trace_delegates_to_plot_trace(self, sample_draws, monkeypatch): + from unittest.mock import MagicMock + + from easyreflectometry.analysis import bayesian as bayesian_mod + from easyreflectometry.analysis.bayesian import PosteriorResults + + draws, param_names = sample_draws + mock_plot = MagicMock() + monkeypatch.setattr(bayesian_mod, 'plot_trace', mock_plot) + PosteriorResults(draws, param_names).trace() + mock_plot.assert_called_once() + + +class TestPlotCornerEdgeCases: + def test_accepts_3d_draws(self, sample_draws): + Figure = pytest.importorskip('plotly.graph_objects').Figure + from easyreflectometry.analysis.bayesian import plot_corner + + draws, param_names = sample_draws + multi = np.stack([draws, draws], axis=0) + fig = plot_corner(multi, param_names) + assert isinstance(fig, Figure) + + def test_thins_scatter_for_large_posteriors(self): + go = pytest.importorskip('plotly.graph_objects') + from easyreflectometry.analysis.bayesian import _POSTERIOR_PAIR_SCATTER_MAX_POINTS + from easyreflectometry.analysis.bayesian import plot_corner + + rng = np.random.default_rng(3) + n_samples = _POSTERIOR_PAIR_SCATTER_MAX_POINTS * 2 + draws = rng.normal(size=(n_samples, 2)) + fig = plot_corner(draws, ['a', 'b']) + scatters = [t for t in fig.data if isinstance(t, go.Scatter) and t.name == 'Posterior samples'] + assert scatters + assert all(len(t.x) <= _POSTERIOR_PAIR_SCATTER_MAX_POINTS for t in scatters) + + def test_single_sample_falls_back_to_histogram(self): + go = pytest.importorskip('plotly.graph_objects') + from easyreflectometry.analysis.bayesian import plot_corner + + # A single draw defeats the KDE, so the diagonal must fall back to a + # histogram and the pair panels must omit contours. + draws = np.array([[250.0, 2.0]]) + fig = plot_corner(draws, ['thickness', 'sld']) + assert any(isinstance(t, go.Histogram) for t in fig.data) + assert not any(isinstance(t, go.Contour) for t in fig.data) + + +# =================================================================== +# Density-estimation helpers +# =================================================================== + + +class TestPosteriorAxisBounds: + def test_empty_returns_none(self): + from easyreflectometry.analysis.bayesian import _posterior_axis_bounds + + assert _posterior_axis_bounds(np.array([])) is None + assert _posterior_axis_bounds(np.array([np.nan, np.inf])) is None + + def test_constant_values_get_padding(self): + from easyreflectometry.analysis.bayesian import _posterior_axis_bounds + + lo, hi = _posterior_axis_bounds(np.array([5.0, 5.0, 5.0])) + assert lo < 5.0 < hi + + def test_constant_zero_gets_padding(self): + from easyreflectometry.analysis.bayesian import _posterior_axis_bounds + + lo, hi = _posterior_axis_bounds(np.zeros(3)) + assert lo < 0.0 < hi + + +class TestPosteriorDensityCurve: + def test_too_few_samples_returns_none(self): + from easyreflectometry.analysis.bayesian import _posterior_density_curve + + assert _posterior_density_curve(np.array([1.0])) is None + + def test_constant_samples_yield_gaussian_bump(self): + pytest.importorskip('scipy') + from easyreflectometry.analysis.bayesian import _posterior_density_curve + + result = _posterior_density_curve(np.full(50, 3.0)) + assert result is not None + grid, density = result + # Density peaks at the constant value and integrates to ~1. + assert grid[np.argmax(density)] == pytest.approx(3.0, abs=(grid[1] - grid[0])) + assert np.trapezoid(density, grid) == pytest.approx(1.0, rel=1e-6) + + def test_returns_none_without_scipy(self, sample_draws, monkeypatch): + import builtins + + from easyreflectometry.analysis.bayesian import _posterior_density_curve + + real_import = builtins.__import__ + + def _fake_import(name, *args, **kwargs): + if name.startswith('scipy'): + raise ImportError('scipy disabled for test') + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, '__import__', _fake_import) + draws, _ = sample_draws + assert _posterior_density_curve(draws[:, 0]) is None + + +class TestPosteriorDensitySurface: + def test_degenerate_samples_return_none(self): + pytest.importorskip('scipy') + from easyreflectometry.analysis.bayesian import _posterior_density_surface + + constant = np.full(50, 1.0) + # Both axes constant. + assert _posterior_density_surface(constant, constant) is None + # One axis constant: rank-deficient covariance. + rng = np.random.default_rng(11) + assert _posterior_density_surface(constant, rng.normal(size=50)) is None + + def test_too_few_finite_samples_return_none(self): + pytest.importorskip('scipy') + from easyreflectometry.analysis.bayesian import _posterior_density_surface + + x = np.array([1.0, np.nan, np.nan]) + y = np.array([2.0, np.nan, np.nan]) + assert _posterior_density_surface(x, y) is None + + def test_valid_samples_return_grids(self, sample_draws): + pytest.importorskip('scipy') + from easyreflectometry.analysis.bayesian import _posterior_density_surface + + draws, _ = sample_draws + result = _posterior_density_surface(draws[:, 0], draws[:, 1]) + assert result is not None + x_grid, y_grid, density = result + assert density.shape == (len(y_grid), len(x_grid)) + + def test_returns_none_without_scipy(self, sample_draws, monkeypatch): + import builtins + + from easyreflectometry.analysis.bayesian import _posterior_density_surface + + real_import = builtins.__import__ + + def _fake_import(name, *args, **kwargs): + if name.startswith('scipy'): + raise ImportError('scipy disabled for test') + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, '__import__', _fake_import) + draws, _ = sample_draws + assert _posterior_density_surface(draws[:, 0], draws[:, 1]) is None + + +class TestPosteriorContourColorscales: + def test_negative_correlation_selects_red_palette(self): + from easyreflectometry.analysis.bayesian import _POSTERIOR_NEGATIVE_CONTOUR_FILL_COLORSCALE + from easyreflectometry.analysis.bayesian import _posterior_contour_colorscales + + x = np.linspace(0, 1, 50) + fill, _ = _posterior_contour_colorscales(x, -x) + assert fill is _POSTERIOR_NEGATIVE_CONTOUR_FILL_COLORSCALE + + def test_positive_correlation_selects_blue_palette(self): + from easyreflectometry.analysis.bayesian import _POSTERIOR_CONTOUR_FILL_COLORSCALE + from easyreflectometry.analysis.bayesian import _posterior_contour_colorscales + + x = np.linspace(0, 1, 50) + fill, _ = _posterior_contour_colorscales(x, x) + assert fill is _POSTERIOR_CONTOUR_FILL_COLORSCALE + + +class TestPosteriorMarginalYRange: + def test_covers_histogram_and_kde_peaks(self, sample_draws): + from easyreflectometry.analysis.bayesian import _posterior_density_curve + from easyreflectometry.analysis.bayesian import _posterior_marginal_y_range + + draws, _ = sample_draws + values = draws[:, 0] + curve = _posterior_density_curve(values) + y_range = _posterior_marginal_y_range(values, curve) + assert y_range is not None + lo, hi = y_range + assert lo == 0.0 + hist, _ = np.histogram(values, bins=40, density=True) + assert hi >= np.max(hist) + + def test_no_data_returns_none(self): + from easyreflectometry.analysis.bayesian import _posterior_marginal_y_range + + assert _posterior_marginal_y_range(np.array([]), None) is None + + +# =================================================================== +# Metadata helpers +# =================================================================== + + +class TestMetadataHelpers: + def test_version_returns_string(self): + from easyreflectometry.analysis.bayesian import _easyreflectometry_version + + assert isinstance(_easyreflectometry_version(), str) + + def test_data_fingerprint_is_deterministic(self): + from easyreflectometry.analysis.bayesian import _data_fingerprint + + x = [np.array([1.0, 2.0])] + y = [np.array([3.0, 4.0])] + w = [np.array([0.1, 0.2])] + first = _data_fingerprint(x, y, w) + assert isinstance(first, str) + assert len(first) == 64 + assert _data_fingerprint(x, y, w) == first + assert _data_fingerprint(x, y, [np.array([0.1, 0.3])]) != first + + def test_data_fingerprint_returns_none_on_bad_input(self): + from easyreflectometry.analysis.bayesian import _data_fingerprint + + assert _data_fingerprint([object()], [], []) is None