Files
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

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);
}