#include "animation/editing/AnimationGraph.h" #include "animation/AnimClip.h" #include "animation/AnimSkeleton.h" #include "animation/AnimStateMachine.h" #include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include using namespace eve::animation_editing; using namespace eve::editing; namespace { GraphDocumentData basicGraph(AnimationStateGraphDomain& domain) { auto idle = domain.makeStateNode(GraphNodeId("idle"), "asset://animations/idle.eva"); auto run = domain.makeStateNode(GraphNodeId("run"), "asset://animations/run.eva"); auto transition = domain.makeTransitionNode(GraphNodeId("idle-to-run")); REQUIRE(idle.ok()); REQUIRE(run.ok()); REQUIRE(transition.ok()); EditorValue::Object transitionProperties = *transition.value().properties.getIf(); transitionProperties["blendSeconds"] = 0.15; transitionProperties["conditionKind"] = "float"; transitionProperties["parameter"] = "speed"; transitionProperties["operator"] = ">"; transitionProperties["threshold"] = 0.1; transition.value().properties = EditorValue(std::move(transitionProperties)); eve::editing::GraphDocument graph; REQUIRE(graph.createNode(idle.value()).ok()); REQUIRE(graph.createNode(run.value()).ok()); REQUIRE(graph.createNode(transition.value()).ok()); const GraphPinRecord* idleOut = graph.findPin(GraphPinId("idle.out")); const GraphPinRecord* transitionIn = graph.findPin(GraphPinId("idle-to-run.in")); const GraphPinRecord* transitionOut = graph.findPin(GraphPinId("idle-to-run.out")); const GraphPinRecord* runIn = graph.findPin(GraphPinId("run.in")); REQUIRE(idleOut); REQUIRE(transitionIn); REQUIRE(transitionOut); REQUIRE(runIn); REQUIRE(graph.connect({StableId("edge-in"), idleOut->id, transitionIn->id}, domain.canConnect(*idleOut, *transitionIn)).ok()); REQUIRE(graph.connect({StableId("edge-out"), transitionOut->id, runIn->id}, domain.canConnect(*transitionOut, *runIn)).ok()); EditorValue::Object parameters; parameters["entry"] = "idle"; REQUIRE(graph.setParameters(EditorValue(std::move(parameters))).ok()); return graph.snapshot(domain.domain()); } } // namespace TEST_CASE("editor.animation.state_graph_compiles_stable_clip_references_and_conditions") { AnimationStateGraphDomain domain; const auto graph = basicGraph(domain); const auto compiled = domain.compile(graph); CHECK_EQ(static_cast(compiled.status), static_cast(EditorStatus::Applied)); CHECK_EQ(compiled.documentRevision, graph.revision); CHECK(compiled.definition.find("EVANIM_STATE_GRAPH 1\n") == 0); CHECK(compiled.definition.find("state \"idle\" \"asset://animations/idle.eva\"") != std::string::npos); CHECK(compiled.definition.find("transition \"idle-to-run\" \"idle\" \"run\" 0.15") != std::string::npos); CHECK(compiled.definition.find("\"float\" \"speed\" \">\" 0.1") != std::string::npos); } TEST_CASE("editor.animation.state_graph_rejects_incomplete_or_invalid_authoring_data") { AnimationStateGraphDomain domain; GraphDocumentData graph = basicGraph(domain); graph.schemaVersion = 99; CHECK_EQ(static_cast(domain.compile(graph).status), static_cast(EditorStatus::Unsupported)); graph = basicGraph(domain); graph.edges.pop_back(); auto result = domain.compile(graph); CHECK_EQ(static_cast(result.status), static_cast(EditorStatus::Failed)); REQUIRE(!result.diagnostics.empty()); graph = basicGraph(domain); auto transition = std::find_if(graph.nodes.begin(), graph.nodes.end(), [](const GraphNodeRecord& node) { return node.type == "animation.transition"; }); REQUIRE(transition != graph.nodes.end()); auto* properties = transition->properties.getIf(); REQUIRE(properties); (*properties)["blendSeconds"] = -1.0; result = domain.compile(graph); CHECK_EQ(static_cast(result.status), static_cast(EditorStatus::Failed)); graph = basicGraph(domain); auto* parameters = graph.parameters.getIf(); REQUIRE(parameters); (*parameters)["entry"] = "missing"; result = domain.compile(graph); CHECK_EQ(static_cast(result.status), static_cast(EditorStatus::Failed)); } TEST_CASE("editor.animation.state_graph_rejects_direct_state_connections") { AnimationStateGraphDomain domain; auto idle = domain.makeStateNode(GraphNodeId("idle"), "idle.eva"); auto run = domain.makeStateNode(GraphNodeId("run"), "run.eva"); REQUIRE(idle.ok()); REQUIRE(run.ok()); CHECK(domain.canConnect(idle.value().pins[1], run.value().pins[0]).allowed); GraphDocumentData invalid; invalid.domain = domain.domain(); invalid.nodes = {idle.value(), run.value()}; invalid.edges = {{StableId("direct"), idle.value().pins[1].id, run.value().pins[0].id}}; invalid.parameters = EditorValue::Object{{"entry", "idle"}}; const auto compiled = domain.compile(invalid); CHECK_EQ(static_cast(compiled.status), static_cast(EditorStatus::Failed)); } TEST_CASE("editor.animation.state_graph_builds_real_runtime_state_machine") { AnimationStateGraphDomain domain; const GraphDocumentData graph = basicGraph(domain); eve::animation::AnimSkeleton skeleton; skeleton.addBone("root"); eve::animation::AnimClip idle("idle"); eve::animation::AnimClip run("run"); idle.setDuration(1.0F); run.setDuration(1.0F); AnimationStateGraphRuntimeBuilder builder; auto built = builder.build(graph, &skeleton, [&](const std::string& asset) { if (asset == "asset://animations/idle.eva") return &idle; if (asset == "asset://animations/run.eva") return &run; return static_cast(nullptr); }); REQUIRE(built.ok()); CHECK_EQ(built.value()->getStateCount(), 2); delete built.value(); auto missing = builder.build(graph, &skeleton, [](const std::string&) { return static_cast(nullptr); }); CHECK_EQ(static_cast(missing.code()), static_cast(EditorStatus::NotFound)); }