Repository: ResearchObject/ro-crate-py
Pull request #38 (2020-12-07 16:29:06)

Test metadata
This PR adds support for a test-oriented extension of Workflow RO-Crates called [Workflow Testing RO-Crate](https://github.com/crs4/life_monitor/wiki/Workflow-Testing-RO-Crate), based on the ["test" namespace](https://github.com/ResearchObject/ro-terms/blob/master/test/vocabulary.csv) recently added to RO-Terms.

It includes:

* new model classes for the test-specific entities
* a new test crate with workflow tests and new unit tests to check the model extension on it
* an example that shows how to use the library to read test metadata and drive a simple test execution

This PR is meant to complement the new Workflow Testing RO-Crate spec with a concrete implementation and runnable tests and examples. Feedback on both this PR and the spec would be very useful. I've opened an issue on the Life Monitor repo for [Feedback on the spec](https://github.com/crs4/life_monitor/issues/51).


Patch:
diff --git a/rocrate/model/entity.py b/rocrate/model/entity.py
index 55d2738..7b75e77 100644
--- a/rocrate/model/entity.py
+++ b/rocrate/model/entity.py
@@ -87,7 +87,7 @@ class Entity(object):
             return return_list
         if isinstance(value, dict) and value['@id']:  # its a reference
             obj = self.crate.dereference(value['@id'])
-            return obj
+            return obj if obj else value['@id']
         return value
 
     def auto_reference(self, value):
diff --git a/rocrate/model/softwareapplication.py b/rocrate/model/softwareapplication.py
new file mode 100644
index 0000000..ddc6445
--- /dev/null
+++ b/rocrate/model/softwareapplication.py
@@ -0,0 +1,52 @@
+# Copyright 2019-2020 The University of Manchester, UK
+# Copyright 2020 Vlaams Instituut voor Biotechnologie (VIB), BE
+# Copyright 2020 Barcelona Supercomputing Center (BSC), ES
+# Copyright 2020 Center for Advanced Studies, Research and Development in Sardinia (CRS4), IT
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from .contextentity import ContextEntity
+from .creativework import CreativeWork
+
+
+class SoftwareApplication(ContextEntity, CreativeWork):
+
+    def _empty(self):
+        return {
+            "@id": self.id,
+            "@type": 'SoftwareApplication'
+        }
+
+    @property
+    def name(self):
+        return self["name"]
+
+    @name.setter
+    def name(self, name):
+        self["name"] = name
+
+    @property
+    def url(self):
+        return self["url"]
+
+    @url.setter
+    def url(self, url):
+        self["url"] = url
+
+    @property
+    def version(self):
+        return self["version"]
+
+    @version.setter
+    def version(self, version):
+        self["version"] = version
diff --git a/rocrate/rocrate.py b/rocrate/rocrate.py
index f718fb6..2eccc45 100644
--- a/rocrate/rocrate.py
+++ b/rocrate/rocrate.py
@@ -36,12 +36,16 @@ from .model.person import Person
 from .model.dataset import Dataset
 from .model.metadata import Metadata, LegacyMetadata
 from .model.preview import Preview
+from .model.testdefinition import TestDefinition
 
-
-from arcp import generate
+# Imports for the __subclasses__ hack below
+from .model.testinstance import TestInstance  # noqa
+from .model.testservice import TestService  # noqa
+from .model.softwareapplication import SoftwareApplication  # noqa
+from .model.testsuite import TestSuite  # noqa
 
 
-TEST_METADATA_BASENAME = "test-metadata.json"
+from arcp import generate
 
 
 class ROCrate():
@@ -178,16 +182,18 @@ class ROCrate():
                             if isinstance(entity['@type'], list)
                             else [entity['@type']])
             if 'File' in entity_types:
+                # temporary workaround, should be handled in the general case
+                cls = TestDefinition if "TestDefinition" in entity_types else File
                 file_path = os.path.join(source, entity['@id'])
                 identifier = entity.pop('@id', None)
                 if os.path.exists(file_path):
                     # referencing a file path relative to crate-root
