#include "serif/utils/error/errors.hpp" #include "serif/utils/dependencies/dependencies.hpp" #include #include #include #include #include #include #include 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("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)); auto graph = dependencies::make_computation_graph( dependencies::make_computation>([&result_calls](int intermediate) -> std::expected { ++result_calls; if (intermediate < 0) { return std::unexpected("input must be nonnegative"); } return intermediate + 1; }), dependencies::make_computation>([&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() == 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()); CHECK_THROWS_AS(cache.get(), 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() == 5); CHECK(intermediate_calls == 3); CHECK(result_calls == 3); source = 4; REQUIRE(cache.try_prepare(context)); CHECK(cache.get() == 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 source = -1; auto context = dependencies::make_dependency_context(dependencies::borrow(source)); auto graph = dependencies::make_computation_graph(dependencies::make_computation>([](int value) -> std::expected { if (value < 0) { return std::unexpected(std::make_unique(value)); } return value; })); auto cache = dependencies::make_computation_cache(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() == 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, ThrowingSnapshot{})); auto graph = dependencies::make_computation_graph(dependencies::make_computation>([&calls](int value) { ++calls; if (value == 3) { serif::utils::errors::serif_error("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(), std::logic_error); source = 2; const auto recovered = cache.try_prepare(context); REQUIRE(recovered); CHECK(recovered->reused_count() == 1); CHECK(cache.get() == 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)); const auto computation = dependencies::make_computation>( [&evaluations](int input) -> std::expected { ++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() == dependencies::PreparationStatus::computed); CHECK(cache.preparation_count() == 1); CHECK(evaluations == 1); const auto reused = cache.try_prepare(context); REQUIRE(reused); CHECK(reused->status() == 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() == 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() == 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() == dependencies::PreparationStatus::reused); CHECK(moved.preparation_count() == 4); CHECK(evaluations == 3); }