test(tests): added test suite

The test suite is extensive. Note that LLMs were used to aid in the development of the test suite. All LLM generated code was manually reviewed.
This commit is contained in:
2026-11-09 13:53:17 -05:00
parent 74e55a6859
commit cb20faeb3d
202 changed files with 35505 additions and 953 deletions

View File

@@ -0,0 +1,71 @@
#include <array>
#include <format>
#include <iterator>
#include <string>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "serif/utils/laneemden/solution.hpp"
namespace {
namespace laneemden = serif::utils::laneemden;
constexpr laneemden::Sample sample{.xi = 2.0, .theta = 0.5, .derivative = -0.25};
/** @brief Use distinct profile and surface values so selection forwarding is observable. */
[[nodiscard]] laneemden::Solution example_solution() {
return {
.n = 1.0,
.samples = {{.xi = 0.0, .theta = 1.0, .derivative = 0.0}, sample},
.surface = laneemden::Sample{.xi = 3.0, .theta = 0.0, .derivative = -0.5}
};
}
}
TEST_CASE("Lane Emden sample formatting selects complete or individual values", "[laneemden][formatting]") {
CHECK(std::format("{}", sample) == "(ξ=2, θ=0.5, dθ/dξ=-0.25)");
CHECK(std::format("{:}", sample) == std::format("{}", sample));
CHECK(std::format("{:all}", sample) == std::format("{}", sample));
CHECK(std::format("{:xi}", sample) == "2");
CHECK(std::format("{:theta}", sample) == "0.5");
CHECK(std::format("{:derivative}", sample) == "-0.25");
CHECK(std::format("{1:theta} / {0:xi}", sample, sample) == "0.5 / 2");
CHECK(std::format("{{{:xi}}}", sample) == "{2}");
}
TEST_CASE("Lane Emden solution formatting forwards selection to its profile and surface", "[laneemden][formatting]") {
const auto solution = example_solution();
CHECK(std::format("{}", solution) == "Solution(n=1, samples=[(ξ=0, θ=1, dθ/dξ=0), (ξ=2, θ=0.5, dθ/dξ=-0.25)], surface=(ξ=3, θ=0, dθ/dξ=-0.5))");
CHECK(std::format("{:}", solution) == std::format("{}", solution));
CHECK(std::format("{:all}", solution) == std::format("{}", solution));
CHECK(std::format("{:xi}", solution) == "Solution(n=1, samples=[0, 2], surface=3)");
CHECK(std::format("{:theta}", solution) == "Solution(n=1, samples=[1, 0.5], surface=0)");
CHECK(std::format("{:derivative}", solution) == "Solution(n=1, samples=[0, -0.25], surface=-0.5)");
const laneemden::Solution empty{.n = 5.0, .samples = {}, .surface = {}};
CHECK(std::format("{}", empty) == "Solution(n=5, samples=[], surface=none)");
CHECK(std::format("{:theta}", empty) == "Solution(n=5, samples=[], surface=none)");
}
TEST_CASE("Lane Emden formatters preserve surrounding output and independent selectors", "[laneemden][formatting]") {
const auto solution = example_solution();
const std::string expected = "prefix Solution(n=1, samples=[1, 0.5], surface=0) | 2 | Solution(n=1, samples=[0, -0.25], surface=-0.5) suffix";
std::string appended = "prefix ";
std::format_to(std::back_inserter(appended), "{:theta} | {:xi} | {:derivative} suffix", solution, sample, solution);
CHECK(appended == expected);
std::array<char, 512> buffer{};
const auto end = std::format_to(buffer.data(), "prefix {:theta} | {:xi} | {:derivative} suffix", solution, sample, solution);
CHECK(std::string(buffer.data(), end) == expected);
}
TEST_CASE("Lane Emden formatters reject unsupported selectors through the central error helper", "[laneemden][formatting]") {
const auto solution = example_solution();
for (const std::string specifier : {"radius", "xi,theta", "theta ", ".2f", "theta:.2f"}) {
CAPTURE(specifier);
const std::string format = "{:" + specifier + "}";
CHECK_THROWS_AS(std::vformat(format, std::make_format_args(sample)), std::format_error);
CHECK_THROWS_AS(std::vformat(format, std::make_format_args(solution)), std::format_error);
CHECK_THROWS_WITH(std::vformat(format, std::make_format_args(sample)), Catch::Matchers::ContainsSubstring("Unknown Lane–Emden sample selector"));
}
CHECK_THROWS_AS(std::vformat("{:xi", std::make_format_args(sample)), std::format_error);
CHECK_THROWS_AS(std::vformat("{:theta", std::make_format_args(solution)), std::format_error);
}

