#include "scene/editor/EditorSceneComponentPayload.h" #include "scene/editor/EditorSceneTarget.h" #include "editor/EditorTransactionService.h" #include "audio/editor/EditorAudioTarget.h" #include "graphics/material/editor/EditorMaterialTarget.h" #include "physics/editor/EditorPhysicsTarget.h" #include "zeroerr/unittest.h" #include using namespace eve::editor; namespace { const EditorValue* field(const EditorValue& value, const char* name) { const auto* object = value.getIf(); if (!object) return nullptr; const auto found = object->find(name); return found == object->end() ? nullptr : &found->second; } class HealthPayloadProvider final : public ISceneComponentPayloadProvider, public IDomainOperationTarget, public IDomainOperationTargetStaging { public: explicit HealthPayloadProvider(std::string id = "health-payload") : id_(std::move(id)) {} const std::string& componentType() const override { static const std::string type = "health"; return type; } TargetId targetId() const override { return TargetId(id_); } std::uint64_t revision() const override { return revision_; } EditRegion dirtyRegion() const override { return {}; } void clearDirtyRegion() override {} std::vector components(const TargetId& scene, const ObjectId& object) const override { if (object != ObjectId("hero")) return {}; return {{scene, object, StableId("hero.health"), componentType(), generation_, revision_}}; } EditorResult payloadOperationTarget( const SelectionSnapshot&) const override { return eve::editing::applied( const_cast(this)); } std::vector validateComponent( const SceneComponentPayloadRef& component) const override { if (component.generation != generation_) return {eve::editing::ruleDiagnostic( eve::DiagnosticCode::PreconditionViolation, RuleId("test.health.stale"), DiagnosticSeverity::Error, "Health component reference is stale")}; return {}; } eve::Result currentRevision(const SelectionSnapshot&) const override { return eve::Result::success(eve::Revision(revision_)); } PropertySchema schema(const SelectionSnapshot&) const override { PropertySchema result; result.typeId = componentType(); PropertyDescriptor points; points.path = PropertyPath("points"); points.type = PropertyType::Float; points.defaultValue = 100.0; points.numeric.minimum = 0.0; result.properties.push_back(std::move(points)); return result; } PropertyReadResult read(const SelectionSnapshot& selection, const PropertyPath& path) const override { if (selection.items.size() != 1 || selection.items.front().item != StableId("hero.health") || path != PropertyPath("points")) return {}; return {PropertyReadState::Value, points_, {}}; } EditorResult makeSet(const SelectionSnapshot& selection, const PropertyPath& path, const EditorValue& value, PropertySetMode mode) const override { if (mode != PropertySetMode::Absolute || selection.items.size() != 1 || selection.items.front().item != StableId("hero.health") || path != PropertyPath("points")) return eve::editing::failed(EditorStatus::Rejected, RuleId("test.health.selection"), "Invalid health component selection"); const auto validation = validatePropertyValue(schema(selection).properties.front(), value); if (!validation.ok()) return EditorResult::failure(validation.status()); DomainOperation operation; operation.type = "test.health.set.v1"; operation.target = TargetId(id_); operation.payload = EditorValue::Object{{"component", "hero.health"}, {"value", value}}; operation.inverse = EditorValue::Object{{"component", "hero.health"}, {"value", points_}}; operation.hasInverse = true; operation.affectedObjects.push_back({TargetId(id_), "hero.health", generation_}); operation.affectedProperties.push_back(path.value()); operation.mergeKey = "health:hero.health:points"; return eve::editing::applied(std::move(operation)); } EditorResult makeReset(const SelectionSnapshot& selection, const PropertyPath& path) const override { return makeSet(selection, path, 100.0, PropertySetMode::Absolute); } EditorResult applyDomainOperation(const DomainOperation& operation) override { if (operation.target != TargetId(id_) || operation.type != "test.health.set.v1") return eve::editing::failed(EditorStatus::Rejected, RuleId("test.health.operation"), "Invalid health operation"); const EditorValue* component = field(operation.payload, "component"); const EditorValue* value = field(operation.payload, "value"); const auto* componentId = component ? component->getIf() : nullptr; const auto* number = value ? value->getIf() : nullptr; if (!componentId || *componentId != "hero.health" || !number || *number < 0.0) return eve::editing::failed(EditorStatus::Rejected, RuleId("test.health.payload"), "Invalid health payload"); points_ = *number; ++revision_; return eve::editing::applied(); } std::unique_ptr cloneDomainState() const override { return std::make_unique(*this); } EditorResult commitDomainState( std::unique_ptr candidate) override { auto* typed = dynamic_cast(candidate.get()); if (!typed) return eve::editing::failed(EditorStatus::Conflict, RuleId("test.health.candidate"), "Invalid health candidate"); *this = *typed; return eve::editing::applied(); } double points() const { return points_; } void invalidate() { ++generation_; } private: std::string id_; unsigned long long revision_ = 1; std::uint64_t generation_ = 7; double points_ = 80.0; }; class RecordingAudioSink final : public IAudioSourceRuntimeSink { public: EditorResult publish(const AudioSourceTarget& candidate) override { if (reject) return eve::editing::failed(EditorStatus::Rejected, RuleId("test.audio.runtime-rejected"), "Runtime rejected candidate"); SelectionSnapshot selection; selection.items.push_back({SelectionDomain::Scene, TargetId(candidate.targetId()), StableId("source"), "audio.source"}); const auto read = candidate.read(selection, PropertyPath("play.volume")); const auto* value = read.value.getIf(); if (!value) return eve::editing::failed(EditorStatus::Failed, RuleId("test.audio.runtime-value"), "Candidate volume is unavailable"); volume = *value; revision = candidate.revision(); ++publishes; return eve::editing::applied(); } bool reject = false; double volume = 1.0; Revision revision = 0; int publishes = 0; }; class RecordingMaterialSink final : public IMaterialRuntimeSink { public: EditorResult publish(const MaterialDocumentTarget& candidate) override { if (reject) return eve::editing::failed(EditorStatus::Rejected, RuleId("test.material.runtime-rejected"), "Runtime rejected material candidate"); SelectionSnapshot selection; selection.items.push_back({SelectionDomain::Asset, TargetId(candidate.targetId()), StableId("material"), "graphics.material"}); const auto read = candidate.read(selection, PropertyPath("shading.roughness")); const auto* value = read.value.getIf(); if (!value) return eve::editing::failed(EditorStatus::Failed, RuleId("test.material.runtime-value"), "Candidate roughness is unavailable"); roughness = *value; revision = candidate.revision(); ++publishes; return eve::editing::applied(); } bool reject = false; double roughness = 0.45; Revision revision = 0; int publishes = 0; }; EditorResult commit(IDomainOperationTarget& target, LocalTransactionBackend& transactions, const DomainOperation& operation, Revision baseRevision, const char* id) { TransactionSpec specification; specification.id = TransactionId(id); specification.label = "Component payload edit"; specification.target = TargetId(target.targetId()); specification.baseRevision = baseRevision; auto begun = transactions.begin(std::move(specification)); if (!begun.ok()) return eve::editing::failed(begun.code(), RuleId("test.begin"), "Could not begin transaction"); auto appended = transactions.append(operation); if (!appended.ok()) return eve::editing::failed(appended.code(), RuleId("test.append"), "Could not append operation"); return transactions.commit(); } } // namespace TEST_CASE("editor.scene.component_payload_registry_routes_schema_transactions_and_undo") { HealthPayloadProvider provider; SceneComponentPayloadRegistry registry; REQUIRE(registry.registerProvider(&provider).ok()); CHECK_EQ(static_cast(registry.registerProvider(&provider).code()), static_cast(EditorStatus::Conflict)); SceneDocumentTarget scene("scene"); scene.bindComponentPayloads(®istry); auto* payloads = static_cast( scene.queryCapability(ISceneComponentPayloadTarget::editorCapabilityId())); REQUIRE(payloads); CHECK_EQ(scene.describe().capabilities.back(), ISceneComponentPayloadTarget::editorCapabilityId()); auto references = payloads->componentPayloads(TargetId("scene"), ObjectId("hero")); REQUIRE(references.ok()); REQUIRE_EQ(references.value().size(), size_t{1}); CHECK_EQ(references.value().front().component, StableId("hero.health")); auto selection = makeSceneComponentSelection("scene-inspector", references.value(), 12); REQUIRE(selection.ok()); auto properties = payloads->propertyProvider(selection.value()); auto target = payloads->operationTarget(selection.value()); REQUIRE(properties.ok()); REQUIRE(target.ok()); CHECK_EQ(properties.value()->schema(selection.value()).typeId, std::string("health")); CHECK_EQ(*properties.value()->read(selection.value(), PropertyPath("points")).value.getIf(), 80.0); auto operation = properties.value()->makeSet(selection.value(), PropertyPath("points"), 25.0, PropertySetMode::Absolute); REQUIRE(operation.ok()); LocalWorldAuthority authority(target.value()); LocalTransactionBackend transactions(&authority); const Revision baseRevision = target.value()->revision(); REQUIRE(commit(*target.value(), transactions, operation.value(), baseRevision, "health.set").ok()); CHECK_EQ(provider.points(), 25.0); REQUIRE(transactions.undo().ok()); CHECK_EQ(provider.points(), 80.0); auto stale = commit(*target.value(), transactions, operation.value(), baseRevision, "health.stale"); CHECK_EQ(static_cast(stale.code()), static_cast(EditorStatus::Conflict)); } TEST_CASE("editor.scene.component_payload_registry_rejects_mixed_and_stale_references") { HealthPayloadProvider provider; SceneComponentPayloadRegistry registry; REQUIRE(registry.registerProvider(&provider).ok()); auto references = registry.componentPayloads(TargetId("scene"), ObjectId("hero")); REQUIRE(references.ok()); SceneComponentPayloadRef stale = references.value().front(); provider.invalidate(); auto diagnostics = registry.validatePayload(stale); CHECK_EQ(static_cast(diagnostics.code()), static_cast(EditorStatus::Conflict)); SelectionSnapshot mixed; mixed.items = {{SelectionDomain::Scene, TargetId("scene"), StableId("hero.health"), "health"}, {SelectionDomain::Scene, TargetId("scene"), StableId("hero.audio"), "audio"}}; CHECK_EQ(static_cast(registry.propertyProvider(mixed).code()), static_cast(EditorStatus::Rejected)); const auto unregisterResult = registry.unregisterProvider(&provider); REQUIRE_EQ(static_cast(unregisterResult), static_cast(SceneComponentChange::Changed)); auto selection = makeSceneComponentSelection("scene", references.value()); REQUIRE(selection.ok()); CHECK_EQ(static_cast(registry.propertyProvider(selection.value()).code()), static_cast(EditorStatus::Unsupported)); } TEST_CASE("editor.scene.component_bindings_reuse_audio_physics_and_material_targets") { AudioSourceTarget audio("hero-audio"); PhysicsColliderTarget collider("hero-collider", 3); MaterialDocumentTarget material("hero-material"); SceneComponentPropertyBindings audioBindings("audio.source"); SceneComponentPropertyBindings physicsBindings("physics.collider3d"); SceneComponentPropertyBindings materialBindings("graphics.material"); const TargetId scene("scene"); const ObjectId hero("hero"); REQUIRE(audioBindings.bind( {scene, hero, StableId("hero.audio"), "audio.source", 1, 0}, {SelectionDomain::Scene, TargetId(audio.targetId()), StableId("source"), "audio.source"}, &audio, &audio, [&] { return audio.validate(); }).ok()); REQUIRE(physicsBindings.bind( {scene, hero, StableId("hero.collider"), "physics.collider3d", 2, 0}, {SelectionDomain::Scene, TargetId(collider.targetId()), StableId("collider"), "physics.collider3d"}, &collider, &collider, [&] { return collider.validate(); }).ok()); REQUIRE(materialBindings.bind( {scene, hero, StableId("hero.material"), "graphics.material", 3, 0}, {SelectionDomain::Scene, TargetId(material.targetId()), StableId("material"), "graphics.material"}, &material, &material, [&] { return material.validate(); }).ok()); SceneComponentPayloadRegistry registry; REQUIRE(registry.registerProvider(&audioBindings).ok()); REQUIRE(registry.registerProvider(&physicsBindings).ok()); REQUIRE(registry.registerProvider(&materialBindings).ok()); auto all = registry.componentPayloads(scene, hero); REQUIRE(all.ok()); REQUIRE_EQ(all.value().size(), size_t{3}); CHECK_EQ(all.value().at(0).type, std::string("audio.source")); CHECK_EQ(all.value().at(1).type, std::string("graphics.material")); CHECK_EQ(all.value().at(2).type, std::string("physics.collider3d")); const auto audioRef = all.value().at(0); auto selection = makeSceneComponentSelection("inspector", {audioRef}); REQUIRE(selection.ok()); auto properties = registry.propertyProvider(selection.value()); auto operationTarget = registry.operationTarget(selection.value()); REQUIRE(properties.ok()); REQUIRE(operationTarget.ok()); CHECK_EQ(properties.value()->schema(selection.value()).typeId, std::string("audio.source")); auto setVolume = properties.value()->makeSet( selection.value(), PropertyPath("play.volume"), 0.25, PropertySetMode::Absolute); REQUIRE(setVolume.ok()); LocalWorldAuthority authority(operationTarget.value()); LocalTransactionBackend transactions(&authority); REQUIRE(commit(*operationTarget.value(), transactions, setVolume.value(), operationTarget.value()->revision(), "component.audio.volume").ok()); CHECK_EQ(*audio.read({"module", {{SelectionDomain::Scene, TargetId(audio.targetId()), StableId("source"), "audio.source"}}, {}, 0}, PropertyPath("play.volume")).value.getIf(), 0.25); CHECK_EQ(static_cast(registry.validatePayload(audioRef).code()), static_cast(EditorStatus::Conflict)); REQUIRE(transactions.undo().ok()); CHECK_EQ(*audio.read({"module", {{SelectionDomain::Scene, TargetId(audio.targetId()), StableId("source"), "audio.source"}}, {}, 0}, PropertyPath("play.volume")).value.getIf(), 1.0); auto colliderSelection = makeSceneComponentSelection("inspector", {all.value().at(2)}); REQUIRE(colliderSelection.ok()); CHECK(registry.propertyProvider(colliderSelection.value()).value()->schema( colliderSelection.value()).find(PropertyPath("material.friction")) != nullptr); auto materialSelection = makeSceneComponentSelection("inspector", {all.value().at(1)}); REQUIRE(materialSelection.ok()); CHECK(registry.propertyProvider(materialSelection.value()).value()->schema( materialSelection.value()).find(PropertyPath("shading.roughness")) != nullptr); } TEST_CASE("editor.scene.audio_component_publication_is_staged_live_and_reversible") { RecordingAudioSink sink; AudioSourcePublishingTarget live("live-audio", &sink); SceneComponentPropertyBindings bindings("audio.source"); REQUIRE(bindings.bind( {TargetId("scene"), ObjectId("hero"), StableId("hero.audio"), "audio.source", 1, 0}, {SelectionDomain::Scene, TargetId(live.targetId()), StableId("source"), "audio.source"}, &live.authoringTarget(), &live, [&] { return live.authoringTarget().validate(); }).ok()); SceneComponentPayloadRegistry registry; REQUIRE(registry.registerProvider(&bindings).ok()); auto components = registry.componentPayloads(TargetId("scene"), ObjectId("hero")); REQUIRE(components.ok()); auto selection = makeSceneComponentSelection("inspector", components.value()); REQUIRE(selection.ok()); auto properties = registry.propertyProvider(selection.value()); auto operationTarget = registry.operationTarget(selection.value()); REQUIRE(properties.ok()); REQUIRE(operationTarget.ok()); auto change = properties.value()->makeSet(selection.value(), PropertyPath("play.volume"), 0.4, PropertySetMode::Absolute); REQUIRE(change.ok()); LocalWorldAuthority authority(operationTarget.value()); LocalTransactionBackend transactions(&authority); REQUIRE(commit(*operationTarget.value(), transactions, change.value(), operationTarget.value()->revision(), "audio.live.volume").ok()); CHECK_EQ(sink.volume, 0.4); CHECK_EQ(sink.revision, live.revision()); CHECK_EQ(sink.publishes, 1); REQUIRE(transactions.undo().ok()); CHECK_EQ(sink.volume, 1.0); CHECK_EQ(sink.publishes, 2); auto rejected = properties.value()->makeSet(selection.value(), PropertyPath("play.volume"), 0.2, PropertySetMode::Absolute); REQUIRE(rejected.ok()); sink.reject = true; const Revision before = live.revision(); auto failed = commit(*operationTarget.value(), transactions, rejected.value(), before, "audio.live.rejected"); CHECK_EQ(static_cast(failed.code()), static_cast(EditorStatus::Rejected)); CHECK_EQ(live.revision(), before); CHECK_EQ(sink.volume, 1.0); } TEST_CASE("editor.scene.material_component_publication_is_staged_live_and_reversible") { RecordingMaterialSink sink; MaterialPublishingTarget live("live-material", &sink); SceneComponentPropertyBindings bindings("graphics.material"); REQUIRE(bindings.bind( {TargetId("scene"), ObjectId("hero"), StableId("hero.material"), "graphics.material", 2, 0}, {SelectionDomain::Asset, TargetId(live.targetId()), StableId("material"), "graphics.material"}, &live.authoringTarget(), &live, [&] { return live.authoringTarget().validate(); }).ok()); SceneComponentPayloadRegistry registry; REQUIRE(registry.registerProvider(&bindings).ok()); auto components = registry.componentPayloads(TargetId("scene"), ObjectId("hero")); REQUIRE(components.ok()); auto selection = makeSceneComponentSelection("inspector", components.value()); REQUIRE(selection.ok()); auto properties = registry.propertyProvider(selection.value()); auto operationTarget = registry.operationTarget(selection.value()); REQUIRE(properties.ok()); REQUIRE(operationTarget.ok()); auto change = properties.value()->makeSet(selection.value(), PropertyPath("shading.roughness"), 0.8, PropertySetMode::Absolute); REQUIRE(change.ok()); LocalWorldAuthority authority(operationTarget.value()); LocalTransactionBackend transactions(&authority); REQUIRE(commit(*operationTarget.value(), transactions, change.value(), operationTarget.value()->revision(), "material.live.roughness").ok()); CHECK_EQ(sink.roughness, 0.8); CHECK_EQ(sink.revision, live.revision()); REQUIRE(transactions.undo().ok()); CHECK_EQ(sink.roughness, 0.45); auto rejected = properties.value()->makeSet(selection.value(), PropertyPath("shading.roughness"), 0.6, PropertySetMode::Absolute); REQUIRE(rejected.ok()); sink.reject = true; const Revision before = live.revision(); auto failed = commit(*operationTarget.value(), transactions, rejected.value(), before, "material.live.rejected"); CHECK_EQ(static_cast(failed.code()), static_cast(EditorStatus::Rejected)); CHECK_EQ(live.revision(), before); CHECK_EQ(sink.roughness, 0.45); }