Files
EVEngine/test/editor_automation_v2.cpp
西风逍遥游andCursor 24502babef Nest satellite packages under their host modules.
Keep CMake module names and trim switches, but stop flattening editing/editor and runtime bridges as top-level src/modules directories. Parent scans skip nested facets so runtime-3d can drop authoring by directory leaf.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 17:56:12 +08:00

378 lines
19 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "common/Capability.h"
#include "common/EditorAutomation.h"
#include "common/RenderCapture.h"
#include "editor/Editor.h"
#include "editor/EditorSession.h"
#include "graphics/material/editor/MaterialEditorModule.h"
#include "graphics/material/editor/EditorMaterialTarget.h"
#include "scene/editor/SceneEditorModule.h"
#include "scene/editor/EditorSceneTarget.h"
#include "graphics/RenderSystem3D.h"
#include "graphics/GraphicsCapabilities.h"
#include "scene/NodeDesc.h"
#include "scene/Scene.h"
#include "scene/SceneHost.h"
using namespace eve::editor;
TEST_CASE("editor.v2.automation_capability_discovers_executes_plans_and_cancels") {
Editor editor;
CommandDescriptor immediate;
immediate.id = CommandId("park.add-tree");
immediate.ownerModule = "park";
immediate.displayName = "Add tree";
REQUIRE(editor.commandService()
.registerCommand(std::move(immediate),
[](const CommandContext&, const EditorValue&) {
return eve::editing::applied<EditorValue>(EditorValue("tree-added"));
})
.ok());
CommandDescriptor planned;
planned.id = CommandId("park.build-path");
planned.ownerModule = "park";
planned.displayName = "Build path";
REQUIRE(editor.commandService()
.registerPlannedCommand(
std::move(planned),
[](const CommandRequest&) {
CommandPlan plan;
plan.summary = EditorValue("path-plan");
return eve::editing::applied<CommandPlan>(std::move(plan));
},
[](const CommandRequest&, const CommandPlan&) {
TransactionReceipt receipt;
receipt.id = TransactionId("path-transaction");
receipt.state = TransactionState::Committed;
return eve::editing::applied<TransactionReceipt>(std::move(receipt));
})
.ok());
auto* automation = eve::cap::query<eve::IEditorAutomation>();
REQUIRE(automation != nullptr);
CHECK(automation->invoke("target-create", R"({"target":"absent.scene","type":"scene"})")
.find("unsupported") != std::string::npos);
const std::string commands = automation->invoke("commands", "{}");
CHECK(commands.find("park.add-tree") != std::string::npos);
CHECK(commands.find("park.build-path") != std::string::npos);
const std::string executed = automation->invoke("execute", R"({"command":"park.add-tree","payload":{}})");
CHECK(executed.find("\"status\":\"applied\"") != std::string::npos);
CHECK(executed.find("transactionId") != std::string::npos);
const std::string plannedJson = automation->invoke("plan", R"({"command":"park.build-path","payload":{}})");
CHECK(plannedJson.find("planId") != std::string::npos);
const std::size_t valueStart = plannedJson.find("park.build-path.plan.");
REQUIRE(valueStart != std::string::npos);
const std::size_t valueEnd = plannedJson.find('"', valueStart);
const std::string planId = plannedJson.substr(valueStart, valueEnd - valueStart);
const std::string cancelled = automation->invoke("cancel", "{\"planId\":\"" + planId + "\"}");
CHECK(cancelled.find("\"status\":\"applied\"") != std::string::npos);
const std::string commitAfterCancel = automation->invoke("commit", "{\"planId\":\"" + planId + "\"}");
CHECK(commitAfterCancel.find("not-found") != std::string::npos);
}
namespace {
void addSceneObject(SceneDocumentTarget& target, const char* id) {
CreateSceneObjectRequest request;
request.id = ObjectId(id);
request.name = id;
auto operation = target.makeCreate(request);
REQUIRE(operation.ok());
REQUIRE(target.applyDomainOperation(operation.value()).ok());
}
} // namespace
TEST_CASE("editor.automation.editing_commands_keep_level_histories_independent") {
SceneDocumentTarget village("level.village");
SceneDocumentTarget forest("level.forest");
addSceneObject(village, "player");
addSceneObject(forest, "player");
Editor editor;
eve::scene_editor::SceneEditorModule sceneAdapter;
eve::material_editor::MaterialEditorModule materialAdapter;
REQUIRE(editor.registerEditingTarget(village).ok());
REQUIRE(editor.registerEditingTarget(forest).ok());
auto* automation = eve::cap::query<eve::IEditorAutomation>();
REQUIRE(automation != nullptr);
const std::string commands = automation->invoke("commands", "{}");
CHECK(commands.find("scene.transform.set.v1") != std::string::npos);
CHECK(commands.find("material.property.set.v1") != std::string::npos);
const std::string villageResult = automation->invoke(
"execute",
R"({"target":"level.village","command":"scene.transform.set.v1","payload":{"object":"player","position":[1,2,3]}})"
);
CHECK(villageResult.find("\"status\":\"applied\"") != std::string::npos);
const std::string forestResult = automation->invoke(
"execute",
R"({"target":"level.forest","command":"scene.transform.set.v1","payload":{"object":"player","position":[7,8,9]}})"
);
CHECK(forestResult.find("\"status\":\"applied\"") != std::string::npos);
auto villageTransform = village.readTransform(ObjectId("player"));
auto forestTransform = forest.readTransform(ObjectId("player"));
REQUIRE(villageTransform.ok());
REQUIRE(forestTransform.ok());
CHECK(villageTransform.value().x == 1.0);
CHECK(forestTransform.value().x == 7.0);
const std::string undone = automation->invoke("undo", R"({"target":"level.village"})");
CHECK(undone.find("\"status\":\"applied\"") != std::string::npos);
villageTransform = village.readTransform(ObjectId("player"));
forestTransform = forest.readTransform(ObjectId("player"));
REQUIRE(villageTransform.ok());
REQUIRE(forestTransform.ok());
CHECK(villageTransform.value().x == 0.0);
CHECK(forestTransform.value().x == 7.0);
const std::string redone = automation->invoke("redo", R"({"target":"level.village"})");
CHECK(redone.find("\"status\":\"applied\"") != std::string::npos);
villageTransform = village.readTransform(ObjectId("player"));
REQUIRE(villageTransform.ok());
CHECK(villageTransform.value().x == 1.0);
const std::string inspected = automation->invoke("inspect", R"({"target":"level.forest"})");
CHECK(inspected.find("\"id\":\"level.forest\"") != std::string::npos);
CHECK(inspected.find("player") != std::string::npos);
CHECK(editor.unregisterEditingTarget(TargetId("level.village")).ok());
CHECK(editor.unregisterEditingTarget(TargetId("level.forest")).ok());
}
TEST_CASE("editor.v2.automation_edits_material_through_the_same_transaction_path") {
MaterialDocumentTarget material("material.player");
Editor editor;
eve::material_editor::MaterialEditorModule materialAdapter;
REQUIRE(editor.registerEditingTarget(material).ok());
auto* automation = eve::cap::query<eve::IEditorAutomation>();
REQUIRE(automation != nullptr);
const std::string changed = automation->invoke(
"execute",
R"({"target":"material.player","command":"material.property.set.v1","payload":{"path":"shading.roughness","value":0.65}})"
);
CHECK(changed.find("\"status\":\"applied\"") != std::string::npos);
CHECK(material.snapshotValue().getIf<EditorValue::Object>()->at("shading.roughness") == EditorValue(0.65));
const std::string undone = automation->invoke("undo", R"({"target":"material.player"})");
CHECK(undone.find("\"status\":\"applied\"") != std::string::npos);
CHECK(material.snapshotValue().getIf<EditorValue::Object>()->at("shading.roughness") != EditorValue(0.65));
CHECK(editor.unregisterEditingTarget(TargetId("material.player")).ok());
}
TEST_CASE("editor.automation_owns_headless_targets_for_agent_lifecycle") {
Editor editor;
eve::scene_editor::SceneEditorModule sceneAdapter;
auto* automation = eve::cap::query<eve::IEditorAutomation>();
REQUIRE(automation != nullptr);
const std::string created = automation->invoke(
"target-create", R"({"target":"agent.scene","type":"scene","object":"player"})");
REQUIRE(created.find("\"status\":\"applied\"") != std::string::npos);
const std::string moved = automation->invoke(
"execute",
R"({"target":"agent.scene","command":"scene.transform.set.v1","payload":{"object":"player","position":[4,5,6]}})");
REQUIRE(moved.find("\"status\":\"applied\"") != std::string::npos);
const std::string inspected = automation->invoke("inspect", R"({"target":"agent.scene"})");
CHECK(inspected.find("player") != std::string::npos);
CHECK(inspected.find("\"x\":4") != std::string::npos);
const std::string closed = automation->invoke("target-close", R"({"target":"agent.scene"})");
CHECK(closed.find("\"status\":\"applied\"") != std::string::npos);
const std::string afterClose = automation->invoke("inspect", R"({"target":"agent.scene"})");
CHECK(afterClose.find("not-found") != std::string::npos);
}
TEST_CASE("editor.automation_rx_observation_session_deduplicates_and_cancels") {
Editor editor;
eve::scene_editor::SceneEditorModule sceneAdapter;
auto* automation = eve::cap::query<eve::IEditorAutomation>();
REQUIRE(automation != nullptr);
const std::string started = automation->invoke(
"observe-start",
R"({"descriptor":{"observer":"scene-node","host":"world","node":"player"},"event":{"observation":{"status":"ok","x":1},"converged":false}})"
);
REQUIRE(started.find("\"status\":\"applied\"") != std::string::npos);
const std::size_t idStart = started.find("editor.observe.");
REQUIRE(idStart != std::string::npos);
const std::size_t idEnd = started.find('"', idStart);
const std::string sessionId = started.substr(idStart, idEnd - idStart);
const std::string request = "{\"sessionId\":\"" + sessionId + "\"}";
CHECK(started.find("\"x\":1") != std::string::npos);
const std::string initial = automation->invoke("observe-poll", request);
CHECK(initial.find("\"events\":[]") != std::string::npos);
REQUIRE(automation->invoke(
"observe-publish",
"{\"sessionId\":\"" + sessionId +
"\",\"event\":{\"observation\":{\"status\":\"ok\",\"x\":1},\"converged\":false}}")
.find("\"status\":\"applied\"") != std::string::npos);
const std::string duplicate = automation->invoke("observe-poll", request);
CHECK(duplicate.find("\"events\":[]") != std::string::npos);
REQUIRE(automation->invoke(
"observe-publish",
"{\"sessionId\":\"" + sessionId +
"\",\"event\":{\"observation\":{\"status\":\"ok\",\"x\":4},\"converged\":true}}")
.find("\"status\":\"applied\"") != std::string::npos);
const std::string changed = automation->invoke("observe-poll", request);
CHECK(changed.find("\"x\":4") != std::string::npos);
CHECK(changed.find("\"converged\":true") != std::string::npos);
CHECK(automation->invoke("observe-close", request).find("\"status\":\"applied\"") != std::string::npos);
CHECK(automation->invoke("observe-describe", request).find("not-found") != std::string::npos);
}
TEST_CASE("editor.automation_binds_live_scene_host_and_preserves_host_ownership") {
auto* scene = eve::scene::Scene::create();
REQUIRE(scene != nullptr);
auto mounted = scene->mountAs("agent-live", eve::scene::node("root", {eve::scene::node("player")}));
REQUIRE(mounted.ok());
eve::scene::SceneHost* host = mounted.value();
Editor editor;
eve::scene_editor::SceneEditorModule sceneAdapter;
auto* automation = eve::cap::query<eve::IEditorAutomation>();
REQUIRE(automation != nullptr);
const std::string created = automation->invoke(
"target-create", R"({"target":"agent.live","type":"scene-host","host":"agent-live"})");
CHECK(created.find("\"status\":\"applied\"") != std::string::npos);
const std::string moved = automation->invoke(
"execute",
R"({"target":"agent.live","command":"scene.transform.set.v1","payload":{"object":"player","position":[40,50,0]}})");
CHECK(moved.find("\"status\":\"applied\"") != std::string::npos);
REQUIRE(host->findById("player").ok());
CHECK_EQ(host->findById("player").value()->x, 40.f);
CHECK_EQ(host->findById("player").value()->y, 50.f);
const std::string undone = automation->invoke("undo", R"({"target":"agent.live"})");
REQUIRE(undone.find("\"status\":\"applied\"") != std::string::npos);
REQUIRE_EQ(host->findById("player").value()->x, 0.f);
const std::string redone = automation->invoke("redo", R"({"target":"agent.live"})");
REQUIRE(redone.find("\"status\":\"applied\"") != std::string::npos);
REQUIRE_EQ(host->findById("player").value()->x, 40.f);
const std::string closed = automation->invoke("target-close", R"({"target":"agent.live"})");
REQUIRE(closed.find("\"status\":\"applied\"") != std::string::npos);
REQUIRE(host->findById("player").ok());
}
TEST_CASE("editor.automation_publishes_material_transactions_to_live_renderable") {
auto* renderable = eve::graphics::Renderable3D::create();
REQUIRE(renderable != nullptr);
renderable->setTint(0.2f, 0.3f, 0.4f, 1.f);
renderable->setRoughness(0.45f);
eve::graphics::registerGraphicsCapabilities();
auto* renderInspection = eve::cap::query<eve::IRenderCapture>();
REQUIRE(renderInspection != nullptr);
auto initial = renderInspection->inspectRenderable3D(renderable->id, renderable->generation);
REQUIRE(initial.ok());
REQUIRE_EQ(initial.value().tintG, 0.3f);
Editor editor;
eve::material_editor::MaterialEditorModule materialAdapter;
auto* automation = eve::cap::query<eve::IEditorAutomation>();
REQUIRE(automation != nullptr);
const std::string handle = "{\"target\":\"agent.material\",\"type\":\"material-renderable3d\","
"\"entityId\":" + std::to_string(renderable->id) +
",\"generation\":" + std::to_string(renderable->generation) + "}";
REQUIRE(automation->invoke("target-create", handle).find("\"status\":\"applied\"") !=
std::string::npos);
const std::string changed = automation->invoke(
"execute",
R"({"target":"agent.material","command":"material.property.set.v1","payload":{"path":"shading.tint","value":[0.1,0.8,0.2,1]}})");
REQUIRE(changed.find("\"status\":\"applied\"") != std::string::npos);
REQUIRE_EQ(renderable->meshRenderer()->g, 0.8f);
auto live = renderInspection->inspectRenderable3D(renderable->id, renderable->generation);
REQUIRE(live.ok());
REQUIRE_EQ(live.value().tintG, 0.8f);
REQUIRE_EQ(renderable->meshRenderer()->r, 0.1f);
const std::string corrected = automation->invoke(
"execute",
R"({"target":"agent.material","command":"material.property.set.v1","payload":{"path":"shading.tint","value":[0.1,0.9,0.2,1]}})");
REQUIRE(corrected.find("\"status\":\"applied\"") != std::string::npos);
REQUIRE_EQ(renderable->meshRenderer()->g, 0.9f);
REQUIRE(automation->invoke("undo", R"({"target":"agent.material"})")
.find("\"status\":\"applied\"") != std::string::npos);
REQUIRE_EQ(renderable->meshRenderer()->g, 0.8f);
REQUIRE(automation->invoke("undo", R"({"target":"agent.material"})")
.find("\"status\":\"applied\"") != std::string::npos);
REQUIRE_EQ(renderable->meshRenderer()->g, 0.3f);
REQUIRE(automation->invoke("redo", R"({"target":"agent.material"})")
.find("\"status\":\"applied\"") != std::string::npos);
REQUIRE_EQ(renderable->meshRenderer()->g, 0.8f);
REQUIRE(automation->invoke("redo", R"({"target":"agent.material"})")
.find("\"status\":\"applied\"") != std::string::npos);
REQUIRE_EQ(renderable->meshRenderer()->g, 0.9f);
REQUIRE(automation->invoke("target-close", R"({"target":"agent.material"})")
.find("\"status\":\"applied\"") != std::string::npos);
REQUIRE_EQ(renderable->meshRenderer()->g, 0.9f);
const std::uint32_t id = renderable->id;
const std::uint32_t generation = renderable->generation;
ecs::DestroyEntity(renderable);
auto stale = renderInspection->inspectRenderable3D(id, generation);
REQUIRE_NOT(stale.ok());
}
TEST_CASE("editor.composition.developer_runtime_and_automation_share_target_transaction_path") {
SceneDocumentTarget scene("level.shared-host-path");
addSceneObject(scene, "player");
Editor editor;
eve::scene_editor::SceneEditorModule sceneAdapter;
REQUIRE(editor.registerEditingTarget(scene).ok());
auto developer = editor.newSession();
REQUIRE(editor.bindEditingTarget(*developer, TargetId(scene.targetId())).ok());
const CommandId transform("scene.transform.set.v1");
const EditorValue developerPayload = EditorValue::Object{
{"object", "player"}, {"position", EditorValue::Array{1.0, 2.0, 3.0}}};
auto developerPlan = developer->planCommand(transform, developerPayload);
REQUIRE(developerPlan.ok());
REQUIRE(developer->executePlan(developerPlan.value(), developerPayload).ok());
auto* automation = eve::cap::query<eve::IEditorAutomation>();
REQUIRE(automation != nullptr);
const std::string developerUndo = automation->invoke(
"undo", R"({"target":"level.shared-host-path"})");
CHECK(developerUndo.find("\"status\":\"applied\"") != std::string::npos);
auto runtime = editor.newSession();
HostProfile runtimeProfile = HostProfile::runtimeBuilder();
runtimeProfile.allowCommand(transform);
runtime->setHostProfile(std::move(runtimeProfile));
REQUIRE(editor.bindEditingTarget(*runtime, TargetId(scene.targetId())).ok());
const EditorValue runtimePayload = EditorValue::Object{
{"object", "player"}, {"position", EditorValue::Array{7.0, 8.0, 9.0}}};
auto runtimePlan = runtime->planCommand(transform, runtimePayload);
REQUIRE(runtimePlan.ok());
REQUIRE(runtime->executePlan(runtimePlan.value(), runtimePayload).ok());
const std::string runtimeUndo = automation->invoke(
"undo", R"({"target":"level.shared-host-path"})");
CHECK(runtimeUndo.find("\"status\":\"applied\"") != std::string::npos);
const auto restored = scene.readTransform(ObjectId("player"));
REQUIRE(restored.ok());
CHECK_EQ(restored.value().x, 0.0);
CHECK_EQ(restored.value().y, 0.0);
CHECK_EQ(restored.value().z, 0.0);
CHECK(editor.unregisterEditingTarget(TargetId(scene.targetId())).ok());
}