// Copyright 2026 DeepMind Technologies Limited // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. #include #include #include #include #include #include #include "src/xml/xml_api.h" #include "test/fixture.h" namespace mujoco { namespace { using ::testing::HasSubstr; using ::testing::IsNull; using ::testing::NotNull; TEST_F(MujocoTest, AttachWarningsSurviveRecompile) { mock_warning_handler.ExpectWarnings(); // attach warnings should persist through recompilation static constexpr char xml_parent[] = R"( )"; static constexpr char xml_child[] = R"( )"; std::array er; mjSpec* parent = mj_parseXMLString(xml_parent, 0, er.data(), er.size()); ASSERT_THAT(parent, NotNull()) << er.data(); mjSpec* child = mj_parseXMLString(xml_child, 0, er.data(), er.size()); ASSERT_THAT(child, NotNull()) << er.data(); mjsFrame* frame = mjs_findFrame(parent, "attachment"); ASSERT_THAT(frame, NotNull()); mjsElement* attached = mjs_attach( frame->element, mjs_findBody(child, "child")->element, "child-", ""); ASSERT_THAT(attached, NotNull()); // attach warning should be present int attach_warnings = mjs_numWarnings(parent); EXPECT_GE(attach_warnings, 1); EXPECT_THAT(mjs_getWarning(parent, 0), HasSubstr("timestep")); // compile mjModel* m = mj_compile(parent, nullptr); ASSERT_THAT(m, NotNull()) << mjs_getError(parent); // attach warnings should survive compilation EXPECT_GE(mjs_numWarnings(parent), attach_warnings); EXPECT_THAT(mjs_getWarning(parent, 0), HasSubstr("timestep")); // recompile mj_deleteModel(m); m = mj_compile(parent, nullptr); ASSERT_THAT(m, NotNull()) << mjs_getError(parent); // attach warnings should still be there after recompile EXPECT_GE(mjs_numWarnings(parent), attach_warnings); EXPECT_THAT(mjs_getWarning(parent, 0), HasSubstr("timestep")); mj_deleteModel(m); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictWarningDefault) { mock_warning_handler.ExpectWarnings(); // default mode (warning): conflicting values should keep parent, set warning mjSpec* parent = mj_makeSpec(); parent->option.timestep = 0.005; mjSpec* child = mj_makeSpec(); child->option.timestep = 0.001; mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); ASSERT_THAT(attached, NotNull()); // parent value should be unchanged EXPECT_EQ(parent->option.timestep, mjtNum(0.005)); // one grouped warning per attach EXPECT_EQ(mjs_numWarnings(parent), 1); std::string w = mjs_getWarning(parent, 0); EXPECT_THAT(w, HasSubstr("policy is 'warning'")); EXPECT_THAT(w, HasSubstr("timestep: parent has 0.005, child has 0.001," " keeping parent value")); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictWarningNoConflict) { mock_warning_handler.ExpectWarnings(); // warning mode: child has non-default, parent is default → warn + keep parent mjSpec* parent = mj_makeSpec(); mjSpec* child = mj_makeSpec(); child->option.timestep = 0.001; mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); ASSERT_THAT(attached, NotNull()); // warning produced when child is non-default EXPECT_TRUE(mjs_isWarning(parent)); EXPECT_EQ(mjs_numWarnings(parent), 1); EXPECT_THAT(mjs_getWarning(parent, 0), HasSubstr("timestep")); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictMergeMin) { mock_warning_handler.ExpectWarnings(); mjSpec* parent = mj_makeSpec(); parent->compiler.conflict = mjCONFLICT_MERGE; parent->option.timestep = 0.005; parent->option.tolerance = 1e-10; mjSpec* child = mj_makeSpec(); child->option.timestep = 0.001; child->option.tolerance = 1e-12; child->option.sleep_tolerance = 0.005; mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); ASSERT_THAT(attached, NotNull()); // min-fields should take the smaller value EXPECT_EQ(parent->option.timestep, mjtNum(0.001)); EXPECT_EQ(parent->option.tolerance, mjtNum(1e-12)); // one side is default -> copy and warn EXPECT_EQ(parent->option.sleep_tolerance, mjtNum(0.005)); // one grouped warning containing all three fields EXPECT_EQ(mjs_numWarnings(parent), 1); std::string w = mjs_getWarning(parent, 0); EXPECT_THAT(w, HasSubstr("policy is 'merge'")); EXPECT_THAT(w, HasSubstr("timestep: parent has 0.005, child has 0.001," " taking the minimum")); EXPECT_THAT(w, HasSubstr("tolerance")); EXPECT_THAT(w, HasSubstr("sleep_tolerance")); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictMergeMax) { mock_warning_handler.ExpectWarnings(); mjSpec* parent = mj_makeSpec(); parent->compiler.conflict = mjCONFLICT_MERGE; parent->option.iterations = 150; parent->nkey = 5; parent->nuser_body = 2; mjSpec* child = mj_makeSpec(); child->option.iterations = 200; child->nkey = 8; child->nuser_body = 4; mjsBody* child_body = mjs_addBody(mjs_findBody(child, "world"), 0); mjsFrame* frame = mjs_addFrame(mjs_findBody(parent, "world"), nullptr); mjsElement* attached = mjs_attach(frame->element, child_body->element, "child_", ""); ASSERT_THAT(attached, NotNull()); // max-fields should take the larger value EXPECT_EQ(parent->option.iterations, 200); EXPECT_EQ(parent->nkey, 8); EXPECT_EQ(parent->nuser_body, 4); // one grouped warning containing all three fields EXPECT_EQ(mjs_numWarnings(parent), 1); std::string w = mjs_getWarning(parent, 0); EXPECT_THAT(w, HasSubstr("iterations: parent has 150, child has 200," " taking the maximum")); EXPECT_THAT(w, HasSubstr("nkey")); EXPECT_THAT(w, HasSubstr("nuser_body")); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictMergeError) { // merge mode: conflicting error-fields should produce an error mjSpec* parent = mj_makeSpec(); parent->compiler.conflict = mjCONFLICT_MERGE; parent->option.integrator = mjINT_RK4; mjSpec* child = mj_makeSpec(); child->option.integrator = mjINT_IMPLICIT; mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); // error-fields should fail on conflict EXPECT_THAT(attached, IsNull()); EXPECT_THAT(mjs_getError(parent), HasSubstr("integrator: parent has")); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictErrorMode) { // error mode: any conflict -> error mjSpec* parent = mj_makeSpec(); parent->compiler.conflict = mjCONFLICT_ERROR; parent->option.timestep = 0.005; mjSpec* child = mj_makeSpec(); child->option.timestep = 0.001; mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); EXPECT_THAT(attached, IsNull()); std::string error = mjs_getError(parent); EXPECT_THAT(error, HasSubstr("policy is 'error'")); EXPECT_THAT(error, HasSubstr("timestep: parent has 0.005, child has 0.001")); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictErrorNoConflict) { // error mode: no conflict (one side default) -> succeeds mjSpec* parent = mj_makeSpec(); parent->compiler.conflict = mjCONFLICT_ERROR; parent->option.timestep = 0.005; mjSpec* child = mj_makeSpec(); // child timestep is default -> no conflict mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); ASSERT_THAT(attached, NotNull()); // parent unchanged EXPECT_EQ(parent->option.timestep, mjtNum(0.005)); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictMergeFromBody) { mock_warning_handler.ExpectWarnings(); // merge mode: attach from body (not spec) still merges mjSpec* parent = mj_makeSpec(); parent->compiler.conflict = mjCONFLICT_MERGE; parent->option.timestep = 0.005; mjSpec* child = mj_makeSpec(); child->option.timestep = 0.001; mjsBody* child_body = mjs_addBody(mjs_findBody(child, "world"), 0); mjsFrame* frame = mjs_addFrame(mjs_findBody(parent, "world"), nullptr); mjsElement* attached = mjs_attach(frame->element, child_body->element, "", ""); ASSERT_THAT(attached, NotNull()); EXPECT_EQ(parent->option.timestep, mjtNum(0.001)); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictMergeGravityError) { // merge mode: conflicting gravity -> error mjSpec* parent = mj_makeSpec(); parent->compiler.conflict = mjCONFLICT_MERGE; parent->option.gravity[2] = -10.0; mjSpec* child = mj_makeSpec(); child->option.gravity[2] = -1.62; mjsBody* child_body = mjs_addBody(mjs_findBody(child, "world"), 0); mjsFrame* frame = mjs_addFrame(mjs_findBody(parent, "world"), nullptr); mjs_attach(frame->element, child_body->element, "child_", ""); EXPECT_THAT(mjs_getError(parent), HasSubstr("gravity: parent has 0 0 -10, child has 0 0 -1.62")); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictMergeRealtimeMin) { mock_warning_handler.ExpectWarnings(); // merge mode: conflicting realtime -> take min mjSpec* parent = mj_makeSpec(); parent->compiler.conflict = mjCONFLICT_MERGE; parent->visual.global.realtime = 2.0f; mjSpec* child = mj_makeSpec(); child->visual.global.realtime = 0.5f; mjsBody* child_body = mjs_addBody(mjs_findBody(child, "world"), 0); mjsFrame* frame = mjs_addFrame(mjs_findBody(parent, "world"), nullptr); mjsElement* attached = mjs_attach(frame->element, child_body->element, "child_", ""); ASSERT_THAT(attached, NotNull()); EXPECT_EQ(parent->visual.global.realtime, 0.5f); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictWarningBitfields) { mock_warning_handler.ExpectWarnings(); // warning mode: conflicting disable flags report individual flag names mjSpec* parent = mj_makeSpec(); parent->option.disableflags = mjDSBL_GRAVITY | mjDSBL_CONTACT; mjSpec* child = mj_makeSpec(); child->option.disableflags = mjDSBL_GRAVITY | mjDSBL_ACTUATION; mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); ASSERT_THAT(attached, NotNull()); // Contact and Actuation differ, Gravity agrees -> 1 grouped warning EXPECT_TRUE(mjs_isWarning(parent)); EXPECT_EQ(mjs_numWarnings(parent), 1); std::string w = mjs_getWarning(parent, 0); EXPECT_THAT(w, HasSubstr("flag 'Contact': parent set, child unset," " keeping parent")); EXPECT_THAT(w, HasSubstr("flag 'Actuation': parent unset, child set," " keeping parent")); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictMergeMultipleWarnings) { mock_warning_handler.ExpectWarnings(); // merge mode: multiple min/max conflicts should be in one grouped warning mjSpec* parent = mj_makeSpec(); parent->compiler.conflict = mjCONFLICT_MERGE; parent->option.timestep = 0.005; parent->option.iterations = 150; parent->visual.global.realtime = 2.0f; mjSpec* child = mj_makeSpec(); child->option.timestep = 0.001; // min conflict child->option.iterations = 200; // max conflict child->visual.global.realtime = 0.5f; // min conflict mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); ASSERT_THAT(attached, NotNull()); // values should be merged EXPECT_EQ(parent->option.timestep, mjtNum(0.001)); EXPECT_EQ(parent->option.iterations, 200); EXPECT_EQ(parent->visual.global.realtime, 0.5f); // one grouped warning containing all three fields EXPECT_EQ(mjs_numWarnings(parent), 1); std::string w = mjs_getWarning(parent, 0); EXPECT_THAT(w, HasSubstr("policy is 'merge'")); EXPECT_THAT(w, HasSubstr("timestep: parent has 0.005, child has 0.001," " taking the minimum")); EXPECT_THAT(w, HasSubstr("iterations: parent has 150, child has 200," " taking the maximum")); EXPECT_THAT(w, HasSubstr("realtime")); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictWarningBitfieldsNoConflict) { // warning mode: one side has zero disableflags -> no conflict mjSpec* parent = mj_makeSpec(); parent->option.disableflags = mjDSBL_GRAVITY; mjSpec* child = mj_makeSpec(); // child disableflags is 0 (default) -> no conflict mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); ASSERT_THAT(attached, NotNull()); EXPECT_FALSE(mjs_isWarning(parent)); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictMergeBitfields) { mock_warning_handler.ExpectWarnings(); // merge mode: flags are ORed together mjSpec* parent = mj_makeSpec(); parent->compiler.conflict = mjCONFLICT_MERGE; parent->option.disableflags = mjDSBL_GRAVITY | mjDSBL_CONTACT; parent->option.enableflags = mjENBL_OVERRIDE; mjSpec* child = mj_makeSpec(); child->option.disableflags = mjDSBL_GRAVITY | mjDSBL_ACTUATION; child->option.enableflags = mjENBL_ENERGY; mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); ASSERT_THAT(attached, NotNull()); // union of flags EXPECT_EQ(parent->option.disableflags, mjDSBL_GRAVITY | mjDSBL_CONTACT | mjDSBL_ACTUATION); EXPECT_EQ(parent->option.enableflags, mjENBL_OVERRIDE | mjENBL_ENERGY); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictErrorBitfields) { // error mode: differing flags -> per-flag error mjSpec* parent = mj_makeSpec(); parent->compiler.conflict = mjCONFLICT_ERROR; parent->option.disableflags = mjDSBL_GRAVITY; mjSpec* child = mj_makeSpec(); child->option.disableflags = mjDSBL_CONTACT; mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); EXPECT_THAT(attached, IsNull()); // should mention the specific flag name, not "disableflags" std::string error = mjs_getError(parent); EXPECT_THAT(error, HasSubstr("flag 'Contact'")); EXPECT_THAT(error, HasSubstr("flag 'Gravity'")); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictErrorNoMutation) { // failed attach should not mutate the parent spec mjSpec* parent = mj_makeSpec(); parent->compiler.conflict = mjCONFLICT_MERGE; parent->option.timestep = 0.005; parent->option.iterations = 150; parent->option.integrator = mjINT_RK4; parent->option.gravity[2] = -10.0; mjSpec* child = mj_makeSpec(); child->option.timestep = 0.001; // min-mergeable child->option.iterations = 200; // max-mergeable child->option.integrator = mjINT_IMPLICIT; // unmergeable -> error child->option.gravity[2] = -1.62; // unmergeable array -> error mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); // attach should fail EXPECT_THAT(attached, IsNull()); // parent should be completely unchanged EXPECT_EQ(parent->option.timestep, mjtNum(0.005)); EXPECT_EQ(parent->option.iterations, 150); EXPECT_EQ(parent->option.integrator, mjINT_RK4); EXPECT_EQ(parent->option.gravity[2], mjtNum(-10.0)); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictMultipleErrors) { // error mode: multiple conflicts should all be reported in the error message mjSpec* parent = mj_makeSpec(); parent->compiler.conflict = mjCONFLICT_ERROR; parent->option.timestep = 0.005; parent->option.iterations = 150; parent->option.gravity[2] = -10.0; mjSpec* child = mj_makeSpec(); child->option.timestep = 0.001; child->option.iterations = 200; child->option.gravity[2] = -1.62; mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); EXPECT_THAT(attached, IsNull()); // all three conflicting fields should appear in the error message std::string error = mjs_getError(parent); EXPECT_THAT(error, HasSubstr("timestep")); EXPECT_THAT(error, HasSubstr("iterations")); EXPECT_THAT(error, HasSubstr("gravity")); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictWarningArray) { mock_warning_handler.ExpectWarnings(); // warning mode: conflicting array -> keep parent, warn mjSpec* parent = mj_makeSpec(); parent->option.gravity[2] = -10.0; mjSpec* child = mj_makeSpec(); child->option.gravity[2] = -1.62; mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); ASSERT_THAT(attached, NotNull()); // parent gravity should be unchanged EXPECT_EQ(parent->option.gravity[2], mjtNum(-10.0)); // one grouped warning containing gravity EXPECT_TRUE(mjs_isWarning(parent)); EXPECT_EQ(mjs_numWarnings(parent), 1); EXPECT_THAT(mjs_getWarning(parent, 0), HasSubstr("gravity: parent has 0 0 -10, child has 0 0 -1.62," " keeping parent value")); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictSubjectNames) { mock_warning_handler.ExpectWarnings(); // Test case 1: both custom names { mjSpec* parent = mj_makeSpec(); mjs_setString(parent->modelname, "parent_model"); parent->option.timestep = 0.005; mjSpec* child = mj_makeSpec(); mjs_setString(child->modelname, "child_model"); child->option.timestep = 0.001; mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); ASSERT_THAT(attached, NotNull()); EXPECT_EQ(mjs_numWarnings(parent), 1); std::string w = mjs_getWarning(parent, 0); EXPECT_THAT(w, HasSubstr("Attach conflict when attaching 'child_model' to " "'parent_model', policy is 'warning'")); mj_deleteSpec(parent); mj_deleteSpec(child); } // Test case 2: only child custom name { mjSpec* parent = mj_makeSpec(); parent->option.timestep = 0.005; mjSpec* child = mj_makeSpec(); mjs_setString(child->modelname, "child_model"); child->option.timestep = 0.001; mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); ASSERT_THAT(attached, NotNull()); EXPECT_EQ(mjs_numWarnings(parent), 1); std::string w = mjs_getWarning(parent, 0); EXPECT_THAT(w, HasSubstr("Attach conflict when attaching 'child_model', " "policy is 'warning'")); mj_deleteSpec(parent); mj_deleteSpec(child); } // Test case 3: only parent custom name { mjSpec* parent = mj_makeSpec(); mjs_setString(parent->modelname, "parent_model"); parent->option.timestep = 0.005; mjSpec* child = mj_makeSpec(); child->option.timestep = 0.001; mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); ASSERT_THAT(attached, NotNull()); EXPECT_EQ(mjs_numWarnings(parent), 1); std::string w = mjs_getWarning(parent, 0); EXPECT_THAT(w, HasSubstr("Attach conflict when attaching to 'parent_model'," " policy is 'warning'")); mj_deleteSpec(parent); mj_deleteSpec(child); } // Test case 4: no custom name (both "MuJoCo Model") { mjSpec* parent = mj_makeSpec(); parent->option.timestep = 0.005; mjSpec* child = mj_makeSpec(); child->option.timestep = 0.001; mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "", ""); ASSERT_THAT(attached, NotNull()); EXPECT_EQ(mjs_numWarnings(parent), 1); std::string w = mjs_getWarning(parent, 0); EXPECT_THAT(w, HasSubstr("Attach conflict on attach, " "policy is 'warning'")); mj_deleteSpec(parent); mj_deleteSpec(child); } } TEST_F(MujocoTest, AttachConflictAuthoredDefault) { // child explicitly sets timestep to its default value (0.002) in XML, // which should still conflict with a non-default parent timestep static constexpr char parent_xml[] = R"( )"; static constexpr char child_xml[] = R"( )"; std::array err; mjSpec* parent = mj_parseXMLString(parent_xml, nullptr, err.data(), err.size()); ASSERT_THAT(parent, NotNull()) << err.data(); mjSpec* child = mj_parseXMLString(child_xml, nullptr, err.data(), err.size()); ASSERT_THAT(child, NotNull()) << err.data(); mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "child_", ""); // attach should fail: child authored timestep=0.002 (the default), // but parent has timestep=0.005 — authored tracking catches this EXPECT_THAT(attached, IsNull()); EXPECT_THAT(mjs_getError(parent), HasSubstr("timestep")); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictNonAuthoredDefault) { // child does NOT set timestep in XML, parent has non-default timestep. // no conflict should be detected (child didn't author the field) static constexpr char parent_xml[] = R"( )"; static constexpr char child_xml[] = R"( )"; std::array err; mjSpec* parent = mj_parseXMLString(parent_xml, nullptr, err.data(), err.size()); ASSERT_THAT(parent, NotNull()) << err.data(); mjSpec* child = mj_parseXMLString(child_xml, nullptr, err.data(), err.size()); ASSERT_THAT(child, NotNull()) << err.data(); mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "child_", ""); // attach should succeed: child didn't author timestep EXPECT_THAT(attached, NotNull()) << mjs_getError(parent); // parent timestep should be unchanged EXPECT_EQ(parent->option.timestep, mjtNum(0.005)); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictXMLMergeAuthoredDefault) { mock_warning_handler.ExpectWarnings(); static constexpr char parent_xml[] = R"( )"; static constexpr char child_xml[] = R"( )"; std::array error; mjSpec* parent = mj_parseXMLString(parent_xml, nullptr, error.data(), error.size()); ASSERT_THAT(parent, NotNull()) << error.data(); mjSpec* child = mj_parseXMLString(child_xml, nullptr, error.data(), error.size()); ASSERT_THAT(child, NotNull()) << error.data(); mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "child_", ""); ASSERT_THAT(attached, NotNull()) << "Error details: " << mjs_getError(parent); // Child explicitly authored timestep=0.002. Parent has timestep=0.005. So // they conflict on both-authored. Under merge mode, min-merge applies -> // child wins. EXPECT_EQ(parent->option.timestep, mjtNum(0.002)); EXPECT_EQ(mjs_numWarnings(parent), 1); EXPECT_THAT(mjs_getWarning(parent, 0), HasSubstr("timestep: parent has 0.005, child has 0.002, " "taking the minimum")); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachConflictXMLBitfieldSilentAdoption) { mock_warning_handler.ExpectWarnings(); static constexpr char parent_xml[] = R"( )"; static constexpr char child_xml[] = R"( )"; std::array error; mjSpec* parent = mj_parseXMLString(parent_xml, nullptr, error.data(), error.size()); ASSERT_THAT(parent, NotNull()) << error.data(); mjSpec* child = mj_parseXMLString(child_xml, nullptr, error.data(), error.size()); ASSERT_THAT(child, NotNull()) << error.data(); mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "child_", ""); ASSERT_THAT(attached, NotNull()) << "Error details: " << mjs_getError(parent); // Parent should adopt child's constraint disable flag since parent didn't // restrict flags. In merge mode, this logging counts as 1 warning. EXPECT_EQ(parent->option.disableflags, mjDSBL_CONSTRAINT); EXPECT_EQ(mjs_numWarnings(parent), 1); EXPECT_THAT(mjs_getWarning(parent, 0), HasSubstr("flag 'Constraint': added from child")); mj_deleteSpec(parent); mj_deleteSpec(child); } TEST_F(MujocoTest, AttachWarningBoundaryAndPreservation) { mock_warning_handler.ExpectWarnings(); // 1. Build a parent XML that generates a compile warning static constexpr char parent_xml[] = R"( )"; // 2. Child 1 has conflicting timestep (0.001) static constexpr char child1_xml[] = R"( )"; // 3. Child 2 has conflicting sleep_tolerance (0.005) static constexpr char child2_xml[] = R"( )"; std::array error; mjSpec* parent = mj_parseXMLString(parent_xml, nullptr, error.data(), error.size()); ASSERT_THAT(parent, NotNull()) << error.data(); mjSpec* child1 = mj_parseXMLString(child1_xml, nullptr, error.data(), error.size()); ASSERT_THAT(child1, NotNull()) << error.data(); mjSpec* child2 = mj_parseXMLString(child2_xml, nullptr, error.data(), error.size()); ASSERT_THAT(child2, NotNull()) << error.data(); mjsBody* world = mjs_findBody(parent, "world"); // Perform Attach 1 mjsElement* attached1 = mjs_attach(world->element, child1->element, "c1_", ""); ASSERT_THAT(attached1, NotNull()) << "Error details: " << mjs_getError(parent); EXPECT_EQ(mjs_numWarnings(parent), 1); EXPECT_THAT(mjs_getWarning(parent, 0), HasSubstr("timestep")); // Perform Attach 2 mjsElement* attached2 = mjs_attach(world->element, child2->element, "c2_", ""); ASSERT_THAT(attached2, NotNull()) << "Error details: " << mjs_getError(parent); EXPECT_EQ(mjs_numWarnings(parent), 2); EXPECT_THAT(mjs_getWarning(parent, 1), HasSubstr("sleep_tolerance")); // Compile 1: compiles and generates a compile warning (flex is not rigid) mjModel* model1 = mj_compile(parent, nullptr); ASSERT_THAT(model1, NotNull()); // Warnings should include both attach warnings (indices 0 and 1) AND the // compile warning (at index 2) int num_warnings_after_compile = mjs_numWarnings(parent); EXPECT_GE(num_warnings_after_compile, 3); EXPECT_THAT(mjs_getWarning(parent, 2), HasSubstr("not rigid")); // Compile 2 (Recompile): Recompiling should CLEAR compile warnings but // PRESERVE all attach warnings! mjModel* model2 = mj_compile(parent, nullptr); ASSERT_THAT(model2, NotNull()); // Attach warnings must still be preserved and compile warnings refreshed EXPECT_EQ(mjs_numWarnings(parent), num_warnings_after_compile); EXPECT_THAT(mjs_getWarning(parent, 0), HasSubstr("timestep")); EXPECT_THAT(mjs_getWarning(parent, 1), HasSubstr("sleep_tolerance")); EXPECT_THAT(mjs_getWarning(parent, 2), HasSubstr("not rigid")); mj_deleteModel(model1); mj_deleteModel(model2); mj_deleteSpec(parent); mj_deleteSpec(child1); mj_deleteSpec(child2); } TEST_F(MujocoTest, AttachConflictWarningZFarDefaultMessage) { mock_warning_handler.ExpectWarnings(); static constexpr char parent_xml[] = R"( )"; static constexpr char child_xml[] = R"( )"; std::array error; mjSpec* parent = mj_parseXMLString(parent_xml, nullptr, error.data(), error.size()); ASSERT_THAT(parent, NotNull()) << error.data(); mjSpec* child = mj_parseXMLString(child_xml, nullptr, error.data(), error.size()); ASSERT_THAT(child, NotNull()) << error.data(); mjsBody* world = mjs_findBody(parent, "world"); mjsElement* attached = mjs_attach(world->element, child->element, "child_", ""); ASSERT_THAT(attached, NotNull()) << "Error details: " << mjs_getError(parent); // Since only the child authored 'zfar' and the parent relied on defaults, // we warning-log and state that the parent has the default value. EXPECT_TRUE(mjs_isWarning(parent)); EXPECT_EQ(mjs_numWarnings(parent), 1); EXPECT_THAT( mjs_getWarning(parent, 0), HasSubstr("zfar: parent has 50 (default), child has " "30, keeping parent value")); mj_deleteSpec(parent); mj_deleteSpec(child); } } // namespace } // namespace mujoco