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

267
tests/serialize/top.cpp Normal file
View File

@@ -0,0 +1,267 @@
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <limits>
#include <numbers>
#include <string>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "serif/utils/serialize/top.hpp"
namespace {
namespace serialization = serif::utils::serialize;
constexpr double pi = std::numbers::pi;
const double monopole_basis = 1/std::sqrt(4*pi);
struct Directory {
std::filesystem::path path;
Directory() {
static std::atomic<unsigned> next{0};
path = std::filesystem::temp_directory_path()/("serif-top-test-"+
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+"-"+std::to_string(next++));
}
~Directory() { std::error_code error; std::filesystem::remove_all(path, error); }
};
serialization::TopPolytropeSource sphere() {
return {.polytropic_index=1., .gravitational_constant=1., .mass=4./pi, .angular_velocity=0.,
.central_density=1., .central_enthalpy=4./pi,
.surface_radius=[](double, double)->serialization::Result<double> { return 1.; },
.enthalpy=[](double radius, double, double)->serialization::Result<double> {
if (radius==0.) { return 4./pi; }
if (radius==1.) { return 0.; }
return (4./pi)*std::sin(pi*radius)/(pi*radius);
}};
}
serialization::TopExportOptions small_options() {
return {.radial_points=32, .maximum_degree=4, .angular_points=12, .azimuthal_points=4};
}
struct Data {
int nr{}, degree{};
std::vector<double> scales, surface;
std::vector<std::vector<double>> h, hz, hzz;
};
Data read(const std::filesystem::path& path) {
Data result;
std::ifstream scales(path/"lambda_eps");
double value;
while (scales >> value) { result.scales.push_back(value); }
REQUIRE(result.scales.size()==9);
std::ifstream surface(path/"domains_boundaries");
surface >> result.degree;
for (int l=0; l<=result.degree; ++l) { surface >> value; CHECK(value==0.); }
for (int l=0; l<=result.degree; ++l) { surface >> value; result.surface.push_back(value); }
std::ifstream fields(path/"enthalpy_et_der");
int count;
fields >> result.nr >> count;
REQUIRE(count==result.degree+1);
std::string line;
std::getline(fields, line); std::getline(fields, line);
int index;
for (int k=0; k<=result.degree/2; ++k) {
fields >> index; REQUIRE(index==k+1);
for (int i=0; i<result.nr; ++i) { fields >> value; }
}
auto block = [&]() {
std::getline(fields, line); std::getline(fields, line);
std::vector<std::vector<double>> values(result.degree+1, std::vector<double>(result.nr));
for (int l=0; l<=result.degree; l+=2) {
fields >> index; REQUIRE(index==l);
for (double& sample : values[l]) { fields >> sample; REQUIRE(std::isfinite(sample)); }
}
return values;
};
result.h=block(); result.hz=block(); result.hzz=block();
REQUIRE(fields.good());
return result;
}
bool close(double actual, double expected, double tolerance) {
return std::abs(actual-expected)<=tolerance*std::max(1., std::abs(expected));
}
}
TEST_CASE("TOP export reproduces the analytic n1 sphere and Chebyshev derivatives", "[serialize][top]") {
const Directory directory;
const auto result=serialization::export_top(directory.path, sphere(), small_options());
if (!result) { INFO(result.error().path << ": " << result.error().message); }
REQUIRE(result);
CHECK(close(result->lambda, pi*pi, 2e-14));
CHECK(close(result->alpha, pi*pi/3, 2e-14));
CHECK(close(result->angular_frequency_unit, std::sqrt(4*pi), 2e-14));
CHECK(std::abs(result->flattening)<1e-14);
CHECK(result->maximum_relative_surface_projection_error<1e-13);
CHECK(result->minimum_enthalpy==0.);
const auto data=read(directory.path);
CHECK(close(data.surface[0], 1/monopole_basis, 2e-14));
CHECK(close(data.h[0][0]*monopole_basis, 1., 2e-14));
CHECK(data.h[0].back()==0.);
CHECK(std::abs(data.hz[0][0]*monopole_basis)<1e-11);
CHECK(close(data.hzz[0][0]*monopole_basis, -pi*pi/3, 1e-8));
CHECK(close(data.hz[0].back()*monopole_basis, -1., 1e-11));
CHECK(close(data.hzz[0].back()*monopole_basis, 2., 1e-8));
for (int i=0; i<data.nr; ++i) {
const double z=.5*(1-std::cos(pi*i/(data.nr-1)));
const double expected=z==0. ? 1. : std::sin(pi*z)/(pi*z);
CHECK(close(data.h[0][i]*monopole_basis, expected, 2e-14));
for (int l=2; l<=data.degree; l+=2) { CHECK(std::abs(data.h[l][i])<1e-13); }
}
CHECK(std::filesystem::exists(directory.path/"metadata.txt"));
}
TEST_CASE("TOP export projects distorted geometry and derivatives at fixed surface coordinate", "[serialize][top]") {
const Directory directory;
auto radius=[](double mu) { return 2*(1-.12*(3*mu*mu-1)/2); };
serialization::TopPolytropeSource source{
.polytropic_index=1., .gravitational_constant=2., .mass=7., .angular_velocity=.31,
.central_density=3., .central_enthalpy=5.,
.surface_radius=[&](double mu, double)->serialization::Result<double> { return radius(mu); },
.enthalpy=[&](double r, double mu, double)->serialization::Result<double> {
const double surface=radius(mu);
if (r==0.) { return 5.; }
if (r==surface) { return 0.; }
double lower=0., upper=1.;
for (int step=0; step<60; ++step) {
const double z=(lower+upper)/2, a=(5*z*z*z-3*std::pow(z,5))/2;
if (1.76*z+a*(surface-1.76)<r) { lower=z; } else { upper=z; }
}
const double z=(lower+upper)/2;
return 5*(1-z*z)*(1+.1*z*z*(3*mu*mu-1)/2);
}};
const auto result=serialization::export_top(directory.path, source, small_options());
if (!result) { INFO(result.error().message); }
REQUIRE(result);
CHECK(close(result->source_equatorial_radius, 2.12, 1e-14));
CHECK(close(result->equatorial_radius, 2.12, 1e-14));
CHECK(close(result->polar_radius, 1.76, 1e-14));
CHECK(result->maximum_relative_surface_projection_error<1e-13);
CHECK(close(result->rotation_over_kepler/std::sqrt(3*result->alpha), .31/std::sqrt(24*pi), 1e-14));
const auto data=read(directory.path);
CHECK(close(data.surface[0], 2/(2.12*monopole_basis), 1e-13));
CHECK(close(data.surface[2], -.24*std::sqrt(4*pi/5)/2.12, 1e-13));
for (int i=0; i<data.nr; ++i) {
const double z=.5*(1-std::cos(pi*i/(data.nr-1)));
CHECK(close(data.h[0][i]*monopole_basis, 1-z*z, 1e-12));
CHECK(close(data.h[2][i]/std::sqrt(4*pi/5), .1*z*z*(1-z*z), 1e-12));
CHECK(close(data.hz[0][i]*monopole_basis, -2*z, 1e-10));
CHECK(close(data.hz[2][i]/std::sqrt(4*pi/5), .1*(2*z-4*z*z*z), 1e-10));
CHECK(close(data.hzz[0][i]*monopole_basis, -2., 2e-8));
CHECK(close(data.hzz[2][i]/std::sqrt(4*pi/5), .1*(2-12*z*z), 2e-8));
}
}
TEST_CASE("TOP dimensionless outputs are invariant under consistent physical unit changes", "[serialize][top]") {
const Directory first, second;
auto source=sphere(); source.angular_velocity=.2;
auto scaled=source;
scaled.gravitational_constant*=2;
scaled.central_density*=5;
scaled.mass*=135;
scaled.central_enthalpy*=90;
scaled.angular_velocity*=std::sqrt(10.);
scaled.surface_radius=[](double, double)->serialization::Result<double> { return 3.; };
scaled.enthalpy=[source](double r, double mu, double phi)->serialization::Result<double> { return 90*source.enthalpy(r/3,mu,phi).value(); };
const auto a=serialization::export_top(first.path,source,small_options());
const auto b=serialization::export_top(second.path,scaled,small_options());
REQUIRE(a); REQUIRE(b);
CHECK(close(a->lambda,b->lambda,1e-13)); CHECK(close(a->alpha,b->alpha,1e-13));
CHECK(close(a->rotation,b->rotation,1e-13)); CHECK(close(a->rotation_over_kepler,b->rotation_over_kepler,1e-13));
CHECK(close(b->angular_frequency_unit/a->angular_frequency_unit,std::sqrt(10.),1e-13));
}
TEST_CASE("TOP rejects unsupported backgrounds and reports sampler failures before publication", "[serialize][top]") {
const Directory directory;
auto source=sphere(); auto options=small_options();
SECTION("odd angular degree") { options.maximum_degree=3; }
SECTION("undersampled angular projection") { options.angular_points=4; }
SECTION("invalid resolution") { options.radial_points=1; }
SECTION("invalid tolerance") { options.relative_surface_tolerance=-1; }
SECTION("missing callback") { source.enthalpy={}; }
SECTION("invalid units") { source.gravitational_constant=0; }
SECTION("singular vacuum index") { source.polytropic_index=.5; }
SECTION("nonfinite rotation") { source.angular_velocity=std::numeric_limits<double>::quiet_NaN(); }
SECTION("invalid surface") { source.surface_radius=[](double,double)->serialization::Result<double> { return -1.; }; }
SECTION("nonaxisymmetric surface") { source.surface_radius=[](double,double phi)->serialization::Result<double> { return 1+.1*std::cos(phi); }; }
SECTION("north south asymmetry") { source.surface_radius=[](double mu,double)->serialization::Result<double> { return 1+.1*mu; }; }
SECTION("surface projection loss") { source.surface_radius=[](double mu,double)->serialization::Result<double> { return 1+.1*(231*std::pow(mu,6)-315*std::pow(mu,4)+105*mu*mu-5)/16; }; }
SECTION("degenerate vacuum surface slope") { source.enthalpy=[](double r,double,double)->serialization::Result<double> { return 4/pi*std::pow(1-r*r,2); }; }
SECTION("finite pressure surface") { source.enthalpy=[](double,double,double)->serialization::Result<double> { return 4/pi; }; }
SECTION("negative interior") { source.enthalpy=[](double r,double,double)->serialization::Result<double> { return r==0. ? 4/pi : -.01; }; }
SECTION("zero interior") { source.enthalpy=[](double r,double,double)->serialization::Result<double> { return r==0. ? 4/pi : 0.; }; }
SECTION("nonaxisymmetric enthalpy") { source.enthalpy=[](double r,double,double phi)->serialization::Result<double> { return 4/pi*(1-r*r)*(1+.1*r*std::cos(phi)); }; }
SECTION("nonfinite enthalpy") { source.enthalpy=[](double,double,double)->serialization::Result<double> { return std::numeric_limits<double>::infinity(); }; }
SECTION("sampler error path") { source.enthalpy=[](double,double,double)->serialization::Result<double> { return std::unexpected(serialization::Error{"element/7","Sample unavailable"}); }; }
const auto result=serialization::export_top(directory.path,source,options);
REQUIRE_FALSE(result);
CHECK_FALSE(result.error().path.empty());
CHECK_FALSE(std::filesystem::exists(directory.path));
}
TEST_CASE("TOP export never replaces destinations including a destination created during sampling", "[serialize][top]") {
const Directory directory;
auto source=sphere();
SECTION("existing empty directory") { std::filesystem::create_directory(directory.path); }
SECTION("existing nonempty directory") { std::filesystem::create_directory(directory.path); std::ofstream(directory.path/"preserved") << "original"; }
SECTION("existing file") { std::ofstream(directory.path) << "original"; }
SECTION("publication race") {
source.roundoff_density_clamps=[&]() {
std::filesystem::create_directory(directory.path);
std::ofstream(directory.path/"preserved") << "original";
return std::size_t{0};
};
}
const auto result=serialization::export_top(directory.path,source,small_options());
REQUIRE_FALSE(result);
CHECK(std::filesystem::exists(directory.path));
CHECK_FALSE(std::filesystem::exists(directory.path/"lambda_eps"));
if (std::filesystem::exists(directory.path/"preserved")) {
std::ifstream input(directory.path/"preserved"); std::string text; input >> text; CHECK(text=="original");
}
bool temporary_remains=false;
for (const auto& entry : std::filesystem::directory_iterator(directory.path.parent_path())) {
temporary_remains |= entry.path().filename().string().starts_with("."+directory.path.filename().string()+".top-tmp-");
}
CHECK_FALSE(temporary_remains);
}
TEST_CASE("TOP records explicit adapter roundoff corrections", "[serialize][top]") {
const Directory directory;
auto source=sphere(); source.roundoff_density_clamps=[] { return std::size_t{3}; };
const auto result=serialization::export_top(directory.path,source,small_options());
REQUIRE(result); CHECK(result->roundoff_density_clamps==3);
}
TEST_CASE("TOP records and removes only a tolerated surface defect", "[serialize][top]") {
const Directory directory;
auto source=sphere();
const auto original=source.enthalpy;
source.enthalpy=[original](double r,double mu,double phi)->serialization::Result<double> {
if (r==1.) { return (4/pi)*1e-8; }
return original(r,mu,phi);
};
const auto result=serialization::export_top(directory.path,source,small_options());
REQUIRE(result);
CHECK(close(result->maximum_relative_surface_enthalpy,1e-8,1e-15));
const auto data=read(directory.path);
for (int l=0;l<=data.degree;l+=2) { CHECK(data.h[l].back()==0.); }
}
TEST_CASE("TOP separates even enthalpy truncation from symmetry error", "[serialize][top]") {
const Directory truncated, resolved;
auto source=sphere();
source.enthalpy=[](double r,double mu,double)->serialization::Result<double> {
const double p6=(231*std::pow(mu,6)-315*std::pow(mu,4)+105*mu*mu-5)/16;
return (4/pi)*(1-r*r)*(1+.1*std::pow(r,6)*p6);
};
auto options=small_options();
const auto low=serialization::export_top(truncated.path,source,options);
REQUIRE(low);
CHECK(low->maximum_relative_enthalpy_symmetry_error<1e-14);
CHECK(low->maximum_relative_enthalpy_projection_error>1e-3);
options.maximum_degree=6;
const auto high=serialization::export_top(resolved.path,source,options);
REQUIRE(high);
CHECK(high->maximum_relative_enthalpy_symmetry_error<1e-14);
CHECK(high->maximum_relative_enthalpy_projection_error<1e-13);
}