Files
EVEngine/test/action_notify_registry.cpp

530 lines
25 KiB
C++

#include "action/ActionBlockRuntime.h"
#include "action/ActionAudioBlock.h"
#include "action/ActionCameraBlock.h"
#include "action/ActionNotifyRegistry.h"
#include "action/ActionParameterCurve.h"
#include "action/ActionPrefabBlock.h"
#include "action/ActionPrefabInstances.h"
#include "action/ActionSpatialBlock.h"
#include "action/ActionVfxBlock.h"
#include "common/Capability.h"
#include "common/EntitySpatialResolver.h"
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include <memory>
#include <string_view>
namespace {
eve::LogicalId id(std::string_view value) {
auto parsed = eve::LogicalId::parse(value);
REQUIRE(parsed.has_value());
return std::move(*parsed);
}
eve::action::ActionTimelineEvent event(eve::action::ActionTimelineEventKind kind, std::string_view type) {
return {kind, id("combat-track:gameplay"), id("combat-notify:test"),
id(type), eve::Duration::fromNanoseconds(10), {}};
}
class RecordingHandler final : public eve::action::IActionNotifyHandler {
public:
eve::Result<void> handle(const eve::action::ActionTimelineEvent& incoming,
const eve::action::ActionNotifyContext& incomingContext) override {
++calls;
lastType = incoming.type.format();
lastExecution = incomingContext.executionId;
return eve::Result<void>::success();
}
eve::Result<void> update(const eve::action::ActionActiveBlock& block,
const eve::action::ActionNotifyContext&) override {
++updates;
lastLocalTime = block.localTime;
return eve::Result<void>::success();
}
eve::Result<void> sample(const eve::action::ActionActiveBlock&,
const eve::action::ActionNotifyContext& context) const override {
if (!context.preview || !context.scrubbing)
return eve::Result<void>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument, "sample context is not preview", "context"));
++samples;
return eve::Result<void>::success();
}
eve::Result<void> advance(const eve::action::ActionNotifyContext&) override {
++advances;
if (failAdvance)
return eve::Result<void>::failure(
eve::Diagnostic::error(eve::DiagnosticCode::Failed, "advance failed", "handler"));
return eve::Result<void>::success();
}
int calls = 0;
int updates = 0;
mutable int samples = 0;
int advances = 0;
bool failAdvance = false;
std::string lastType;
eve::action::ActionExecutionId lastExecution;
eve::Duration lastLocalTime = eve::Duration::zero();
};
class RecordingParameterSink final : public eve::action::IActionParameterSink {
public:
bool supports(const eve::LogicalId& target) const noexcept override {
return target.format() == "test:parameter";
}
eve::Result<void> apply(const eve::action::ActionParameterSample& sample) override {
samples.push_back(sample);
return eve::Result<void>::success();
}
std::vector<eve::action::ActionParameterSample> samples;
};
class RecordingCameraSink final : public eve::action::IActionCameraCueSink {
public:
bool supports(const eve::LogicalId& cue) const noexcept override {
return cue.format() == "combat:impact";
}
eve::Result<void> trigger(const eve::action::ActionCameraCueBinding& binding,
const eve::action::ActionNotifyContext& context) override {
++calls;
last = binding;
execution = context.executionId;
return eve::Result<void>::success();
}
int calls = 0;
eve::action::ActionCameraCueBinding last;
eve::action::ActionExecutionId execution;
};
eve::Value::Object curvePayload() {
return {{"target", "test:parameter"},
{"operation", "multiply"},
{"keys", eve::Value::Array{
eve::Value::Object{{"time", 0.0}, {"value", 0.0}, {"interpolation", "linear"}},
eve::Value::Object{{"time", 0.5}, {"value", 1.0}, {"interpolation", "step"}},
eve::Value::Object{{"time", 1.0}, {"value", 0.25}, {"interpolation", "linear"}}}}};
}
} // namespace
TEST_CASE("actionNotifyRegistry.builtinsExposeStableEditorContracts") {
CHECK(eve::cap::query<eve::action::IActionPrefabInstances>() == nullptr);
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
const auto descriptors = registry.value().descriptors();
REQUIRE_EQ(descriptors.size(), 14u);
CHECK_EQ(descriptors.front().type, "collision:ignore-window");
CHECK_EQ(descriptors.back().type, "presentation:vfx-state");
auto damage = registry.value().descriptor("combat:damage");
REQUIRE(damage.ok());
CHECK(static_cast<int>(damage.value().shape) == static_cast<int>(eve::action::ActionNotifyShape::Instant));
CHECK_EQ(damage.value().requiredPayloadFields.size(), 2u);
auto hitbox = registry.value().descriptor("combat:hitbox-window");
REQUIRE(hitbox.ok());
CHECK(static_cast<int>(hitbox.value().shape) == static_cast<int>(eve::action::ActionNotifyShape::State));
auto prefab = registry.value().descriptor("gameplay:prefab-spawn");
REQUIRE(prefab.ok());
CHECK_EQ(prefab.value().displayName, "Spawn Prefab");
CHECK(static_cast<int>(prefab.value().shape) == static_cast<int>(eve::action::ActionNotifyShape::State));
}
TEST_CASE("actionCameraCue.validatesAndRoutesToOptionalSink") {
eve::Value::Object payload{{"cue", "combat:impact"},
{"positionAmplitude", 0.2},
{"rotationAmplitude", 2.5},
{"fovAmplitude", -1.0},
{"durationSeconds", 0.4},
{"seed", 17}};
auto binding = eve::action::ActionCameraCueBinding::fromPayload(payload);
REQUIRE(binding.ok());
CHECK_EQ(binding.value().cue, id("combat:impact"));
CHECK_EQ(binding.value().positionAmplitude, 0.2);
CHECK_EQ(binding.value().rotationAmplitude, 2.5);
CHECK_EQ(binding.value().fovAmplitude, -1.0);
CHECK_EQ(binding.value().duration, eve::Duration::fromNanoseconds(400000000));
CHECK_EQ(binding.value().seed, 17u);
auto invalid = payload;
invalid["durationSeconds"] = 0.0;
auto rejected = eve::action::ActionCameraCueBinding::fromPayload(invalid);
CHECK(!rejected.ok());
CHECK_EQ(rejected.status().diagnostics().front().path(), "durationSeconds");
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
auto cameraEvent = event(eve::action::ActionTimelineEventKind::Notify, "presentation:camera");
cameraEvent.payload = payload;
eve::action::ActionNotifyContext context;
context.executionId = eve::action::ActionExecutionId{202};
auto missing = registry.value().dispatch(cameraEvent, context);
CHECK(!missing.ok());
CHECK_EQ(missing.status().code(), eve::StatusCode::NotFound);
RecordingCameraSink sink;
eve::cap::addListener<eve::action::IActionCameraCueSink>(&sink);
REQUIRE(registry.value().dispatch(cameraEvent, context).ok());
REQUIRE_EQ(sink.calls, 1);
CHECK_EQ(sink.last.seed, 17u);
CHECK_EQ(sink.execution, context.executionId);
eve::cap::removeListener<eve::action::IActionCameraCueSink>(&sink);
}
TEST_CASE("actionParameterCurve.validatesSamplesAndRoutesPairedRuntimeLifecycle") {
auto payload = curvePayload();
auto binding = eve::action::ActionParameterCurveBinding::fromPayload(payload);
REQUIRE(binding.ok());
CHECK_EQ(binding.value().target, id("test:parameter"));
CHECK_EQ(binding.value().operation, eve::action::ActionParameterOperation::Multiply);
CHECK_EQ(binding.value().sample(0.25), 0.5);
CHECK_EQ(binding.value().sample(0.75), 1.0);
CHECK_EQ(binding.value().toPayload({{"extension", true}}).at("extension").asBool(), true);
auto invalid = payload;
invalid["keys"] = eve::Value::Array{
eve::Value::Object{{"time", 0.1}, {"value", 0.0}},
eve::Value::Object{{"time", 1.0}, {"value", 1.0}}};
auto rejected = eve::action::ActionParameterCurveBinding::fromPayload(invalid);
CHECK(!rejected.ok());
CHECK_EQ(rejected.status().diagnostics().front().path(), "keys");
{
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
eve::action::ActionBlockRuntime runtime(registry.value());
eve::action::ActionAdvance advance;
advance.id = eve::action::ActionExecutionId{100};
advance.activeBlocks.push_back({id("parameter-track:main"), id("parameter-curve:missing-sink"),
id("presentation:parameter-curve"), eve::Duration::zero(),
eve::Duration::fromNanoseconds(100), payload});
eve::action::ActionNotifyContext context;
context.executionId = advance.id;
auto missing = runtime.apply(advance, context);
CHECK(!missing.ok());
CHECK_EQ(missing.status().code(), eve::StatusCode::NotFound);
}
RecordingParameterSink sink;
eve::cap::addListener<eve::action::IActionParameterSink>(&sink);
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
eve::action::ActionBlockRuntime runtime(registry.value());
eve::action::ActionAdvance advance;
advance.id = eve::action::ActionExecutionId{101};
advance.timelineEvents.push_back({eve::action::ActionTimelineEventKind::StateEnter,
id("parameter-track:main"), id("parameter-curve:fade"),
id("presentation:parameter-curve"), eve::Duration::zero(), payload});
advance.activeBlocks.push_back({id("parameter-track:main"), id("parameter-curve:fade"),
id("presentation:parameter-curve"), eve::Duration::zero(),
eve::Duration::fromNanoseconds(100), payload});
eve::action::ActionNotifyContext context;
context.executionId = advance.id;
REQUIRE(runtime.apply(advance, context).ok());
REQUIRE_EQ(sink.samples.size(), 1u);
CHECK_EQ(sink.samples.back().phase, eve::action::ActionParameterPhase::Begin);
CHECK_EQ(sink.samples.back().value, 0.0);
advance.timelineEvents.clear();
advance.activeBlocks.front().localTime = eve::Duration::fromNanoseconds(25);
REQUIRE(runtime.apply(advance, context).ok());
REQUIRE_EQ(sink.samples.size(), 2u);
CHECK_EQ(sink.samples.back().phase, eve::action::ActionParameterPhase::Update);
CHECK_EQ(sink.samples.back().value, 0.5);
REQUIRE(runtime.interrupt(context).ok());
REQUIRE_EQ(sink.samples.size(), 3u);
CHECK_EQ(sink.samples.back().phase, eve::action::ActionParameterPhase::End);
eve::cap::removeListener<eve::action::IActionParameterSink>(&sink);
}
TEST_CASE("entitySpatialResolver.absentProviderFailsObservably") {
auto resolved = eve::resolveEntitySpatialPose({});
CHECK(!resolved.ok());
CHECK(static_cast<int>(resolved.status().code()) == static_cast<int>(eve::StatusCode::NotFound));
}
TEST_CASE("actionBlockRuntime.routesEnterUpdateExitAndSideEffectFreeSample") {
eve::action::ActionNotifyRegistry registry;
REQUIRE(registry
.registerDescriptor({"project:combat.window", "Window", "Project",
eve::action::ActionNotifyShape::State, {}})
.ok());
CHECK(!registry.hasHandler("project:combat.window"));
auto handler = std::make_shared<RecordingHandler>();
REQUIRE(registry.registerHandler("project:combat.window", handler).ok());
CHECK(registry.hasHandler("project:combat.window"));
eve::action::ActionBlockRuntime runtime(registry);
eve::action::ActionAdvance advance;
advance.id = eve::action::ActionExecutionId{7};
advance.totalElapsed = eve::Duration::fromNanoseconds(25);
advance.timelineEvents.push_back(event(eve::action::ActionTimelineEventKind::StateEnter,
"project:combat.window"));
advance.activeBlocks.push_back({id("combat-track:gameplay"), id("combat-notify:test"),
id("project:combat.window"), eve::Duration::fromNanoseconds(15),
eve::Duration::fromNanoseconds(40), {}});
eve::action::ActionNotifyContext context;
context.executionId = advance.id;
REQUIRE(runtime.apply(advance, context).ok());
CHECK_EQ(handler->calls, 1);
CHECK_EQ(handler->updates, 1);
CHECK_EQ(handler->advances, 1);
CHECK_EQ(handler->lastLocalTime, eve::Duration::fromNanoseconds(15));
CHECK_EQ(runtime.executionCount(), 1u);
REQUIRE(runtime.sample(advance.activeBlocks, context).ok());
CHECK_EQ(handler->samples, 1);
CHECK_EQ(runtime.executionCount(), 1u);
context.time = eve::Duration::fromNanoseconds(30);
REQUIRE(runtime.interrupt(context).ok());
CHECK_EQ(handler->calls, 2);
CHECK_EQ(runtime.executionCount(), 0u);
auto noOp = runtime.interrupt(context);
REQUIRE(noOp.ok());
CHECK_EQ(noOp.status().code(), eve::StatusCode::NoOp);
}
TEST_CASE("actionSpatialBlock.decodesSharedAttachmentContractTransactionally") {
eve::Value::Object payload{
{"attachment", "follow_position_only"},
{"spatialTarget", "target"},
{"targetIndex", 2},
{"bone", "hand_r"},
{"positionOffset", eve::Value::Array{1.0, 2, 3.5}},
{"rotationOffsetDegrees", eve::Value::Array{0, 90.0, 0}},
{"scale", eve::Value::Array{1.0, 2.0, 1.0}},
{"uri", "asset://vfx/sword-trail"},
};
auto binding = eve::action::ActionSpatialBinding::fromPayload(payload);
REQUIRE(binding.ok());
CHECK_EQ(binding.value().mode, eve::action::ActionSpatialAttachmentMode::FollowPositionOnly);
CHECK_EQ(binding.value().target, eve::action::ActionSpatialTarget::Target);
CHECK_EQ(binding.value().targetIndex, 2u);
CHECK_EQ(binding.value().bone, "hand_r");
CHECK_EQ(binding.value().positionOffset.z, 3.5);
CHECK_EQ(binding.value().rotationOffsetDegrees.y, 90.0);
CHECK_EQ(binding.value().scale.y, 2.0);
payload["scale"] = eve::Value::Array{1.0, 0.0, 1.0};
auto rejected = eve::action::ActionSpatialBinding::fromPayload(payload);
CHECK(!rejected.ok());
CHECK_EQ(rejected.status().diagnostics().front().path(), "scale");
}
TEST_CASE("actionPrefabBlock.validatesLifecycleDurationAndSpatialContract") {
eve::Value::Object payload{
{"uri", "asset://prefabs/sword-wave.glb"},
{"lifecycle", "custom_duration"},
{"customDurationSeconds", 1.25},
{"attachment", "follow_target"},
{"bone", "hand_r"},
{"scale", eve::Value::Array{1.0, 2.0, 1.0}},
};
auto binding = eve::action::ActionPrefabSpawnBinding::fromPayload(payload);
REQUIRE(binding.ok());
CHECK_EQ(binding.value().uri, "asset://prefabs/sword-wave.glb");
CHECK_EQ(binding.value().lifecycle, eve::action::PrefabSpawnLifecycle::CustomDuration);
CHECK_EQ(binding.value().customDuration, eve::Duration::fromNanoseconds(1250000000));
CHECK_EQ(binding.value().spatial.bone, "hand_r");
CHECK_EQ(binding.value().spatial.scale.y, 2.0);
payload["customDurationSeconds"] = 0.0;
auto rejected = eve::action::ActionPrefabSpawnBinding::fromPayload(payload);
CHECK(!rejected.ok());
CHECK_EQ(rejected.status().diagnostics().front().path(), "customDurationSeconds");
payload["customDurationSeconds"] = 0.5;
payload["lifecycle"] = "unknown";
rejected = eve::action::ActionPrefabSpawnBinding::fromPayload(payload);
CHECK(!rejected.ok());
CHECK_EQ(rejected.status().diagnostics().front().path(), "lifecycle");
}
TEST_CASE("actionAudioBlock.validatesSharedRuntimeAndPreviewContract") {
eve::Value::Object payload{{"uri", "asset://audio/sword.wav"},
{"randomUris", eve::Value::Array{"asset://audio/sword-a.wav",
"asset://audio/sword-b.wav"}},
{"volume", 0.5}, {"pitch", 1.25}, {"randomPitchOffset", 0.1},
{"spatialBlend", 0.75}, {"minDistance", 2.0}, {"maxDistance", 28.0},
{"looping", true}, {"fadeOutOnExit", true}, {"fadeOutDuration", 0.25}};
auto state = eve::action::ActionAudioBinding::fromPayload(
payload, eve::action::ActionAudioShape::State);
REQUIRE(state.ok());
CHECK_EQ(state.value().uri, "asset://audio/sword.wav");
CHECK_EQ(state.value().volume, 0.5);
CHECK_EQ(state.value().pitch, 1.25);
REQUIRE_EQ(state.value().randomUris.size(), 2U);
CHECK_EQ(state.value().randomUris[1], "asset://audio/sword-b.wav");
CHECK_EQ(state.value().randomPitchOffset, 0.1);
CHECK_EQ(state.value().spatialBlend, 0.75);
CHECK_EQ(state.value().minDistance, 2.0);
CHECK_EQ(state.value().maxDistance, 28.0);
CHECK(state.value().fadeOutOnExit);
CHECK_EQ(state.value().fadeOutDuration, 0.25);
CHECK(state.value().looping);
auto instant = eve::action::ActionAudioBinding::fromPayload(
payload, eve::action::ActionAudioShape::Instant);
CHECK(!instant.ok());
CHECK_EQ(instant.status().diagnostics().front().path(), "looping");
payload["looping"] = false;
payload["pitch"] = 0.0;
auto invalidPitch = eve::action::ActionAudioBinding::fromPayload(
payload, eve::action::ActionAudioShape::Instant);
CHECK(!invalidPitch.ok());
CHECK_EQ(invalidPitch.status().diagnostics().front().path(), "pitch");
payload["pitch"] = 1.0;
payload["randomPitchOffset"] = 0.6;
auto invalidVariation = eve::action::ActionAudioBinding::fromPayload(
payload, eve::action::ActionAudioShape::Instant);
CHECK(!invalidVariation.ok());
CHECK_EQ(invalidVariation.status().diagnostics().front().path(), "randomPitchOffset");
payload["randomPitchOffset"] = 0.1;
payload["maxDistance"] = 1.0;
auto invalidAttenuation = eve::action::ActionAudioBinding::fromPayload(
payload, eve::action::ActionAudioShape::Instant);
CHECK(!invalidAttenuation.ok());
CHECK_EQ(invalidAttenuation.status().diagnostics().front().path(), "maxDistance");
payload["maxDistance"] = 28.0;
payload["randomUris"] = eve::Value::Array{"asset://audio/repeated.wav", "asset://audio/repeated.wav"};
auto duplicateUri = eve::action::ActionAudioBinding::fromPayload(
payload, eve::action::ActionAudioShape::Instant);
CHECK(!duplicateUri.ok());
CHECK_EQ(duplicateUri.status().diagnostics().front().path(), "randomUris[1]");
payload["randomUris"] = eve::Value::Array{};
payload["fadeOutDuration"] = 0.0;
auto invalidFade = eve::action::ActionAudioBinding::fromPayload(
payload, eve::action::ActionAudioShape::Instant);
CHECK(!invalidFade.ok());
CHECK_EQ(invalidFade.status().diagnostics().front().path(), "fadeOutDuration");
}
TEST_CASE("actionVfxBlock.validatesTrimStopAndLifetimeContract") {
eve::Value::Object payload{{"uri", "asset://vfx/sword-trail.effect.json"},
{"stopBehavior", "clear_immediately"},
{"playbackRateSynced", false},
{"clipStartTime", 0.25},
{"clipEndTime", 1.75},
{"lifetimeSeconds", 2.0}};
auto instant = eve::action::ActionVfxBinding::fromPayload(
payload, eve::action::ActionVfxShape::Instant);
REQUIRE(instant.ok());
CHECK_EQ(instant.value().uri, "asset://vfx/sword-trail.effect.json");
CHECK_EQ(instant.value().stopBehavior, eve::action::ActionVfxStopBehavior::ClearImmediately);
CHECK(!instant.value().playbackRateSynced);
CHECK_EQ(instant.value().clipStartTime, 0.25);
CHECK_EQ(instant.value().clipEndTime, 1.75);
CHECK_EQ(instant.value().lifetimeSeconds, 2.0);
payload["clipEndTime"] = 0.25;
auto invalidClip = eve::action::ActionVfxBinding::fromPayload(
payload, eve::action::ActionVfxShape::State);
CHECK(!invalidClip.ok());
CHECK_EQ(invalidClip.status().diagnostics().front().path(), "clipEndTime");
payload["clipEndTime"] = 1.75;
payload["lifetimeSeconds"] = 0.0;
auto invalidLifetime = eve::action::ActionVfxBinding::fromPayload(
payload, eve::action::ActionVfxShape::Instant);
CHECK(!invalidLifetime.ok());
CHECK_EQ(invalidLifetime.status().diagnostics().front().path(), "lifetimeSeconds");
}
TEST_CASE("actionNotifyRegistry.validatesShapeAndRequiredPayload") {
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
auto gameplay = event(eve::action::ActionTimelineEventKind::Notify, "gameplay:event");
CHECK(!registry.value().validate(gameplay).ok());
gameplay.payload.emplace("tag", eve::Value("Combat.Action.Hit"));
CHECK(registry.value().validate(gameplay).ok());
auto hitbox = event(eve::action::ActionTimelineEventKind::StateEnter, "combat:hitbox-window");
hitbox.payload.emplace("hitbox", eve::Value("weapon.main"));
CHECK(registry.value().validate(hitbox).ok());
hitbox.kind = eve::action::ActionTimelineEventKind::StateExit;
CHECK(registry.value().validate(hitbox).ok());
hitbox.kind = eve::action::ActionTimelineEventKind::Notify;
CHECK(!registry.value().validate(hitbox).ok());
auto prefab = event(eve::action::ActionTimelineEventKind::StateEnter, "gameplay:prefab-spawn");
prefab.payload.emplace("uri", eve::Value("asset://prefabs/sword-wave.glb"));
prefab.payload.emplace("lifecycle", eve::Value("custom_duration"));
CHECK(!registry.value().validate(prefab).ok());
prefab.payload.emplace("customDurationSeconds", eve::Value(0.5));
CHECK(registry.value().validate(prefab).ok());
eve::action::ActionNotifyContext context;
context.executionId = eve::action::ActionExecutionId{99};
auto dispatched = registry.value().dispatch(prefab, context);
CHECK(!dispatched.ok());
CHECK_EQ(dispatched.status().code(), eve::StatusCode::NotFound);
}
TEST_CASE("actionNotifyRegistry.rejectsInvalidAndDuplicateDescriptors") {
eve::action::ActionNotifyRegistry registry;
eve::action::ActionNotifyDescriptor descriptor{
"project:combat.custom", "Custom", "Project", eve::action::ActionNotifyShape::Instant, {"value"}};
CHECK(registry.registerDescriptor(descriptor).ok());
CHECK(!registry.registerDescriptor(descriptor).ok());
descriptor.type = "project..invalid";
CHECK(!registry.registerDescriptor(std::move(descriptor)).ok());
}
TEST_CASE("actionNotifyRegistry.handlerLifecycleIsExplicitAndObservable") {
eve::action::ActionNotifyRegistry registry;
eve::action::ActionNotifyDescriptor descriptor{
"project:combat.custom", "Custom", "Project", eve::action::ActionNotifyShape::Instant, {}};
REQUIRE(registry.registerDescriptor(std::move(descriptor)).ok());
auto handler = std::make_shared<RecordingHandler>();
REQUIRE(registry.registerHandler("project:combat.custom", handler).ok());
auto routed = event(eve::action::ActionTimelineEventKind::Notify, "project:combat.custom");
eve::action::ActionNotifyContext context;
context.executionId = eve::action::ActionExecutionId{42};
REQUIRE(registry.dispatch(routed, context).ok());
CHECK_EQ(handler->calls, 1);
CHECK_EQ(handler->lastType, "project:combat.custom");
CHECK_EQ(handler->lastExecution, context.executionId);
REQUIRE(registry.unregisterHandler("project:combat.custom").ok());
CHECK(!registry.dispatch(routed, context).ok());
CHECK(!registry.unregisterHandler("project:combat.custom").ok());
}
TEST_CASE("actionNotifyRegistry.advancesSharedHandlerOnceAndPropagatesFailure") {
eve::action::ActionNotifyRegistry registry;
REQUIRE(registry
.registerDescriptor({"project:first", "First", "Project",
eve::action::ActionNotifyShape::Instant, {}})
.ok());
REQUIRE(registry
.registerDescriptor({"project:second", "Second", "Project",
eve::action::ActionNotifyShape::Instant, {}})
.ok());
auto handler = std::make_shared<RecordingHandler>();
REQUIRE(registry.registerHandler("project:first", handler).ok());
REQUIRE(registry.registerHandler("project:second", handler).ok());
eve::action::ActionNotifyContext context;
context.executionId = eve::action::ActionExecutionId{43};
REQUIRE(registry.advanceHandlers(context).ok());
CHECK_EQ(handler->advances, 1);
handler->failAdvance = true;
auto failed = registry.advanceHandlers(context);
CHECK(!failed.ok());
CHECK_EQ(failed.status().diagnostics().front().path(), "handler");
CHECK_EQ(handler->advances, 2);
}