Files
Mujoco_WASM/test/user/user_resolver_test.cc
T
Yuval Tassa 080668df42 Add "(default)" to conflict warnings in user resolver.
When reporting conflicts between parent and child elements during attachment, the warning message now indicates if a value is derived from the default rather than being explicitly authored in the XML. This improves clarity in conflict resolution warnings.

PiperOrigin-RevId: 936724265
Change-Id: I68c677b19020c19bf0260f5b3b77fe6ca3661bb9
2026-06-23 09:44:29 -07:00

966 lines
31 KiB
C++

// 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 <array>
#include <string>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <mujoco/mjspec.h>
#include <mujoco/mujoco.h>
#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"(
<mujoco>
<option timestep="0.005"/>
<worldbody>
<frame name="attachment"/>
</worldbody>
</mujoco>
)";
static constexpr char xml_child[] = R"(
<mujoco>
<option timestep="0.001"/>
<worldbody>
<body name="child">
<geom size="1"/>
</body>
</worldbody>
</mujoco>
)";
std::array<char, 1024> 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"(
<mujoco>
<compiler conflict="error"/>
<option timestep="0.005"/>
<worldbody>
<body name="parent_body"><geom size="1"/></body>
</worldbody>
</mujoco>
)";
static constexpr char child_xml[] = R"(
<mujoco>
<option timestep="0.002"/>
<worldbody>
<body name="child_body"><geom size="1"/></body>
</worldbody>
</mujoco>
)";
std::array<char, 1024> 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"(
<mujoco>
<compiler conflict="error"/>
<option timestep="0.005"/>
<worldbody>
<body name="parent_body"><geom size="1"/></body>
</worldbody>
</mujoco>
)";
static constexpr char child_xml[] = R"(
<mujoco>
<worldbody>
<body name="child_body"><geom size="1"/></body>
</worldbody>
</mujoco>
)";
std::array<char, 1024> 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"(
<mujoco>
<compiler conflict="merge"/>
<option timestep="0.005"/>
<worldbody/>
</mujoco>
)";
static constexpr char child_xml[] = R"(
<mujoco>
<option timestep="0.002"/>
<worldbody/>
</mujoco>
)";
std::array<char, 1024> 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"(
<mujoco>
<compiler conflict="merge"/>
<worldbody/>
</mujoco>
)";
static constexpr char child_xml[] = R"(
<mujoco>
<option>
<flag constraint="disable"/>
</option>
<worldbody/>
</mujoco>
)";
std::array<char, 1024> 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"(
<mujoco>
<compiler conflict="warning"/>
<option timestep="0.005"/>
<worldbody>
<body name="parent">
<flexcomp name="grid" type="grid" count="3 3 1" spacing="0.1 0.1 0.1"
dim="2" radius="0.01">
<contact internal="false"/>
</flexcomp>
</body>
</worldbody>
</mujoco>
)";
// 2. Child 1 has conflicting timestep (0.001)
static constexpr char child1_xml[] = R"(
<mujoco>
<option timestep="0.001"/>
<worldbody/>
</mujoco>
)";
// 3. Child 2 has conflicting sleep_tolerance (0.005)
static constexpr char child2_xml[] = R"(
<mujoco>
<option sleep_tolerance="0.005"/>
<worldbody/>
</mujoco>
)";
std::array<char, 1024> 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"(
<mujoco>
<compiler conflict="warning"/>
<worldbody/>
</mujoco>
)";
static constexpr char child_xml[] = R"(
<mujoco>
<visual>
<map zfar="30"/>
</visual>
<worldbody/>
</mujoco>
)";
std::array<char, 1024> 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