// Copyright 2021 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 "test/fixture.h" #include #include #include #include #include #include // NOLINT #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "src/xml/xml_global.h" namespace mujoco { namespace { using ::testing::_; using ::testing::Not; using ::testing::Return; using ::testing::Truly; // Returns true if the warning matches a known benign warning to ignore. bool IsBenignWarning(const std::string& msg) { static const char* const kBenignWarnings[] = { "is not rigid and has no equality constraints", "Attach conflict", }; for (const char* warning : kBenignWarnings) { if (absl::StrContains(msg, warning)) { return true; } } return false; } } // namespace thread_local MockWarningHandler* MockWarningHandler::active_handler = nullptr; // Registers this handler as the active one. MockWarningHandler::MockWarningHandler() { prev_ = active_handler; active_handler = this; // By default, ignore matches in the benign warnings list ON_CALL(*this, Warn(Truly(IsBenignWarning))) .WillByDefault([](const std::string&) {}); // Fail on all other warnings ON_CALL(*this, Warn(Not(Truly(IsBenignWarning)))) .WillByDefault([](const std::string& msg) { ADD_FAILURE() << "mju_user_warning: " << msg; }); } // Restores the previously active warning handler. MockWarningHandler::~MockWarningHandler() { active_handler = prev_; } // Configures the mock warning handler to ignore all warnings (if empty) or // expect at least one warning containing substring (if non-empty). void MockWarningHandler::ExpectWarnings(std::string_view substring) { if (substring.empty()) { EXPECT_CALL(*this, Warn(_)).WillRepeatedly(Return()); } else { EXPECT_CALL(*this, Warn(::testing::HasSubstr(std::string(substring)))) .Times(::testing::AtLeast(1)) .WillRepeatedly(Return()); } } // Returns the active warning handler. MockWarningHandler* MockWarningHandler::GetActive() { return active_handler; } namespace { using ::testing::NotNull; ABSL_CONST_INIT static absl::Mutex handlers_mutex(absl::kConstInit); static int guard_count ABSL_GUARDED_BY(handlers_mutex) = 0; static mjfLogHandler prev_log_handler ABSL_GUARDED_BY(handlers_mutex) = nullptr; void default_mj_log_handler(const mjLogMessage* msg) { std::string subject = msg->subject; if (msg->func) { subject = absl::StrCat(msg->func, ": ", msg->subject); } if (msg->level == mjLOG_ERROR) { // legacy fallback: some tests still install mju_user_error to intercept // errors with longjmp-based capture if (mju_user_error) { mju_user_error(subject.c_str()); } else { FAIL() << "mju_user_error: " << subject; } } else if (msg->level == mjLOG_WARNING) { std::string full_msg = subject; if (msg->body) { absl::StrAppend(&full_msg, "\n", msg->body); } if (auto* handler = MockWarningHandler::GetActive()) { handler->Warn(full_msg); } else { ADD_FAILURE() << "Unexpected warning: " << full_msg; } } } } // namespace MujocoErrorTestGuard::MujocoErrorTestGuard() { absl::MutexLock lock(handlers_mutex); if (++guard_count == 1) { prev_log_handler = mju_setLogHandler(default_mj_log_handler); } } MujocoErrorTestGuard::~MujocoErrorTestGuard() { absl::MutexLock lock(handlers_mutex); if (--guard_count == 0) { mju_setLogHandler(prev_log_handler); prev_log_handler = nullptr; } } const std::string GetTestDataFilePath(std::string_view path) { // NOLINT return std::string(path); } const std::string GetModelPath(std::string_view path) { // NOLINT return absl::StrCat("../model/", path); } MjModelPtr LoadModelFromString(std::string_view xml, char* error, int error_size, mjVFS* vfs) { if (error) { error[0] = '\0'; } // This duplicates the logic in mj_loadXML, but allows us to use a string // directly rather than having to write the contents to a file. Most // importantly, we "save" the spec to global storage so that subsequent calls // to mj_saveLastXML will be done using the parsed mjSpec. mjSpec* spec = mj_parseXMLString(xml.data(), vfs, error, error_size); mjModel* model = nullptr; if (spec) { model = mj_compile(spec, vfs); if (error) { if (!model) { strncpy(error, mjs_getError(spec), error_size); error[error_size - 1] = '\0'; } else { int num_warnings = mjs_numWarnings(spec); if (num_warnings > 0) { std::string all_warnings; for (int i = 0; i < num_warnings; ++i) { if (!all_warnings.empty()) { all_warnings += '\n'; } all_warnings += mjs_getWarning(spec, i); } strncpy(error, all_warnings.c_str(), error_size); error[error_size - 1] = '\0'; } else { error[0] = '\0'; } } } } SetGlobalXmlSpec(spec); return MjModelPtr(model); } MjDataPtr MakeData(const MjModelPtr& model) { return MjDataPtr(mj_makeData(model.get())); } static void AssertModelNotNull(mjModel* model, const std::array& error) { ASSERT_THAT(model, NotNull()) << "Failed to load model: " << error.data(); } mjModel* LoadModelFromPath(const char* model_path) { const std::string xml_path = GetModelPath(model_path); std::array error; mjModel* model = mj_loadXML( xml_path.c_str(), nullptr, error.data(), error.size()); AssertModelNotNull(model, error); return model; } std::string GetFileContents(const char* path) { std::ifstream ifs; ifs.open(path, std::ifstream::in); EXPECT_FALSE(ifs.fail()); std::ostringstream sstream; sstream << ifs.rdbuf(); return sstream.str(); } std::string SaveAndReadXmlImpl(const mjModel* model, const mjSpec* spec) { constexpr int kMaxPathLen = 1024; std::string path_template = std::filesystem::temp_directory_path().append("tmp.XXXXXX").string(); EXPECT_LT(path_template.size(), kMaxPathLen); char filepath[kMaxPathLen]; mju_strncpy(filepath, path_template.c_str(), path_template.size() + 1); #if defined(_POSIX_VERSION) && _POSIX_VERSION >= 200112L int fd = mkstemp(filepath); EXPECT_NE(fd, -1) << std::strerror(errno); #elif defined(_WIN32) EXPECT_NE(_mktemp_s(filepath), EINVAL); #endif if (spec) { mj_saveXML(spec, filepath, nullptr, 0); } else if (model) { mj_saveLastXML(filepath, model, nullptr, 0); } std::string contents = GetFileContents(filepath); #if defined(_POSIX_VERSION) && _POSIX_VERSION >= 200112L close(fd); #endif std::remove(filepath); return contents; } std::string SaveAndReadXml(const mjModel* model) { EXPECT_THAT(model, testing::NotNull()); return SaveAndReadXmlImpl(model, nullptr); } std::string SaveAndReadXml(const mjSpec* spec) { EXPECT_THAT(spec, testing::NotNull()); return SaveAndReadXmlImpl(nullptr, spec); } std::vector GetCtrlNoise(const mjModel* m, int nsteps, mjtNum ctrlnoise) { std::vector ctrl; for (int step=0; step < nsteps; step++) { for (int i = 0; i < m->nu; i++) { mjtNum center = 0.0; mjtNum radius = 1.0; mjtNum* range = m->actuator_ctrlrange + 2 * i; if (m->actuator_ctrllimited[i]) { center = (range[1] + range[0]) / 2; radius = (range[1] - range[0]) / 2; } radius *= ctrlnoise; ctrl.push_back(center + radius * (2 * mju_Halton(step, i+2) - 1)); } } return ctrl; } MockFilesystem::MockFilesystem(std::string unit_test_name) { prefix_ = absl::StrCat("MjMock.", unit_test_name); dir_ = "/"; if (mjp_getResourceProvider(prefix_.c_str()) != nullptr) { return; } mjpResourceProvider resourceProvider; mjp_defaultResourceProvider(&resourceProvider); resourceProvider.prefix = prefix_.c_str(); resourceProvider.data = (void *) this; resourceProvider.open = +[](mjResource* resource) { MockFilesystem *fs = static_cast(resource->provider->data); std::string filename = fs->StripPrefix(resource->name); return fs->FileExists(filename) ? 1 : 0; }; resourceProvider.read =+[](mjResource* resource, const void** buffer) { MockFilesystem *fs = static_cast(resource->provider->data); std::string filename = fs->StripPrefix(resource->name); return (int) fs->GetFile(filename, (const unsigned char**) buffer); }; resourceProvider.close = +[](mjResource* resource) {}; mjp_registerResourceProvider(&resourceProvider); } bool MockFilesystem::AddFile(std::string filename, const unsigned char* data, std::size_t ndata) { std::string fullfilename = PathReduce(dir_, filename); auto [it, inserted] = filenames_.insert(fullfilename); if (inserted) { data_[fullfilename] = std::vector(data, data + ndata); } return inserted; } bool MockFilesystem::FileExists(const std::string& filename) { std::string fullfilename = PathReduce(dir_, filename); return filenames_.find(fullfilename) != filenames_.end(); } std::size_t MockFilesystem::GetFile(const std::string& filename, const unsigned char** buffer) const { std::string fullfilename = PathReduce(dir_, filename); auto it = data_.find(fullfilename); if (it == data_.end()) { return 0; } *buffer = it->second.data(); return it->second.size(); } void MockFilesystem::ChangeDirectory(std::string dir) { if (dir.empty()) { return; } dir_ = PathReduce(dir_, dir); if (dir_.back() != '/') { dir_ = absl::StrCat(dir_, "/"); } } std::string MockFilesystem::FullPath(const std::string& path) const { return absl::StrCat(prefix_, ":", PathReduce(dir_, path)); } std::string MockFilesystem::StripPrefix(const char* path) const { return &path[prefix_.size() + 1]; } std::string MockFilesystem::PathReduce(const std::string& current_dir, const std::string& path) { std::stringstream stream; if (!path.empty() && path[0] != '/') { stream = std::stringstream(absl::StrCat(current_dir, path)); } else { stream = std::stringstream(path); } std::string temp; std::vector dirs; while (std::getline(stream, temp, '/')) { if (temp == ".." && !dirs.empty()) { dirs.pop_back(); continue; } if (temp != "." && !temp.empty()) { dirs.push_back(temp); } } if (dirs.empty()) { return "/"; } return absl::StrJoin(dirs, "/"); } } // namespace mujoco