Files
MeanField_SERiF/tests/utils/dependencies/rejection.cpp
Emily Boudreaux cb20faeb3d 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.
2026-11-09 13:53:17 -05:00

194 lines
7.7 KiB
C++

#include "serif/utils/error/errors.hpp"
#include "serif/utils/dependencies/dependencies.hpp"
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include <expected>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
namespace {
namespace dependencies = serif::utils::dependencies;
struct Source {};
struct Intermediate {};
struct Result {};
struct ThrowingSnapshot {
/// Simulate a policy failing while it tries to capture replacement state.
int capture(const int& value) const {
if (value < 0) {
serif::utils::errors::serif_error<std::runtime_error, serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>("source snapshot unavailable");
}
return value;
}
/// Compare only successfully captured states.
bool matches(const int& captured, const int& value) const { return captured == value; }
};
}
TEST_CASE("Rejected dependency preparation publishes neither candidate results nor snapshots", "[unit][utils][dependencies]") {
int source = 2;
int intermediate_calls = 0;
int result_calls = 0;
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
auto graph = dependencies::make_computation_graph(
dependencies::make_computation<Result, dependencies::InputList<Intermediate>>([&result_calls](int intermediate) -> std::expected<int, std::string> {
++result_calls;
if (intermediate < 0) { return std::unexpected("input must be nonnegative"); }
return intermediate + 1;
}),
dependencies::make_computation<Intermediate, dependencies::InputList<Source>>([&intermediate_calls](int input) { ++intermediate_calls; return 2 * input; })
);
auto cache = dependencies::make_computation_cache(std::move(graph), context);
REQUIRE(cache.try_prepare(context));
CHECK(cache.get<Result>() == 5);
source = -3;
auto rejected = cache.try_prepare(context);
REQUIRE_FALSE(rejected);
CHECK(rejected.error().computation_index == 0);
CHECK(rejected.error().error == "input must be nonnegative");
CHECK_FALSE(cache.ready());
CHECK_FALSE(cache.contains<Intermediate>());
CHECK_THROWS_AS(cache.get<Result>(), std::logic_error);
CHECK(cache.report().computed_count() == 0);
CHECK(cache.report().reused_count() == 0);
CHECK(intermediate_calls == 2);
CHECK(result_calls == 2);
// Retrying rejected inputs must rebuild the prerequisite: its candidate was never committed.
REQUIRE_FALSE(cache.try_prepare(context));
CHECK(intermediate_calls == 3);
CHECK(result_calls == 3);
// The last accepted cache is still usable when the source returns to its accepted state.
source = 2;
const auto recovered = cache.try_prepare(context);
REQUIRE(recovered);
CHECK(recovered->reused_count() == 2);
CHECK(cache.get<Result>() == 5);
CHECK(intermediate_calls == 3);
CHECK(result_calls == 3);
source = 4;
REQUIRE(cache.try_prepare(context));
CHECK(cache.get<Result>() == 9);
CHECK(intermediate_calls == 4);
CHECK(result_calls == 4);
}
TEST_CASE("Dependency computations can return move-only typed errors", "[unit][utils][dependencies]") {
using Error = std::unique_ptr<int>;
int source = -1;
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Source>>([](int value) -> std::expected<int, Error> {
if (value < 0) { return std::unexpected(std::make_unique<int>(value)); }
return value;
}));
auto cache = dependencies::make_computation_cache<Error>(std::move(graph), context);
const auto rejected = cache.try_prepare(context);
REQUIRE_FALSE(rejected);
CHECK(*rejected.error().error == -1);
source = 3;
REQUIRE(cache.try_prepare(context));
CHECK(cache.get<Result>() == 3);
}
TEST_CASE("Exceptions leave dependency results unavailable without replacing the accepted cache", "[unit][utils][dependencies]") {
int source = 2;
int calls = 0;
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source, ThrowingSnapshot{}));
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Source>>([&calls](int value) {
++calls;
if (value == 3) {
serif::utils::errors::serif_error<std::runtime_error, serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>("calculation failed");
}
return value;
}));
auto cache = dependencies::make_computation_cache(std::move(graph), context);
REQUIRE(cache.try_prepare(context));
CHECK(calls == 1);
SECTION("A source policy throws before evaluation") {
source = -1;
CHECK_THROWS_WITH(cache.try_prepare(context), Catch::Matchers::ContainsSubstring("source snapshot unavailable"));
CHECK(calls == 1);
}
SECTION("The calculation throws after snapshot capture") {
source = 3;
CHECK_THROWS_WITH(cache.try_prepare(context), Catch::Matchers::ContainsSubstring("calculation failed"));
CHECK(calls == 2);
}
CHECK_FALSE(cache.ready());
CHECK_THROWS_AS(cache.get<Result>(), std::logic_error);
source = 2;
const auto recovered = cache.try_prepare(context);
REQUIRE(recovered);
CHECK(recovered->reused_count() == 1);
CHECK(cache.get<Result>() == 2);
}
TEST_CASE("Preparation counts distinguish successful requests from rejected attempts and survive cache moves", "[unit][utils][dependencies]") {
int source = 2;
int evaluations = 0;
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
const auto computation = dependencies::make_computation<Result, dependencies::InputList<Source>>(
[&evaluations](int input) -> std::expected<int, std::string> {
++evaluations;
if (input < 0) {
return std::unexpected("input must be nonnegative");
}
return 2 * input;
}
);
auto cache = dependencies::make_computation_cache(context, computation);
CHECK(cache.preparation_count() == 0);
const auto computed = cache.try_prepare(context);
REQUIRE(computed);
CHECK(computed->status<Result>() == dependencies::PreparationStatus::computed);
CHECK(cache.preparation_count() == 1);
CHECK(evaluations == 1);
const auto reused = cache.try_prepare(context);
REQUIRE(reused);
CHECK(reused->status<Result>() == dependencies::PreparationStatus::reused);
CHECK(cache.preparation_count() == 2);
CHECK(evaluations == 1);
source = -1;
const auto rejected = cache.try_prepare(context);
REQUIRE_FALSE(rejected);
CHECK_FALSE(cache.ready());
CHECK(cache.preparation_count() == 2);
CHECK(evaluations == 2);
cache.invalidate();
CHECK_FALSE(cache.ready());
CHECK(cache.preparation_count() == 2);
source = 2;
const auto recovered = cache.try_prepare(context);
REQUIRE(recovered);
CHECK(recovered->status<Result>() == dependencies::PreparationStatus::computed);
CHECK(cache.preparation_count() == 3);
CHECK(evaluations == 3);
auto moved = std::move(cache);
CHECK(moved.ready());
CHECK(moved.preparation_count() == 3);
CHECK(moved.get<Result>() == 4);
CHECK_FALSE(cache.ready());
CHECK(cache.preparation_count() == 0);
const auto moved_report = moved.try_prepare(context);
REQUIRE(moved_report);
CHECK(moved_report->status<Result>() == dependencies::PreparationStatus::reused);
CHECK(moved.preparation_count() == 4);
CHECK(evaluations == 3);
}