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,197 @@
#include <array>
#include <memory>
#include <span>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "serif/backend/mfem/operators/actions.hpp"
#include "serif/solver/preconditioner/preconditioner.hpp"
#include "serif/backend/mfem/solver/normalization/operators.hpp"
#include "serif/utils/dependencies/computation_cache.hpp"
namespace
{
namespace pc = serif::solver::preconditioner;
namespace dependencies = serif::utils::dependencies;
namespace operators = serif::operators::backend::mfem;
namespace inverse = pc::strategies::inverse;
namespace coupling = pc::strategies::coupling;
using Inverse = pc::backend::mfem::Inverse;
using Code = serif::utils::errors::SERiFErrorCode;
struct Temperature
{
};
struct HeatBalance
{
};
struct Density
{
};
struct MassConstraint
{
};
struct MassParameter
{
};
struct Potential
{
};
struct Gradient
{
};
/** @brief Construct owned matrices independently of the preconditioner factorizations. */
std::shared_ptr<const mfem::DenseMatrix> matrix(const int rows, const int columns, std::initializer_list<double> values)
{
auto result = std::make_shared<mfem::DenseMatrix>(rows, columns);
auto value = values.begin();
for (int row = 0; row < rows; ++row)
{
for (int column = 0; column < columns; ++column)
{
(*result)(row, column) = *value++;
}
}
return result;
}
/** @brief Apply using preallocated MFEM coordinates, retaining the factory's zero-guess contract. */
mfem::Vector apply(const Inverse& inverse, std::initializer_list<double> values)
{
mfem::Vector input(values.size());
std::copy(values.begin(), values.end(), input.GetData());
mfem::Vector output(inverse.action->Height());
output = 91.0;
inverse.action->Mult(input, output);
return output;
}
} // namespace
TEST_CASE("A physical inverse graph reuses accepted results and preserves a frozen solve after failed preparation", "[unit][preconditioner][graph]")
{
const auto choice = pc::make_preconditioner<Temperature, HeatBalance>(inverse::Diagonal{});
using Recipe = decltype(choice);
using Block = pc::keys::Block<Recipe::Rows, Recipe::Values>;
auto physical = operators::linearized_block(matrix(2, 2, {2.0, 0.0, 0.0, -4.0}));
const auto context = dependencies::make_dependency_context(dependencies::borrow<Block>(physical));
auto cache = dependencies::make_computation_cache<pc::Failure>(pc::make_preconditioner_graph(choice), context);
REQUIRE(cache.try_prepare(context));
auto frozen = cache.share<Recipe::Result>();
CHECK(apply(*frozen, {6.0, 8.0})[0] == Catch::Approx(3.0));
CHECK(apply(*frozen, {6.0, 8.0})[1] == Catch::Approx(-2.0));
const auto reused = cache.try_prepare(context);
REQUIRE(reused);
CHECK(reused->computed_count() == 0);
CHECK(cache.share<Recipe::Result>() == frozen);
physical = operators::linearized_block(matrix(2, 2, {0.0, 0.0, 0.0, -4.0}));
const auto rejected = cache.try_prepare(context);
REQUIRE_FALSE(rejected);
CHECK(rejected.error().error.code == Code::PRECONDITIONER_SINGULAR_OPERATOR);
CHECK_FALSE(cache.ready());
CHECK_THROWS(cache.share<Recipe::Result>());
CHECK(apply(*frozen, {6.0, 8.0})[0] == Catch::Approx(3.0));
physical = operators::linearized_block(matrix(2, 2, {3.0, 0.0, 0.0, -8.0}));
REQUIRE(cache.try_prepare(context));
CHECK(cache.share<Recipe::Result>() != frozen);
CHECK(apply(cache.get<Recipe::Result>(), {6.0, 8.0})[0] == Catch::Approx(2.0));
cache.invalidate();
CHECK(apply(*frozen, {6.0, 8.0})[1] == Catch::Approx(-2.0));
}
TEST_CASE("Composed inverse graphs obey separate physical state and equation orders", "[unit][preconditioner][graph]")
{
using Values = dependencies::KeyList<Density, Temperature>;
using Rows = dependencies::KeyList<HeatBalance, Density>;
const serif::discretization::blocks::SystemLayout<Values, Rows> layout{.state_sizes = {1, 2}, .residual_sizes = {2, 1}};
const auto heat = pc::make_preconditioner<Temperature, HeatBalance>(inverse::Diagonal{});
const auto density = pc::make_preconditioner<Density>(inverse::Diagonal{});
// A zero cross block makes the triangular coupling act blockwise, isolating the coordinate orders.
const auto choice = pc::couple(heat, density, coupling::SecondThenFirst{});
const auto context = dependencies::make_dependency_context(
pc::physical_block<HeatBalance, Temperature>(operators::linearized_block(matrix(2, 2, {2.0, 0.0, 0.0, 4.0}))),
pc::physical_block<Density, Density>(operators::linearized_block(matrix(1, 1, {5.0}))),
pc::physical_block<HeatBalance, Density>(operators::linearized_block(matrix(2, 1, {0.0, 0.0}))),
dependencies::borrow<pc::keys::Layout>(layout));
auto cache = dependencies::make_computation_cache<pc::Failure>(pc::make_preconditioner_graph(choice, layout), context);
REQUIRE(cache.try_prepare(context));
const auto result = apply(cache.get<pc::keys::Preconditioner>(), {6.0, 8.0, 20.0});
CHECK(result[0] == Catch::Approx(4.0));
CHECK(result[1] == Catch::Approx(3.0));
CHECK(result[2] == Catch::Approx(2.0));
}
TEST_CASE("Constraint graph refresh follows child inverse changes without requiring a new root Jacobian", "[unit][preconditioner][graph]")
{
using D = dependencies::KeyList<Density>;
using R = dependencies::KeyList<MassConstraint>;
using V = dependencies::KeyList<MassParameter>;
using Constraints = pc::ConstraintCoordinates<R, V>;
const auto field_choice = pc::make_preconditioner<Density>(inverse::Diagonal{});
const auto choice = pc::with_constraints<Constraints>(field_choice);
using Recipe = decltype(choice);
auto fields = operators::linearized_block(matrix(1, 1, {2.0}));
const auto context = dependencies::make_dependency_context(
dependencies::borrow<pc::keys::Block<D, D>>(fields),
dependencies::own<pc::keys::Block<D, V>>(operators::linearized_block(matrix(1, 1, {1.0}))),
dependencies::own<pc::keys::Block<R, D>>(operators::linearized_block(matrix(1, 1, {3.0}))),
dependencies::own<pc::keys::Block<R, V>>(operators::linearized_block(matrix(1, 1, {4.0}))));
auto cache = dependencies::make_computation_cache<pc::Failure>(pc::make_preconditioner_graph(choice), context);
REQUIRE(cache.try_prepare(context));
auto old = cache.share<Recipe::Result>();
const auto old_result = apply(*old, {7.0, 15.0});
CHECK(old_result[0] == Catch::Approx(2.6));
CHECK(old_result[1] == Catch::Approx(1.8));
fields = operators::linearized_block(matrix(1, 1, {4.0}));
auto refreshed = cache.try_prepare(context);
REQUIRE(refreshed);
CHECK(refreshed->status<Recipe::Result>() == dependencies::PreparationStatus::computed);
const auto result = apply(cache.get<Recipe::Result>(), {7.0, 15.0});
CHECK(4.0 * result[0] + result[1] == Catch::Approx(7.0));
CHECK(3.0 * result[0] + 4.0 * result[1] == Catch::Approx(15.0));
CHECK(apply(*old, {7.0, 15.0})[0] == Catch::Approx(old_result[0]));
}
TEST_CASE("An incompatible physical representation returns a preparation error", "[unit][preconditioner][graph]")
{
const auto choice = pc::make_preconditioner<Density>(inverse::Diagonal{});
const auto context = dependencies::make_dependency_context(pc::physical_block<Density, Density>(operators::LinearizedBlock(matrix(1, 1, {2.0}))));
auto cache = dependencies::make_computation_cache<pc::Failure>(pc::make_preconditioner_graph(choice), context);
const auto result = cache.try_prepare(context);
REQUIRE_FALSE(result);
CHECK(result.error().error.code == Code::PRECONDITIONER_INCOMPATIBLE_STRATEGY);
}
TEST_CASE("MFEM adapters bind accepted Jacobians and reuse the existing normalization transform", "[unit][preconditioner][graph]")
{
namespace normalization = serif::solver::normalization;
auto physical = matrix(2, 2, {2.0, 0.0, 0.0, 4.0});
const auto inverse = pc::backend::mfem::try_dense_inverse(*physical);
REQUIRE(inverse);
pc::backend::mfem::Solver solver(*inverse, physical);
mfem::Vector input(2), output(2), wrong_size(1);
input = 2.0;
CHECK_THROWS_WITH(solver.Mult(wrong_size, output), Catch::Matchers::ContainsSubstring("PRECONDITIONER_SIZE_MISMATCH"));
CHECK_THROWS_WITH(solver.Mult(input, wrong_size), Catch::Matchers::ContainsSubstring("PRECONDITIONER_SIZE_MISMATCH"));
solver.Mult(input, output);
CHECK(output[0] == Catch::Approx(1.0));
CHECK(output[1] == Catch::Approx(0.5));
CHECK_NOTHROW(solver.SetOperator(*physical));
const auto other = matrix(2, 2, {2.0, 0.0, 0.0, 4.0});
CHECK_THROWS(solver.SetOperator(*other));
const auto scaling = normalization::make_diagonal_normalization(std::vector<double>{3.0, 5.0}, std::vector<double>{7.0, 11.0});
REQUIRE(scaling);
normalization::backend::mfem::ScaledJacobianOperator jacobian(*physical, *scaling);
normalization::backend::mfem::ScaledPreconditioner scaled(solver, jacobian);
mfem::Vector state(2), residual(2), recovered(2);
state[0] = 2.0;
state[1] = -3.0;
jacobian.Mult(state, residual);
scaled.Mult(residual, recovered);
CHECK(recovered[0] == Catch::Approx(state[0]));
CHECK(recovered[1] == Catch::Approx(state[1]));
}