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