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.
This commit is contained in:
103
tests/utils/dependencies/context.cpp
Normal file
103
tests/utils/dependencies/context.cpp
Normal file
@@ -0,0 +1,103 @@
|
||||
#include "serif/utils/dependencies/dependencies.hpp"
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
|
||||
struct Owned {};
|
||||
struct Borrowed {};
|
||||
struct Result {};
|
||||
}
|
||||
|
||||
TEST_CASE("Dependency contexts distinguish ownership and borrowed roles", "[unit][utils][dependencies]") {
|
||||
int value = 3;
|
||||
auto context = dependencies::make_dependency_context(dependencies::own<Owned>(value), dependencies::borrow<Borrowed>(value));
|
||||
auto selection = context.select(dependencies::KeyList<Borrowed>{});
|
||||
|
||||
value = 8;
|
||||
CHECK(context.get<Owned>() == 3);
|
||||
CHECK(context.get<Borrowed>() == 8);
|
||||
CHECK(std::addressof(selection.get<Borrowed>()) == std::addressof(value));
|
||||
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Borrowed>>([](int input) { return 2 * input; }));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), selection);
|
||||
REQUIRE(cache.try_prepare(selection));
|
||||
CHECK(cache.get<Result>() == 16);
|
||||
|
||||
value = 9;
|
||||
CHECK(cache.get<Result>() == 16); // An accepted result remains frozen until explicitly refreshed.
|
||||
REQUIRE(cache.try_prepare(selection));
|
||||
CHECK(cache.get<Result>() == 18);
|
||||
}
|
||||
|
||||
TEST_CASE("Replacing a borrowed source invalidates its identity even at equal value", "[unit][utils][dependencies]") {
|
||||
int original = 4;
|
||||
int replacement = 4;
|
||||
int calls = 0;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Borrowed>(original));
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Borrowed>>([&calls](int input) { ++calls; return input; }));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
context = dependencies::make_dependency_context(dependencies::borrow<Borrowed>(replacement));
|
||||
auto report = cache.try_prepare(context);
|
||||
REQUIRE(report);
|
||||
CHECK(report->source_changed<Borrowed>());
|
||||
CHECK(calls == 2);
|
||||
|
||||
original = 7;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(calls == 2);
|
||||
CHECK(cache.get<Result>() == 4);
|
||||
}
|
||||
|
||||
TEST_CASE("ComputationCache dependency results can move without leaving readable moved-from state", "[unit][utils][dependencies]") {
|
||||
const auto context = dependencies::make_dependency_context();
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<>>([coefficient = std::make_unique<int>(7)] { return std::make_unique<int>(*coefficient); }));
|
||||
auto original = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(original.try_prepare(context));
|
||||
|
||||
auto moved = std::move(original);
|
||||
CHECK_FALSE(original.ready());
|
||||
CHECK_FALSE(original.contains<Result>());
|
||||
CHECK_THROWS_AS(original.get<Result>(), std::logic_error);
|
||||
REQUIRE(moved.contains<Result>());
|
||||
CHECK(*moved.get<Result>() == 7);
|
||||
const auto report = moved.try_prepare(context);
|
||||
REQUIRE(report);
|
||||
CHECK(report->reused_count() == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("Combining disjoint contexts preserves owned values borrowed identities and selected tracking policies", "[unit][utils][dependencies]")
|
||||
{
|
||||
int value = 4;
|
||||
const auto borrowed = dependencies::make_dependency_context(dependencies::borrow<Borrowed>(value, dependencies::track_by([](const int input)
|
||||
{
|
||||
return input / 10;
|
||||
})));
|
||||
const auto context = dependencies::combine_dependency_contexts(
|
||||
dependencies::make_dependency_context(dependencies::own<Owned>(3)), borrowed.select(dependencies::KeyList<Borrowed>{}));
|
||||
CHECK(context.get<Owned>() == 3);
|
||||
CHECK(&context.get<Borrowed>() == &value);
|
||||
STATIC_CHECK(std::same_as<typename decltype(context)::Keys, dependencies::KeyList<Owned, Borrowed>>);
|
||||
|
||||
auto cache = dependencies::make_computation_cache(context, dependencies::make_computation<Result, dependencies::InputList<Owned, Borrowed>>([](const int owned, const int input)
|
||||
{
|
||||
return owned + input / 10;
|
||||
}));
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Result>() == 3);
|
||||
value = 8;
|
||||
const auto unchanged = cache.try_prepare(context);
|
||||
REQUIRE(unchanged);
|
||||
CHECK(unchanged->reused_count() == 1);
|
||||
value = 12;
|
||||
const auto changed = cache.try_prepare(context);
|
||||
REQUIRE(changed);
|
||||
CHECK(changed->source_changed<Borrowed>());
|
||||
CHECK(cache.get<Result>() == 4);
|
||||
}
|
||||
189
tests/utils/dependencies/mixed_errors.cpp
Normal file
189
tests/utils/dependencies/mixed_errors.cpp
Normal file
@@ -0,0 +1,189 @@
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "serif/utils/dependencies/computation_cache.hpp"
|
||||
|
||||
namespace {
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
|
||||
struct Source {};
|
||||
struct CheckedInput {};
|
||||
struct PhysicsResult {};
|
||||
struct Output {};
|
||||
|
||||
/** @brief Preserve the original invalid argument without converting all failures into text. */
|
||||
struct InputFailure {
|
||||
int value;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
/** @brief Preserve downstream numerical diagnostics independently of input validation. */
|
||||
struct PhysicsFailure {
|
||||
double value;
|
||||
int sample;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
/** @brief A cache error may own diagnostic data that cannot be copied. */
|
||||
struct OwnedFailure {
|
||||
std::unique_ptr<int> value;
|
||||
};
|
||||
}
|
||||
|
||||
TEST_CASE("Explicit error unions preserve each computation's structured failure and transactional recovery", "[unit][utils][dependencies]") {
|
||||
using Failure = std::variant<InputFailure, PhysicsFailure>;
|
||||
|
||||
int input = 2;
|
||||
int input_calls = 0;
|
||||
int physics_calls = 0;
|
||||
int output_calls = 0;
|
||||
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(input));
|
||||
const auto checked = dependencies::make_computation<CheckedInput, dependencies::InputList<Source>>(
|
||||
[&](int value) -> std::expected<double, InputFailure> {
|
||||
++input_calls;
|
||||
if (value < 0) {
|
||||
return std::unexpected(InputFailure{.value = value, .message = "The input must be nonnegative."});
|
||||
}
|
||||
return 2.0 * value;
|
||||
}
|
||||
);
|
||||
const auto physics = dependencies::make_computation<PhysicsResult, dependencies::InputList<CheckedInput>>(
|
||||
[&](double value) -> std::expected<double, PhysicsFailure> {
|
||||
++physics_calls;
|
||||
if (value > 10.0) {
|
||||
return std::unexpected(PhysicsFailure{.value = value, .sample = 3, .message = "The physical state exceeds the admissible range."});
|
||||
}
|
||||
return value + 0.5;
|
||||
}
|
||||
);
|
||||
const auto output = dependencies::make_computation<Output, dependencies::InputList<PhysicsResult>>(
|
||||
[&](double value) {
|
||||
++output_calls;
|
||||
return 3.0 * value;
|
||||
}
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache<Failure>(context, checked, physics, output);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Output>() == 13.5);
|
||||
CHECK(cache.preparation_count() == 1);
|
||||
|
||||
input = -1;
|
||||
const auto rejected_input = cache.try_prepare(context);
|
||||
REQUIRE_FALSE(rejected_input);
|
||||
CHECK(rejected_input.error().computation_index == 0);
|
||||
const InputFailure* input_failure = std::get_if<InputFailure>(&rejected_input.error().error);
|
||||
REQUIRE(input_failure != nullptr);
|
||||
CHECK(input_failure->value == -1);
|
||||
CHECK(input_failure->message == "The input must be nonnegative.");
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK(cache.preparation_count() == 1);
|
||||
CHECK(input_calls == 2);
|
||||
CHECK(physics_calls == 1);
|
||||
CHECK(output_calls == 1);
|
||||
CHECK_THROWS_AS(cache.get<CheckedInput>(), std::logic_error);
|
||||
CHECK_THROWS_AS(cache.get<PhysicsResult>(), std::logic_error);
|
||||
CHECK_THROWS_AS(cache.get<Output>(), std::logic_error);
|
||||
|
||||
input = 3;
|
||||
const auto recovered_input = cache.try_prepare(context);
|
||||
REQUIRE(recovered_input);
|
||||
CHECK(recovered_input->computed_count() == 3);
|
||||
CHECK(cache.get<Output>() == 19.5);
|
||||
CHECK(cache.preparation_count() == 2);
|
||||
|
||||
input = 6;
|
||||
const auto rejected_physics = cache.try_prepare(context);
|
||||
REQUIRE_FALSE(rejected_physics);
|
||||
CHECK(rejected_physics.error().computation_index == 1);
|
||||
const PhysicsFailure* physics_failure = std::get_if<PhysicsFailure>(&rejected_physics.error().error);
|
||||
REQUIRE(physics_failure != nullptr);
|
||||
CHECK(physics_failure->value == 12.0);
|
||||
CHECK(physics_failure->sample == 3);
|
||||
CHECK(physics_failure->message == "The physical state exceeds the admissible range.");
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK(cache.preparation_count() == 2);
|
||||
CHECK(input_calls == 4);
|
||||
CHECK(physics_calls == 3);
|
||||
CHECK(output_calls == 2);
|
||||
CHECK_THROWS_AS(cache.get<CheckedInput>(), std::logic_error);
|
||||
CHECK_THROWS_AS(cache.get<Output>(), std::logic_error);
|
||||
|
||||
// The rejected candidate must not replace the last accepted source or intermediate result.
|
||||
input = 3;
|
||||
const auto recovered_physics = cache.try_prepare(context);
|
||||
REQUIRE(recovered_physics);
|
||||
CHECK(recovered_physics->computed_count() == 0);
|
||||
CHECK(cache.get<CheckedInput>() == 6.0);
|
||||
CHECK(cache.get<PhysicsResult>() == 6.5);
|
||||
CHECK(cache.get<Output>() == 19.5);
|
||||
CHECK(input_calls == 4);
|
||||
CHECK(physics_calls == 3);
|
||||
CHECK(output_calls == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("Requested results avoid unrelated errors in a graph with an explicit failure union", "[unit][utils][dependencies]") {
|
||||
using Failure = std::variant<InputFailure, PhysicsFailure>;
|
||||
|
||||
int input = 7;
|
||||
int physics_calls = 0;
|
||||
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(input));
|
||||
const auto checked = dependencies::make_computation<CheckedInput, dependencies::InputList<Source>>(
|
||||
[](int value) -> std::expected<int, InputFailure> {
|
||||
return value;
|
||||
}
|
||||
);
|
||||
const auto physics = dependencies::make_computation<PhysicsResult, dependencies::InputList<CheckedInput>>(
|
||||
[&](int value) -> std::expected<int, PhysicsFailure> {
|
||||
++physics_calls;
|
||||
return std::unexpected(PhysicsFailure{.value = static_cast<double>(value), .sample = 2, .message = "This downstream result is deliberately unavailable."});
|
||||
}
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache<Failure>(context, checked, physics);
|
||||
const auto requested = cache.try_prepare(context, dependencies::RequestedResults<CheckedInput>{});
|
||||
REQUIRE(requested);
|
||||
CHECK(requested->computed_count() == 1);
|
||||
CHECK(requested->status<PhysicsResult>() == dependencies::PreparationStatus::not_required);
|
||||
CHECK(physics_calls == 0);
|
||||
CHECK(cache.get<CheckedInput>() == 7);
|
||||
CHECK_FALSE(cache.contains<PhysicsResult>());
|
||||
|
||||
const auto rejected = cache.try_prepare(context);
|
||||
REQUIRE_FALSE(rejected);
|
||||
CHECK(std::holds_alternative<PhysicsFailure>(rejected.error().error));
|
||||
CHECK_FALSE(cache.ready());
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<CheckedInput>{}));
|
||||
CHECK(cache.get<CheckedInput>() == 7);
|
||||
CHECK(physics_calls == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("Explicit failure unions transfer move-only diagnostic payloads without copying", "[unit][utils][dependencies]") {
|
||||
using Failure = std::variant<InputFailure, OwnedFailure>;
|
||||
|
||||
int input = -4;
|
||||
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(input));
|
||||
const auto computation = dependencies::make_computation<Output, dependencies::InputList<Source>>(
|
||||
[](int value) -> std::expected<int, OwnedFailure> {
|
||||
if (value < 0) {
|
||||
return std::unexpected(OwnedFailure{.value = std::make_unique<int>(value)});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache<Failure>(context, computation);
|
||||
const auto rejected = cache.try_prepare(context);
|
||||
REQUIRE_FALSE(rejected);
|
||||
const OwnedFailure* failure = std::get_if<OwnedFailure>(&rejected.error().error);
|
||||
REQUIRE(failure != nullptr);
|
||||
REQUIRE(failure->value != nullptr);
|
||||
CHECK(*failure->value == -4);
|
||||
CHECK_FALSE(cache.ready());
|
||||
input = 4;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Output>() == 4);
|
||||
CHECK(*failure->value == -4);
|
||||
}
|
||||
378
tests/utils/dependencies/observers.cpp
Normal file
378
tests/utils/dependencies/observers.cpp
Normal file
@@ -0,0 +1,378 @@
|
||||
#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);
|
||||
}
|
||||
203
tests/utils/dependencies/ownership.cpp
Normal file
203
tests/utils/dependencies/ownership.cpp
Normal file
@@ -0,0 +1,203 @@
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include "serif/utils/dependencies/dependencies.hpp"
|
||||
|
||||
namespace
|
||||
{
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
struct Source
|
||||
{
|
||||
};
|
||||
struct Result
|
||||
{
|
||||
};
|
||||
struct OtherResult
|
||||
{
|
||||
};
|
||||
struct FinalResult
|
||||
{
|
||||
};
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Shared dependency results survive refresh rejection invalidation and cache destruction", "[unit][utils][dependencies]")
|
||||
{
|
||||
std::shared_ptr<const std::vector<int>> first;
|
||||
std::shared_ptr<const std::vector<int>> second;
|
||||
std::weak_ptr<const std::vector<int>> released;
|
||||
{
|
||||
int value = 4;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(value));
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Source>>(
|
||||
[](const int& input) -> std::expected<std::vector<int>, std::string>
|
||||
{
|
||||
if (input < 0)
|
||||
{
|
||||
return std::unexpected("negative trial");
|
||||
}
|
||||
return std::vector<int>{input, input + 1};
|
||||
}));
|
||||
auto cache = dependencies::make_computation_cache<std::string>(std::move(graph), context);
|
||||
STATIC_CHECK(std::same_as<decltype(cache.share<Result>()), std::shared_ptr<const std::vector<int>>>);
|
||||
CHECK_THROWS(cache.share<Result>());
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
first = cache.share<Result>();
|
||||
CHECK((*first == std::vector<int>{4, 5}));
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.share<Result>() == first);
|
||||
value = 9;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
second = cache.share<Result>();
|
||||
CHECK(second != first);
|
||||
CHECK((*first == std::vector<int>{4, 5}));
|
||||
CHECK((*second == std::vector<int>{9, 10}));
|
||||
value = -1;
|
||||
CHECK_FALSE(cache.try_prepare(context));
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK_THROWS(cache.share<Result>());
|
||||
CHECK((*first == std::vector<int>{4, 5}));
|
||||
CHECK((*second == std::vector<int>{9, 10}));
|
||||
value = 9;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.share<Result>() == second);
|
||||
cache.invalidate();
|
||||
CHECK_THROWS(cache.share<Result>());
|
||||
CHECK((*second == std::vector<int>{9, 10}));
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
released = cache.share<Result>();
|
||||
CHECK_FALSE(released.expired());
|
||||
}
|
||||
CHECK(released.expired());
|
||||
CHECK((*first == std::vector<int>{4, 5}));
|
||||
CHECK((*second == std::vector<int>{9, 10}));
|
||||
released = first;
|
||||
first.reset();
|
||||
CHECK(released.expired());
|
||||
}
|
||||
|
||||
TEST_CASE("Only currently requested dependency results can be shared while retained snapshots stay valid", "[unit][utils][dependencies]")
|
||||
{
|
||||
const auto context = dependencies::make_dependency_context(dependencies::own<Source>(3));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_computation<Result, dependencies::InputList<Source>>([](const int& value)
|
||||
{
|
||||
return value;
|
||||
}),
|
||||
dependencies::make_computation<OtherResult, dependencies::InputList<Source>>([](const int& value)
|
||||
{
|
||||
return 2 * value;
|
||||
}));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
const auto previous = cache.share<OtherResult>();
|
||||
REQUIRE(cache.try_prepare(context, dependencies::KeyList<Result>{}));
|
||||
CHECK_THROWS(cache.share<OtherResult>());
|
||||
CHECK(*previous == 6);
|
||||
CHECK(*cache.share<Result>() == 3);
|
||||
auto moved = std::move(cache);
|
||||
CHECK_THROWS(cache.share<Result>());
|
||||
CHECK(*moved.share<Result>() == 3);
|
||||
CHECK(*previous == 6);
|
||||
}
|
||||
|
||||
TEST_CASE("Owning computations retain the exact move-only prerequisite across cache generations and owner lifetimes", "[unit][utils][dependencies]")
|
||||
{
|
||||
using Snapshot = std::shared_ptr<const std::unique_ptr<int>>;
|
||||
Snapshot first;
|
||||
Snapshot second;
|
||||
std::weak_ptr<const std::unique_ptr<int>> unretained;
|
||||
{
|
||||
int value = 4;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(value));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_owning_computation<OtherResult, dependencies::InputList<Result>>([](Snapshot prerequisite)
|
||||
{
|
||||
return prerequisite;
|
||||
}),
|
||||
dependencies::make_computation<Result, dependencies::InputList<Source>>([](const int input)
|
||||
{
|
||||
return std::make_unique<int>(input);
|
||||
}),
|
||||
dependencies::make_computation<FinalResult, dependencies::InputList<OtherResult>>([](const Snapshot& input)
|
||||
{
|
||||
return **input + 1;
|
||||
}));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
first = cache.get<OtherResult>();
|
||||
CHECK(first == cache.share<Result>());
|
||||
CHECK(cache.get<FinalResult>() == 5);
|
||||
|
||||
const auto reused = cache.try_prepare(context);
|
||||
REQUIRE(reused);
|
||||
CHECK(reused->reused_count() == 3);
|
||||
CHECK(cache.get<OtherResult>() == first);
|
||||
|
||||
value = 9;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
second = cache.get<OtherResult>();
|
||||
CHECK(second == cache.share<Result>());
|
||||
CHECK(second != first);
|
||||
CHECK(**first == 4);
|
||||
CHECK(**second == 9);
|
||||
CHECK(cache.get<FinalResult>() == 10);
|
||||
|
||||
cache.invalidate();
|
||||
CHECK(**first == 4);
|
||||
CHECK(**second == 9);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
unretained = cache.share<Result>();
|
||||
CHECK_FALSE(unretained.expired());
|
||||
}
|
||||
CHECK(unretained.expired());
|
||||
CHECK(**first == 4);
|
||||
CHECK(**second == 9);
|
||||
}
|
||||
|
||||
TEST_CASE("Rejected owning computations release candidate prerequisites and preserve the accepted generation", "[unit][utils][dependencies]")
|
||||
{
|
||||
using Snapshot = std::shared_ptr<const std::unique_ptr<int>>;
|
||||
int value = 4;
|
||||
std::weak_ptr<const std::unique_ptr<int>> rejected;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(value));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_computation<Result, dependencies::InputList<Source>>([](const int input)
|
||||
{
|
||||
return std::make_unique<int>(input);
|
||||
}),
|
||||
dependencies::make_owning_computation<OtherResult, dependencies::InputList<Result>>([&rejected](Snapshot prerequisite) -> std::expected<Snapshot, std::string>
|
||||
{
|
||||
if (**prerequisite < 0)
|
||||
{
|
||||
rejected = prerequisite;
|
||||
return std::unexpected("negative candidate");
|
||||
}
|
||||
return prerequisite;
|
||||
}));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
const Snapshot accepted = cache.get<OtherResult>();
|
||||
|
||||
value = -1;
|
||||
const auto failure = cache.try_prepare(context);
|
||||
REQUIRE_FALSE(failure);
|
||||
CHECK(failure.error().error == "negative candidate");
|
||||
CHECK(rejected.expired());
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK(**accepted == 4);
|
||||
|
||||
value = 4;
|
||||
const auto recovered = cache.try_prepare(context);
|
||||
REQUIRE(recovered);
|
||||
CHECK(recovered->reused_count() == 2);
|
||||
CHECK(cache.get<OtherResult>() == accepted);
|
||||
|
||||
const auto rebuilt = cache.try_prepare(context, dependencies::KeyList<OtherResult>{}, dependencies::PreparationMode::rebuild);
|
||||
REQUIRE(rebuilt);
|
||||
CHECK(rebuilt->computed_count() == 2);
|
||||
CHECK(cache.get<OtherResult>() != accepted);
|
||||
CHECK(**accepted == 4);
|
||||
}
|
||||
260
tests/utils/dependencies/preparation.cpp
Normal file
260
tests/utils/dependencies/preparation.cpp
Normal file
@@ -0,0 +1,260 @@
|
||||
#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);
|
||||
}
|
||||
62
tests/utils/dependencies/reachability.cpp
Normal file
62
tests/utils/dependencies/reachability.cpp
Normal file
@@ -0,0 +1,62 @@
|
||||
#include <array>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "serif/utils/dependencies/dependencies.hpp"
|
||||
|
||||
namespace
|
||||
{
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
struct Source
|
||||
{
|
||||
};
|
||||
template <int Index>
|
||||
struct Stage
|
||||
{
|
||||
};
|
||||
}
|
||||
|
||||
TEST_CASE("Requested dependency branches follow topology independently of declaration order", "[unit][utils][dependencies]")
|
||||
{
|
||||
int input = 2;
|
||||
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(input));
|
||||
std::array<int, 8> calls{};
|
||||
const auto branch = [&]<int Result, int Input>()
|
||||
{
|
||||
return dependencies::make_computation<Stage<Result>, dependencies::InputList<Stage<Input>>>([&](const int value)
|
||||
{
|
||||
++calls[Result];
|
||||
return value + Result;
|
||||
});
|
||||
};
|
||||
const auto graph = dependencies::make_computation_graph(
|
||||
branch.template operator()<4, 2>(),
|
||||
dependencies::make_computation<Stage<6>, dependencies::InputList<Stage<4>, Stage<5>>>([&](const int left, const int right)
|
||||
{
|
||||
++calls[6];
|
||||
return left + right;
|
||||
}),
|
||||
branch.template operator()<2, 1>(),
|
||||
branch.template operator()<7, 3>(),
|
||||
branch.template operator()<5, 3>(),
|
||||
branch.template operator()<1, 0>(),
|
||||
dependencies::make_computation<Stage<0>, dependencies::InputList<Source>>([&](const int value)
|
||||
{
|
||||
++calls[0];
|
||||
return value;
|
||||
}),
|
||||
branch.template operator()<3, 1>());
|
||||
auto cache = dependencies::make_computation_cache(graph, context);
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Stage<4>>{}));
|
||||
CHECK(cache.get<Stage<4>>() == 9);
|
||||
CHECK(calls == std::array{1, 1, 1, 0, 1, 0, 0, 0});
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Stage<6>>{}));
|
||||
CHECK(cache.get<Stage<6>>() == 20);
|
||||
CHECK(calls == std::array{1, 1, 1, 1, 1, 1, 1, 0});
|
||||
input = 5;
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Stage<4>, Stage<7>>{}));
|
||||
CHECK(cache.get<Stage<4>>() == 12);
|
||||
CHECK(cache.get<Stage<7>>() == 16);
|
||||
CHECK(calls == std::array{2, 2, 2, 2, 2, 1, 1, 1});
|
||||
CHECK(cache.report().status<Stage<5>>() == dependencies::PreparationStatus::not_required);
|
||||
CHECK(cache.report().status<Stage<6>>() == dependencies::PreparationStatus::not_required);
|
||||
}
|
||||
193
tests/utils/dependencies/rejection.cpp
Normal file
193
tests/utils/dependencies/rejection.cpp
Normal file
@@ -0,0 +1,193 @@
|
||||
#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);
|
||||
}
|
||||
137
tests/utils/dependencies/snapshots.cpp
Normal file
137
tests/utils/dependencies/snapshots.cpp
Normal file
@@ -0,0 +1,137 @@
|
||||
#include "serif/utils/dependencies/dependencies.hpp"
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <memory>
|
||||
#include <numeric>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
|
||||
struct Source {};
|
||||
struct Unused {};
|
||||
struct Result {};
|
||||
struct OtherResult {};
|
||||
struct Untrackable {};
|
||||
|
||||
struct View {
|
||||
const int& binding;
|
||||
const int& value;
|
||||
};
|
||||
struct ViewTracking {
|
||||
/// Snapshot content without retaining the temporary view.
|
||||
int capture(const View& view) const { return view.value; }
|
||||
/// Vector storage may move or be copied without changing its value.
|
||||
bool matches(const int& captured, const View& view) const { return captured == view.value; }
|
||||
/// The persistent role, rather than its temporary wrapper, defines identity.
|
||||
const void* identity(const View& view) const { return &view.binding; }
|
||||
};
|
||||
|
||||
struct RevisionedSource {
|
||||
std::unique_ptr<int> value;
|
||||
unsigned revision{0};
|
||||
};
|
||||
|
||||
struct OwnedSnapshot {
|
||||
int& captures;
|
||||
int& comparisons;
|
||||
|
||||
/// Exercise an owning snapshot that cannot itself be copied.
|
||||
std::unique_ptr<int> capture(const RevisionedSource& source) const {
|
||||
++captures;
|
||||
return std::make_unique<int>(*source.value);
|
||||
}
|
||||
|
||||
/// Compare the frozen content, independent of source revision counters.
|
||||
bool matches(const std::unique_ptr<int>& captured, const RevisionedSource& source) const {
|
||||
++comparisons;
|
||||
return *captured == *source.value;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
TEST_CASE("Dependency views can preserve underlying identity across temporary wrappers", "[unit][utils][dependencies]") {
|
||||
int binding = 1, other_binding = 1, value = 4, copied_value = 4;
|
||||
const auto context = dependencies::make_dependency_context(dependencies::own<Source>(View{binding, value}, ViewTracking{}));
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Source>>([](const View& view) { return view.value; }));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
const auto copied = dependencies::make_dependency_context(dependencies::own<Source>(View{binding, copied_value}, ViewTracking{}));
|
||||
auto report = cache.try_prepare(copied);
|
||||
REQUIRE(report);
|
||||
CHECK(report->reused_count() == 1);
|
||||
|
||||
const auto rebound = dependencies::make_dependency_context(dependencies::own<Source>(View{other_binding, copied_value}, ViewTracking{}));
|
||||
report = cache.try_prepare(rebound);
|
||||
REQUIRE(report);
|
||||
CHECK(report->computed_count() == 1);
|
||||
copied_value = 8;
|
||||
REQUIRE(cache.try_prepare(rebound));
|
||||
CHECK(cache.get<Result>() == 8);
|
||||
}
|
||||
|
||||
TEST_CASE("Dependency value snapshots detect in-place container changes", "[unit][utils][dependencies]") {
|
||||
std::vector<int> values{2, 3};
|
||||
int calls = 0;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(values), dependencies::own<Unused>(Untrackable{}));
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Source>>([&calls](const std::vector<int>& input) { ++calls; return std::accumulate(input.begin(), input.end(), 0); }));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Result>() == 5);
|
||||
|
||||
values[1] = 7;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Result>() == 9);
|
||||
CHECK(calls == 2);
|
||||
values.push_back(4);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Result>() == 13);
|
||||
CHECK(calls == 3);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(calls == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("A dependency projection tracks noncopyable sources by revision", "[unit][utils][dependencies]") {
|
||||
RevisionedSource source{.value = std::make_unique<int>(2)};
|
||||
int calls = 0;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source,
|
||||
dependencies::track_by([](const RevisionedSource& input) { return input.revision; })));
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Source>>([&calls](const RevisionedSource& input) { ++calls; return *input.value; }));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(calls == 1);
|
||||
*source.value = 8;
|
||||
++source.revision;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(calls == 2);
|
||||
CHECK(cache.get<Result>() == 8);
|
||||
}
|
||||
|
||||
TEST_CASE("Required sources are inspected once and can use move-only snapshot state", "[unit][utils][dependencies]") {
|
||||
RevisionedSource source{.value = std::make_unique<int>(2)};
|
||||
int captures = 0;
|
||||
int comparisons = 0;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source,
|
||||
OwnedSnapshot{.captures = captures, .comparisons = comparisons}));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_computation<Result, dependencies::InputList<Source>>([](const RevisionedSource& input) { return *input.value; }),
|
||||
dependencies::make_computation<OtherResult, dependencies::InputList<Source>>([](const RevisionedSource& input) { return 2 * *input.value; })
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(captures == 1);
|
||||
CHECK(comparisons == 0);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(captures == 1);
|
||||
CHECK(comparisons == 1);
|
||||
|
||||
*source.value = 9;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(captures == 2);
|
||||
CHECK(comparisons == 2);
|
||||
CHECK(cache.get<Result>() == 9);
|
||||
CHECK(cache.get<OtherResult>() == 18);
|
||||
}
|
||||
Reference in New Issue
Block a user