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.
379 lines
18 KiB
C++
379 lines
18 KiB
C++
#include "serif/utils/dependencies/dependencies.hpp"
|
|
#include "serif/utils/dependencies/timing.hpp"
|
|
#include "serif/utils/error/errors.hpp"
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include <expected>
|
|
#include <memory>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
namespace dependencies = serif::utils::dependencies;
|
|
using Outcome = dependencies::NodeOutcome;
|
|
|
|
struct Source {};
|
|
struct Offset {};
|
|
struct Shared {};
|
|
struct Total {};
|
|
struct Unrequested {};
|
|
|
|
/// Simulate exceptional application failures through the library's error entry point.
|
|
void fail_observed_work() {
|
|
serif::utils::errors::serif_error<std::runtime_error, serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>("observed work failed");
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Graph observers follow nested node scopes and declared input order only during execution", "[unit][utils][dependencies][observers]") {
|
|
int source = 2;
|
|
int offset = 3;
|
|
std::vector<int> events;
|
|
events.reserve(128);
|
|
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source), dependencies::borrow<Offset>(offset));
|
|
auto graph = dependencies::make_computation_graph(
|
|
dependencies::make_computation<Total, dependencies::InputList<Offset, Shared>>([&events](int shift, int value) {
|
|
events.push_back(70);
|
|
return value + shift;
|
|
}),
|
|
dependencies::make_computation<Shared, dependencies::InputList<Source>>([&events](int value) {
|
|
events.push_back(30);
|
|
return 2 * value;
|
|
}),
|
|
dependencies::make_computation<Unrequested, dependencies::InputList<Source>>([&events](int value) {
|
|
events.push_back(99);
|
|
return value;
|
|
})
|
|
);
|
|
auto observers = dependencies::make_observers(
|
|
dependencies::observe_node<Shared>([&events] {
|
|
events.push_back(10);
|
|
return std::make_unique<int>(40);
|
|
}, [&events](std::unique_ptr<int>& state, Outcome outcome) noexcept {
|
|
events.push_back(*state);
|
|
CHECK(outcome == Outcome::completed);
|
|
}),
|
|
dependencies::observe_node<Shared>([&events] {
|
|
events.push_back(11);
|
|
return 41;
|
|
}, [&events](int& state, Outcome outcome) noexcept {
|
|
events.push_back(state);
|
|
CHECK(outcome == Outcome::completed);
|
|
}),
|
|
dependencies::observe_node<Total>([&events] {
|
|
events.push_back(50);
|
|
return 80;
|
|
}, [&events](int& state, Outcome outcome) noexcept {
|
|
events.push_back(state);
|
|
CHECK(outcome == Outcome::completed);
|
|
}),
|
|
dependencies::observe_edge<Shared, Total>([&events](const int&) { events.push_back(61); }),
|
|
dependencies::observe_edge<Offset, Total>([&events](const int&) { events.push_back(60); }),
|
|
dependencies::observe_edge<Source, Shared>([&events](const int&) { events.push_back(20); }),
|
|
dependencies::observe_edge<Source, Shared>([&events](const int&) { events.push_back(21); })
|
|
);
|
|
auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers));
|
|
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}));
|
|
CHECK(events == std::vector<int>{10, 11, 20, 21, 30, 41, 40, 50, 60, 61, 70, 80});
|
|
CHECK(cache.get<Total>() == 7);
|
|
const auto first = cache.share<Shared>();
|
|
|
|
events.clear();
|
|
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}));
|
|
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<>{}));
|
|
CHECK(events.empty());
|
|
|
|
source = 4;
|
|
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Shared>{}));
|
|
CHECK(events == std::vector<int>{10, 11, 20, 21, 30, 41, 40});
|
|
CHECK_FALSE(cache.contains<Total>());
|
|
CHECK(*first == 4);
|
|
events.clear();
|
|
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}));
|
|
CHECK(events == std::vector<int>{50, 60, 61, 70, 80});
|
|
CHECK(cache.get<Total>() == 11);
|
|
|
|
events.clear();
|
|
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}, dependencies::PreparationMode::rebuild));
|
|
CHECK(events == std::vector<int>{10, 11, 20, 21, 30, 41, 40, 50, 60, 61, 70, 80});
|
|
events.clear();
|
|
cache.invalidate();
|
|
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Shared>{}));
|
|
CHECK(events == std::vector<int>{10, 11, 20, 21, 30, 41, 40});
|
|
}
|
|
|
|
TEST_CASE("Node outcomes describe attempted computation while cache publication remains transactional", "[unit][utils][dependencies][observers]") {
|
|
int source = 2;
|
|
std::vector<std::pair<int, Outcome>> outcomes;
|
|
outcomes.reserve(32);
|
|
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
|
|
auto graph = dependencies::make_computation_graph(
|
|
dependencies::make_computation<Shared, dependencies::InputList<Source>>([](int value) { return value; }),
|
|
dependencies::make_computation<Total, dependencies::InputList<Shared>>([](int value) -> std::expected<int, std::string> {
|
|
if (value == -2) {
|
|
fail_observed_work();
|
|
}
|
|
if (value < 0) {
|
|
return std::unexpected("negative input");
|
|
}
|
|
return 2 * value;
|
|
}),
|
|
dependencies::make_computation<Unrequested, dependencies::InputList<Total>>([](int value) { return value + 1; })
|
|
);
|
|
auto observers = dependencies::make_observers(
|
|
dependencies::observe_node<Shared>([] { return 1; }, [&outcomes](int& state, Outcome outcome) noexcept { outcomes.emplace_back(state, outcome); }),
|
|
dependencies::observe_node<Total>([] { return 2; }, [&outcomes](int& state, Outcome outcome) noexcept { outcomes.emplace_back(state, outcome); }),
|
|
dependencies::observe_node<Unrequested>([] { return 3; }, [&outcomes](int& state, Outcome outcome) noexcept { outcomes.emplace_back(state, outcome); })
|
|
);
|
|
auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers));
|
|
REQUIRE(cache.try_prepare(context));
|
|
const auto accepted = cache.share<Shared>();
|
|
CHECK(outcomes == std::vector<std::pair<int, Outcome>>{{1, Outcome::completed}, {2, Outcome::completed}, {3, Outcome::completed}});
|
|
outcomes.clear();
|
|
|
|
SECTION("A typed rejection completes earlier nodes but does not publish them") {
|
|
source = -1;
|
|
const auto rejected = cache.try_prepare(context);
|
|
REQUIRE_FALSE(rejected);
|
|
CHECK(rejected.error().error == "negative input");
|
|
CHECK(outcomes == std::vector<std::pair<int, Outcome>>{{1, Outcome::completed}, {2, Outcome::rejected}});
|
|
}
|
|
SECTION("An escaping exception closes the active node and propagates") {
|
|
source = -2;
|
|
CHECK_THROWS_AS(cache.try_prepare(context), std::runtime_error);
|
|
CHECK(outcomes == std::vector<std::pair<int, Outcome>>{{1, Outcome::completed}, {2, Outcome::exception}});
|
|
}
|
|
CHECK_FALSE(cache.ready());
|
|
CHECK_FALSE(cache.contains<Shared>());
|
|
CHECK(*accepted == 2);
|
|
|
|
outcomes.clear();
|
|
source = 2;
|
|
const auto recovered = cache.try_prepare(context);
|
|
REQUIRE(recovered);
|
|
CHECK(recovered->reused_count() == 3);
|
|
CHECK(cache.share<Shared>() == accepted);
|
|
CHECK(outcomes.empty());
|
|
}
|
|
|
|
TEST_CASE("Observer exceptions close only scopes whose before callback completed", "[unit][utils][dependencies][observers]") {
|
|
bool fail_before = false;
|
|
int computations = 0;
|
|
std::vector<int> events;
|
|
events.reserve(16);
|
|
const auto context = dependencies::make_dependency_context(dependencies::own<Source>(2));
|
|
auto graph = dependencies::make_computation_graph(
|
|
dependencies::make_computation<Total, dependencies::InputList<Source>>([&computations](int value) {
|
|
++computations;
|
|
return value;
|
|
})
|
|
);
|
|
auto observers = dependencies::make_observers(
|
|
dependencies::observe_node<Total>([&events] {
|
|
events.push_back(1);
|
|
return std::make_unique<int>(-1);
|
|
}, [&events](std::unique_ptr<int>& state, Outcome outcome) noexcept {
|
|
events.push_back(*state);
|
|
CHECK(outcome == Outcome::exception);
|
|
}),
|
|
dependencies::observe_node<Total>([&events, &fail_before] {
|
|
events.push_back(2);
|
|
if (fail_before) {
|
|
fail_observed_work();
|
|
}
|
|
return -2;
|
|
}, [&events](int& state, Outcome outcome) noexcept {
|
|
events.push_back(state);
|
|
CHECK(outcome == Outcome::exception);
|
|
}),
|
|
dependencies::observe_edge<Source, Total>([&events](const int&) {
|
|
events.push_back(3);
|
|
fail_observed_work();
|
|
})
|
|
);
|
|
auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers));
|
|
SECTION("A failed before callback has no matching after callback") {
|
|
fail_before = true;
|
|
CHECK_THROWS_AS(cache.try_prepare(context), std::runtime_error);
|
|
CHECK(events == std::vector<int>{1, 2, -1});
|
|
}
|
|
SECTION("An edge failure unwinds every entered node scope") {
|
|
CHECK_THROWS_AS(cache.try_prepare(context), std::runtime_error);
|
|
CHECK(events == std::vector<int>{1, 2, 3, -2, -1});
|
|
}
|
|
CHECK(computations == 0);
|
|
CHECK_FALSE(cache.ready());
|
|
}
|
|
|
|
TEST_CASE("Observers follow moved caches and read owning edges without copying move-only results", "[unit][utils][dependencies][observers]") {
|
|
using Value = std::unique_ptr<int>;
|
|
using Inputs = dependencies::InputList<Source>;
|
|
using Snapshot = std::shared_ptr<const Value>;
|
|
int source = 4;
|
|
const Value* observed = nullptr;
|
|
std::vector<std::pair<int, Outcome>> outcomes;
|
|
outcomes.reserve(16);
|
|
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
|
|
auto graph = dependencies::make_computation_graph(
|
|
dependencies::make_computation<Shared, Inputs>([](int value) -> std::expected<Value, std::string> {
|
|
if (value < 0) {
|
|
return std::unexpected("negative input");
|
|
}
|
|
return std::make_unique<int>(value);
|
|
}),
|
|
dependencies::make_owning_computation<Total, dependencies::InputList<Shared>>([](Snapshot value) { return value; })
|
|
);
|
|
auto observers = dependencies::make_observers(
|
|
dependencies::observe_node<Shared>([calls = std::make_unique<int>(0)] { return ++*calls; },
|
|
[&outcomes](int& invocation, Outcome outcome) noexcept { outcomes.emplace_back(invocation, outcome); }),
|
|
dependencies::observe_edge<Shared, Total>([&observed](const Value& value) { observed = &value; })
|
|
);
|
|
auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers));
|
|
REQUIRE(cache.try_prepare(context));
|
|
const Snapshot first = cache.share<Shared>();
|
|
CHECK(observed == first.get());
|
|
CHECK(cache.get<Total>() == first);
|
|
REQUIRE(cache.try_prepare(context));
|
|
CHECK(outcomes == std::vector<std::pair<int, Outcome>>{{1, Outcome::completed}});
|
|
|
|
auto moved = std::move(cache);
|
|
CHECK_FALSE(cache.ready());
|
|
source = 9;
|
|
REQUIRE(moved.try_prepare(context));
|
|
const Snapshot accepted = moved.share<Shared>();
|
|
CHECK(observed == accepted.get());
|
|
CHECK(**first == 4);
|
|
CHECK(**accepted == 9);
|
|
|
|
source = -1;
|
|
REQUIRE_FALSE(moved.try_prepare(context));
|
|
CHECK(observed == accepted.get()); // The owning consumer and its edge never ran for the rejected candidate.
|
|
source = 9;
|
|
REQUIRE(moved.try_prepare(context));
|
|
CHECK(moved.share<Shared>() == accepted);
|
|
REQUIRE(moved.try_prepare(context, dependencies::RequestedResults<Total>{}, dependencies::PreparationMode::rebuild));
|
|
CHECK(observed == moved.share<Shared>().get());
|
|
CHECK(outcomes == std::vector<std::pair<int, Outcome>>{{1, Outcome::completed}, {2, Outcome::completed}, {3, Outcome::rejected}, {4, Outcome::completed}});
|
|
}
|
|
|
|
TEST_CASE("A generic node observer composes with selected scopes without observing cache hits", "[unit][utils][dependencies][observers][timing]") {
|
|
int source = 3;
|
|
std::vector<int> events;
|
|
events.reserve(32);
|
|
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
|
|
auto graph = dependencies::make_computation_graph(
|
|
dependencies::make_computation<Shared, dependencies::InputList<Source>>([](int value) { return value * 2; }),
|
|
dependencies::make_computation<Total, dependencies::InputList<Shared>>([](int value) { return value + 1; }));
|
|
auto observers = dependencies::make_observers(
|
|
dependencies::observe_nodes([&events]<typename Key>(std::type_identity<Key>) {
|
|
const int identity = std::same_as<Key, Shared> ? 1 : 2;
|
|
events.push_back(identity);
|
|
return identity;
|
|
}, [&events](int& identity, Outcome outcome) noexcept {
|
|
CHECK(outcome == Outcome::completed);
|
|
events.push_back(-identity);
|
|
}),
|
|
dependencies::observe_node<Shared>([&events] { events.push_back(3); return 3; },
|
|
[&events](int& identity, Outcome) noexcept { events.push_back(-identity); }));
|
|
auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers));
|
|
REQUIRE(cache.try_prepare(context));
|
|
CHECK(events == std::vector<int>{1, 3, -3, -1, 2, -2});
|
|
CHECK(cache.get<Total>() == 7);
|
|
events.clear();
|
|
REQUIRE(cache.try_prepare(context));
|
|
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<>{}));
|
|
CHECK(events.empty());
|
|
}
|
|
|
|
TEST_CASE("Graph timing counts attempted work and preserves rollback with a deterministic clock", "[unit][utils][dependencies][observers][timing]") {
|
|
using TimePoint = std::chrono::steady_clock::time_point;
|
|
TimePoint now{};
|
|
int clock_reads = 0;
|
|
int source = 2;
|
|
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
|
|
auto graph = dependencies::make_computation_graph(
|
|
dependencies::make_computation<Total, dependencies::InputList<Shared>>([&now](int value) -> std::expected<int, std::string> {
|
|
now += std::chrono::seconds{3};
|
|
if (value == -2) { fail_observed_work(); }
|
|
if (value < 0) { return std::unexpected("negative input"); }
|
|
return 2 * value;
|
|
}),
|
|
dependencies::make_computation<Shared, dependencies::InputList<Source>>([&now](int value) {
|
|
now += std::chrono::seconds{2};
|
|
return value;
|
|
}),
|
|
dependencies::make_computation<Unrequested, dependencies::InputList<Source>>([&now](int value) {
|
|
now += std::chrono::seconds{7};
|
|
return value;
|
|
}));
|
|
dependencies::GraphTiming timing;
|
|
const auto factory = dependencies::time_graph_nodes(timing, [&]() noexcept { ++clock_reads; return now; });
|
|
auto observers = factory(std::type_identity<decltype(graph)>{});
|
|
auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers));
|
|
REQUIRE(timing.runs().size() == 1);
|
|
const auto& nodes = timing.runs()[0]->nodes;
|
|
REQUIRE(nodes.size() == 3);
|
|
CHECK(nodes[0].index == 0);
|
|
CHECK(nodes[1].index == 1);
|
|
CHECK(nodes[0].key_name != nodes[1].key_name);
|
|
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}));
|
|
const auto accepted = cache.share<Shared>();
|
|
CHECK(nodes[0].seconds == 3.0);
|
|
CHECK(nodes[1].seconds == 2.0);
|
|
CHECK(nodes[0].calls == 1);
|
|
CHECK(nodes[0].completed == 1);
|
|
CHECK(nodes[2].calls == 0);
|
|
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}));
|
|
CHECK(clock_reads == 4);
|
|
|
|
source = -1;
|
|
const auto rejected = cache.try_prepare(context, dependencies::RequestedResults<Total>{});
|
|
REQUIRE_FALSE(rejected);
|
|
CHECK(rejected.error().computation_index == 0);
|
|
CHECK(nodes[0].rejected == 1);
|
|
CHECK(nodes[1].completed == 2);
|
|
CHECK(*accepted == 2);
|
|
source = 2;
|
|
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}));
|
|
CHECK(clock_reads == 8);
|
|
CHECK(cache.share<Shared>() == accepted);
|
|
|
|
auto moved = std::move(cache);
|
|
source = -2;
|
|
CHECK_THROWS_AS(moved.try_prepare(context, dependencies::RequestedResults<Total>{}), std::runtime_error);
|
|
CHECK(nodes[0].exceptions == 1);
|
|
CHECK(nodes[0].calls == 3);
|
|
CHECK(nodes[0].seconds == 9.0);
|
|
CHECK(nodes[0].maximum_seconds == 3.0);
|
|
CHECK(nodes[1].completed == 3);
|
|
CHECK(nodes[2].calls == 0);
|
|
CHECK(clock_reads == 12);
|
|
|
|
source = 2;
|
|
REQUIRE(moved.try_prepare(context, dependencies::RequestedResults<Total>{}));
|
|
CHECK(clock_reads == 12);
|
|
REQUIRE(moved.try_prepare(context, dependencies::RequestedResults<Unrequested>{}));
|
|
CHECK(nodes[2].calls == 1);
|
|
CHECK(nodes[2].seconds == 7.0);
|
|
CHECK(clock_reads == 14);
|
|
}
|
|
|
|
TEST_CASE("Timing observers own their records after the collector is released", "[unit][utils][dependencies][observers][timing]") {
|
|
const auto context = dependencies::make_dependency_context();
|
|
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Total, dependencies::InputList<>>([] { return 7; }));
|
|
std::shared_ptr<const dependencies::GraphTimingRun> run;
|
|
auto observers = [&] {
|
|
dependencies::GraphTiming timing;
|
|
auto result = dependencies::time_graph_nodes(timing)(std::type_identity<decltype(graph)>{});
|
|
run = timing.runs()[0];
|
|
return result;
|
|
}();
|
|
auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers));
|
|
REQUIRE(cache.try_prepare(context));
|
|
CHECK(cache.get<Total>() == 7);
|
|
CHECK(run->nodes[0].calls == 1);
|
|
CHECK(run->nodes[0].completed == 1);
|
|
CHECK(run->nodes[0].seconds >= 0.0);
|
|
}
|