diff --git a/VERSION.txt b/VERSION.txt index d3a9b7684..5e9950b8e 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.20261110.0 +1.20261111.0 diff --git a/src/misc/utils.cpp b/src/misc/utils.cpp index 7c3f342f4..e5a1d9ebc 100644 --- a/src/misc/utils.cpp +++ b/src/misc/utils.cpp @@ -983,6 +983,119 @@ bool hasScheme(const std::string &pUriReference) }); } +FileLocation fileLocation(bool pIsLocalFile, const std::string &pFileNameOrUrl) +{ + // Determine the location of a file, i.e. what a relative URI reference in that file is relative to. + // Note: for a remote file, we must not use std::filesystem::path since its separators are platform specific (e.g., + // "https://example.com/dir" would become "https:\\example.com\dir" on Windows) and since the query and/or + // fragment of the URL, if any, may contain forward slashes. + + FileLocation res; + + res.isLocal = pIsLocalFile; + + if (pIsLocalFile) { + res.fileName = pFileNameOrUrl; + res.location = pathToString(stringToPath(pFileNameOrUrl).parent_path()); + + if (!res.location.empty()) { + res.location += "/"; + } + } else { + // Note #1: the URL may have no path (e.g., "https://example.com?a=b"), in which case its location is its root + // (e.g., "https://example.com/"). + // Note #2: we also keep track of the scheme (e.g., "https:") and origin (e.g., "https://example.com") of the + // URL, as well as of the URL without its query and fragment (e.g., "https://example.com/dir/file.xml") + // and without its fragment (e.g., "https://example.com/dir/file.xml?a=b"), which a network-path, an + // absolute-path, a query-only, and a fragment-only URI reference, respectively, are relative to (see + // resolveUriReference()). + + static constexpr std::string_view SCHEME_SEPARATOR {"://"}; + + res.urlWithoutQueryAndFragment = pFileNameOrUrl.substr(0, pFileNameOrUrl.find_first_of("?#")); + res.urlWithoutFragment = pFileNameOrUrl.substr(0, pFileNameOrUrl.find('#')); + + auto schemeSeparatorPos {res.urlWithoutQueryAndFragment.find(SCHEME_SEPARATOR)}; + auto pathPos {res.urlWithoutQueryAndFragment.find('/', schemeSeparatorPos + SCHEME_SEPARATOR.size())}; + + res.location = (pathPos == std::string::npos) ? + res.urlWithoutQueryAndFragment + "/" : + res.urlWithoutQueryAndFragment.substr(0, res.urlWithoutQueryAndFragment.rfind('/') + 1); + res.urlScheme = res.urlWithoutQueryAndFragment.substr(0, schemeSeparatorPos + 1); + res.urlOrigin = res.urlWithoutQueryAndFragment.substr(0, pathPos); + } + + return res; +} + +std::optional resolveUriReference(const FileLocation &pFileLocation, const std::string &pUriReference) +{ + // Resolve the given URI reference against the given file location. + // Note #1: a relative URI reference is relative to the file, not to the current working directory, so we only + // canonicalise it once we have resolved it against the location of the file (see FileManager::file() and + // File::create()). + // Note #2: a URI reference (see https://www.rfc-editor.org/rfc/rfc3986#section-4.1) may be percent-encoded (e.g., + // "my%20model.cellml"). If the file is a local file and the URI reference is a file name (i.e. neither a + // URL nor a "file" URL, which path is decoded by retrieveFileInfo()) then we must decode the URI reference + // to get the corresponding file name. We must, however, do this after having checked whether the URI + // reference is relative since a percent-encoded forward slash (i.e. "%2F") must not make a relative URI + // reference absolute. If the file is a remote file then a relative URI reference is decoded as part of + // the resolved URL (see retrieveFileInfo()). + // Note #3: if the file is a remote file then a URI reference that is a file name is never a local file name, but a + // reference that is relative to the URL of the file, i.e. a network-path reference (e.g., + // "//example.org/models/model.cellml") is relative to its scheme, an absolute-path reference (e.g., + // "/models/model.cellml") to its origin, a query-only reference (e.g., "?model=1") to its URL without its + // query and fragment, a fragment-only reference (e.g., "#model") to its URL without its fragment, and a + // relative-path reference (e.g., "../models/model.cellml") to its location (see + // https://www.rfc-editor.org/rfc/rfc3986#section-5.2.2). + // Note #4: a URI reference with a scheme other than "http", "https", or "file" (e.g., + // "urn:miriam:biomodels.db:BIOMD0000000012") is an absolute URI (see + // https://www.rfc-editor.org/rfc/rfc3986#section-4.3), not a reference that is relative to the file. We + // cannot retrieve such a file, so we return no resolved URI reference. + // Note #5: if the file is a local file and the URI reference is a file name then the query and fragment of the URI + // reference (e.g., "?a=b" and "#c" in "model.cellml?a=b#c") are not part of its path, so we ignore them + // (see retrieveFileInfo()). A query-only or fragment-only URI reference (e.g., "?model=1" or "#model") + // therefore refers to the file itself (see https://www.rfc-editor.org/rfc/rfc3986#section-5.2.2). + + auto [isLocalFile, fileNameOrUrl] {retrieveFileInfo(pUriReference, false)}; + auto isRelativeFileName {isLocalFile && !stringToPath(fileNameOrUrl).has_root_directory()}; + auto isFileName {isLocalFile && (fileNameOrUrl == pUriReference)}; + + if (isFileName && hasScheme(pUriReference)) { + return std::nullopt; + } + + if (pFileLocation.isLocal && isFileName) { + fileNameOrUrl = decodeUrl(fileNameOrUrl.substr(0, fileNameOrUrl.find_first_of("?#"))); + } + + if (!pFileLocation.isLocal && isFileName) { + if (pUriReference.starts_with("//")) { + return pFileLocation.urlScheme + pUriReference; + } + + if (pUriReference.starts_with('/')) { + return pFileLocation.urlOrigin + pUriReference; + } + + if (pUriReference.starts_with('?')) { + return pFileLocation.urlWithoutQueryAndFragment + pUriReference; + } + + if (pUriReference.starts_with('#')) { + return pFileLocation.urlWithoutFragment + pUriReference; + } + + return pFileLocation.location + pUriReference; + } + + if (pFileLocation.isLocal && isFileName && fileNameOrUrl.empty()) { + return pFileLocation.fileName; + } + + return isRelativeFileName ? pFileLocation.location + fileNameOrUrl : fileNameOrUrl; +} + #ifndef __EMSCRIPTEN__ namespace { diff --git a/src/misc/utils.h b/src/misc/utils.h index e2c7190e1..137c26918 100644 --- a/src/misc/utils.h +++ b/src/misc/utils.h @@ -26,6 +26,7 @@ limitations under the License. # include #endif #include +#include #include #include @@ -101,6 +102,20 @@ std::string relativePath(const std::string &pPath, const std::string &pBasePath) std::string urlPath(const std::string &pPath); bool LIBOPENCOR_UNIT_TESTING_EXPORT hasScheme(const std::string &pUriReference); +struct FileLocation +{ + bool isLocal {true}; + std::string fileName; + std::string location; + std::string urlScheme; + std::string urlOrigin; + std::string urlWithoutQueryAndFragment; + std::string urlWithoutFragment; +}; + +FileLocation fileLocation(bool pIsLocalFile, const std::string &pFileNameOrUrl); +std::optional resolveUriReference(const FileLocation &pFileLocation, const std::string &pUriReference); + #ifndef __EMSCRIPTEN__ std::tuple downloadFile(const std::string &pUrl); diff --git a/src/support/cellml/cellmlfile.cpp b/src/support/cellml/cellmlfile.cpp index d8654b3ba..4f7a258f3 100644 --- a/src/support/cellml/cellmlfile.cpp +++ b/src/support/cellml/cellmlfile.cpp @@ -19,6 +19,7 @@ limitations under the License. #include "utils.h" +#include "libopencor/filemanager.h" #include "libopencor/seddocument.h" #include "libopencor/sedmodel.h" #include "libopencor/sedsteadystate.h" @@ -27,8 +28,11 @@ limitations under the License. #include "libopencor/solvercvode.h" #include "libopencor/solverkinsol.h" +#include #include +#include #include +#include namespace libOpenCOR { @@ -39,6 +43,273 @@ namespace { std::mutex sRuntimesMutex; // NOLINT std::unordered_map sRuntimes; // NOLINT +// Normalise the given path the way libCellML does it, so that we can determine the key that libCellML uses for an +// imported model in the library of its importer. + +std::string normalisedDirectorySeparator(const std::string &pPath) +{ + auto res {pPath}; + + std::ranges::replace(res, '\\', '/'); + + return res; +} + +std::string pathFromUrl(const std::string &pUrl) +{ + auto res {normalisedDirectorySeparator(pUrl)}; + auto path {res.substr(0, res.find_first_of("?#"))}; + + return path.substr(0, path.find_last_of('/') + 1); +} + +// Retrieve the contents of an imported file. + +std::optional importedFileContents(bool pIsLocalFile, const std::string &pFileNameOrUrl) +{ + // Use the contents of the managed file, if any, with the given file name or URL. This is the case for a file in a + // COMBINE archive or for a file which contents were set in memory (e.g., from JavaScript). + +#ifdef __EMSCRIPTEN__ + auto file {FileManager::instance().fileFromFileNameOrUrl(pFileNameOrUrl)}; +#else + auto file {FileManager::instance().file(pFileNameOrUrl)}; +#endif + + if (file != nullptr) { + const auto &contents {file->contents()}; + + if (!contents.empty()) { + return toString(contents); + } + } + +#ifdef __EMSCRIPTEN__ + (void)pIsLocalFile; +#else + // Retrieve the contents of the local file or download the remote file and retrieve its contents. + // Note #1: we use the std::error_code version of std::filesystem::is_regular_file() so that nothing gets thrown + // (e.g., if the file name is too long), in which case we consider that the file doesn't exist. + // Note #2: the file name cannot contain a null character (which would truncate it) since it comes from an XML + // attribute, which cannot contain one, and since decodeUrl() never decodes a percent-encoded null + // character. + + if (pIsLocalFile) { + std::error_code errorCode; + auto filePath {stringToPath(pFileNameOrUrl)}; + + if (std::filesystem::is_regular_file(filePath, errorCode)) { + return toString(fileContents(filePath)); + } + } else { + auto [res, filePath] {downloadFile(pFileNameOrUrl)}; + + if (res) { + auto contents {toString(fileContents(filePath))}; + std::error_code errorCode; + + std::filesystem::remove(filePath, errorCode); + + return contents; + } + } +#endif + + return std::nullopt; +} + +bool hasXmlError(const libcellml::ParserPtr &pParser) +{ + // Return whether the given parser has found some XML errors. + + for (size_t i {0}; i < pParser->errorCount(); ++i) { + if (pParser->error(i)->referenceRule() == libcellml::Issue::ReferenceRule::XML) { + return true; + } + } + + return false; +} + +Strings importUrls(const libcellml::ModelPtr &pModel) +{ + // Retrieve the URLs of the imports of the given model, in the order in which they are encountered. + + Strings res; + std::unordered_set urls; + + auto addUrl = [&](const libcellml::ImportedEntityPtr &pImportedEntity) { + if (pImportedEntity->isImport()) { + const auto &url {pImportedEntity->importSource()->url()}; + + if (urls.insert(url).second) { + res.push_back(url); + } + } + }; + + for (size_t i {0}; i < pModel->unitsCount(); ++i) { + addUrl(pModel->units(i)); + } + + std::vector components; + + for (size_t i {0}; i < pModel->componentCount(); ++i) { + components.push_back(pModel->component(i)); + } + + while (!components.empty()) { + auto component {components.back()}; + + components.pop_back(); + + addUrl(component); + + for (size_t i {0}; i < component->componentCount(); ++i) { + components.push_back(component->component(i)); + } + } + + return res; +} + +// A model that has some imports and that we need to go through to retrieve the models that it imports. + +struct ImportingModel +{ + libcellml::ModelPtr model; + std::string keyBase; + FileLocation location; + Strings fileNamesOrUrls; +}; + +using ImportedFileContentsCache = std::unordered_map>; + +libcellml::ModelPtr retrieveImportedModel(ImportedFileContentsCache &pCache, bool pIsLocalFile, + const std::string &pFileNameOrUrl, bool pStrict, Strings &pErrors) +{ + // Retrieve and parse an imported model, returning a null pointer if we cannot retrieve its contents or if it is not + // valid XML (libCellML will report it). + // Note #1: the same file may be imported several times (with different keys), so we only retrieve its contents once + // and report its errors, if any, once. + // Note #2: an imported model that has some errors (other than XML ones) is still returned, so that its imports can + // be resolved, but we report its errors since libCellML doesn't (it doesn't parse the models that we have + // retrieved). + + libcellml::ModelPtr res; + auto contents {pCache.find(pFileNameOrUrl)}; + auto firstTime {contents == pCache.end()}; + + if (firstTime) { + contents = pCache.emplace(pFileNameOrUrl, importedFileContents(pIsLocalFile, pFileNameOrUrl)).first; + } + + if (!contents->second.has_value()) { + return res; + } + + auto parser {libcellml::Parser::create(pStrict)}; + + res = parser->parseModel(*contents->second); + + if (hasXmlError(parser)) { + res = nullptr; + + return res; + } + + if (firstTime) { + for (size_t i {0}; i < parser->errorCount(); ++i) { + pErrors.push_back("the model at '" + pFileNameOrUrl + "' is invalid: " + parser->error(i)->description()); + } + } + + return res; +} + +void populateImporterLibrary(const libcellml::ImporterPtr &pImporter, const libcellml::ModelPtr &pModel, + const FileLocation &pLocation, const std::string &pFileNameOrUrl, Strings &pErrors) +{ + // Populate the library of the given importer with the models that are (directly or indirectly) imported by the + // given model. + // Note #1: libCellML can only resolve imports that are local files. So, we retrieve the imported models ourselves + // (be they local files, remote files, files in a COMBINE archive, or files which contents were set in + // memory) and add them to the library of its importer, which is what libCellML looks up before trying to + // open a file. + // Note #2: libCellML looks up an imported model using its URL as is or, if there is no such key, using the URL + // prefixed with a base path. The base path for the imports of the given model is the location of its file + // (which is either empty or ends with a directory separator) while the base path for the imports of an + // imported model is the base path of its importing model followed by the path (i.e. up to the last + // forward slash) of its URL. + // Note #3: we keep track of the files that lead to an imported model, so that we don't end up in an infinite loop + // in the case of an import cycle (which libCellML will report). + // Note #4: if we cannot retrieve an imported model, or if it is not valid XML, then we simply don't add it to the + // library of the importer, and libCellML will report the issue when trying to resolve the imports. On the + // other hand, if an imported model has some other errors then we add it to the library, and report its + // errors, if any, through the given errors. + + std::stack importingModels; + ImportedFileContentsCache importedFileContentsCache; + + importingModels.push({pModel, normalisedDirectorySeparator(pLocation.location), pLocation, {pFileNameOrUrl}}); + + while (!importingModels.empty()) { + auto importingModel {std::move(importingModels.top())}; + + importingModels.pop(); + + for (const auto &url : importUrls(importingModel.model)) { + // Determine the key that libCellML uses for the imported model and skip it if we already have it in the + // library of the importer. + + auto normalisedUrl {normalisedDirectorySeparator(url)}; + auto key {hasScheme(url) ? normalisedUrl : importingModel.keyBase + normalisedUrl}; + + if (pImporter->library(key) != nullptr) { + continue; + } + + // Resolve the URL against the location of the importing model, skipping it if it cannot be retrieved, + // e.g., if it has an unsupported scheme. + + auto resolvedUrl {resolveUriReference(importingModel.location, url)}; + + if (!resolvedUrl.has_value()) { + continue; + } + + // Skip the imported file if it is one of the files that lead to the importing model, i.e. if there is an + // import cycle. + + auto [isLocalFile, fileNameOrUrl] {retrieveFileInfo(*resolvedUrl)}; + + if (std::ranges::find(importingModel.fileNamesOrUrls, fileNameOrUrl) != importingModel.fileNamesOrUrls.end()) { + continue; + } + + // Retrieve the imported model, skipping it if we cannot. + + auto model {retrieveImportedModel(importedFileContentsCache, isLocalFile, fileNameOrUrl, pImporter->isStrict(), pErrors)}; + + if (model == nullptr) { + continue; + } + + // Add the imported model to the library of the importer and keep track of the files that lead to it, so + // that we can retrieve the models that it imports. + + pImporter->addModel(model, key); + + auto fileNamesOrUrls {importingModel.fileNamesOrUrls}; + + fileNamesOrUrls.push_back(fileNameOrUrl); + + importingModels.push({model, importingModel.keyBase + pathFromUrl(url), + fileLocation(isLocalFile, fileNameOrUrl), std::move(fileNamesOrUrls)}); + } + } +} + } // namespace CellmlFile::Impl::Impl(const FilePtr &pFile, const libcellml::ModelPtr &pModel, bool pStrict) @@ -48,13 +319,7 @@ CellmlFile::Impl::Impl(const FilePtr &pFile, const libcellml::ModelPtr &pModel, // Resolve imports and flatten the model, if needed and possible. if (mModel->hasUnresolvedImports()) { - auto importer {libcellml::Importer::create(pStrict)}; - - if (importer->resolveImports(mModel, pathToString(stringToPath(pFile->path()).parent_path()))) { - mModel = importer->flattenModel(mModel); - } else { - addIssues(importer, "Importer"); - } + resolveImports(pFile, pStrict); } // Analyse the model. @@ -71,6 +336,30 @@ CellmlFile::Impl::Impl(const FilePtr &pFile, const libcellml::ModelPtr &pModel, } } +void CellmlFile::Impl::resolveImports(const FilePtr &pFile, bool pStrict) +{ + // Resolve the imports of our model and flatten it, if possible. + + auto importer {libcellml::Importer::create(pStrict)}; + auto isLocalFile {pFile->url().empty()}; + const auto &fileNameOrUrl {isLocalFile ? pFile->fileName() : pFile->url()}; + auto location {fileLocation(isLocalFile, fileNameOrUrl)}; + + Strings errors; + + populateImporterLibrary(importer, mModel, location, fileNameOrUrl, errors); + + for (const auto &error : errors) { + addError("Importer: " + error); + } + + if (importer->resolveImports(mModel, location.location)) { + mModel = importer->flattenModel(mModel); + } else { + addIssues(importer, "Importer"); + } +} + void CellmlFile::Impl::populateDocument(const SedDocumentPtr &pDocument) const { // Add a model. diff --git a/src/support/cellml/cellmlfile_p.h b/src/support/cellml/cellmlfile_p.h index 34f0c3100..c92832b0e 100644 --- a/src/support/cellml/cellmlfile_p.h +++ b/src/support/cellml/cellmlfile_p.h @@ -34,6 +34,8 @@ class CellmlFile::Impl: public Logger::Impl explicit Impl(const FilePtr &pFile, const libcellml::ModelPtr &pModel, bool pStrict); + void resolveImports(const FilePtr &pFile, bool pStrict); + void populateDocument(const SedDocumentPtr &pDocument) const; libcellml::AnalyserModel::Type type() const; diff --git a/src/support/combine/combinearchive.cpp b/src/support/combine/combinearchive.cpp index 2dad14d84..4343c41d6 100644 --- a/src/support/combine/combinearchive.cpp +++ b/src/support/combine/combinearchive.cpp @@ -74,6 +74,9 @@ CombineArchive::Impl::Impl(const FilePtr &pFile, libcombine::CombineArchive *pAr FilePtr cellmlFile; FilePtr sedmlFile; std::unordered_map fileIndexes; + Strings fileFormats; + + fileFormats.reserve(fileCount); for (int i {0}; i < mArchive->getNumEntries(); ++i) { const auto *entry {mArchive->getEntry(i)}; @@ -181,25 +184,36 @@ CombineArchive::Impl::Impl(const FilePtr &pFile, libcombine::CombineArchive *pAr contents = mArchive->extractEntryToBuffer(location); } + // Note: we only set the contents of the file for now, i.e. we don't check its type yet (see below). + auto file {File::create(fileNameOrUrl)}; - file->setContents(contents); + file->pimpl()->setContents(contents); mFiles.push_back(file); mFileNames.push_back(location); + fileFormats.push_back(entry->getFormat()); if (entry->getMaster()) { mMasterFile = file; } + } + + // Determine the type of each file using its contents or, if they are not recognised (e.g., malformed XML), using + // the format specified in the manifest. This means that a broken SED-ML file is still considered to be a SED-ML + // file and, as such, cannot be silently ignored in favour of a CellML file. + // Note: we do this only once the contents of all the files have been set since a CellML file may import another + // CellML file from the COMBINE archive, and that file may come after it in the manifest. + + for (size_t i {0}; i < mFiles.size(); ++i) { + const auto &file {mFiles[i]}; - // Determine the type of the file using its contents or, if they are not recognised (e.g., malformed XML), using - // the format specified in the manifest. This means that a broken SED-ML file is still considered to be a SED-ML - // file and, as such, cannot be silently ignored in favour of a CellML file. + file->pimpl()->checkType(file, true); auto fileType {file->type()}; if (fileType == File::Type::UNKNOWN_FILE) { - const auto &format {entry->getFormat()}; + const auto &format {fileFormats[i]}; if (isCellmlFormat(format)) { fileType = File::Type::CELLML_FILE; diff --git a/src/support/sedml/sedmlfile.cpp b/src/support/sedml/sedmlfile.cpp index 276498f3d..ed017cb2b 100644 --- a/src/support/sedml/sedmlfile.cpp +++ b/src/support/sedml/sedmlfile.cpp @@ -53,47 +53,10 @@ limitations under the License. namespace libOpenCOR { SedmlFile::Impl::Impl(const FilePtr &pFile, libsedml::SedDocument *pDocument) - : mIsLocalLocation(pFile->url().empty()) + : mLocation(fileLocation(pFile->url().empty(), pFile->url().empty() ? pFile->fileName() : pFile->url())) , mDocument(pDocument) , mContents(toString(pFile->contents())) { - // Determine the location of the SED-ML file, i.e. what a relative model source is relative to. - // Note: for a remote SED-ML file, we must not use std::filesystem::path since its separators are platform specific - // (e.g., "https://example.com/dir" would become "https:\\example.com\dir" on Windows) and since the query - // and/or fragment of the URL, if any, may contain forward slashes. - - const auto &url {pFile->url()}; - - if (mIsLocalLocation) { - mFileName = pFile->fileName(); - mLocation = pathToString(stringToPath(mFileName).parent_path()); - - if (!mLocation.empty()) { - mLocation += "/"; - } - } else { - // Note #1: the URL may have no path (e.g., "https://example.com?a=b"), in which case its location is its root - // (e.g., "https://example.com/"). - // Note #2: we also keep track of the scheme (e.g., "https:") and origin (e.g., "https://example.com") of the - // URL, as well as of the URL without its query and fragment (e.g., - // "https://example.com/dir/file.sedml") and without its fragment (e.g., - // "https://example.com/dir/file.sedml?a=b"), which a network-path, an absolute-path, a query-only, and - // a fragment-only model source, respectively, are relative to (see populateDocument()). - - static constexpr std::string_view SCHEME_SEPARATOR {"://"}; - - mUrlWithoutQueryAndFragment = url.substr(0, url.find_first_of("?#")); - mUrlWithoutFragment = url.substr(0, url.find('#')); - - auto schemeSeparatorPos {mUrlWithoutQueryAndFragment.find(SCHEME_SEPARATOR)}; - auto pathPos {mUrlWithoutQueryAndFragment.find('/', schemeSeparatorPos + SCHEME_SEPARATOR.size())}; - - mLocation = (pathPos == std::string::npos) ? - mUrlWithoutQueryAndFragment + "/" : - mUrlWithoutQueryAndFragment.substr(0, mUrlWithoutQueryAndFragment.rfind('/') + 1); - mUrlScheme = mUrlWithoutQueryAndFragment.substr(0, schemeSeparatorPos + 1); - mUrlOrigin = mUrlWithoutQueryAndFragment.substr(0, pathPos); - } } SedmlFile::Impl::~Impl() @@ -387,39 +350,13 @@ void SedmlFile::Impl::populateDocument(const SedDocumentPtr &pDocument) for (unsigned int i {0}; i < mDocument->getNumModels(); ++i) { auto source {mDocument->getModel(i)->getSource()}; - // Note #1: a relative model source is relative to the SED-ML file, not to the current working directory, so we - // only canonicalise it once we have resolved it against the location of the SED-ML file (see - // FileManager::file() and File::create()). - // Note #2: a model source is a URI reference (see https://www.rfc-editor.org/rfc/rfc3986#section-4.1), so it - // may be percent-encoded (e.g., "my%20model.cellml"). If the SED-ML file is a local file and the model - // source is a file name (i.e. neither a URL nor a "file" URL, which path is decoded by - // retrieveFileInfo()) then we must decode the model source to get the file name of the model. We must, - // however, do this after having checked whether the model source is relative since a percent-encoded - // forward slash (i.e. "%2F") must not make a relative model source absolute. If the SED-ML file is a - // remote file then a relative model source is decoded as part of the URL of the model (see - // retrieveFileInfo()). - // Note #3: if the SED-ML file is a remote file then a model source that is a file name is never a local file - // name, but a reference that is relative to the URL of the SED-ML file, i.e. a network-path reference - // (e.g., "//example.org/models/model.cellml") is relative to its scheme, an absolute-path reference - // (e.g., "/models/model.cellml") to its origin, a query-only reference (e.g., "?model=1") to its URL - // without its query and fragment, a fragment-only reference (e.g., "#model") to its URL without its - // fragment, and a relative-path reference (e.g., "../models/model.cellml") to its location (see - // https://www.rfc-editor.org/rfc/rfc3986#section-5.2.2). - // Note #4: a model source with a scheme other than "http", "https", or "file" (e.g., - // "urn:miriam:biomodels.db:BIOMD0000000012") is an absolute URI (see - // https://www.rfc-editor.org/rfc/rfc3986#section-4.3), not a reference that is relative to the SED-ML - // file. We cannot retrieve such a model, so we ignore it. - // Note #5: if the SED-ML file is a local file and the model source is a file name then the query and fragment - // of the model source (e.g., "?a=b" and "#c" in "model.cellml?a=b#c") are not part of its path, so we - // ignore them (see retrieveFileInfo()). A query-only or fragment-only model source (e.g., "?model=1" - // or "#model") therefore refers to the SED-ML file itself (see - // https://www.rfc-editor.org/rfc/rfc3986#section-5.2.2). - - auto [isLocalFile, fileNameOrUrl] {retrieveFileInfo(source, false)}; - auto isRelativeFileName {isLocalFile && !stringToPath(fileNameOrUrl).has_root_directory()}; - auto isFileName {isLocalFile && (fileNameOrUrl == source)}; - - if (isFileName && hasScheme(source)) { + // Note: a model source is a URI reference that is relative to the SED-ML file (see resolveUriReference()). A + // model source with a scheme other than "http", "https", or "file" (e.g., + // "urn:miriam:biomodels.db:BIOMD0000000012") cannot be retrieved, so we ignore it. + + auto resolvedSource {resolveUriReference(mLocation, source)}; + + if (!resolvedSource.has_value()) { std::string warning; warning.reserve(source.size() + 64); // NOLINT @@ -433,29 +370,8 @@ void SedmlFile::Impl::populateDocument(const SedDocumentPtr &pDocument) continue; } - if (mIsLocalLocation && isFileName) { - fileNameOrUrl = decodeUrl(fileNameOrUrl.substr(0, fileNameOrUrl.find_first_of("?#"))); - } + const auto &modelSource {*resolvedSource}; - std::string modelSource; - - if (!mIsLocalLocation && isFileName) { - if (source.starts_with("//")) { - modelSource = mUrlScheme + source; - } else if (source.starts_with('/')) { - modelSource = mUrlOrigin + source; - } else if (source.starts_with('?')) { - modelSource = mUrlWithoutQueryAndFragment + source; - } else if (source.starts_with('#')) { - modelSource = mUrlWithoutFragment + source; - } else { - modelSource = mLocation + source; - } - } else if (mIsLocalLocation && isFileName && fileNameOrUrl.empty()) { - modelSource = mFileName; - } else { - modelSource = isRelativeFileName ? mLocation + fileNameOrUrl : fileNameOrUrl; - } #ifdef __EMSCRIPTEN__ const auto &file {fileManager.fileFromFileNameOrUrl(modelSource)}; #else diff --git a/src/support/sedml/sedmlfile_p.h b/src/support/sedml/sedmlfile_p.h index cbc6ce139..65f1c9c25 100644 --- a/src/support/sedml/sedmlfile_p.h +++ b/src/support/sedml/sedmlfile_p.h @@ -36,13 +36,7 @@ struct NlaSolverInfo class SedmlFile::Impl: public Logger::Impl { public: - std::string mFileName; - std::string mLocation; - std::string mUrlScheme; - std::string mUrlOrigin; - std::string mUrlWithoutQueryAndFragment; - std::string mUrlWithoutFragment; - bool mIsLocalLocation; + FileLocation mLocation; libsedml::SedDocument *mDocument; std::string mContents; diff --git a/tests/api/sed/instancetests.cpp b/tests/api/sed/instancetests.cpp index 4e1c84479..a9b0ce2a7 100644 --- a/tests/api/sed/instancetests.cpp +++ b/tests/api/sed/instancetests.cpp @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +#include "utils.h" + #include "tests/utils.h" #include @@ -70,6 +72,137 @@ TEST(InstanceSedTest, cellmlFileWithInvalidImports) EXPECT_EQ(instance->issue(0)->description().substr(0, IMPORTER_ERROR_START.size()), IMPORTER_ERROR_START); } +TEST(InstanceSedTest, cellmlFileWithImports) +{ + // Note: the model is such that dx/dt = -x with x(0) = 1, i.e. x(t) = exp(-t). + + static constexpr auto ABS_TOL {1e-5}; + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("support/cellml/model_with_nested_imports/model.cellml"))}; + auto document {libOpenCOR::SedDocument::create(file)}; + auto instance {document->instantiate()}; + + EXPECT_FALSE(instance->hasIssues()); + + instance->run(); + + EXPECT_FALSE(instance->hasIssues()); + + const auto &instanceTask {instance->tasks()[0]}; + const auto &voi {instanceTask->voi()}; + const auto &state {instanceTask->state(0)}; + + GTEST_ASSERT_GT(voi.size(), 1U); + + for (size_t i {0}; i < voi.size(); ++i) { + EXPECT_NEAR(state[i], std::exp(-voi[i]), ABS_TOL); + } +} + +TEST(InstanceSedTest, cellmlFileWithInMemoryImports) +{ + // Note #1: the model is such that dx/dt = -x with x(0) = 1, i.e. x(t) = exp(-t). + // Note #2: the imported files are only available in memory, so they must be available before the contents of the + // importing file are set. + + static constexpr auto ABS_TOL {1e-5}; + static const std::string BASE_URL {"https://example.com/model_with_nested_imports/"}; + static const std::vector FILE_NAMES {"units.cellml", "components/component.cellml", "model.cellml"}; + + libOpenCOR::FilePtrs files; + + for (const auto &fileName : FILE_NAMES) { + auto file {libOpenCOR::File::create(BASE_URL + fileName, false)}; + + file->setContents(libOpenCOR::fileContents(libOpenCOR::resourcePath("support/cellml/model_with_nested_imports/" + fileName))); + + files.push_back(file); + } + + auto document {libOpenCOR::SedDocument::create(files.back())}; + auto instance {document->instantiate()}; + + EXPECT_FALSE(instance->hasIssues()); + + instance->run(); + + EXPECT_FALSE(instance->hasIssues()); + + const auto &instanceTask {instance->tasks()[0]}; + const auto &voi {instanceTask->voi()}; + const auto &state {instanceTask->state(0)}; + + GTEST_ASSERT_GT(voi.size(), 1U); + + for (size_t i {0}; i < voi.size(); ++i) { + EXPECT_NEAR(state[i], std::exp(-voi[i]), ABS_TOL); + } +} + +TEST(InstanceSedTest, combineArchiveWithCellmlFileWithImports) +{ + // Note: the model is such that dx/dt = -x with x(0) = 1, i.e. x(t) = exp(-t). + + static constexpr auto ABS_TOL {1e-5}; + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("support/combine/model_with_nested_imports.omex"))}; + auto document {libOpenCOR::SedDocument::create(file)}; + auto instance {document->instantiate()}; + + EXPECT_FALSE(instance->hasIssues()); + + instance->run(); + + EXPECT_FALSE(instance->hasIssues()); + + const auto &instanceTask {instance->tasks()[0]}; + const auto &voi {instanceTask->voi()}; + const auto &state {instanceTask->state(0)}; + + GTEST_ASSERT_GT(voi.size(), 1U); + + for (size_t i {0}; i < voi.size(); ++i) { + EXPECT_NEAR(state[i], std::exp(-voi[i]), ABS_TOL); + } +} + +TEST(InstanceSedTest, hh52WithImports) +{ + auto withImportsFile {libOpenCOR::File::create(libOpenCOR::resourcePath("api/file/hh52_with_imports.omex"))}; + auto withImportsDocument {libOpenCOR::SedDocument::create(withImportsFile)}; + auto withImportsInstance {withImportsDocument->instantiate()}; + + auto withoutImportsFile {libOpenCOR::File::create(libOpenCOR::resourcePath("api/file/hh52_without_imports.omex"))}; + auto withoutImportsDocument {libOpenCOR::SedDocument::create(withoutImportsFile)}; + auto withoutImportsInstance {withoutImportsDocument->instantiate()}; + + EXPECT_FALSE(withImportsInstance->hasIssues()); + EXPECT_FALSE(withoutImportsInstance->hasIssues()); + + withImportsInstance->run(); + withoutImportsInstance->run(); + + EXPECT_FALSE(withImportsInstance->hasIssues()); + EXPECT_FALSE(withoutImportsInstance->hasIssues()); + + const auto &withImportsTask {withImportsInstance->tasks()[0]}; + const auto &withoutImportsTask {withoutImportsInstance->tasks()[0]}; + + GTEST_ASSERT_GT(withImportsTask->voi().size(), 1U); + GTEST_ASSERT_EQ(withImportsTask->voi().size(), withoutImportsTask->voi().size()); + GTEST_ASSERT_EQ(withImportsTask->stateCount(), withoutImportsTask->stateCount()); + + for (size_t i {0}; i < withImportsTask->voi().size(); ++i) { + EXPECT_DOUBLE_EQ(withImportsTask->voi()[i], withoutImportsTask->voi()[i]); + } + + for (size_t i {0}; i < withImportsTask->stateCount(); ++i) { + for (size_t j {0}; j < withImportsTask->voi().size(); ++j) { + EXPECT_DOUBLE_EQ(withImportsTask->state(i)[j], withoutImportsTask->state(i)[j]); + } + } +} + TEST(InstanceSedTest, invalidCellmlFile) { static const libOpenCOR::ExpectedIssues EXPECTED_ISSUES {{ @@ -345,18 +478,18 @@ TEST(InstanceSedTest, stopRunResultsHaveNans) const auto &instanceTask {instance->tasks()[0]}; const auto &voi {instanceTask->voi()}; - const auto &state0 {instanceTask->state(0)}; + const auto &state {instanceTask->state(0)}; - EXPECT_EQ(voi.size(), state0.size()); + EXPECT_EQ(voi.size(), state.size()); EXPECT_EQ(voi.size(), SIMULATION_PROPERTY + 1); EXPECT_FALSE(std::isnan(voi[0])); - EXPECT_FALSE(std::isnan(state0[0])); + EXPECT_FALSE(std::isnan(state[0])); size_t nanIndex {voi.size()}; for (size_t i {1}; i < voi.size(); ++i) { - if (std::isnan(state0[i])) { + if (std::isnan(state[i])) { nanIndex = i; break; diff --git a/tests/bindings/javascript/sed.instance.test.js b/tests/bindings/javascript/sed.instance.test.js index 1111840f9..d40690ccd 100644 --- a/tests/bindings/javascript/sed.instance.test.js +++ b/tests/bindings/javascript/sed.instance.test.js @@ -76,6 +76,111 @@ test.describe('Sed instance tests', () => { assert.strictEqual(instance.issue(0).description.startsWith(IMPORTER_ERROR_START), true); }); + test('CellML file with in-memory imports', () => { + // Note #1: the model is such that dx/dt = -x with x(0) = 1, i.e. x(t) = exp(-t). + // Note #2: the imported files are only available in memory, so they must be available before the contents of the + // importing file are set. + + const baseUrl = 'https://example.com/model_with_nested_imports/'; + const files = []; + + for (const fileName of ['units.cellml', 'components/component.cellml', 'model.cellml']) { + const file = new loc.File(baseUrl + fileName); + + file.setContents(utils.fileContents(utils.resourcePath(`support/cellml/model_with_nested_imports/${fileName}`))); + + files.push(file); + } + + const document = new loc.SedDocument(files[files.length - 1]); + const instance = document.instantiate(); + + assert.strictEqual(instance.hasIssues, false); + + instance.run(); + + assert.strictEqual(instance.hasIssues, false); + + const instanceTask = instance.tasks[0]; + const voi = instanceTask.voi; + const state = instanceTask.state(0); + + assert.ok(voi.length > 1); + + for (let i = 0; i < voi.length; ++i) { + assert.ok(Math.abs(state[i] - Math.exp(-voi[i])) < 1e-5); + } + }); + + test('COMBINE archive with CellML file with imports', () => { + // Note: the model is such that dx/dt = -x with x(0) = 1, i.e. x(t) = exp(-t). + + const file = new loc.File(utils.resourcePath('support/combine/model_with_nested_imports.omex')); + + file.setContents(utils.fileContents(file.path)); + + const document = new loc.SedDocument(file); + const instance = document.instantiate(); + + assert.strictEqual(instance.hasIssues, false); + + instance.run(); + + assert.strictEqual(instance.hasIssues, false); + + const instanceTask = instance.tasks[0]; + const voi = instanceTask.voi; + const state = instanceTask.state(0); + + assert.ok(voi.length > 1); + + for (let i = 0; i < voi.length; ++i) { + assert.ok(Math.abs(state[i] - Math.exp(-voi[i])) < 1e-5); + } + }); + + test('HH52 with imports', () => { + const withImportsFile = new loc.File(utils.resourcePath('api/file/hh52_with_imports.omex')); + + withImportsFile.setContents(utils.fileContents(withImportsFile.path)); + + const withImportsDocument = new loc.SedDocument(withImportsFile); + const withImportsInstance = withImportsDocument.instantiate(); + + const withoutImportsFile = new loc.File(utils.resourcePath('api/file/hh52_without_imports.omex')); + + withoutImportsFile.setContents(utils.fileContents(withoutImportsFile.path)); + + const withoutImportsDocument = new loc.SedDocument(withoutImportsFile); + const withoutImportsInstance = withoutImportsDocument.instantiate(); + + assert.strictEqual(withImportsInstance.hasIssues, false); + assert.strictEqual(withoutImportsInstance.hasIssues, false); + + withImportsInstance.run(); + withoutImportsInstance.run(); + + assert.strictEqual(withImportsInstance.hasIssues, false); + assert.strictEqual(withoutImportsInstance.hasIssues, false); + + const withImportsTask = withImportsInstance.tasks[0]; + const withoutImportsTask = withoutImportsInstance.tasks[0]; + + assert.ok(withImportsTask.voi.length > 1); + assert.strictEqual(withImportsTask.voi.length, withoutImportsTask.voi.length); + assert.strictEqual(withImportsTask.stateCount, withoutImportsTask.stateCount); + + for (let i = 0; i < withImportsTask.voi.length; ++i) { + assert.strictEqual(withImportsTask.voi[i], withoutImportsTask.voi[i]); + } + + for (let i = 0; i < withImportsTask.stateCount; ++i) { + for (let j = 0; j < withImportsTask.voi.length; ++j) { + assert.strictEqual(withImportsTask.state(i)[j], withoutImportsTask.state(i)[j]); + } + } + }); + test('Invalid CellML file', () => { const file = new loc.File(utils.resourcePath('error.cellml')); @@ -429,18 +534,18 @@ test.describe('Sed instance tests', () => { const instanceTask = instance.tasks[0]; const voi = instanceTask.voi; - const state0 = instanceTask.state(0); + const state = instanceTask.state(0); - assert.strictEqual(voi.length, state0.length); + assert.strictEqual(voi.length, state.length); assert.strictEqual(voi.length, SIMULATION_PROPERTY + 1); assert.strictEqual(Number.isNaN(voi[0]), false); - assert.strictEqual(Number.isNaN(state0[0]), false); + assert.strictEqual(Number.isNaN(state[0]), false); let nanIndex = voi.length; for (let i = 1; i < voi.length; ++i) { - if (Number.isNaN(state0[i])) { + if (Number.isNaN(state[i])) { nanIndex = i; break; diff --git a/tests/bindings/python/test_sed_instance.py b/tests/bindings/python/test_sed_instance.py index 746a5e12d..d3f4f17f9 100644 --- a/tests/bindings/python/test_sed_instance.py +++ b/tests/bindings/python/test_sed_instance.py @@ -69,6 +69,134 @@ def test_cellml_file_with_invalid_imports(): assert instance.issues[0].description.startswith(importer_error_start) +def test_cellml_file_with_imports(): + # Note: the model is such that dx/dt = -x with x(0) = 1, i.e. x(t) = exp(-t). + + file = loc.File( + utils.resource_path("support/cellml/model_with_nested_imports/model.cellml") + ) + document = loc.SedDocument(file) + instance = document.instantiate() + + assert not instance.has_issues + + instance.run() + + assert not instance.has_issues + + instance_task = instance.tasks[0] + voi = instance_task.voi + state = instance_task.state(0) + + assert len(voi) > 1 + + for i in range(len(voi)): + assert math.isclose(state[i], math.exp(-voi[i]), abs_tol=1e-5) + + +def test_cellml_file_with_in_memory_imports(): + # Note #1: the model is such that dx/dt = -x with x(0) = 1, i.e. x(t) = exp(-t). + # Note #2: the imported files are only available in memory, so they must be available before the contents of the + # importing file are set. + + base_url = "https://example.com/model_with_nested_imports/" + files = [] + + for file_name in ["units.cellml", "components/component.cellml", "model.cellml"]: + file = loc.File(base_url + file_name, False) + + file.contents = utils.text_to_list( + utils.text_file_contents( + utils.resource_path( + "support/cellml/model_with_nested_imports/" + file_name + ) + ) + ) + + files.append(file) + + document = loc.SedDocument(files[-1]) + instance = document.instantiate() + + assert not instance.has_issues + + instance.run() + + assert not instance.has_issues + + instance_task = instance.tasks[0] + voi = instance_task.voi + state = instance_task.state(0) + + assert len(voi) > 1 + + for i in range(len(voi)): + assert math.isclose(state[i], math.exp(-voi[i]), abs_tol=1e-5) + + +def test_combine_archive_with_cellml_file_with_imports(): + # Note: the model is such that dx/dt = -x with x(0) = 1, i.e. x(t) = exp(-t). + + file = loc.File( + utils.resource_path("support/combine/model_with_nested_imports.omex") + ) + document = loc.SedDocument(file) + instance = document.instantiate() + + assert not instance.has_issues + + instance.run() + + assert not instance.has_issues + + instance_task = instance.tasks[0] + voi = instance_task.voi + state = instance_task.state(0) + + assert len(voi) > 1 + + for i in range(len(voi)): + assert math.isclose(state[i], math.exp(-voi[i]), abs_tol=1e-5) + + +def test_hh52_with_imports(): + with_imports_file = loc.File(utils.resource_path("api/file/hh52_with_imports.omex")) + with_imports_document = loc.SedDocument(with_imports_file) + with_imports_instance = with_imports_document.instantiate() + + without_imports_file = loc.File( + utils.resource_path("api/file/hh52_without_imports.omex") + ) + without_imports_document = loc.SedDocument(without_imports_file) + without_imports_instance = without_imports_document.instantiate() + + assert not with_imports_instance.has_issues + assert not without_imports_instance.has_issues + + with_imports_instance.run() + without_imports_instance.run() + + assert not with_imports_instance.has_issues + assert not without_imports_instance.has_issues + + with_imports_task = with_imports_instance.tasks[0] + without_imports_task = without_imports_instance.tasks[0] + + assert len(with_imports_task.voi) > 1 + assert len(with_imports_task.voi) == len(without_imports_task.voi) + assert with_imports_task.state_count == without_imports_task.state_count + + for i in range(len(with_imports_task.voi)): + assert math.isclose(with_imports_task.voi[i], without_imports_task.voi[i]) + + for i in range(with_imports_task.state_count): + for j in range(len(with_imports_task.voi)): + assert math.isclose( + with_imports_task.state(i)[j], + without_imports_task.state(i)[j], + ) + + def test_invalid_cellml_file(): expected_issues = [ [ @@ -353,18 +481,18 @@ def test_stop_run_results_have_nans(): instance_task = instance.tasks[0] voi = instance_task.voi - state0 = instance_task.state(0) + state = instance_task.state(0) - assert len(voi) == len(state0) + assert len(voi) == len(state) assert len(voi) == SIMULATION_PROPERTY + 1 assert not math.isnan(voi[0]) - assert not math.isnan(state0[0]) + assert not math.isnan(state[0]) nan_index = len(voi) for i in range(1, len(voi)): - if math.isnan(state0[i]): + if math.isnan(state[i]): nan_index = i break diff --git a/tests/res/api/file/hh52_with_imports.omex b/tests/res/api/file/hh52_with_imports.omex new file mode 100644 index 000000000..e1e613c31 Binary files /dev/null and b/tests/res/api/file/hh52_with_imports.omex differ diff --git a/tests/res/api/file/hh52_without_imports.omex b/tests/res/api/file/hh52_without_imports.omex new file mode 100644 index 000000000..f0beb6ccc Binary files /dev/null and b/tests/res/api/file/hh52_without_imports.omex differ diff --git a/tests/res/support/cellml/model_with_import_cycle/a.cellml b/tests/res/support/cellml/model_with_import_cycle/a.cellml new file mode 100644 index 000000000..6b419f189 --- /dev/null +++ b/tests/res/support/cellml/model_with_import_cycle/a.cellml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/res/support/cellml/model_with_import_cycle/b.cellml b/tests/res/support/cellml/model_with_import_cycle/b.cellml new file mode 100644 index 000000000..61a6e178e --- /dev/null +++ b/tests/res/support/cellml/model_with_import_cycle/b.cellml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/res/support/cellml/model_with_invalid_cellml_import/model.cellml b/tests/res/support/cellml/model_with_invalid_cellml_import/model.cellml new file mode 100644 index 000000000..a973a8c0d --- /dev/null +++ b/tests/res/support/cellml/model_with_invalid_cellml_import/model.cellml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/res/support/cellml/model_with_invalid_cellml_import/units.cellml b/tests/res/support/cellml/model_with_invalid_cellml_import/units.cellml new file mode 100644 index 000000000..5849c60f5 --- /dev/null +++ b/tests/res/support/cellml/model_with_invalid_cellml_import/units.cellml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/res/support/cellml/model_with_invalid_xml_import.cellml b/tests/res/support/cellml/model_with_invalid_xml_import.cellml new file mode 100644 index 000000000..a157ccead --- /dev/null +++ b/tests/res/support/cellml/model_with_invalid_xml_import.cellml @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/res/support/cellml/model_with_nested_imports/components/component.cellml b/tests/res/support/cellml/model_with_nested_imports/components/component.cellml new file mode 100644 index 000000000..b472201ca --- /dev/null +++ b/tests/res/support/cellml/model_with_nested_imports/components/component.cellml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + time + + x + + + + + + k + + x + + + + + diff --git a/tests/res/support/cellml/model_with_nested_imports/model.cellml b/tests/res/support/cellml/model_with_nested_imports/model.cellml new file mode 100644 index 000000000..f079ad9ee --- /dev/null +++ b/tests/res/support/cellml/model_with_nested_imports/model.cellml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/res/support/cellml/model_with_nested_imports/units.cellml b/tests/res/support/cellml/model_with_nested_imports/units.cellml new file mode 100644 index 000000000..744fd1620 --- /dev/null +++ b/tests/res/support/cellml/model_with_nested_imports/units.cellml @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/res/support/cellml/model_with_null_character_import.cellml b/tests/res/support/cellml/model_with_null_character_import.cellml new file mode 100644 index 000000000..a971f957d --- /dev/null +++ b/tests/res/support/cellml/model_with_null_character_import.cellml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/res/support/cellml/model_with_unsupported_scheme_import.cellml b/tests/res/support/cellml/model_with_unsupported_scheme_import.cellml new file mode 100644 index 000000000..33bfd7090 --- /dev/null +++ b/tests/res/support/cellml/model_with_unsupported_scheme_import.cellml @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/res/support/cellml/model_with_various_imports/model.cellml b/tests/res/support/cellml/model_with_various_imports/model.cellml new file mode 100644 index 000000000..48036fcd7 --- /dev/null +++ b/tests/res/support/cellml/model_with_various_imports/model.cellml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/res/support/cellml/model_with_various_imports/other_component.cellml b/tests/res/support/cellml/model_with_various_imports/other_component.cellml new file mode 100644 index 000000000..46c56fa5e --- /dev/null +++ b/tests/res/support/cellml/model_with_various_imports/other_component.cellml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + time + + y + + + + + + k + + y + + + + + diff --git a/tests/res/support/combine/model_with_nested_imports.omex b/tests/res/support/combine/model_with_nested_imports.omex new file mode 100644 index 000000000..b0ae9f6de Binary files /dev/null and b/tests/res/support/combine/model_with_nested_imports.omex differ diff --git a/tests/support/cellml/basictests.cpp b/tests/support/cellml/basictests.cpp index bf2aae5eb..b42b1dd2a 100644 --- a/tests/support/cellml/basictests.cpp +++ b/tests/support/cellml/basictests.cpp @@ -15,6 +15,7 @@ limitations under the License. */ #include "cellmlfile.h" +#include "utils.h" #include "tests/utils.h" @@ -42,6 +43,172 @@ TEST(BasicCellmlTest, modelWithValidImports) auto cellmlFile = libOpenCOR::CellmlFile::create(file); EXPECT_FALSE(cellmlFile->hasIssues()); + EXPECT_EQ(cellmlFile->type(), libcellml::AnalyserModel::Type::ODE); +} + +TEST(BasicCellmlTest, modelWithValidRemoteImports) +{ + auto file {libOpenCOR::File::create(std::string(libOpenCOR::REMOTE_BASE_PATH) + "/support/cellml/model_with_valid_imports/model.cellml")}; + auto cellmlFile {libOpenCOR::CellmlFile::create(file)}; + + EXPECT_FALSE(cellmlFile->hasIssues()); + EXPECT_EQ(cellmlFile->type(), libcellml::AnalyserModel::Type::ODE); +} + +TEST(BasicCellmlTest, modelWithNestedImports) +{ + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("support/cellml/model_with_nested_imports/model.cellml"))}; + auto cellmlFile {libOpenCOR::CellmlFile::create(file)}; + + EXPECT_FALSE(cellmlFile->hasIssues()); + EXPECT_EQ(cellmlFile->type(), libcellml::AnalyserModel::Type::ODE); +} + +TEST(BasicCellmlTest, modelWithNestedImportsInCombineArchive) +{ + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("support/combine/model_with_nested_imports.omex"))}; + + EXPECT_FALSE(file->hasIssues()); + EXPECT_EQ(file->childFileCount(), 3U); + + auto cellmlFile {libOpenCOR::CellmlFile::create(file->childFile("model.cellml"))}; + + EXPECT_FALSE(cellmlFile->hasIssues()); + EXPECT_EQ(cellmlFile->type(), libcellml::AnalyserModel::Type::ODE); +} + +TEST(BasicCellmlTest, modelWithInMemoryImports) +{ + static const std::string BASE_URL {"https://example.com/model_with_nested_imports/"}; + static const std::vector FILE_NAMES {"units.cellml", "components/component.cellml", "model.cellml"}; + + libOpenCOR::FilePtrs files; + + for (const auto &fileName : FILE_NAMES) { + auto file {libOpenCOR::File::create(BASE_URL + fileName, false)}; + + file->setContents(libOpenCOR::fileContents(libOpenCOR::resourcePath("support/cellml/model_with_nested_imports/" + fileName))); + + files.push_back(file); + } + + auto cellmlFile {libOpenCOR::CellmlFile::create(files.back())}; + + EXPECT_FALSE(cellmlFile->hasIssues()); + EXPECT_EQ(cellmlFile->type(), libcellml::AnalyserModel::Type::ODE); +} + +TEST(BasicCellmlTest, modelWithMissingInMemoryImports) +{ + static const libOpenCOR::ExpectedIssues EXPECTED_ISSUES {{ + {libOpenCOR::Issue::Type::ERROR, "Importer: the attempt to resolve imports with the model at 'https://raw.githubusercontent.com/opencor/libopencor/master/tests/res/support/cellml/missing_imports/components/component.cellml' failed: the file could not be opened."}, + {libOpenCOR::Issue::Type::ERROR, "Analyser: the type of variable 'time' in component 'decay' is unknown."}, + }}; + + auto file {libOpenCOR::File::create(std::string(libOpenCOR::REMOTE_BASE_PATH) + "/support/cellml/missing_imports/model.cellml", false)}; + + file->setContents(libOpenCOR::fileContents(libOpenCOR::resourcePath("support/cellml/model_with_nested_imports/model.cellml"))); + + auto cellmlFile {libOpenCOR::CellmlFile::create(file)}; + + EXPECT_EQ_ISSUES(cellmlFile, EXPECTED_ISSUES); +} + +TEST(BasicCellmlTest, modelWithManagedImportsWithNoContents) +{ + static const std::vector FILE_NAMES {"units.cellml", "components/component.cellml"}; + + libOpenCOR::FilePtrs files; + + for (const auto &fileName : FILE_NAMES) { + files.push_back(libOpenCOR::File::create(libOpenCOR::resourcePath("support/cellml/model_with_nested_imports/" + fileName), false)); + } + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("support/cellml/model_with_nested_imports/model.cellml"))}; + auto cellmlFile {libOpenCOR::CellmlFile::create(file)}; + + EXPECT_FALSE(cellmlFile->hasIssues()); + EXPECT_EQ(cellmlFile->type(), libcellml::AnalyserModel::Type::ODE); +} + +TEST(BasicCellmlTest, modelWithVariousImports) +{ + // A model that imports: + // - some units from a file in another directory, i.e. "../model_with_nested_imports/units.cellml", and this twice + // (using two different import elements); + // - a component from a file in another directory, which itself imports some units from the same file as above, + // albeit using a different URL, i.e. "../units.cellml"; + // - a component from a file in the same directory, which itself imports some units from the same file as above, + // using the same URL as above; and + // - a component from a remote file, using an absolute URL. + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("support/cellml/model_with_various_imports/model.cellml"))}; + auto cellmlFile {libOpenCOR::CellmlFile::create(file)}; + + EXPECT_FALSE(cellmlFile->hasIssues()); + EXPECT_EQ(cellmlFile->type(), libcellml::AnalyserModel::Type::ODE); +} + +TEST(BasicCellmlTest, modelWithUnsupportedSchemeImport) +{ + // We cannot retrieve a file using a scheme other than "http", "https", or "file". + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("support/cellml/model_with_unsupported_scheme_import.cellml"))}; + auto cellmlFile {libOpenCOR::CellmlFile::create(file)}; + + GTEST_ASSERT_EQ(cellmlFile->issueCount(), 1U); + EXPECT_EQ(cellmlFile->issue(0)->type(), libOpenCOR::Issue::Type::ERROR); + EXPECT_TRUE(cellmlFile->issue(0)->description().ends_with("urn:miriam:biomodels.db:BIOMD0000000012' failed: the file could not be opened.")); +} + +TEST(BasicCellmlTest, modelWithInvalidXmlImport) +{ + // We don't add an imported model that is not valid XML to the library of the importer, so libCellML reports it. + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("support/cellml/model_with_invalid_xml_import.cellml"))}; + auto cellmlFile {libOpenCOR::CellmlFile::create(file)}; + + GTEST_ASSERT_EQ(cellmlFile->issueCount(), 1U); + EXPECT_EQ(cellmlFile->issue(0)->type(), libOpenCOR::Issue::Type::ERROR); + EXPECT_TRUE(cellmlFile->issue(0)->description().ends_with("unknown_file.txt' failed: the file is not valid XML.")); +} + +TEST(BasicCellmlTest, modelWithInvalidCellmlImport) +{ + // An imported model that is valid XML, but not valid CellML (it has an unknown element), is added to the library of + // the importer, so that its imports can be resolved, but its errors are reported. + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("support/cellml/model_with_invalid_cellml_import/model.cellml"))}; + auto cellmlFile {libOpenCOR::CellmlFile::create(file)}; + + GTEST_ASSERT_EQ(cellmlFile->issueCount(), 1U); + EXPECT_EQ(cellmlFile->issue(0)->type(), libOpenCOR::Issue::Type::ERROR); + EXPECT_TRUE(cellmlFile->issue(0)->description().starts_with("Importer: the model at '")); + EXPECT_TRUE(cellmlFile->issue(0)->description().ends_with("units.cellml' is invalid: Model 'units' has an invalid child element 'unknown_element'.")); +} + +TEST(BasicCellmlTest, modelWithNullCharacterImport) +{ + // A percent-encoded null character must not result in "units.cellml" being imported (see decodeUrl()). + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("support/cellml/model_with_null_character_import.cellml"))}; + auto cellmlFile {libOpenCOR::CellmlFile::create(file)}; + + GTEST_ASSERT_EQ(cellmlFile->issueCount(), 1U); + EXPECT_EQ(cellmlFile->issue(0)->type(), libOpenCOR::Issue::Type::ERROR); + EXPECT_TRUE(cellmlFile->issue(0)->description().ends_with("units.cellml%00.txt' failed: the file could not be opened.")); +} + +TEST(BasicCellmlTest, modelWithImportCycle) +{ + // We must not end up in an infinite loop when retrieving the imported models, and libCellML reports the cycle. + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("support/cellml/model_with_import_cycle/a.cellml"))}; + auto cellmlFile {libOpenCOR::CellmlFile::create(file)}; + + GTEST_ASSERT_EQ(cellmlFile->issueCount(), 1U); + EXPECT_EQ(cellmlFile->issue(0)->type(), libOpenCOR::Issue::Type::ERROR); + EXPECT_TRUE(cellmlFile->issue(0)->description().starts_with("Importer: cyclic dependencies were found when attempting to resolve a component in the model 'a'.")); } TEST(BasicCellmlTest, modelWithInvalidImports)