View File

@@ -0,0 +1,152 @@
#include <cmath>
#include <limits>
#include <numbers>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/utils/laneemden/laneemden.hpp"
namespace {
namespace laneemden = serif::utils::laneemden;
namespace dimensions = serif::dimensions;
using Mass = dimensions::QuantityValue<dimensions::Mass>;
using Density = dimensions::QuantityValue<dimensions::MassDensity>;
using Length = dimensions::QuantityValue<dimensions::Length>;
using Enthalpy = dimensions::QuantityValue<dimensions::SpecificEnthalpy>;
using GravityConstant = dimensions::QuantityValue<dimensions::GravitationalConstant>;
}
TEST_CASE("Lane–Emden scaling prescriptions describe the same physical polytrope", "[laneemden][scaling]") {
for (double n : {0.5, 1.0, 1.5, 3.0}) {
const auto solution = laneemden::solve(n);
REQUIRE(solution);
const auto mass_scales = laneemden::scale_from_mass(*solution, Mass{2.0}, Density{3.0}, GravityConstant{0.7});
REQUIRE(mass_scales);
CHECK(mass_scales->mass.value() == Catch::Approx(2.0).epsilon(1e-14));
REQUIRE(mass_scales->polytropic_constant);
const auto enthalpy_scales = laneemden::scale_from_enthalpy(*solution, mass_scales->central_enthalpy, Density{3.0}, GravityConstant{0.7});
const auto eos_scales = laneemden::scale_from_polytropic_constant(*solution, *mass_scales->polytropic_constant, Density{3.0}, GravityConstant{0.7});
REQUIRE(enthalpy_scales);
REQUIRE(eos_scales);
CHECK(enthalpy_scales->radius.value() == Catch::Approx(mass_scales->radius.value()).epsilon(1e-14));
CHECK(eos_scales->radius.value() == Catch::Approx(mass_scales->radius.value()).epsilon(1e-14));
CHECK(eos_scales->mass.value() == Catch::Approx(2.0).epsilon(1e-14));
for (double fraction : {0.0, 0.1, 0.5, 0.9}) {
const auto fields = laneemden::evaluate_physical(*solution, *mass_scales, Length{fraction * mass_scales->radius.value()});
REQUIRE(fields);
CHECK(fields->pressure.value() == Catch::Approx(*mass_scales->polytropic_constant * std::pow(fields->density.value(), 1.0 + 1.0 / n)).epsilon(2e-13));
CHECK(fields->enthalpy.value() == Catch::Approx((n + 1.0) * fields->pressure.value() / fields->density.value()).epsilon(2e-13));
CHECK(fields->potential.value() + fields->enthalpy.value() == Catch::Approx(-0.7 * 2.0 / mass_scales->radius.value()).epsilon(2e-13));
}
}
}
TEST_CASE("The n equals three EOS mass is independent of central density", "[laneemden][scaling]") {
const auto solution = laneemden::solve(3.0);
REQUIRE(solution);
const auto first = laneemden::scale_from_polytropic_constant(*solution, 2.0, Density{1.0}, GravityConstant{0.7});
const auto second = laneemden::scale_from_polytropic_constant(*solution, 2.0, Density{8.0}, GravityConstant{0.7});
REQUIRE(first);
REQUIRE(second);
CHECK(second->mass.value() == Catch::Approx(first->mass.value()).epsilon(1e-14));
CHECK(second->radius.value() == Catch::Approx(0.5 * first->radius.value()).epsilon(1e-14));
CHECK(second->polytropic_constant == first->polytropic_constant);
}
TEST_CASE("Physical incompressible polytropes recover uniform sphere fields", "[laneemden][scaling][analytic]") {
const auto solution = laneemden::solve(0.0);
REQUIRE(solution);
const auto scales = laneemden::scale_from_mass(*solution, Mass{2.0}, Density{3.0}, GravityConstant{0.7});
REQUIRE(scales);
CHECK_FALSE(scales->polytropic_constant);
const double surface_radius = std::cbrt(3.0 * 2.0 / (4.0 * std::numbers::pi * 3.0));
CHECK(scales->radius.value() == Catch::Approx(surface_radius).epsilon(2e-11));
for (double fraction : {0.0, 0.01, 0.2, 0.6, 0.99}) {
const double radius = fraction * scales->radius.value();
const auto fields = laneemden::evaluate_physical(*solution, *scales, Length{radius});
REQUIRE(fields);
CHECK(fields->density.value() == 3.0);
CHECK(fields->enclosed_mass.value() == Catch::Approx(2.0 * fraction * fraction * fraction).margin(1e-10));
CHECK(fields->gravity.value() == Catch::Approx(4.0 * std::numbers::pi * 0.7 * radius).margin(1e-10));
const double enthalpy = 2.0 * std::numbers::pi * 0.7 * (surface_radius * surface_radius - radius * radius);
CHECK(fields->enthalpy.value() == Catch::Approx(enthalpy).margin(1e-10));
CHECK(fields->pressure.value() == Catch::Approx(3.0 * enthalpy).margin(3e-10));
const double potential = -0.7 * 2.0 * (3.0 - fraction * fraction) / (2.0 * surface_radius);
CHECK(fields->potential.value() == Catch::Approx(potential).margin(1e-10));
}
}
TEST_CASE("Physical profiles match the vacuum exterior continuously", "[laneemden][scaling]") {
const auto solution = laneemden::solve(1.5);
REQUIRE(solution);
const auto scales = laneemden::scale_from_mass(*solution, Mass{2.0}, Density{3.0}, GravityConstant{0.7});
REQUIRE(scales);
for (double fraction : {1.0, 1.5, 100.0}) {
const double radius = fraction * scales->radius.value();
const auto fields = laneemden::evaluate_physical(*solution, *scales, Length{radius});
REQUIRE(fields);
CHECK(fields->density.value() == 0.0);
CHECK(fields->pressure.value() == 0.0);
CHECK(fields->enthalpy.value() == 0.0);
CHECK(fields->enclosed_mass.value() == Catch::Approx(2.0));
CHECK(fields->potential.value() == Catch::Approx(-1.4 / radius));
CHECK(fields->gravity.value() == Catch::Approx(1.4 / (radius * radius)));
}
const auto inside = laneemden::evaluate_physical(*solution, *scales, Length{scales->radius.value() * (1.0 - 1e-8)});
const auto surface = laneemden::evaluate_physical(*solution, *scales, scales->radius);
REQUIRE(inside);
REQUIRE(surface);
CHECK(inside->potential.value() == Catch::Approx(surface->potential.value()).epsilon(3e-8));
CHECK(inside->gravity.value() == Catch::Approx(surface->gravity.value()).epsilon(3e-8));
CHECK(inside->enclosed_mass.value() == Catch::Approx(surface->enclosed_mass.value()).epsilon(3e-8));
}
TEST_CASE("Physical scaling is covariant between metre kilogram and centimetre gram units", "[laneemden][scaling][units]") {
const auto solution = laneemden::solve(3.0);
REQUIRE(solution);
const auto metres = laneemden::scale_from_mass(*solution, Mass{2e30}, Density{1e5}, GravityConstant{6.6743e-11});
const auto centimetres = laneemden::scale_from_mass(*solution, Mass{2e33}, Density{1e2}, GravityConstant{6.6743e-8});
REQUIRE(metres);
REQUIRE(centimetres);
CHECK(centimetres->radius.value() == Catch::Approx(100.0 * metres->radius.value()).epsilon(1e-14));
CHECK(centimetres->central_enthalpy.value() == Catch::Approx(1e4 * metres->central_enthalpy.value()).epsilon(1e-14));
for (double fraction : {0.0, 0.3, 0.9, 1.0, 3.0}) {
const auto first = laneemden::evaluate_physical(*solution, *metres, metres->radius * fraction);
const auto second = laneemden::evaluate_physical(*solution, *centimetres, centimetres->radius * fraction);
REQUIRE(first);
REQUIRE(second);
CHECK(second->density.value() == Catch::Approx(1e-3 * first->density.value()).margin(1e-12));
CHECK(second->pressure.value() == Catch::Approx(10.0 * first->pressure.value()).epsilon(1e-13));
CHECK(second->enthalpy.value() == Catch::Approx(1e4 * first->enthalpy.value()).epsilon(1e-13));
CHECK(second->potential.value() == Catch::Approx(1e4 * first->potential.value()).epsilon(1e-13));
CHECK(second->enclosed_mass.value() == Catch::Approx(1e3 * first->enclosed_mass.value()).epsilon(1e-13));
CHECK(second->gravity.value() == Catch::Approx(100.0 * first->gravity.value()).epsilon(1e-13));
}
}
TEST_CASE("Physical scaling rejects invalid data and finite interval profiles without surfaces", "[laneemden][scaling][rejection]") {
const auto solution = laneemden::solve(1.0);
const auto infinite = laneemden::solve(5.0, {.maximum_radius = 2.0});
REQUIRE(solution);
REQUIRE(infinite);
CHECK_FALSE(laneemden::scale_from_mass(*infinite, Mass{1.0}, Density{1.0}, GravityConstant{1.0}));
CHECK_FALSE(laneemden::scale_from_enthalpy(*infinite, Enthalpy{1.0}, Density{1.0}, GravityConstant{1.0}));
for (double invalid : {0.0, -1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_FALSE(laneemden::scale_from_mass(*solution, Mass{invalid}, Density{1.0}, GravityConstant{1.0}));
CHECK_FALSE(laneemden::scale_from_mass(*solution, Mass{1.0}, Density{invalid}, GravityConstant{1.0}));
CHECK_FALSE(laneemden::scale_from_mass(*solution, Mass{1.0}, Density{1.0}, GravityConstant{invalid}));
CHECK_FALSE(laneemden::scale_from_enthalpy(*solution, Enthalpy{invalid}, Density{1.0}, GravityConstant{1.0}));
CHECK_FALSE(laneemden::scale_from_polytropic_constant(*solution, invalid, Density{1.0}, GravityConstant{1.0}));
}
const auto incompressible = laneemden::solve(0.0);
REQUIRE(incompressible);
CHECK_FALSE(laneemden::scale_from_polytropic_constant(*incompressible, 1.0, Density{1.0}, GravityConstant{1.0}));
const auto scales = laneemden::scale_from_mass(*solution, Mass{1.0}, Density{1.0}, GravityConstant{1.0});
REQUIRE(scales);
for (double invalid : {-1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_FALSE(laneemden::evaluate_physical(*solution, *scales, Length{invalid}));
}
CHECK_FALSE(laneemden::evaluate_physical(*incompressible, *scales, Length{0.0}));
CHECK_FALSE(laneemden::scale_from_enthalpy(*solution, Enthalpy{1e308}, Density{1e-300}, GravityConstant{1e-300}));
}

View File

@@ -0,0 +1,155 @@
#include <cmath>
#include <limits>
#include <numbers>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/utils/laneemden/solution.hpp"
namespace {
namespace laneemden = serif::utils::laneemden;
/** @brief The three analytic regular-center Lane–Emden profiles and their derivatives. */
laneemden::Sample analytic(double n, double xi) {
if (n == 0.0) {
return {.xi = xi, .theta = 1.0 - xi * xi / 6.0, .derivative = -xi / 3.0};
}
if (n == 1.0) {
if (xi == 0.0) {
return {.xi = 0.0, .theta = 1.0, .derivative = 0.0};
}
return {.xi = xi, .theta = std::sin(xi) / xi, .derivative = (xi * std::cos(xi) - std::sin(xi)) / (xi * xi)};
}
const double denominator = 1.0 + xi * xi / 3.0;
return {.xi = xi, .theta = 1.0 / std::sqrt(denominator), .derivative = -xi / (3.0 * std::pow(denominator, 1.5))};
}
}
TEST_CASE("Lane–Emden matches all regular-center analytic solutions", "[laneemden][analytic]") {
for (double n : {0.0, 1.0, 5.0}) {
CAPTURE(n);
const auto solution = laneemden::solve(n, {.maximum_radius = 20.0});
REQUIRE(solution);
REQUIRE(solution->samples.front().xi == 0.0);
REQUIRE(solution->samples.front().theta == 1.0);
REQUIRE(solution->samples.front().derivative == 0.0);
const double endpoint = solution->samples.back().xi;
for (int index = 0; index <= 400; ++index) {
const double xi = endpoint * static_cast<double>(index) / 400.0;
const auto actual = laneemden::evaluate(*solution, xi);
REQUIRE(actual);
const laneemden::Sample exact = analytic(n, xi);
CHECK(actual->theta == Catch::Approx(exact.theta).margin(3e-10));
CHECK(actual->derivative == Catch::Approx(exact.derivative).margin(3e-9));
}
if (n < 5.0) {
REQUIRE(solution->surface);
const double exact_radius = n == 0.0 ? std::sqrt(6.0) : std::numbers::pi;
CHECK(solution->surface->xi == Catch::Approx(exact_radius).margin(2e-9));
CHECK(solution->surface->derivative == Catch::Approx(analytic(n, exact_radius).derivative).margin(2e-9));
CHECK(solution->samples.back().theta == 0.0);
} else {
CHECK_FALSE(solution->surface);
CHECK(endpoint == 20.0);
}
}
}
TEST_CASE("Lane–Emden advances the coupled system at fourth order", "[laneemden][convergence]") {
double previous_error = 0.0;
for (double step : {0.08, 0.04, 0.02, 0.01}) {
const auto solution = laneemden::solve(1.0, {.step = step, .maximum_radius = 2.0});
REQUIRE(solution);
const double error = std::abs(solution->samples.back().theta - analytic(1.0, 2.0).theta);
if (previous_error > 0.0) {
CHECK(previous_error / error > 14.0);
CHECK(previous_error / error < 18.0);
}
previous_error = error;
}
}
TEST_CASE("Lane–Emden finds stellar surfaces for fractional and centrally concentrated profiles", "[laneemden][surface]") {
struct Reference final {
double n;
double radius;
double mass;
double mass_tolerance;
};
// Standard first-zero values provide an independent endpoint check beyond the analytic indices.
for (const Reference reference : {
Reference{.n = 0.5, .radius = 2.752698054065, .mass = 3.78865118488, .mass_tolerance = 2e-8},
Reference{.n = 1.5, .radius = 3.653753736219, .mass = 2.714055120109, .mass_tolerance = 2e-9},
Reference{.n = 3.0, .radius = 6.896848619377, .mass = 2.018235950966, .mass_tolerance = 2e-9}}) {
CAPTURE(reference.n);
const auto solution = laneemden::solve(reference.n, {.step = 0.0005});
REQUIRE(solution);
REQUIRE(solution->surface);
const laneemden::Sample surface = *solution->surface;
CHECK(surface.xi == Catch::Approx(reference.radius).margin(2e-9));
CHECK(-surface.xi * surface.xi * surface.derivative == Catch::Approx(reference.mass).margin(reference.mass_tolerance));
for (std::size_t index = 1; index < solution->samples.size(); ++index) {
const laneemden::Sample &previous = solution->samples[index - 1];
const laneemden::Sample &current = solution->samples[index];
REQUIRE(current.xi > previous.xi);
REQUIRE(current.theta >= 0.0);
REQUIRE(current.theta < previous.theta);
REQUIRE(std::isfinite(current.derivative));
}
}
}
TEST_CASE("Fractional-index surface values converge without taking nonreal powers", "[laneemden][convergence]") {
double previous_error = 0.0;
for (double step : {0.04, 0.02, 0.01, 0.005}) {
const auto solution = laneemden::solve(0.5, {.step = step});
REQUIRE(solution);
REQUIRE(solution->surface);
const laneemden::Sample surface = *solution->surface;
const double error = std::abs(-surface.xi * surface.xi * surface.derivative - 3.78865118488);
if (previous_error > 0.0) {
CHECK(error < 0.45 * previous_error);
}
previous_error = error;
}
}
TEST_CASE("Lane–Emden distinguishes finite interval profiles from failures", "[laneemden]") {
for (double n : {1.0, 5.0, 6.0}) {
const auto solution = laneemden::solve(n, {.maximum_radius = 1.0});
REQUIRE(solution);
CHECK_FALSE(solution->surface);
CHECK(solution->samples.back().xi == 1.0);
CHECK(solution->samples.back().theta > 0.0);
}
const auto exhausted = laneemden::solve(3.0, {.maximum_steps = 2});
CHECK_FALSE(exhausted);
const auto tiny_interval = laneemden::solve(1.0, {.maximum_radius = 1e-6});
REQUIRE(tiny_interval);
CHECK(tiny_interval->samples.size() == 2);
CHECK(tiny_interval->samples.back().derivative == Catch::Approx(-1e-6 / 3.0).margin(1e-18));
}
TEST_CASE("Lane–Emden rejects invalid controls and evaluation radii", "[laneemden][rejection]") {
const double infinity = std::numeric_limits<double>::infinity();
const double nan = std::numeric_limits<double>::quiet_NaN();
for (double invalid : {-1.0, infinity, nan}) {
CHECK_FALSE(laneemden::solve(invalid));
}
for (double invalid : {0.0, -1.0, infinity, nan}) {
CHECK_FALSE(laneemden::solve(1.0, {.step = invalid}));
CHECK_FALSE(laneemden::solve(1.0, {.maximum_radius = invalid}));
CHECK_FALSE(laneemden::solve(1.0, {.surface_tolerance = invalid}));
}
CHECK_FALSE(laneemden::solve(1.0, {.maximum_steps = 0}));
CHECK_FALSE(laneemden::solve(1.0, {.surface_tolerance = 0.01}));
CHECK_FALSE(laneemden::solve(1.0, {.central_series_terms = 5}));
CHECK_FALSE(laneemden::solve(1.0, {.central_series_terms = 65}));
const auto solution = laneemden::solve(1.0);
REQUIRE(solution);
for (double invalid : {-1.0, infinity, nan, 4.0}) {
CHECK_FALSE(laneemden::evaluate(*solution, invalid));
}
CHECK_FALSE(laneemden::evaluate(laneemden::Solution{.n = 1.0, .samples = {}, .surface = {}}, 0.0));
}