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.
261 lines
11 KiB
C++
261 lines
11 KiB
C++
#include "serif/utils/dependencies/dependencies.hpp"
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include <array>
|
|
#include <expected>
|
|
#include <memory>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <utility>
|
|
|
|
namespace {
|
|
namespace dependencies = serif::utils::dependencies;
|
|
|
|
struct Source {};
|
|
struct OtherSource {};
|
|
struct Shared {};
|
|
struct Left {};
|
|
struct Right {};
|
|
struct Total {};
|
|
}
|
|
|
|
TEST_CASE("Named computations build a cache directly with general and custom keys", "[unit][utils][dependencies]") {
|
|
namespace keys = dependencies::keys;
|
|
struct Offset { double unused; };
|
|
|
|
std::array<double, 3> a{1.0, 2.0, 3.0};
|
|
double scale = 2.0;
|
|
double offset = 0.5;
|
|
std::array<int, 2> calls{};
|
|
const auto context = dependencies::make_dependency_context(
|
|
dependencies::borrow<keys::Input>(a), dependencies::borrow<keys::Scale>(scale), dependencies::borrow<Offset>(offset)
|
|
);
|
|
const auto multiply = dependencies::make_computation<keys::Output, dependencies::InputList<keys::Input, keys::Scale>>(
|
|
[&calls](const std::array<double, 3>& input, double factor) {
|
|
++calls[0];
|
|
return std::array<double, 3>{factor * input[0], factor * input[1], factor * input[2]};
|
|
}
|
|
);
|
|
const auto sum = dependencies::make_computation<Total, dependencies::InputList<keys::Output, Offset>>(
|
|
[&calls](const std::array<double, 3>& input, double shift) {
|
|
++calls[1];
|
|
return input[0] + input[1] + input[2] + shift;
|
|
}
|
|
);
|
|
|
|
// No explicit graph or ownership transfer is needed; prerequisites may be supplied last.
|
|
auto cache = dependencies::make_computation_cache(context, sum, multiply);
|
|
CHECK_FALSE(cache.ready());
|
|
CHECK(calls == std::array<int, 2>{0, 0});
|
|
auto report = cache.try_prepare(context, dependencies::RequestedResults<Total>{});
|
|
REQUIRE(report);
|
|
CHECK(report->computed_count() == 2);
|
|
CHECK(cache.get<keys::Output>() == std::array<double, 3>{2.0, 4.0, 6.0});
|
|
CHECK(cache.get<Total>() == 12.5);
|
|
CHECK(calls == std::array<int, 2>{1, 1});
|
|
|
|
report = cache.try_prepare(context);
|
|
REQUIRE(report);
|
|
CHECK(report->reused_count() == 2);
|
|
CHECK(calls == std::array<int, 2>{1, 1});
|
|
|
|
offset = 1.5;
|
|
report = cache.try_prepare(context);
|
|
REQUIRE(report);
|
|
CHECK(report->status<keys::Output>() == dependencies::PreparationStatus::reused);
|
|
CHECK(report->status<Total>() == dependencies::PreparationStatus::computed);
|
|
CHECK(cache.get<Total>() == 13.5);
|
|
CHECK(calls == std::array<int, 2>{1, 2});
|
|
|
|
scale = 3.0;
|
|
report = cache.try_prepare(context);
|
|
REQUIRE(report);
|
|
CHECK(report->computed_count() == 2);
|
|
CHECK(cache.get<Total>() == 19.5);
|
|
CHECK(calls == std::array<int, 2>{2, 3});
|
|
}
|
|
|
|
TEST_CASE("Direct cache construction transfers computations owning noncopyable state", "[unit][utils][dependencies]") {
|
|
const auto context = dependencies::make_dependency_context(dependencies::own<Source>(4));
|
|
auto computation = dependencies::make_computation<Total, dependencies::InputList<Source>>(
|
|
[offset = std::make_unique<int>(3)](int input) { return input + *offset; }
|
|
);
|
|
auto cache = dependencies::make_computation_cache(context, std::move(computation));
|
|
REQUIRE(cache.try_prepare(context));
|
|
CHECK(cache.get<Total>() == 7);
|
|
const auto report = cache.try_prepare(context);
|
|
REQUIRE(report);
|
|
CHECK(report->status<Total>() == dependencies::PreparationStatus::reused);
|
|
}
|
|
|
|
TEST_CASE("A dependency diamond prepares shared work once in prerequisite order", "[unit][utils][dependencies]") {
|
|
int source = 2;
|
|
std::array<int, 4> calls{};
|
|
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
|
|
auto graph = dependencies::make_computation_graph(
|
|
dependencies::make_computation<Total, dependencies::InputList<Left, Right>>([&calls](int left, int right) { ++calls[3]; return left + right; }),
|
|
dependencies::make_computation<Right, dependencies::InputList<Shared>>([&calls](int shared) { ++calls[2]; return 3 * shared; }),
|
|
dependencies::make_computation<Shared, dependencies::InputList<Source>>([&calls](int input) { ++calls[0]; return 2 * input; }),
|
|
dependencies::make_computation<Left, dependencies::InputList<Shared>>([&calls](int shared) { ++calls[1]; return shared + 1; })
|
|
);
|
|
auto cache = dependencies::make_computation_cache(graph, context);
|
|
CHECK_FALSE(cache.ready());
|
|
CHECK_THROWS_AS(cache.get<Total>(), std::logic_error);
|
|
|
|
auto report = cache.try_prepare(context, dependencies::RequestedResults<Total>{});
|
|
REQUIRE(report);
|
|
CHECK(report->computed_count() == 4);
|
|
CHECK(report->source_changed<Source>());
|
|
CHECK(cache.get<Total>() == 17);
|
|
CHECK(calls == std::array<int, 4>{1, 1, 1, 1});
|
|
|
|
report = cache.try_prepare(context);
|
|
REQUIRE(report);
|
|
CHECK(report->reused_count() == 4);
|
|
CHECK_FALSE(report->source_changed<Source>());
|
|
CHECK(calls == std::array<int, 4>{1, 1, 1, 1});
|
|
|
|
source = 3;
|
|
report = cache.try_prepare(context);
|
|
REQUIRE(report);
|
|
CHECK(report->computed_count() == 4);
|
|
CHECK(cache.get<Total>() == 25);
|
|
CHECK(calls == std::array<int, 4>{2, 2, 2, 2});
|
|
|
|
report = cache.try_prepare(context, dependencies::RequestedResults<Total>{}, dependencies::PreparationMode::rebuild);
|
|
REQUIRE(report);
|
|
CHECK(report->computed_count() == 4);
|
|
CHECK(calls == std::array<int, 4>{3, 3, 3, 3});
|
|
|
|
cache.invalidate();
|
|
CHECK_FALSE(cache.ready());
|
|
CHECK_THROWS_AS(cache.get<Left>(), std::logic_error);
|
|
report = cache.try_prepare(context);
|
|
REQUIRE(report);
|
|
CHECK(report->computed_count() == 4);
|
|
CHECK(calls == std::array<int, 4>{4, 4, 4, 4});
|
|
}
|
|
|
|
TEST_CASE("Partial dependency requests retain caches and propagate later changes", "[unit][utils][dependencies]") {
|
|
int source = 2;
|
|
int other_source = 3;
|
|
std::array<int, 3> calls{};
|
|
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source), dependencies::borrow<OtherSource>(other_source));
|
|
auto graph = dependencies::make_computation_graph(
|
|
dependencies::make_computation<Total, dependencies::InputList<Left, Right>>([&calls](int left, int right) { ++calls[2]; return left + right; }),
|
|
dependencies::make_computation<Left, dependencies::InputList<Source>>([&calls](int input) { ++calls[0]; return 2 * input; }),
|
|
dependencies::make_computation<Right, dependencies::InputList<OtherSource>>([&calls](int input) { ++calls[1]; return 2 * input; })
|
|
);
|
|
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
|
REQUIRE(cache.try_prepare(context));
|
|
CHECK(cache.get<Total>() == 10);
|
|
|
|
source = 4;
|
|
auto report = cache.try_prepare(context, dependencies::RequestedResults<Left>{});
|
|
REQUIRE(report);
|
|
CHECK(calls == std::array<int, 3>{2, 1, 1});
|
|
CHECK(report->status<Right>() == dependencies::PreparationStatus::not_required);
|
|
CHECK_FALSE(cache.contains<Total>());
|
|
CHECK_THROWS_AS(cache.get<Right>(), std::logic_error);
|
|
|
|
report = cache.try_prepare(context, dependencies::RequestedResults<Total>{});
|
|
REQUIRE(report);
|
|
CHECK(cache.get<Total>() == 14);
|
|
CHECK(report->computed_count() == 1);
|
|
CHECK(report->reused_count() == 2);
|
|
CHECK(calls == std::array<int, 3>{2, 1, 2});
|
|
|
|
other_source = 5;
|
|
report = cache.try_prepare(context, dependencies::RequestedResults<Left>{});
|
|
REQUIRE(report);
|
|
CHECK_FALSE(report->source_changed<OtherSource>()); // This source was outside the request and was not inspected.
|
|
CHECK(report->reused_count() == 1);
|
|
report = cache.try_prepare(context, dependencies::RequestedResults<Right>{});
|
|
REQUIRE(report);
|
|
CHECK(report->source_changed<OtherSource>());
|
|
CHECK(cache.get<Right>() == 10);
|
|
|
|
report = cache.try_prepare(context);
|
|
REQUIRE(report);
|
|
CHECK(report->computed_count() == 1);
|
|
CHECK(cache.get<Total>() == 18);
|
|
CHECK(calls == std::array<int, 3>{2, 2, 3});
|
|
|
|
report = cache.try_prepare(context, dependencies::RequestedResults<>{});
|
|
REQUIRE(report);
|
|
CHECK(cache.ready());
|
|
CHECK_FALSE(cache.contains<Total>());
|
|
CHECK(report->computed_count() == 0);
|
|
CHECK(report->reused_count() == 0);
|
|
}
|
|
|
|
TEST_CASE("Empty dependency graphs and constant calculations need no sources", "[unit][utils][dependencies]") {
|
|
const auto context = dependencies::make_dependency_context();
|
|
auto empty = dependencies::make_computation_cache(dependencies::make_computation_graph(), context);
|
|
const auto empty_report = empty.try_prepare(context);
|
|
REQUIRE(empty_report);
|
|
CHECK(empty.ready());
|
|
CHECK(empty_report->computed_count() == 0);
|
|
|
|
int calls = 0;
|
|
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Total, dependencies::InputList<>>([&calls] { ++calls; return 42; }));
|
|
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
|
REQUIRE(cache.try_prepare(context));
|
|
REQUIRE(cache.try_prepare(context));
|
|
CHECK(calls == 1);
|
|
CHECK(cache.get<Total>() == 42);
|
|
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}, dependencies::PreparationMode::rebuild));
|
|
CHECK(calls == 2);
|
|
}
|
|
|
|
TEST_CASE("Move-only results retain accepted generations across rebuilds and rejection", "[unit][utils][dependencies]") {
|
|
using Value = std::unique_ptr<int>;
|
|
int source = 4;
|
|
int calls = 0;
|
|
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
|
|
const auto computation = dependencies::make_computation<Total, dependencies::InputList<Source>>(
|
|
[&calls](int input) -> std::expected<Value, std::string> {
|
|
++calls;
|
|
if (input < 0) {
|
|
return std::unexpected("negative input");
|
|
}
|
|
return std::make_unique<int>(input);
|
|
}
|
|
);
|
|
auto cache = dependencies::make_computation_cache(context, computation);
|
|
|
|
REQUIRE(cache.try_prepare(context));
|
|
const auto first = cache.share<Total>();
|
|
CHECK(**first == 4);
|
|
const auto reused = cache.try_prepare(context);
|
|
REQUIRE(reused);
|
|
CHECK(reused->status<Total>() == dependencies::PreparationStatus::reused);
|
|
CHECK(cache.share<Total>() == first);
|
|
CHECK(calls == 1);
|
|
|
|
const auto forced = cache.try_prepare(context, dependencies::RequestedResults<Total>{}, dependencies::PreparationMode::rebuild);
|
|
REQUIRE(forced);
|
|
CHECK(forced->status<Total>() == dependencies::PreparationStatus::computed);
|
|
const auto accepted = cache.share<Total>();
|
|
CHECK(accepted != first);
|
|
CHECK(calls == 2);
|
|
CHECK(**accepted == 4);
|
|
CHECK(**first == 4);
|
|
|
|
source = -1;
|
|
const auto rejected = cache.try_prepare(context);
|
|
REQUIRE_FALSE(rejected);
|
|
CHECK(rejected.error().error == "negative input");
|
|
CHECK_FALSE(cache.contains<Total>());
|
|
CHECK(calls == 3);
|
|
CHECK(**accepted == 4);
|
|
CHECK(**first == 4);
|
|
|
|
source = 4;
|
|
const auto recovered = cache.try_prepare(context);
|
|
REQUIRE(recovered);
|
|
CHECK(recovered->status<Total>() == dependencies::PreparationStatus::reused);
|
|
CHECK(cache.share<Total>() == accepted);
|
|
CHECK(calls == 3);
|
|
}
|