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.
194 lines
7.7 KiB
C++
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);
|
|
}
|