From c31499fbb7057f78dd5d25d56441425e1465a5c6 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Tue, 10 Nov 2026 16:45:12 +1300 Subject: [PATCH 1/8] New version. --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From c83c7bab8e03d4abad195b66cb56728e8581d4e5 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 11 Nov 2026 08:55:32 +1300 Subject: [PATCH 2/8] Utils: added fileLocation() and resolveUriReference(), and used them in SedmlFile. --- src/misc/utils.cpp | 113 ++++++++++++++++++++++++++++++++ src/misc/utils.h | 15 +++++ src/support/sedml/sedmlfile.cpp | 102 +++------------------------- src/support/sedml/sedmlfile_p.h | 8 +-- 4 files changed, 138 insertions(+), 100 deletions(-) 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/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; From 84cd6df9317d558093d4d7170ac617903a21e78f Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 11 Nov 2026 10:14:44 +1300 Subject: [PATCH 3/8] CellmlFile: resolve imports from local, remote, and in-memory files. --- src/support/cellml/cellmlfile.cpp | 276 +++++++++++++++++- src/support/cellml/cellmlfile_p.h | 2 + .../components/component.cellml | 31 ++ .../model_with_nested_imports/model.cellml | 12 + .../model_with_nested_imports/units.cellml | 6 + tests/support/cellml/basictests.cpp | 74 +++++ 6 files changed, 394 insertions(+), 7 deletions(-) create mode 100644 tests/res/support/cellml/model_with_nested_imports/components/component.cellml create mode 100644 tests/res/support/cellml/model_with_nested_imports/model.cellml create mode 100644 tests/res/support/cellml/model_with_nested_imports/units.cellml diff --git a/src/support/cellml/cellmlfile.cpp b/src/support/cellml/cellmlfile.cpp index d8654b3ba..800d2602a 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,252 @@ 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) +{ + // 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: the same file may be imported several times (with different keys), so we only retrieve its contents once. + + auto contents {pCache.find(pFileNameOrUrl)}; + + if (contents == pCache.end()) { + contents = pCache.emplace(pFileNameOrUrl, importedFileContents(pIsLocalFile, pFileNameOrUrl)).first; + } + + if (!contents->second.has_value()) { + return nullptr; + } + + auto parser {libcellml::Parser::create(pStrict)}; + auto res {parser->parseModel(*contents->second)}; + + return hasXmlError(parser) ? nullptr : res; +} + +void populateImporterLibrary(const libcellml::ImporterPtr &pImporter, const libcellml::ModelPtr &pModel, + const FileLocation &pLocation, const std::string &pFileNameOrUrl) +{ + // 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: we don't report any issue. If we cannot retrieve or parse an imported model 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. + + 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())}; + + 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 +298,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 +315,24 @@ 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)}; + + populateImporterLibrary(importer, mModel, location, fileNameOrUrl); + + 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/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/support/cellml/basictests.cpp b/tests/support/cellml/basictests.cpp index bf2aae5eb..31cdc57af 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,79 @@ 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, 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, modelWithInvalidImports) From dba17bd28ac562031477ba52b1ef97d665661fb5 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 11 Nov 2026 16:09:45 +1300 Subject: [PATCH 4/8] CombineArchive: set the contents of all files before checking their type. --- src/support/combine/combinearchive.cpp | 24 +++++++++--- tests/api/sed/instancetests.cpp | 35 ++++++++++++++++-- .../bindings/javascript/sed.instance.test.js | 35 ++++++++++++++++-- tests/bindings/python/test_sed_instance.py | 33 +++++++++++++++-- .../combine/model_with_nested_imports.omex | Bin 0 -> 1408 bytes tests/support/cellml/basictests.cpp | 13 +++++++ 6 files changed, 123 insertions(+), 17 deletions(-) create mode 100644 tests/res/support/combine/model_with_nested_imports.omex 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/tests/api/sed/instancetests.cpp b/tests/api/sed/instancetests.cpp index 4e1c84479..0dfe6b059 100644 --- a/tests/api/sed/instancetests.cpp +++ b/tests/api/sed/instancetests.cpp @@ -70,6 +70,33 @@ TEST(InstanceSedTest, cellmlFileWithInvalidImports) EXPECT_EQ(instance->issue(0)->description().substr(0, IMPORTER_ERROR_START.size()), IMPORTER_ERROR_START); } +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, invalidCellmlFile) { static const libOpenCOR::ExpectedIssues EXPECTED_ISSUES {{ @@ -345,18 +372,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..76aaf3cf1 100644 --- a/tests/bindings/javascript/sed.instance.test.js +++ b/tests/bindings/javascript/sed.instance.test.js @@ -76,6 +76,33 @@ test.describe('Sed instance tests', () => { assert.strictEqual(instance.issue(0).description.startsWith(IMPORTER_ERROR_START), true); }); + 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('Invalid CellML file', () => { const file = new loc.File(utils.resourcePath('error.cellml')); @@ -429,18 +456,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..867c9d86a 100644 --- a/tests/bindings/python/test_sed_instance.py +++ b/tests/bindings/python/test_sed_instance.py @@ -69,6 +69,31 @@ def test_cellml_file_with_invalid_imports(): assert instance.issues[0].description.startswith(importer_error_start) +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_invalid_cellml_file(): expected_issues = [ [ @@ -353,18 +378,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/support/combine/model_with_nested_imports.omex b/tests/res/support/combine/model_with_nested_imports.omex new file mode 100644 index 0000000000000000000000000000000000000000..b0ae9f6de86895b8315e5e68b3a8f8860de2c8a6 GIT binary patch literal 1408 zcmWIWW@Zs#U|`^2$g|FhHT3j7zXQlKVPat50n)jNd6{Xc#U*+bxjAbm8uA@7;Boyv zPwA5%leClX%^No!FsPO)1|@}ec3apvHY6-`ZdE`1ss8+=*Kc*%N{>#lo!mM#uF0wB zPNh|}gVkRr*1?Qyl$)Cwz*Lf%L0-tk7;<_hu`*Ov6#8i4M zGJ%@qoviF`XKhJ-^{yV+y#U{7eaw3lF7A|EBek5I(^YZG>#Pc_7&bKlw z-J;dAcLVFDRX6s!KK-{)Hn08H6 zmEcVdf8k>~>4M>x?+QQkd-Wsqbarh#s&{Xmu$b-xm9>s6$}u;t)9p-#RxGr8mC->It}y==5oCEP{eoPMJ4o|@A8A*;Uf&UTr_f86w{N7xtp z6wc{0w2WpOtloLwg!}c|P4=kaW%#jjS1vHTN>~^eq=6|SIX|}`KQA?}q*x!$ga+T# zkb^}{1_FD3i~3YO2noC0ZPl=S-mA5?)`DVZXRvTNet0kKw7`N@#xmu>+v?wCn^pd; z6T7oBDQ-gu+q+ZIyZBCN^v<+lefRzP@z=NRwsOZWF)VTqYGmvTnz&SGsZyWVX(fg9 zrJHmeLPO(P=5W7T`jhikc*W;w`qOsiE>veRP5)GMeEmJ%@~t;>4!C&kDERW&I5u>h zOmpseOEt!iHX5qIt)?4&4x8;rzSjIOZH7EoPmrYW?dJ@PuNl{N{(WVBh%2e*MQ%pX zQp?5eeP-)sI9#e?Tk<(6C~@9}6^1(fb6n@%jA--Lm!EdpI^@f)`y}RV5|1!9GFGkr{^Js{=v6Ha4&$Qr2>u#NnSzJ8T_U23b z3+>O+U!L=>cljdA^h}6Td_QU|-M+M-bs{i!jsr0$-c zn{1z?gjH)DeX%zF!aVPbN4ZYA`K+m{Q{|m|`M@5@WiQLpA0GCeu}32GwHKrJ`*u&a z#d6=HD+6PrP0sP>IL7tyt}{B?P#~fzQFGiPzH?jdW!vc2YrdWKu=h@nox4HzLBQm! z%A{vcC3k!`dh*5J;SkR=k&5|yH@GyX_D)%@;dorvYyYiB+yUN`;p!be-s#8KRGYVM*g7kWQqmjjj_tStE1`0Sg9r%EnCO=mw#OJi?$I%&74Y;LXYg Ql4SwH??Ad3n4=gN02vxGbN~PV literal 0 HcmV?d00001 diff --git a/tests/support/cellml/basictests.cpp b/tests/support/cellml/basictests.cpp index 31cdc57af..2cd54f86e 100644 --- a/tests/support/cellml/basictests.cpp +++ b/tests/support/cellml/basictests.cpp @@ -64,6 +64,19 @@ TEST(BasicCellmlTest, modelWithNestedImports) 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/"}; From 2726bf9e8d8d5f5a6a81c3dc4427167e91e7fecb Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 11 Nov 2026 16:47:25 +1300 Subject: [PATCH 5/8] Tests: added some edge cases for CellML imports. --- .../cellml/model_with_import_cycle/a.cellml | 12 +++ .../cellml/model_with_import_cycle/b.cellml | 12 +++ .../model.cellml | 9 +++ .../units.cellml | 7 ++ .../model_with_invalid_xml_import.cellml | 6 ++ .../model_with_null_character_import.cellml | 9 +++ ...odel_with_unsupported_scheme_import.cellml | 6 ++ .../model_with_various_imports/model.cellml | 29 +++++++ .../other_component.cellml | 31 ++++++++ tests/support/cellml/basictests.cpp | 77 +++++++++++++++++++ 10 files changed, 198 insertions(+) create mode 100644 tests/res/support/cellml/model_with_import_cycle/a.cellml create mode 100644 tests/res/support/cellml/model_with_import_cycle/b.cellml create mode 100644 tests/res/support/cellml/model_with_invalid_cellml_import/model.cellml create mode 100644 tests/res/support/cellml/model_with_invalid_cellml_import/units.cellml create mode 100644 tests/res/support/cellml/model_with_invalid_xml_import.cellml create mode 100644 tests/res/support/cellml/model_with_null_character_import.cellml create mode 100644 tests/res/support/cellml/model_with_unsupported_scheme_import.cellml create mode 100644 tests/res/support/cellml/model_with_various_imports/model.cellml create mode 100644 tests/res/support/cellml/model_with_various_imports/other_component.cellml 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_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/support/cellml/basictests.cpp b/tests/support/cellml/basictests.cpp index 2cd54f86e..732cbd1e4 100644 --- a/tests/support/cellml/basictests.cpp +++ b/tests/support/cellml/basictests.cpp @@ -131,6 +131,83 @@ TEST(BasicCellmlTest, modelWithManagedImportsWithNoContents) 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 still added to the + // library of the importer (and, like libCellML, we don't report an issue for it). + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("support/cellml/model_with_invalid_cellml_import/model.cellml"))}; + auto cellmlFile {libOpenCOR::CellmlFile::create(file)}; + + EXPECT_FALSE(cellmlFile->hasIssues()); +} + +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) { auto file = libOpenCOR::File::create(libOpenCOR::resourcePath("support/cellml/model_with_invalid_imports.cellml")); From 9af023569a4d4f7c76bbaa8cbda7a9a431851b6b Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 11 Nov 2026 17:43:50 +1300 Subject: [PATCH 6/8] CellmlFile: report the errors of an imported model. --- src/support/cellml/cellmlfile.cpp | 49 ++++++++++++++++++++++------- tests/support/cellml/basictests.cpp | 9 ++++-- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/support/cellml/cellmlfile.cpp b/src/support/cellml/cellmlfile.cpp index 800d2602a..4f7a258f3 100644 --- a/src/support/cellml/cellmlfile.cpp +++ b/src/support/cellml/cellmlfile.cpp @@ -186,30 +186,49 @@ struct ImportingModel using ImportedFileContentsCache = std::unordered_map>; libcellml::ModelPtr retrieveImportedModel(ImportedFileContentsCache &pCache, bool pIsLocalFile, - const std::string &pFileNameOrUrl, bool pStrict) + 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: the same file may be imported several times (with different keys), so we only retrieve its contents once. + // 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 (contents == pCache.end()) { + if (firstTime) { contents = pCache.emplace(pFileNameOrUrl, importedFileContents(pIsLocalFile, pFileNameOrUrl)).first; } if (!contents->second.has_value()) { - return nullptr; + return res; } auto parser {libcellml::Parser::create(pStrict)}; - auto res {parser->parseModel(*contents->second)}; - return hasXmlError(parser) ? nullptr : res; + 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) + 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. @@ -224,8 +243,10 @@ void populateImporterLibrary(const libcellml::ImporterPtr &pImporter, const libc // 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: we don't report any issue. If we cannot retrieve or parse an imported model 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. + // 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; @@ -268,7 +289,7 @@ void populateImporterLibrary(const libcellml::ImporterPtr &pImporter, const libc // Retrieve the imported model, skipping it if we cannot. - auto model {retrieveImportedModel(importedFileContentsCache, isLocalFile, fileNameOrUrl, pImporter->isStrict())}; + auto model {retrieveImportedModel(importedFileContentsCache, isLocalFile, fileNameOrUrl, pImporter->isStrict(), pErrors)}; if (model == nullptr) { continue; @@ -324,7 +345,13 @@ void CellmlFile::Impl::resolveImports(const FilePtr &pFile, bool pStrict) const auto &fileNameOrUrl {isLocalFile ? pFile->fileName() : pFile->url()}; auto location {fileLocation(isLocalFile, fileNameOrUrl)}; - populateImporterLibrary(importer, mModel, location, 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); diff --git a/tests/support/cellml/basictests.cpp b/tests/support/cellml/basictests.cpp index 732cbd1e4..b42b1dd2a 100644 --- a/tests/support/cellml/basictests.cpp +++ b/tests/support/cellml/basictests.cpp @@ -175,13 +175,16 @@ TEST(BasicCellmlTest, modelWithInvalidXmlImport) TEST(BasicCellmlTest, modelWithInvalidCellmlImport) { - // An imported model that is valid XML, but not valid CellML (it has an unknown element), is still added to the - // library of the importer (and, like libCellML, we don't report an issue for it). + // 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)}; - EXPECT_FALSE(cellmlFile->hasIssues()); + 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) From 35bfc86e500e3dd8e4af208926c3a09488a7dcf7 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 11 Nov 2026 17:57:35 +1300 Subject: [PATCH 7/8] Tests: added some tests for CellML files with local and in-memory imports. --- tests/api/sed/instancetests.cpp | 69 +++++++++++++++++++ .../bindings/javascript/sed.instance.test.js | 36 ++++++++++ tests/bindings/python/test_sed_instance.py | 65 +++++++++++++++++ 3 files changed, 170 insertions(+) diff --git a/tests/api/sed/instancetests.cpp b/tests/api/sed/instancetests.cpp index 0dfe6b059..e09f33319 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,73 @@ 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). diff --git a/tests/bindings/javascript/sed.instance.test.js b/tests/bindings/javascript/sed.instance.test.js index 76aaf3cf1..30a13b795 100644 --- a/tests/bindings/javascript/sed.instance.test.js +++ b/tests/bindings/javascript/sed.instance.test.js @@ -76,6 +76,42 @@ 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). diff --git a/tests/bindings/python/test_sed_instance.py b/tests/bindings/python/test_sed_instance.py index 867c9d86a..cfdc688cb 100644 --- a/tests/bindings/python/test_sed_instance.py +++ b/tests/bindings/python/test_sed_instance.py @@ -69,6 +69,71 @@ 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). From e058207fc9accd1c0eab6067c22d6b2c97221169 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Wed, 11 Nov 2026 18:21:46 +1300 Subject: [PATCH 8/8] Tests: added a test for the HH52 model with and without imports. --- tests/api/sed/instancetests.cpp | 37 +++++++++++++++ .../bindings/javascript/sed.instance.test.js | 42 ++++++++++++++++++ tests/bindings/python/test_sed_instance.py | 38 ++++++++++++++++ tests/res/api/file/hh52_with_imports.omex | Bin 0 -> 17756 bytes tests/res/api/file/hh52_without_imports.omex | Bin 0 -> 5257 bytes 5 files changed, 117 insertions(+) create mode 100644 tests/res/api/file/hh52_with_imports.omex create mode 100644 tests/res/api/file/hh52_without_imports.omex diff --git a/tests/api/sed/instancetests.cpp b/tests/api/sed/instancetests.cpp index e09f33319..a9b0ce2a7 100644 --- a/tests/api/sed/instancetests.cpp +++ b/tests/api/sed/instancetests.cpp @@ -166,6 +166,43 @@ TEST(InstanceSedTest, combineArchiveWithCellmlFileWithImports) } } +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 {{ diff --git a/tests/bindings/javascript/sed.instance.test.js b/tests/bindings/javascript/sed.instance.test.js index 30a13b795..d40690ccd 100644 --- a/tests/bindings/javascript/sed.instance.test.js +++ b/tests/bindings/javascript/sed.instance.test.js @@ -139,6 +139,48 @@ test.describe('Sed instance tests', () => { } }); + 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')); diff --git a/tests/bindings/python/test_sed_instance.py b/tests/bindings/python/test_sed_instance.py index cfdc688cb..d3f4f17f9 100644 --- a/tests/bindings/python/test_sed_instance.py +++ b/tests/bindings/python/test_sed_instance.py @@ -159,6 +159,44 @@ def test_combine_archive_with_cellml_file_with_imports(): 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 = [ [ 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 0000000000000000000000000000000000000000..e1e613c31b3483740172d6f104f216a468524688 GIT binary patch literal 17756 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 S(B)N literal 0 HcmV?d00001