-                    instance = File(self, file_path, identifier, properties=entity)
+                    instance = cls(self, file_path, identifier, properties=entity)
                 else:
                     # check if it is a valid absolute URI
                     try:
                         requests.get(identifier)
-                        instance = File(self, identifier, properties=entity)
+                        instance = cls(self, identifier, properties=entity)
                     except requests.ConnectionError:
                         print("Source is not a valid URI")
             if 'Dataset' in entity_types:
@@ -329,12 +335,6 @@ class ROCrate():
             return rval
         return None
 
-    @property
-    def test_metadata_path(self):
-        if self.test_dir is None:
-            return None
-        return Path(self.test_dir.filepath()) / TEST_METADATA_BASENAME
-
     def resolve_id(self, relative_id):
         return generate.arcp_random(relative_id.strip('./'), uuid=self.uuid)
 


Test patch:
diff --git a/examples/read_test_metadata.py b/examples/read_test_metadata.py
new file mode 100644
index 0000000..654336e
--- /dev/null
+++ b/examples/read_test_metadata.py
@@ -0,0 +1,100 @@
+# Copyright 2019-2020 The University of Manchester, UK
+# Copyright 2020 Vlaams Instituut voor Biotechnologie (VIB), BE
+# Copyright 2020 Barcelona Supercomputing Center (BSC), ES
+# Copyright 2020 Center for Advanced Studies, Research and Development in Sardinia (CRS4), IT
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""\
+Read test metadata from an RO-Crate and print the information to the console.
+If Planemo is installed (pip install planemo), also run a test.
+"""
+
+import pathlib
+import shutil
+import subprocess
+import tempfile
+
+from rocrate.rocrate import ROCrate
+
+GALAXY_IMG = "bgruening/galaxy-stable:20.05"
+THIS_DIR = pathlib.Path(__file__).absolute().parent
+REPO_DIR = THIS_DIR.parent
+RO_CRATE_DIR = REPO_DIR / "test/test-data/ro-crate-galaxy-sortchangecase"
+PLANEMO = "https://w3id.org/ro/terms/test#PlanemoEngine"
+
+
+def print_suites(crate):
+    print("test suites:")
+    for suite in crate.test_dir["about"]:
+        print(" ", suite.id)
+        print("    workflow:", suite["mainEntity"].id)
+        print("    instances:")
+        for inst in suite.instance:
+            print("     ", inst.id)
+            print("       service:", inst.service.name)
+            print("       url:", inst.url)
+            print("       resource:", inst.resource)
+        print("   definition:")
+        print("     id:", suite.definition.id)
+        engine = suite.definition.engine
+        print("     engine:", engine.name)
+        print("     engine version:", suite.definition.engineVersion)
+
+
+def main():
+
+    wd = pathlib.Path(tempfile.mkdtemp(prefix="ro_crate_py_"))
+    crate_dir = wd / RO_CRATE_DIR.name
+    shutil.copytree(RO_CRATE_DIR, crate_dir)
+    crate = ROCrate(crate_dir)
+    print_suites(crate)
+
+    main_workflow = crate.root_dataset["mainEntity"]
+    print("main workflow:", main_workflow.id)
+
+    try:
+        exe = subprocess.check_output(
+            "command -v planemo", shell=True, universal_newlines=True
+        ).strip()
+    except subprocess.CalledProcessError:
+        print("planemo executable not found, won't try to run tests")
+        return
+    else:
+        print("planemo executable:", exe)
+
+    # run a test suite
+    suite = crate.test_dir["about"][0]
+    def_path = crate_dir / suite.definition.id
+    workflow = suite["mainEntity"]
+    workflow_path = crate_dir / workflow.id
+
+    print("running suite:", suite.id)
+    print("definition path:", def_path)
+    print("workflow:", workflow.id)
+    assert suite.definition.engine.id == PLANEMO
+    new_workflow_path = def_path.parent / workflow_path.name
+    # Planemo expects the test definition in the same dir as the workflow file
+    shutil.copy2(workflow_path, new_workflow_path)
+    cmd = ["planemo", "test", "--engine", "docker_galaxy",
+           "--docker_galaxy_image", GALAXY_IMG, new_workflow_path]
+    print("Running Planemo (this may take a while)")
+    p = subprocess.run(cmd)
+    p.check_returncode()
+    print("OK")
+
+    shutil.rmtree(wd)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/rocrate/model/testdefinition.py b/rocrate/model/testdefinition.py
new file mode 100644
index 0000000..6527cc4
--- /dev/null
+++ b/rocrate/model/testdefinition.py
@@ -0,0 +1,49 @@
+# Copyright 2019-2020 The University of Manchester, UK
+# Copyright 2020 Vlaams Instituut voor Biotechnologie (VIB), BE
+# Copyright 2020 Barcelona Supercomputing Center (BSC), ES
+# Copyright 2020 Center for Advanced Studies, Research and Development in Sardinia (CRS4), IT
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from .file import File
+
+
+class TestDefinition(File):
+
+    def _empty(self):
+        return {
+            "@id": self.id,
+            "@type": 'TestDefinition'
+        }
+
+    @property
+    def _default_type(self):
+        return "TestDefinition"
+
+    @property
+    def engineVersion(self):
+        return self["engineVersion"]
+
+    @engineVersion.setter
+    def engineVersion(self, engineVersion):
+        self["engineVersion"] = engineVersion
+
+    @property
+    def conformsTo(self):
+        return self["conformsTo"]
+
+    @conformsTo.setter
+    def conformsTo(self, conformsTo):
+        self["conformsTo"] = conformsTo
+
+    engine = conformsTo
diff --git a/rocrate/model/testinstance.py b/rocrate/model/testinstance.py
new file mode 100644
index 0000000..4cd1273
--- /dev/null
+++ b/rocrate/model/testinstance.py
@@ -0,0 +1,65 @@
+# Copyright 2019-2020 The University of Manchester, UK
+# Copyright 2020 Vlaams Instituut voor Biotechnologie (VIB), BE
+# Copyright 2020 Barcelona Supercomputing Center (BSC), ES
+# Copyright 2020 Center for Advanced Studies, Research and Development in Sardinia (CRS4), IT
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from .contextentity import ContextEntity
+
+
+class TestInstance(ContextEntity):
+
+    def _empty(self):
+        return {
+            "@id": self.id,
+            "@type": 'TestInstance'
+        }
+
+    @property
+    def _default_type(self):
+        return "TestInstance"
+
+    @property
+    def name(self):
+        return self["name"]
+
+    @name.setter
+    def name(self, name):
+        self["name"] = name
+
+    @property
+    def resource(self):
+        return self["resource"]
+
+    @resource.setter
+    def resource(self, resource):
+        self["resource"] = resource
+
+    @property
+    def runsOn(self):
+        return self["runsOn"]
+
+    @runsOn.setter
+    def runsOn(self, runsOn):
+        self["runsOn"] = runsOn
+
+    @property
+    def url(self):
+        return self["url"]
+
+    @url.setter
+    def url(self, url):
+        self["url"] = url
+
+    service = runsOn
diff --git a/rocrate/model/testservice.py b/rocrate/model/testservice.py
new file mode 100644
index 0000000..4faaabf
--- /dev/null
+++ b/rocrate/model/testservice.py
@@ -0,0 +1,47 @@
+# Copyright 2019-2020 The University of Manchester, UK
+# Copyright 2020 Vlaams Instituut voor Biotechnologie (VIB), BE
+# Copyright 2020 Barcelona Supercomputing Center (BSC), ES
+# Copyright 2020 Center for Advanced Studies, Research and Development in Sardinia (CRS4), IT
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from .contextentity import ContextEntity
+
+
+class TestService(ContextEntity):
+
+    def _empty(self):
+        return {
+            "@id": self.id,
+            "@type": 'TestService'
+        }
+
+    @property
+    def _default_type(self):
+        return "TestService"
+
+    @property
+    def name(self):
+        return self["name"]
+
+    @name.setter
+    def name(self, name):
+        self["name"] = name
+
+    @property
+    def url(self):
+        return self["url"]
+
+    @url.setter
+    def url(self, url):
+        self["url"] = url
diff --git a/rocrate/model/testsuite.py b/rocrate/model/testsuite.py
new file mode 100644
index 0000000..556308a
--- /dev/null
+++ b/rocrate/model/testsuite.py
@@ -0,0 +1,55 @@
+# Copyright 2019-2020 The University of Manchester, UK
+# Copyright 2020 Vlaams Instituut voor Biotechnologie (VIB), BE
+# Copyright 2020 Barcelona Supercomputing Center (BSC), ES
+# Copyright 2020 Center for Advanced Studies, Research and Development in Sardinia (CRS4), IT
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from .contextentity import ContextEntity
+
+
+class TestSuite(ContextEntity):
+
+    def _empty(self):
+        return {
+            "@id": self.id,
+            "@type": 'TestSuite'
+        }
+
+    @property
+    def _default_type(self):
+        return "TestSuite"
+
+    @property
+    def name(self):
+        return self["name"]
+
+    @name.setter
+    def name(self, name):
+        self["name"] = name
+
+    @property
+    def instance(self):
+        return self["instance"]
+
+    @instance.setter
+    def instance(self, instance):
+        self["instance"] = instance
+
+    @property
+    def definition(self):
+        return self["definition"]
+
+    @definition.setter
+    def definition(self, definition):
+        self["definition"] = definition
diff --git a/test/test-data/ro-crate-galaxy-sortchangecase/LICENSE b/test/test-data/ro-crate-galaxy-sortchangecase/LICENSE
new file mode 100644
index 0000000..68c771a
--- /dev/null
+++ b/test/test-data/ro-crate-galaxy-sortchangecase/LICENSE
@@ -0,0 +1,176 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
diff --git a/test/test-data/ro-crate-galaxy-sortchangecase/README.md b/test/test-data/ro-crate-galaxy-sortchangecase/README.md
new file mode 100644
index 0000000..b8e7438
--- /dev/null
+++ b/test/test-data/ro-crate-galaxy-sortchangecase/README.md
@@ -0,0 +1,6 @@
+# Galaxy mini workflow
+
+A tiny Galaxy workflow that sorts tabular lines according to the first column
+and changes the text to upper case.
+
+This RO-Crate is based on an [example from the Life Monitor repository](https://github.com/crs4/life_monitor/tree/8437be177822d0e2b3fed30db094b91c2fc14391/interaction_experiments/data/crates/ro-crate-galaxy-sortchangecase).
diff --git a/test/test-data/ro-crate-galaxy-sortchangecase/ro-crate-metadata.json b/test/test-data/ro-crate-galaxy-sortchangecase/ro-crate-metadata.json
new file mode 100644
index 0000000..2cd8d44
--- /dev/null
+++ b/test/test-data/ro-crate-galaxy-sortchangecase/ro-crate-metadata.json
@@ -0,0 +1,139 @@
+{
+    "@context": [
+        "https://w3id.org/ro/crate/1.1/context",
+        {
+            "TestSuite": "https://w3id.org/ro/terms/test#TestSuite",
+            "TestInstance": "https://w3id.org/ro/terms/test#TestInstance",
+            "TestService": "https://w3id.org/ro/terms/test#TestService",
+            "TestDefinition": "https://w3id.org/ro/terms/test#TestDefinition",
+            "PlanemoEngine": "https://w3id.org/ro/terms/test#PlanemoEngine",
+            "JenkinsService": "https://w3id.org/ro/terms/test#JenkinsService",
+            "TravisService": "https://w3id.org/ro/terms/test#TravisService",
+            "instance": "https://w3id.org/ro/terms/test#instance",
+            "runsOn": "https://w3id.org/ro/terms/test#runsOn",
+            "resource": "https://w3id.org/ro/terms/test#resource",
+            "definition": "https://w3id.org/ro/terms/test#definition",
+            "engineVersion": "https://w3id.org/ro/terms/test#engineVersion"
+        }
+    ],
+    "@graph": [
+        {
+            "@id": "ro-crate-metadata.json",
+            "@type": "CreativeWork",
+            "about": {
+                "@id": "./"
+            },
+            "conformsTo": {
+                "@id": "https://w3id.org/ro/crate/1.1"
+            }
+        },
+        {
+            "@id": "./",
+            "@type": "Dataset",
+            "name": "sort-and-change-case",
+            "description": "sort lines and change text to upper case",
+            "license": "Apache-2.0",
+            "mainEntity": {
+                "@id": "sort-and-change-case.ga"
+            },
+            "hasPart": [
+                {
+                    "@id": "sort-and-change-case.ga"
+                },
+                {
+                    "@id": "test/"
+                },
+                {
+                    "@id": "LICENSE"
+                },
+                {
+                    "@id": "README.md"
+                },
+                {
+                    "@id": "test/test1/sort-and-change-case-test.yml"
+                }
+            ]
+        },
+        {
+            "@id": "sort-and-change-case.ga",
+            "@type": [
+                "File",
+                "SoftwareSourceCode",
+                "ComputationalWorkflow"
+            ],
+            "programmingLanguage": {
+                "@id": "#galaxy"
+            },
+            "name": "sort-and-change-case"
+        },
+        {
+            "@id": "test/",
+            "@type": "Dataset",
+            "about": [
+                {
+                    "@id": "#test1"
+                }
+            ]
+        },
+        {
+            "@id": "LICENSE",
+            "@type": "File"
+        },
+        {
+            "@id": "README.md",
+            "@type": "File"
+        },
+        {
+            "@id": "#galaxy",
+            "@type": "ComputerLanguage",
+            "name": "Galaxy",
+            "identifier": {
+                "@id": "https://galaxyproject.org/"
+            },
+            "url": {
+                "@id": "https://galaxyproject.org/"
+            }
+        },
+        {
+            "@id": "#test1",
+            "name": "test1",
+            "@type": "TestSuite",
+            "mainEntity": {
+                "@id": "sort-and-change-case.ga"
+            },
+            "instance": [
+                {"@id": "#test1_1"}
+            ],
+            "definition": {"@id": "test/test1/sort-and-change-case-test.yml"}
+        },
+        {
+            "@id": "#test1_1",
+            "name": "test1_1",
+            "@type": "TestInstance",
+            "runsOn": {"@id": "https://w3id.org/ro/terms/test#JenkinsService"},
+            "url": "http://example.org/jenkins",
+            "resource": "job/tests/"
+        },
+        {
+            "@id": "test/test1/sort-and-change-case-test.yml",
+            "@type": [
+                "File",
+                "TestDefinition"
+            ],
+            "conformsTo": {"@id": "https://w3id.org/ro/terms/test#PlanemoEngine"},
+            "engineVersion": ">=0.70"
+        },
+        {
+            "@id": "https://w3id.org/ro/terms/test#JenkinsService",
+            "@type": "TestService",
+            "name": "Jenkins",
+            "url": {"@id": "https://www.jenkins.io"}
+        },
+        {
+            "@id": "https://w3id.org/ro/terms/test#PlanemoEngine",
+            "@type": "SoftwareApplication",
+            "name": "Planemo",
+            "url": {"@id": "https://github.com/galaxyproject/planemo"}
+        }
+    ]
+}
diff --git a/test/test-data/ro-crate-galaxy-sortchangecase/sort-and-change-case.ga b/test/test-data/ro-crate-galaxy-sortchangecase/sort-and-change-case.ga
new file mode 100644
index 0000000..5a19996
--- /dev/null
+++ b/test/test-data/ro-crate-galaxy-sortchangecase/sort-and-change-case.ga
@@ -0,0 +1,118 @@
+{
+    "uuid": "e2a8566c-c025-4181-9e90-7ed29d4e4df1",
+    "tags": [],
+    "format-version": "0.1",
+    "name": "sort-and-change-case",
+    "version": 0,
+    "steps": {
+        "0": {
+            "tool_id": null,
+            "tool_version": null,
+            "outputs": [],
+            "workflow_outputs": [],
+            "input_connections": {},
+            "tool_state": "{}",
+            "id": 0,
+            "uuid": "5a36fad2-66c7-4b9e-8759-0fbcae9b8541",
+            "errors": null,
+            "name": "Input dataset",
+            "label": "bed_input",
+            "inputs": [],
+            "position": {
+                "top": 200,
+                "left": 200
+            },
+            "annotation": "",
+            "content_id": null,
+            "type": "data_input"
+        },
+        "1": {
+            "tool_id": "sort1",
+            "tool_version": "1.1.0",
+            "outputs": [
+                {
+                    "type": "input",
+                    "name": "out_file1"
+                }
+            ],
+            "workflow_outputs": [
+                {
+                    "output_name": "out_file1",
+                    "uuid": "8237f71a-bc2a-494e-a63c-09c1e65ef7c8",
+                    "label": "sorted_bed"
+                }
+            ],
+            "input_connections": {
+                "input": {
+                    "output_name": "output",
+                    "id": 0
+                }
+            },
+            "tool_state": "{\"__page__\": null, \"style\": \"\\\"alpha\\\"\", \"column\": \"\\\"1\\\"\", \"__rerun_remap_job_id__\": null, \"column_set\": \"[]\", \"input\": \"{\\\"__class__\\\": \\\"RuntimeValue\\\"}\", \"header_lines\": \"\\\"0\\\"\", \"order\": \"\\\"ASC\\\"\"}",
+            "id": 1,
+            "uuid": "0b6b3cda-c75f-452b-85b1-8ae4f3302ba4",
+            "errors": null,
+            "name": "Sort",
+            "post_job_actions": {},
+            "label": "sort",
+            "inputs": [
+                {
+                    "name": "input",
+                    "description": "runtime parameter for tool Sort"
+                }
+            ],
+            "position": {
+                "top": 200,
+                "left": 420
+            },
+            "annotation": "",
+            "content_id": "sort1",
+            "type": "tool"
+        },
+        "2": {
+            "tool_id": "ChangeCase",
+            "tool_version": "1.0.0",
+            "outputs": [
+                {
+                    "type": "tabular",
+                    "name": "out_file1"
+                }
+            ],
+            "workflow_outputs": [
+                {
+                    "output_name": "out_file1",
+                    "uuid": "c31cd733-dab6-4d50-9fec-b644d162397b",
+                    "label": "uppercase_bed"
+                }
+            ],
+            "input_connections": {
+                "input": {
+                    "output_name": "out_file1",
+                    "id": 1
+                }
+            },
+            "tool_state": "{\"__page__\": null, \"casing\": \"\\\"up\\\"\", \"__rerun_remap_job_id__\": null, \"cols\": \"\\\"c1\\\"\", \"delimiter\": \"\\\"TAB\\\"\", \"input\": \"{\\\"__class__\\\": \\\"RuntimeValue\\\"}\"}",
+            "id": 2,
+            "uuid": "9698bcde-0729-48fe-b88d-ccfb6f6153b4",
+            "errors": null,
+            "name": "Change Case",
+            "post_job_actions": {},
+            "label": "change_case",
+            "inputs": [
+                {
+                    "name": "input",
+                    "description": "runtime parameter for tool Change Case"
+                }
+            ],
+            "position": {
+                "top": 200,
+                "left": 640
+            },
+            "annotation": "",
+            "content_id": "ChangeCase",
+            "type": "tool"
+        }
+    },
+    "annotation": "",
+    "a_galaxy_workflow": "true"
+}
diff --git a/test/test-data/ro-crate-galaxy-sortchangecase/test/test1/input.bed b/test/test-data/ro-crate-galaxy-sortchangecase/test/test1/input.bed
new file mode 100644
index 0000000..f231880
--- /dev/null
+++ b/test/test-data/ro-crate-galaxy-sortchangecase/test/test1/input.bed
@@ -0,0 +1,3 @@
+chr1	66999824	67210768
+chr7	48998526	50489626
+chr1	66874821	66010368
diff --git a/test/test-data/ro-crate-galaxy-sortchangecase/test/test1/output_exp.bed b/test/test-data/ro-crate-galaxy-sortchangecase/test/test1/output_exp.bed
new file mode 100644
index 0000000..21da503
--- /dev/null
+++ b/test/test-data/ro-crate-galaxy-sortchangecase/test/test1/output_exp.bed
@@ -0,0 +1,3 @@
+CHR1	66874821	66010368
+CHR1	66999824	67210768
+CHR7	48998526	50489626
diff --git a/test/test-data/ro-crate-galaxy-sortchangecase/test/test1/sort-and-change-case-test.yml b/test/test-data/ro-crate-galaxy-sortchangecase/test/test1/sort-and-change-case-test.yml
new file mode 100644
index 0000000..9ae9ebb
--- /dev/null
+++ b/test/test-data/ro-crate-galaxy-sortchangecase/test/test1/sort-and-change-case-test.yml
@@ -0,0 +1,8 @@
+- doc: test with a small input
+  job:
+    bed_input:
+      class: File
+      path: input.bed
+  outputs:
+    uppercase_bed:
+      path: output_exp.bed
diff --git a/test/test_read.py b/test/test_read.py
index 4ed4e6b..d98af60 100644
--- a/test/test_read.py
+++ b/test/test_read.py
@@ -19,7 +19,7 @@ import pytest
 import shutil
 from pathlib import Path
 
-from rocrate.rocrate import ROCrate, TEST_METADATA_BASENAME
+from rocrate.rocrate import ROCrate
 
 _URL = ('https://raw.githubusercontent.com/ResearchObject/ro-crate-py/master/'
         'test/test-data/sample_file.txt')
@@ -108,7 +108,6 @@ def test_crate_dir_loading(test_data_dir, tmpdir, helpers, load_preview, from_zi
     assert test_dataset_prop['@id'] == 'test/'
     assert test_dataset_prop['@id'] == test_dataset.id
     assert crate.test_dir is test_dataset
-    assert crate.test_metadata_path == Path("test") / TEST_METADATA_BASENAME
 
     # write the crate in a different directory
     out_path = tmpdir / 'crate_read_out'
diff --git a/test/test_test_metadata.py b/test/test_test_metadata.py
new file mode 100644
index 0000000..8072620
--- /dev/null
+++ b/test/test_test_metadata.py
@@ -0,0 +1,144 @@
+# Copyright 2019-2020 The University of Manchester, UK
+# Copyright 2020 Vlaams Instituut voor Biotechnologie (VIB), BE
+# Copyright 2020 Barcelona Supercomputing Center (BSC), ES
+# Copyright 2020 Center for Advanced Studies, Research and Development in Sardinia (CRS4), IT
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from rocrate.rocrate import ROCrate
+from rocrate.model.testservice import TestService
+from rocrate.model.testinstance import TestInstance
+from rocrate.model.testdefinition import TestDefinition
+from rocrate.model.testsuite import TestSuite
+from rocrate.model.softwareapplication import SoftwareApplication
+
+# Tell pytest these are not test classes (so it doesn't try to collect them)
+TestService.__test__ = False
+TestInstance.__test__ = False
+TestDefinition.__test__ = False
+TestSuite.__test__ = False
+
+
+JENKINS = "https://w3id.org/ro/terms/test#JenkinsService"
+PLANEMO = "https://w3id.org/ro/terms/test#PlanemoEngine"
+
+
+def test_read(test_data_dir, helpers):
+    crate_dir = test_data_dir / 'ro-crate-galaxy-sortchangecase'
+    crate = ROCrate(crate_dir)
+
+    wf_id = 'sort-and-change-case.ga'
+    main_wf = crate.dereference(wf_id)
+    wf_prop = main_wf.properties()
+    assert wf_prop['@id'] == wf_id
+    assert wf_prop['@id'] == main_wf.id
+    assert set(wf_prop['@type']) == helpers.WORKFLOW_TYPES
+
+    test_service = crate.dereference(JENKINS)
+    assert test_service.id == JENKINS
+    assert test_service.type == "TestService"
+    assert test_service.name == "Jenkins"
+    assert test_service.url == "https://www.jenkins.io"
+
+    test_instance = crate.dereference("#test1_1")
+    assert test_instance.id == "#test1_1"
+    assert test_instance.type == "TestInstance"
+    assert test_instance.name == "test1_1"
+    assert test_instance.url == "http://example.org/jenkins"
+    assert test_instance.resource == "job/tests/"
+    assert test_instance.runsOn is test_service
+    assert test_instance.service is test_service
+
+    test_engine = crate.dereference(PLANEMO)
+    assert test_engine.id == PLANEMO
+    assert test_engine.type == "SoftwareApplication"
+    assert test_engine.name == "Planemo"
+    assert test_engine.url == "https://github.com/galaxyproject/planemo"
+
+    def_id = "test/test1/sort-and-change-case-test.yml"
+    test_definition = crate.dereference(def_id)
+    assert test_definition.id == def_id
+    assert set(test_definition.type) == {"File", "TestDefinition"}
+    assert test_definition.conformsTo is test_engine
+    assert test_definition.engine is test_engine
+    assert test_definition.engineVersion == ">=0.70"
+
+    test_suite = crate.dereference("#test1")
+    assert test_suite.id == "#test1"
+    assert test_suite.type == "TestSuite"
+    assert test_suite.name == "test1"
+    assert len(test_suite.instance) == 1
+    assert test_suite.instance[0] is test_instance
+    assert test_suite.definition is test_definition
+    assert test_suite["mainEntity"] is main_wf
+
+    test_dataset = crate.dereference('test/')
+    test_dataset_prop = test_dataset.properties()
+    assert test_dataset_prop['@id'] == 'test/'
+    assert test_dataset_prop['@id'] == test_dataset.id
+    assert crate.test_dir is test_dataset
+    assert set(crate.test_dir["about"]) == {test_suite}
+
+
+def test_create():
+    crate = ROCrate()
+
+    test_service = TestService(crate, identifier=JENKINS)
+    crate._add_context_entity(test_service)
+    test_service.name = "Jenkins"
+    test_service.url = {"@id": "https://www.jenkins.io"}
+    assert test_service.name == "Jenkins"
+    assert test_service.url == "https://www.jenkins.io"
+
+    test_instance = TestInstance(crate, identifier="#foo_instance_1")
+    crate._add_context_entity(test_instance)
+    test_instance.name = "Foo Instance 1"
+    test_instance.url = {"@id": "http://example.org/foo"}
+    test_instance.resource = "job/foobar"
+    test_instance.runsOn = test_service
+    assert test_instance.name == "Foo Instance 1"
+    assert test_instance.url == "http://example.org/foo"
+    assert test_instance.resource == "job/foobar"
+    assert test_instance.runsOn is test_service
+    test_instance.runsOn = None
+    test_instance.service = test_service
+    assert test_instance.service is test_service
+
+    test_engine = SoftwareApplication(crate, identifier=PLANEMO)
+    crate._add_context_entity(test_engine)
+    test_engine.name = "Planemo"
+    test_engine.url = {"@id": "https://github.com/galaxyproject/planemo"}
+    assert test_engine.name == "Planemo"
+    assert test_engine.url == "https://github.com/galaxyproject/planemo"
+
+    test_definition = TestDefinition(crate, dest_path="test/foo/bar.yml")
+    crate._add_context_entity(test_definition)
+    test_definition.conformsTo = test_service
+    assert test_definition.conformsTo is test_service
+    test_definition.conformsTo = None
+    test_definition.engine = test_engine
+    test_definition.engineVersion = ">=0.70"
+    assert test_definition.engine is test_engine
+    assert test_definition.engineVersion == ">=0.70"
+
+    test_suite = TestSuite(crate, "#foosuite")
+    crate._add_data_entity(test_suite)
+    assert test_suite.id == "#foosuite"
+    assert test_suite.type == "TestSuite"
+    test_suite.name = "Foo Suite"
+    test_suite.instance = [test_instance]
+    test_suite.definition = test_definition
+    assert test_suite.name == "Foo Suite"
+    assert len(test_suite.instance) == 1
+    assert test_suite.instance[0] is test_instance
+    assert test_suite.definition is test_definition