forked from enviPath/enviPy
[Feature] Minimal IUCLID export (#338)
This is an initial implementation that creates a working minimal .i6z document. It passes schema validation and can be imported into IUCLID. Caveat: IUCLID files target individual compounds. Pathway is not actually covered by the format. It can be added in either soil or water and soil OECD endpoints. **I currently only implemented the soil endpoint for all data.** This sort of works, and I can report all degradation products in a pathway (not a nice view, but we can report many transformation products and add a diagram attachment in the future). Adding additional information is an absolute pain, as we need to explicitly map each type of information to the relevant OECD field. I use the XSD scheme for validation, but unfortunately the IUCLID parser is not fully compliant and requires a specific order, etc. The workflow is: finding the AI structure from the XSD scheme -> make the scheme validation pass -> upload to IUCLID to get obscure error messages -> guess what could be wrong -> repeat 💣 New specifications get released once per year, so we will have to update accordingly. I believe that this should be a more expensive feature, as it requires significant effort to uphold. Currently implemented for root compound only in SOIL: - Soil Texture 2 - Soil Texture 1 - pH value - Half-life per soil sample / scenario (mapped to disappearance; not sure about that). - CEC - Organic Matter (only Carbon) - Moisture content - Humidity <img width="2123" alt="image.png" src="attachments/d29830e1-65ef-4136-8939-1825e0959c62"> <img width="2124" alt="image.png" src="attachments/ac9de2ac-bf68-4ba4-b40b-82f810a9de93"> <img width="2139" alt="image.png" src="attachments/5674c7e6-865e-420e-974a-6b825b331e6c"> Reviewed-on: enviPath/enviPy#338 Co-authored-by: Tobias O <tobias.olenyi@envipath.com> Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
This commit is contained in:
0
epiuclid/tests/__init__.py
Normal file
0
epiuclid/tests/__init__.py
Normal file
102
epiuclid/tests/factories.py
Normal file
102
epiuclid/tests/factories.py
Normal file
@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from epiuclid.serializers.pathway_mapper import (
|
||||
HalfLifeEntry,
|
||||
IUCLIDEndpointStudyRecordData,
|
||||
IUCLIDReferenceSubstanceData,
|
||||
IUCLIDSubstanceData,
|
||||
IUCLIDTransformationProductEntry,
|
||||
SoilPropertiesData,
|
||||
)
|
||||
|
||||
|
||||
def make_substance_data(**overrides) -> IUCLIDSubstanceData:
|
||||
payload = {
|
||||
"uuid": uuid4(),
|
||||
"name": "Atrazine",
|
||||
"reference_substance_uuid": uuid4(),
|
||||
}
|
||||
payload.update(overrides)
|
||||
return IUCLIDSubstanceData(**payload)
|
||||
|
||||
|
||||
def make_reference_substance_data(**overrides) -> IUCLIDReferenceSubstanceData:
|
||||
payload = {
|
||||
"uuid": uuid4(),
|
||||
"name": "Atrazine",
|
||||
"smiles": "CCNc1nc(Cl)nc(NC(C)C)n1",
|
||||
"cas_number": "1912-24-9",
|
||||
"ec_number": "217-617-8",
|
||||
"iupac_name": "6-chloro-N2-ethyl-N4-isopropyl-1,3,5-triazine-2,4-diamine",
|
||||
"molecular_formula": "C8H14ClN5",
|
||||
"molecular_weight": 215.68,
|
||||
"inchi": (
|
||||
"InChI=1S/C8H14ClN5/c1-4-10-7-12-6(9)11-8(13-7)"
|
||||
"14-5(2)3/h5H,4H2,1-3H3,(H2,10,11,12,13,14)"
|
||||
),
|
||||
"inchi_key": "MXWJVTOOROXGIU-UHFFFAOYSA-N",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return IUCLIDReferenceSubstanceData(**payload)
|
||||
|
||||
|
||||
def make_half_life_entry(**overrides) -> HalfLifeEntry:
|
||||
payload = {
|
||||
"model": "SFO",
|
||||
"dt50_start": 12.5,
|
||||
"dt50_end": 15.0,
|
||||
"unit": "d",
|
||||
"source": "Model prediction",
|
||||
"soil_no_code": None,
|
||||
"temperature": None,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return HalfLifeEntry(**payload)
|
||||
|
||||
|
||||
def make_transformation_entry(**overrides) -> IUCLIDTransformationProductEntry:
|
||||
payload = {
|
||||
"uuid": uuid4(),
|
||||
"product_reference_uuid": uuid4(),
|
||||
"parent_reference_uuids": [uuid4()],
|
||||
"kinetic_formation_fraction": 0.42,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return IUCLIDTransformationProductEntry(**payload)
|
||||
|
||||
|
||||
def make_soil_properties_data(**overrides) -> SoilPropertiesData:
|
||||
payload = {
|
||||
"soil_no_code": None,
|
||||
"clay": 20.0,
|
||||
"silt": 30.0,
|
||||
"sand": 50.0,
|
||||
"org_carbon": 1.5,
|
||||
"ph_lower": 6.0,
|
||||
"ph_upper": 7.0,
|
||||
"ph_method": "CaCl2",
|
||||
"cec": 12.0,
|
||||
"moisture_content": 40.0,
|
||||
"soil_type": "LOAM",
|
||||
"soil_classification": "USDA",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return SoilPropertiesData(**payload)
|
||||
|
||||
|
||||
def make_endpoint_study_record_data(**overrides) -> IUCLIDEndpointStudyRecordData:
|
||||
payload = {
|
||||
"uuid": uuid4(),
|
||||
"substance_uuid": uuid4(),
|
||||
"name": "Biodegradation study",
|
||||
"half_lives": [],
|
||||
"temperature": None,
|
||||
"transformation_products": [],
|
||||
"model_name_and_version": [],
|
||||
"software_name_and_version": [],
|
||||
"model_remarks": [],
|
||||
}
|
||||
payload.update(overrides)
|
||||
return IUCLIDEndpointStudyRecordData(**payload)
|
||||
92
epiuclid/tests/test_api.py
Normal file
92
epiuclid/tests/test_api.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""Integration tests for IUCLID export API endpoint."""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from uuid import uuid4
|
||||
|
||||
from django.test import TestCase, tag
|
||||
|
||||
from epdb.logic import PackageManager, UserManager
|
||||
from epdb.models import Node, Pathway
|
||||
|
||||
|
||||
@tag("iuclid")
|
||||
class IUCLIDExportAPITest(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = UserManager.create_user(
|
||||
"iuclid-api-user",
|
||||
"iuclid-api@test.com",
|
||||
"TestPass123",
|
||||
set_setting=False,
|
||||
add_to_group=False,
|
||||
is_active=True,
|
||||
)
|
||||
default_pkg = cls.user.default_package
|
||||
cls.user.default_package = None
|
||||
cls.user.save()
|
||||
default_pkg.delete()
|
||||
|
||||
cls.package = PackageManager.create_package(cls.user, "IUCLID API Test Package")
|
||||
|
||||
cls.pathway = Pathway.create(
|
||||
cls.package,
|
||||
"c1ccccc1",
|
||||
name="Export Test Pathway",
|
||||
)
|
||||
Node.create(cls.pathway, "c1ccc(O)cc1", depth=1, name="Phenol")
|
||||
|
||||
# Unauthorized user
|
||||
cls.other_user = UserManager.create_user(
|
||||
"iuclid-other-user",
|
||||
"other@test.com",
|
||||
"TestPass123",
|
||||
set_setting=False,
|
||||
add_to_group=False,
|
||||
is_active=True,
|
||||
)
|
||||
other_pkg = cls.other_user.default_package
|
||||
cls.other_user.default_package = None
|
||||
cls.other_user.save()
|
||||
other_pkg.delete()
|
||||
|
||||
def test_export_returns_zip(self):
|
||||
self.client.force_login(self.user)
|
||||
url = f"/api/v1/pathway/{self.pathway.uuid}/export/iuclid"
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "application/zip")
|
||||
self.assertIn(".i6z", response["Content-Disposition"])
|
||||
|
||||
self.assertTrue(zipfile.is_zipfile(io.BytesIO(response.content)))
|
||||
|
||||
def test_export_contains_expected_files(self):
|
||||
self.client.force_login(self.user)
|
||||
url = f"/api/v1/pathway/{self.pathway.uuid}/export/iuclid"
|
||||
response = self.client.get(url)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(response.content)) as zf:
|
||||
names = zf.namelist()
|
||||
self.assertIn("manifest.xml", names)
|
||||
i6d_files = [n for n in names if n.endswith(".i6d")]
|
||||
# 2 substances + 2 ref substances + 1 ESR = 5 i6d files
|
||||
self.assertEqual(len(i6d_files), 5)
|
||||
|
||||
def test_anonymous_returns_401(self):
|
||||
self.client.logout()
|
||||
url = f"/api/v1/pathway/{self.pathway.uuid}/export/iuclid"
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_unauthorized_user_returns_403(self):
|
||||
self.client.force_login(self.other_user)
|
||||
url = f"/api/v1/pathway/{self.pathway.uuid}/export/iuclid"
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_nonexistent_pathway_returns_404(self):
|
||||
self.client.force_login(self.user)
|
||||
url = f"/api/v1/pathway/{uuid4()}/export/iuclid"
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
521
epiuclid/tests/test_builders.py
Normal file
521
epiuclid/tests/test_builders.py
Normal file
@ -0,0 +1,521 @@
|
||||
"""Contract tests for IUCLID XML builders - no DB required."""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from uuid import uuid4
|
||||
|
||||
from django.test import SimpleTestCase, tag
|
||||
|
||||
from epiuclid.builders.base import (
|
||||
NS_PLATFORM_CONTAINER,
|
||||
NS_PLATFORM_FIELDS,
|
||||
NS_PLATFORM_METADATA,
|
||||
document_key,
|
||||
)
|
||||
from epiuclid.builders.endpoint_study import DOC_SUBTYPE, EndpointStudyRecordBuilder, NS_ESR_BIODEG
|
||||
from epiuclid.builders.reference_substance import NS_REFERENCE_SUBSTANCE, ReferenceSubstanceBuilder
|
||||
from epiuclid.builders.substance import NS_SUBSTANCE, SubstanceBuilder
|
||||
|
||||
from .factories import (
|
||||
make_endpoint_study_record_data,
|
||||
make_half_life_entry,
|
||||
make_reference_substance_data,
|
||||
make_soil_properties_data,
|
||||
make_substance_data,
|
||||
make_transformation_entry,
|
||||
)
|
||||
from .xml_assertions import assert_xpath_absent, assert_xpath_text
|
||||
|
||||
|
||||
@tag("iuclid")
|
||||
class SubstanceBuilderContractTest(SimpleTestCase):
|
||||
def test_maps_name_and_reference_key(self):
|
||||
reference_uuid = uuid4()
|
||||
data = make_substance_data(name="Atrazine", reference_substance_uuid=reference_uuid)
|
||||
|
||||
root = ET.fromstring(SubstanceBuilder().build(data))
|
||||
|
||||
assert_xpath_text(self, root, f".//{{{NS_SUBSTANCE}}}ChemicalName", "Atrazine")
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_SUBSTANCE}}}ReferenceSubstance/{{{NS_SUBSTANCE}}}ReferenceSubstance",
|
||||
document_key(reference_uuid),
|
||||
)
|
||||
|
||||
def test_omits_reference_substance_when_missing(self):
|
||||
data = make_substance_data(reference_substance_uuid=None)
|
||||
|
||||
root = ET.fromstring(SubstanceBuilder().build(data))
|
||||
|
||||
assert_xpath_absent(self, root, f".//{{{NS_SUBSTANCE}}}ReferenceSubstance")
|
||||
|
||||
def test_sets_substance_document_type(self):
|
||||
data = make_substance_data()
|
||||
|
||||
root = ET.fromstring(SubstanceBuilder().build(data))
|
||||
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f"{{{NS_PLATFORM_CONTAINER}}}PlatformMetadata/{{{NS_PLATFORM_METADATA}}}documentType",
|
||||
"SUBSTANCE",
|
||||
)
|
||||
|
||||
|
||||
@tag("iuclid")
|
||||
class ReferenceSubstanceBuilderContractTest(SimpleTestCase):
|
||||
def test_maps_structural_identifiers_and_mass_precision(self):
|
||||
data = make_reference_substance_data(molecular_weight=215.6)
|
||||
|
||||
root = ET.fromstring(ReferenceSubstanceBuilder().build(data))
|
||||
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_REFERENCE_SUBSTANCE}}}Inventory/{{{NS_REFERENCE_SUBSTANCE}}}CASNumber",
|
||||
"1912-24-9",
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_REFERENCE_SUBSTANCE}}}MolecularStructuralInfo/{{{NS_REFERENCE_SUBSTANCE}}}InChl",
|
||||
(
|
||||
"InChI=1S/C8H14ClN5/c1-4-10-7-12-6(9)11-8(13-7)"
|
||||
"14-5(2)3/h5H,4H2,1-3H3,(H2,10,11,12,13,14)"
|
||||
),
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_REFERENCE_SUBSTANCE}}}MolecularStructuralInfo/{{{NS_REFERENCE_SUBSTANCE}}}InChIKey",
|
||||
"MXWJVTOOROXGIU-UHFFFAOYSA-N",
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_REFERENCE_SUBSTANCE}}}MolecularStructuralInfo"
|
||||
f"/{{{NS_REFERENCE_SUBSTANCE}}}MolecularWeightRange"
|
||||
f"/{{{NS_REFERENCE_SUBSTANCE}}}lowerValue",
|
||||
"215.60",
|
||||
)
|
||||
|
||||
def test_omits_inventory_and_weight_for_minimal_payload(self):
|
||||
data = make_reference_substance_data(
|
||||
cas_number=None,
|
||||
molecular_formula=None,
|
||||
molecular_weight=None,
|
||||
inchi=None,
|
||||
inchi_key=None,
|
||||
smiles="CC",
|
||||
)
|
||||
|
||||
root = ET.fromstring(ReferenceSubstanceBuilder().build(data))
|
||||
|
||||
assert_xpath_absent(self, root, f".//{{{NS_REFERENCE_SUBSTANCE}}}Inventory")
|
||||
assert_xpath_absent(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_REFERENCE_SUBSTANCE}}}MolecularWeightRange",
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_REFERENCE_SUBSTANCE}}}MolecularStructuralInfo/{{{NS_REFERENCE_SUBSTANCE}}}SmilesNotation",
|
||||
"CC",
|
||||
)
|
||||
|
||||
|
||||
@tag("iuclid")
|
||||
class EndpointStudyRecordBuilderContractTest(SimpleTestCase):
|
||||
def test_sets_document_metadata_and_parent_link(self):
|
||||
substance_uuid = uuid4()
|
||||
data = make_endpoint_study_record_data(substance_uuid=substance_uuid)
|
||||
|
||||
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||
|
||||
metadata_root = f"{{{NS_PLATFORM_CONTAINER}}}PlatformMetadata"
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f"{metadata_root}/{{{NS_PLATFORM_METADATA}}}documentType",
|
||||
"ENDPOINT_STUDY_RECORD",
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f"{metadata_root}/{{{NS_PLATFORM_METADATA}}}documentSubType",
|
||||
DOC_SUBTYPE,
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f"{metadata_root}/{{{NS_PLATFORM_METADATA}}}parentDocumentKey",
|
||||
document_key(substance_uuid),
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f"{metadata_root}/{{{NS_PLATFORM_METADATA}}}orderInSectionNo",
|
||||
"1",
|
||||
)
|
||||
|
||||
def test_esr_metadata_order_uses_stax_safe_layout(self):
|
||||
data = make_endpoint_study_record_data()
|
||||
|
||||
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||
metadata = root.find(f"{{{NS_PLATFORM_CONTAINER}}}PlatformMetadata")
|
||||
|
||||
self.assertIsNotNone(metadata)
|
||||
assert metadata is not None
|
||||
child_tags = [el.tag.split("}", 1)[1] for el in list(metadata)]
|
||||
self.assertEqual(
|
||||
child_tags,
|
||||
[
|
||||
"iuclidVersion",
|
||||
"documentKey",
|
||||
"documentType",
|
||||
"definitionVersion",
|
||||
"creationDate",
|
||||
"lastModificationDate",
|
||||
"name",
|
||||
"documentSubType",
|
||||
"parentDocumentKey",
|
||||
"orderInSectionNo",
|
||||
"i5Origin",
|
||||
"creationTool",
|
||||
],
|
||||
)
|
||||
|
||||
def test_omits_results_for_skeleton_payload(self):
|
||||
data = make_endpoint_study_record_data()
|
||||
|
||||
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||
|
||||
assert_xpath_absent(self, root, f".//{{{NS_ESR_BIODEG}}}ResultsAndDiscussion")
|
||||
|
||||
def test_maps_half_life_and_temperature_ranges(self):
|
||||
data = make_endpoint_study_record_data(
|
||||
half_lives=[make_half_life_entry(model="SFO", dt50_start=12.5, dt50_end=15.0)],
|
||||
temperature=(20.0, 20.0),
|
||||
)
|
||||
|
||||
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||
|
||||
base = (
|
||||
f".//{{{NS_ESR_BIODEG}}}ResultsAndDiscussion"
|
||||
f"/{{{NS_ESR_BIODEG}}}DTParentCompound"
|
||||
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||
)
|
||||
assert_xpath_text(self, root, f"{base}/{{{NS_ESR_BIODEG}}}KineticParameters", "SFO")
|
||||
assert_xpath_text(
|
||||
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Value/{{{NS_ESR_BIODEG}}}lowerValue", "12.5"
|
||||
)
|
||||
assert_xpath_text(
|
||||
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Value/{{{NS_ESR_BIODEG}}}upperValue", "15.0"
|
||||
)
|
||||
assert_xpath_text(
|
||||
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Temp/{{{NS_ESR_BIODEG}}}lowerValue", "20.0"
|
||||
)
|
||||
assert_xpath_text(
|
||||
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Temp/{{{NS_ESR_BIODEG}}}upperValue", "20.0"
|
||||
)
|
||||
|
||||
def test_maps_soil_no_on_dt_entries(self):
|
||||
data = make_endpoint_study_record_data(
|
||||
half_lives=[
|
||||
make_half_life_entry(
|
||||
model="SFO",
|
||||
dt50_start=12.5,
|
||||
dt50_end=15.0,
|
||||
soil_no_code="2",
|
||||
temperature=(22.0, 22.0),
|
||||
)
|
||||
],
|
||||
temperature=None,
|
||||
)
|
||||
|
||||
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||
base = (
|
||||
f".//{{{NS_ESR_BIODEG}}}ResultsAndDiscussion"
|
||||
f"/{{{NS_ESR_BIODEG}}}DTParentCompound"
|
||||
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||
)
|
||||
assert_xpath_text(
|
||||
self, root, f"{base}/{{{NS_ESR_BIODEG}}}SoilNo/{{{NS_ESR_BIODEG}}}value", "2"
|
||||
)
|
||||
assert_xpath_text(
|
||||
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Temp/{{{NS_ESR_BIODEG}}}lowerValue", "22.0"
|
||||
)
|
||||
|
||||
def test_maps_transformation_entries_and_model_context(self):
|
||||
parent_ref_uuid = uuid4()
|
||||
product_ref_uuid = uuid4()
|
||||
data = make_endpoint_study_record_data(
|
||||
transformation_products=[
|
||||
make_transformation_entry(
|
||||
parent_reference_uuids=[parent_ref_uuid],
|
||||
product_reference_uuid=product_ref_uuid,
|
||||
kinetic_formation_fraction=0.42,
|
||||
)
|
||||
],
|
||||
model_name_and_version=["Test model 1.0"],
|
||||
software_name_and_version=["enviPath"],
|
||||
model_remarks=["Model UUID: 00000000-0000-0000-0000-000000000000"],
|
||||
)
|
||||
|
||||
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_ESR_BIODEG}}}MaterialsAndMethods"
|
||||
f"/{{{NS_ESR_BIODEG}}}ModelAndSoftware"
|
||||
f"/{{{NS_ESR_BIODEG}}}ModelNameAndVersion",
|
||||
"Test model 1.0",
|
||||
)
|
||||
entry_base = (
|
||||
f".//{{{NS_ESR_BIODEG}}}ResultsAndDiscussion"
|
||||
f"/{{{NS_ESR_BIODEG}}}TransformationProductsDetails"
|
||||
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f"{entry_base}/{{{NS_ESR_BIODEG}}}IdentityOfCompound",
|
||||
document_key(product_ref_uuid),
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f"{entry_base}/{{{NS_ESR_BIODEG}}}ParentCompoundS/{{{NS_PLATFORM_FIELDS}}}key",
|
||||
document_key(parent_ref_uuid),
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f"{entry_base}/{{{NS_ESR_BIODEG}}}KineticFormationFraction",
|
||||
"0.42",
|
||||
)
|
||||
|
||||
def test_temperature_without_half_lives_in_xml(self):
|
||||
"""Temperature with no half-lives still renders a DTParentCompound entry."""
|
||||
data = make_endpoint_study_record_data(temperature=(21.0, 21.0))
|
||||
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||
base = (
|
||||
f".//{{{NS_ESR_BIODEG}}}ResultsAndDiscussion"
|
||||
f"/{{{NS_ESR_BIODEG}}}DTParentCompound"
|
||||
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||
)
|
||||
assert_xpath_text(
|
||||
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Temp/{{{NS_ESR_BIODEG}}}lowerValue", "21.0"
|
||||
)
|
||||
assert_xpath_text(
|
||||
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Temp/{{{NS_ESR_BIODEG}}}upperValue", "21.0"
|
||||
)
|
||||
|
||||
def test_temperature_interval_in_xml(self):
|
||||
"""Temperature tuple renders as lowerValue/upperValue in Temp element."""
|
||||
hl = make_half_life_entry()
|
||||
data = make_endpoint_study_record_data(half_lives=[hl], temperature=(20.0, 25.0))
|
||||
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||
base = (
|
||||
f".//{{{NS_ESR_BIODEG}}}ResultsAndDiscussion"
|
||||
f"/{{{NS_ESR_BIODEG}}}DTParentCompound"
|
||||
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||
)
|
||||
assert_xpath_text(
|
||||
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Temp/{{{NS_ESR_BIODEG}}}lowerValue", "20.0"
|
||||
)
|
||||
assert_xpath_text(
|
||||
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Temp/{{{NS_ESR_BIODEG}}}upperValue", "25.0"
|
||||
)
|
||||
|
||||
def test_esr_with_soil_properties_emits_structured_soil_by_default(self):
|
||||
props = make_soil_properties_data(clay=15.0, silt=35.0, sand=50.0)
|
||||
data = make_endpoint_study_record_data(soil_properties=props)
|
||||
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||
|
||||
entry_path = (
|
||||
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||
f"/{{{NS_ESR_BIODEG}}}SoilProperties"
|
||||
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f"{entry_path}/{{{NS_ESR_BIODEG}}}Clay/{{{NS_ESR_BIODEG}}}lowerValue",
|
||||
"15.0",
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f"{entry_path}/{{{NS_ESR_BIODEG}}}Silt/{{{NS_ESR_BIODEG}}}lowerValue",
|
||||
"35.0",
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f"{entry_path}/{{{NS_ESR_BIODEG}}}Sand/{{{NS_ESR_BIODEG}}}lowerValue",
|
||||
"50.0",
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||
f"/{{{NS_ESR_BIODEG}}}SoilClassification"
|
||||
f"/{{{NS_ESR_BIODEG}}}value",
|
||||
"1649",
|
||||
)
|
||||
assert_xpath_absent(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||
f"/{{{NS_ESR_BIODEG}}}SoilClassification"
|
||||
f"/{{{NS_ESR_BIODEG}}}other",
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f"{entry_path}/{{{NS_ESR_BIODEG}}}SoilType/{{{NS_ESR_BIODEG}}}value",
|
||||
"1026",
|
||||
)
|
||||
assert_xpath_absent(
|
||||
self,
|
||||
root,
|
||||
f"{entry_path}/{{{NS_ESR_BIODEG}}}SoilType/{{{NS_ESR_BIODEG}}}other",
|
||||
)
|
||||
assert_xpath_absent(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_ESR_BIODEG}}}AnyOtherInformationOnMaterialsAndMethodsInclTables",
|
||||
)
|
||||
assert_xpath_absent(self, root, f".//{{{NS_ESR_BIODEG}}}DetailsOnSoilCharacteristics")
|
||||
|
||||
def test_maps_multiple_soil_entries_with_soil_no(self):
|
||||
data = make_endpoint_study_record_data(
|
||||
soil_properties_entries=[
|
||||
make_soil_properties_data(soil_no_code="2", soil_type="LOAMY_SAND", sand=83.1),
|
||||
make_soil_properties_data(soil_no_code="4", soil_type="CLAY_LOAM", sand=23.7),
|
||||
]
|
||||
)
|
||||
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||
|
||||
entries = root.findall(
|
||||
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||
f"/{{{NS_ESR_BIODEG}}}SoilProperties"
|
||||
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||
)
|
||||
self.assertEqual(len(entries), 2)
|
||||
|
||||
soil_no_values = [
|
||||
entry.findtext(f"{{{NS_ESR_BIODEG}}}SoilNo/{{{NS_ESR_BIODEG}}}value")
|
||||
for entry in entries
|
||||
]
|
||||
self.assertEqual(soil_no_values, ["2", "4"])
|
||||
|
||||
def test_maps_soil_type_and_soil_classification_to_structured_fields(self):
|
||||
props = make_soil_properties_data(soil_type="LOAMY_SAND", soil_classification="USDA")
|
||||
data = make_endpoint_study_record_data(soil_properties=props)
|
||||
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||
|
||||
entry_path = (
|
||||
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||
f"/{{{NS_ESR_BIODEG}}}SoilProperties"
|
||||
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f"{entry_path}/{{{NS_ESR_BIODEG}}}SoilType/{{{NS_ESR_BIODEG}}}value",
|
||||
"1027",
|
||||
)
|
||||
assert_xpath_absent(
|
||||
self, root, f"{entry_path}/{{{NS_ESR_BIODEG}}}SoilType/{{{NS_ESR_BIODEG}}}other"
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||
f"/{{{NS_ESR_BIODEG}}}SoilClassification"
|
||||
f"/{{{NS_ESR_BIODEG}}}value",
|
||||
"1649",
|
||||
)
|
||||
assert_xpath_absent(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||
f"/{{{NS_ESR_BIODEG}}}SoilClassification"
|
||||
f"/{{{NS_ESR_BIODEG}}}other",
|
||||
)
|
||||
|
||||
def test_unknown_soil_type_and_classification_use_open_picklist(self):
|
||||
props = make_soil_properties_data(soil_type="SILTY_SAND", soil_classification="UK_ADAS")
|
||||
data = make_endpoint_study_record_data(soil_properties=props)
|
||||
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||
|
||||
entry_path = (
|
||||
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||
f"/{{{NS_ESR_BIODEG}}}SoilProperties"
|
||||
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f"{entry_path}/{{{NS_ESR_BIODEG}}}SoilType/{{{NS_ESR_BIODEG}}}value",
|
||||
"1342",
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f"{entry_path}/{{{NS_ESR_BIODEG}}}SoilType/{{{NS_ESR_BIODEG}}}other",
|
||||
"SILTY SAND",
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||
f"/{{{NS_ESR_BIODEG}}}SoilClassification"
|
||||
f"/{{{NS_ESR_BIODEG}}}value",
|
||||
"1342",
|
||||
)
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||
f"/{{{NS_ESR_BIODEG}}}SoilClassification"
|
||||
f"/{{{NS_ESR_BIODEG}}}other",
|
||||
"UK ADAS",
|
||||
)
|
||||
|
||||
def test_infers_usda_soil_classification_from_soil_type(self):
|
||||
props = make_soil_properties_data(soil_type="LOAMY_SAND", soil_classification=None)
|
||||
data = make_endpoint_study_record_data(soil_properties=props)
|
||||
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||
|
||||
assert_xpath_text(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||
f"/{{{NS_ESR_BIODEG}}}SoilClassification"
|
||||
f"/{{{NS_ESR_BIODEG}}}value",
|
||||
"1649",
|
||||
)
|
||||
assert_xpath_absent(
|
||||
self,
|
||||
root,
|
||||
f".//{{{NS_ESR_BIODEG}}}StudyDesign/{{{NS_ESR_BIODEG}}}SoilClassification/{{{NS_ESR_BIODEG}}}other",
|
||||
)
|
||||
|
||||
def test_esr_without_soil_properties_omits_study_design(self):
|
||||
"""ESR with soil_properties=None → no <StudyDesign> in XML."""
|
||||
data = make_endpoint_study_record_data(soil_properties=None)
|
||||
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||
assert_xpath_absent(self, root, f".//{{{NS_ESR_BIODEG}}}StudyDesign")
|
||||
|
||||
def test_omits_empty_ph_measured_in(self):
|
||||
props = make_soil_properties_data(ph_method="")
|
||||
data = make_endpoint_study_record_data(soil_properties=props)
|
||||
|
||||
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||
|
||||
self.assertNotIn("PHMeasuredIn", ET.tostring(root, encoding="unicode"))
|
||||
199
epiuclid/tests/test_i6z.py
Normal file
199
epiuclid/tests/test_i6z.py
Normal file
@ -0,0 +1,199 @@
|
||||
"""Tests for i6z archive assembly."""
|
||||
|
||||
import io
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from uuid import uuid4
|
||||
|
||||
from django.test import SimpleTestCase, tag
|
||||
|
||||
from epiuclid.serializers.i6z import I6ZSerializer
|
||||
from epiuclid.serializers.pathway_mapper import (
|
||||
IUCLIDDocumentBundle,
|
||||
IUCLIDEndpointStudyRecordData,
|
||||
IUCLIDReferenceSubstanceData,
|
||||
IUCLIDSubstanceData,
|
||||
IUCLIDTransformationProductEntry,
|
||||
)
|
||||
|
||||
|
||||
def _make_bundle() -> IUCLIDDocumentBundle:
|
||||
ref_uuid = uuid4()
|
||||
sub_uuid = uuid4()
|
||||
return IUCLIDDocumentBundle(
|
||||
substances=[
|
||||
IUCLIDSubstanceData(
|
||||
uuid=sub_uuid,
|
||||
name="Benzene",
|
||||
reference_substance_uuid=ref_uuid,
|
||||
),
|
||||
],
|
||||
reference_substances=[
|
||||
IUCLIDReferenceSubstanceData(
|
||||
uuid=ref_uuid,
|
||||
name="Benzene",
|
||||
smiles="c1ccccc1",
|
||||
cas_number="71-43-2",
|
||||
molecular_formula="C6H6",
|
||||
molecular_weight=78.11,
|
||||
),
|
||||
],
|
||||
endpoint_study_records=[
|
||||
IUCLIDEndpointStudyRecordData(
|
||||
uuid=uuid4(),
|
||||
substance_uuid=sub_uuid,
|
||||
name="Endpoint study - Benzene",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _make_bundle_with_transformation_links() -> tuple[IUCLIDDocumentBundle, str, str]:
|
||||
parent_ref_uuid = uuid4()
|
||||
product_ref_uuid = uuid4()
|
||||
sub_uuid = uuid4()
|
||||
|
||||
bundle = IUCLIDDocumentBundle(
|
||||
substances=[
|
||||
IUCLIDSubstanceData(
|
||||
uuid=sub_uuid,
|
||||
name="Benzene",
|
||||
reference_substance_uuid=parent_ref_uuid,
|
||||
),
|
||||
],
|
||||
reference_substances=[
|
||||
IUCLIDReferenceSubstanceData(uuid=parent_ref_uuid, name="Benzene", smiles="c1ccccc1"),
|
||||
IUCLIDReferenceSubstanceData(
|
||||
uuid=product_ref_uuid, name="Phenol", smiles="c1ccc(O)cc1"
|
||||
),
|
||||
],
|
||||
endpoint_study_records=[
|
||||
IUCLIDEndpointStudyRecordData(
|
||||
uuid=uuid4(),
|
||||
substance_uuid=sub_uuid,
|
||||
name="Endpoint study - Benzene",
|
||||
transformation_products=[
|
||||
IUCLIDTransformationProductEntry(
|
||||
uuid=uuid4(),
|
||||
product_reference_uuid=product_ref_uuid,
|
||||
parent_reference_uuids=[parent_ref_uuid],
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
return bundle, f"{parent_ref_uuid}/0", f"{product_ref_uuid}/0"
|
||||
|
||||
|
||||
@tag("iuclid")
|
||||
class I6ZSerializerTest(SimpleTestCase):
|
||||
def test_output_is_valid_zip(self):
|
||||
bundle = _make_bundle()
|
||||
data = I6ZSerializer().serialize(bundle)
|
||||
self.assertTrue(zipfile.is_zipfile(io.BytesIO(data)))
|
||||
|
||||
def test_contains_manifest(self):
|
||||
bundle = _make_bundle()
|
||||
data = I6ZSerializer().serialize(bundle)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
self.assertIn("manifest.xml", zf.namelist())
|
||||
|
||||
def test_contains_i6d_files(self):
|
||||
bundle = _make_bundle()
|
||||
data = I6ZSerializer().serialize(bundle)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
names = zf.namelist()
|
||||
# manifest + 1 substance + 1 ref substance + 1 ESR = 4 files
|
||||
self.assertEqual(len(names), 4)
|
||||
i6d_files = [n for n in names if n.endswith(".i6d")]
|
||||
self.assertEqual(len(i6d_files), 3)
|
||||
|
||||
def test_manifest_references_all_documents(self):
|
||||
bundle = _make_bundle()
|
||||
data = I6ZSerializer().serialize(bundle)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
manifest_xml = zf.read("manifest.xml").decode("utf-8")
|
||||
root = ET.fromstring(manifest_xml)
|
||||
|
||||
ns = "http://iuclid6.echa.europa.eu/namespaces/manifest/v1"
|
||||
docs = root.findall(f".//{{{ns}}}document")
|
||||
self.assertEqual(len(docs), 3)
|
||||
|
||||
types = set()
|
||||
for doc in docs:
|
||||
type_elem = doc.find(f"{{{ns}}}type")
|
||||
self.assertIsNotNone(type_elem)
|
||||
assert type_elem is not None
|
||||
types.add(type_elem.text)
|
||||
self.assertEqual(types, {"SUBSTANCE", "REFERENCE_SUBSTANCE", "ENDPOINT_STUDY_RECORD"})
|
||||
|
||||
def test_manifest_contains_expected_document_links(self):
|
||||
bundle = _make_bundle()
|
||||
data = I6ZSerializer().serialize(bundle)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
manifest_xml = zf.read("manifest.xml").decode("utf-8")
|
||||
root = ET.fromstring(manifest_xml)
|
||||
|
||||
ns = "http://iuclid6.echa.europa.eu/namespaces/manifest/v1"
|
||||
docs = root.findall(f".//{{{ns}}}document")
|
||||
|
||||
links_by_type: dict[str, set[tuple[str | None, str | None]]] = {}
|
||||
for doc in docs:
|
||||
doc_type = doc.findtext(f"{{{ns}}}type")
|
||||
links = set()
|
||||
for link in doc.findall(f"{{{ns}}}links/{{{ns}}}link"):
|
||||
links.add(
|
||||
(
|
||||
link.findtext(f"{{{ns}}}ref-type"),
|
||||
link.findtext(f"{{{ns}}}ref-uuid"),
|
||||
)
|
||||
)
|
||||
if doc_type:
|
||||
links_by_type[doc_type] = links
|
||||
|
||||
self.assertIn("REFERENCE", {ref_type for ref_type, _ in links_by_type["SUBSTANCE"]})
|
||||
self.assertIn("CHILD", {ref_type for ref_type, _ in links_by_type["SUBSTANCE"]})
|
||||
self.assertIn(
|
||||
"PARENT", {ref_type for ref_type, _ in links_by_type["ENDPOINT_STUDY_RECORD"]}
|
||||
)
|
||||
|
||||
def test_i6d_files_are_valid_xml(self):
|
||||
bundle = _make_bundle()
|
||||
data = I6ZSerializer().serialize(bundle)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith(".i6d"):
|
||||
content = zf.read(name).decode("utf-8")
|
||||
# Should not raise
|
||||
ET.fromstring(content)
|
||||
|
||||
def test_manifest_links_esr_to_transformation_reference_substances(self):
|
||||
bundle, parent_ref_key, product_ref_key = _make_bundle_with_transformation_links()
|
||||
data = I6ZSerializer().serialize(bundle)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
manifest_xml = zf.read("manifest.xml").decode("utf-8")
|
||||
root = ET.fromstring(manifest_xml)
|
||||
|
||||
ns = "http://iuclid6.echa.europa.eu/namespaces/manifest/v1"
|
||||
esr_doc = None
|
||||
for doc in root.findall(f".//{{{ns}}}document"):
|
||||
if doc.findtext(f"{{{ns}}}type") == "ENDPOINT_STUDY_RECORD":
|
||||
esr_doc = doc
|
||||
break
|
||||
|
||||
self.assertIsNotNone(esr_doc)
|
||||
assert esr_doc is not None
|
||||
|
||||
reference_links = {
|
||||
link.findtext(f"{{{ns}}}ref-uuid")
|
||||
for link in esr_doc.findall(f"{{{ns}}}links/{{{ns}}}link")
|
||||
if link.findtext(f"{{{ns}}}ref-type") == "REFERENCE"
|
||||
}
|
||||
self.assertIn(parent_ref_key, reference_links)
|
||||
self.assertIn(product_ref_key, reference_links)
|
||||
86
epiuclid/tests/test_iuclid_export.py
Normal file
86
epiuclid/tests/test_iuclid_export.py
Normal file
@ -0,0 +1,86 @@
|
||||
"""
|
||||
Tests for the IUCLID projection layer.
|
||||
"""
|
||||
|
||||
from django.test import TestCase, tag
|
||||
|
||||
from envipy_additional_information.information import Temperature
|
||||
|
||||
from epdb.logic import PackageManager, UserManager
|
||||
from epdb.models import AdditionalInformation, Pathway, Scenario
|
||||
from epapi.v1.interfaces.iuclid.projections import get_pathway_for_iuclid_export
|
||||
|
||||
|
||||
@tag("api", "iuclid")
|
||||
class IUCLIDExportScenarioAITests(TestCase):
|
||||
"""Test that scenario additional information is correctly reflected in the IUCLID export."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = UserManager.create_user(
|
||||
"iuclid-test-user",
|
||||
"iuclid-test@envipath.com",
|
||||
"SuperSafe",
|
||||
set_setting=False,
|
||||
add_to_group=False,
|
||||
is_active=True,
|
||||
)
|
||||
cls.package = PackageManager.create_package(
|
||||
cls.user, "IUCLID Test Package", "Package for IUCLID export tests"
|
||||
)
|
||||
cls.pathway = Pathway.create(cls.package, "CCO", name="Test Pathway")
|
||||
cls.root_node = cls.pathway.node_set.get(depth=0)
|
||||
cls.compound = cls.root_node.default_node_label.compound
|
||||
cls.scenario = Scenario.objects.create(
|
||||
package=cls.package,
|
||||
name="Test Scenario",
|
||||
scenario_type="biodegradation",
|
||||
scenario_date="2024-01-01",
|
||||
)
|
||||
cls.root_node.scenarios.add(cls.scenario)
|
||||
|
||||
def test_scenario_with_no_ai_exports_without_error(self):
|
||||
"""Scenario attached to a node but with no AI must export cleanly with empty lists.
|
||||
|
||||
Regression: previously scenario.parent was accessed here, causing AttributeError.
|
||||
"""
|
||||
result = get_pathway_for_iuclid_export(self.user, self.pathway.uuid)
|
||||
|
||||
self.assertEqual(len(result.nodes), 1)
|
||||
node = result.nodes[0]
|
||||
self.assertEqual(len(node.scenarios), 1)
|
||||
self.assertEqual(node.scenarios[0].scenario_uuid, self.scenario.uuid)
|
||||
self.assertEqual(node.scenarios[0].additional_info, [])
|
||||
self.assertEqual(node.additional_info, [])
|
||||
|
||||
def test_direct_scenario_ai_appears_in_export(self):
|
||||
"""AI added directly to a scenario (no object binding) must appear in the export DTO."""
|
||||
ai = Temperature(interval={"start": 20, "end": 25})
|
||||
AdditionalInformation.create(self.package, ai, scenario=self.scenario)
|
||||
|
||||
result = get_pathway_for_iuclid_export(self.user, self.pathway.uuid)
|
||||
|
||||
node = result.nodes[0]
|
||||
self.assertEqual(len(node.scenarios), 1)
|
||||
scenario_dto = node.scenarios[0]
|
||||
self.assertEqual(len(scenario_dto.additional_info), 1)
|
||||
# The same AI must roll up to the node level
|
||||
self.assertEqual(len(node.additional_info), 1)
|
||||
self.assertEqual(node.additional_info[0], scenario_dto.additional_info[0])
|
||||
|
||||
def test_object_bound_ai_excluded_from_scenario_direct_info(self):
|
||||
"""AI bound to a specific object (object_id set) must not appear in direct scenario info.
|
||||
|
||||
get_additional_information(direct_only=True) filters object_id__isnull=False.
|
||||
"""
|
||||
ai = Temperature(interval={"start": 10, "end": 15})
|
||||
AdditionalInformation.create(
|
||||
self.package, ai, scenario=self.scenario, content_object=self.compound
|
||||
)
|
||||
|
||||
result = get_pathway_for_iuclid_export(self.user, self.pathway.uuid)
|
||||
|
||||
node = result.nodes[0]
|
||||
scenario_dto = node.scenarios[0]
|
||||
self.assertEqual(scenario_dto.additional_info, [])
|
||||
self.assertEqual(node.additional_info, [])
|
||||
512
epiuclid/tests/test_pathway_mapper.py
Normal file
512
epiuclid/tests/test_pathway_mapper.py
Normal file
@ -0,0 +1,512 @@
|
||||
"""Tests for PathwayMapper - no DB needed, uses DTO fixtures."""
|
||||
|
||||
from django.test import SimpleTestCase, tag
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from epapi.v1.interfaces.iuclid.dto import (
|
||||
PathwayCompoundDTO,
|
||||
PathwayEdgeDTO,
|
||||
PathwayExportDTO,
|
||||
PathwayNodeDTO,
|
||||
PathwayScenarioDTO,
|
||||
)
|
||||
from epiuclid.serializers.pathway_mapper import PathwayMapper
|
||||
|
||||
|
||||
@tag("iuclid")
|
||||
class PathwayMapperTest(SimpleTestCase):
|
||||
def setUp(self):
|
||||
self.compounds = [
|
||||
PathwayCompoundDTO(pk=1, name="Benzene", smiles="c1ccccc1"),
|
||||
PathwayCompoundDTO(pk=2, name="Phenol", smiles="c1ccc(O)cc1"),
|
||||
]
|
||||
|
||||
def test_mapper_produces_bundle(self):
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="Test Pathway",
|
||||
compounds=self.compounds,
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
bundle = PathwayMapper().map(export)
|
||||
|
||||
self.assertEqual(len(bundle.substances), 2)
|
||||
self.assertEqual(len(bundle.reference_substances), 2)
|
||||
self.assertEqual(len(bundle.endpoint_study_records), 1)
|
||||
|
||||
def test_mapper_deduplicates_compounds(self):
|
||||
compounds_with_dup = [
|
||||
PathwayCompoundDTO(pk=1, name="Benzene", smiles="c1ccccc1"),
|
||||
PathwayCompoundDTO(pk=2, name="Phenol", smiles="c1ccc(O)cc1"),
|
||||
PathwayCompoundDTO(pk=1, name="Benzene", smiles="c1ccccc1"),
|
||||
]
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="Test Pathway",
|
||||
compounds=compounds_with_dup,
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
bundle = PathwayMapper().map(export)
|
||||
|
||||
# 2 unique compounds -> 2 substances, 2 ref substances
|
||||
self.assertEqual(len(bundle.substances), 2)
|
||||
self.assertEqual(len(bundle.reference_substances), 2)
|
||||
# One endpoint study record per pathway
|
||||
self.assertEqual(len(bundle.endpoint_study_records), 1)
|
||||
|
||||
def test_mapper_extracts_smiles(self):
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="Test Pathway",
|
||||
compounds=self.compounds,
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
bundle = PathwayMapper().map(export)
|
||||
|
||||
smiles_values = [s.smiles for s in bundle.reference_substances]
|
||||
self.assertTrue(all(s is not None for s in smiles_values))
|
||||
|
||||
def test_mapper_extracts_cas_when_present(self):
|
||||
compounds = [
|
||||
PathwayCompoundDTO(pk=1, name="Benzene", smiles="c1ccccc1", cas_number="71-43-2"),
|
||||
PathwayCompoundDTO(pk=2, name="Phenol", smiles="c1ccc(O)cc1"),
|
||||
]
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="Test Pathway",
|
||||
compounds=compounds,
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
bundle = PathwayMapper().map(export)
|
||||
|
||||
cas_values = [r.cas_number for r in bundle.reference_substances]
|
||||
self.assertIn("71-43-2", cas_values)
|
||||
|
||||
def test_mapper_builds_transformation_entries(self):
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="Test Pathway",
|
||||
compounds=self.compounds,
|
||||
edges=[
|
||||
PathwayEdgeDTO(
|
||||
edge_uuid=uuid4(),
|
||||
start_compound_pks=[1],
|
||||
end_compound_pks=[2],
|
||||
probability=0.73,
|
||||
)
|
||||
],
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
bundle = PathwayMapper().map(export)
|
||||
|
||||
self.assertEqual(len(bundle.endpoint_study_records), 1)
|
||||
esr = bundle.endpoint_study_records[0]
|
||||
self.assertEqual(len(esr.transformation_products), 1)
|
||||
self.assertIsNone(esr.transformation_products[0].kinetic_formation_fraction)
|
||||
|
||||
def test_mapper_deduplicates_transformation_entries(self):
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="Test Pathway",
|
||||
compounds=self.compounds,
|
||||
edges=[
|
||||
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1], end_compound_pks=[2]),
|
||||
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1], end_compound_pks=[2]),
|
||||
],
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
|
||||
bundle = PathwayMapper().map(export)
|
||||
esr = bundle.endpoint_study_records[0]
|
||||
|
||||
self.assertEqual(len(esr.transformation_products), 1)
|
||||
|
||||
def test_mapper_creates_endpoint_record_for_each_root_compound(self):
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="Test Pathway",
|
||||
compounds=self.compounds,
|
||||
root_compound_pks=[1, 2],
|
||||
)
|
||||
|
||||
bundle = PathwayMapper().map(export)
|
||||
|
||||
self.assertEqual(len(bundle.endpoint_study_records), 2)
|
||||
esr_names = {record.name for record in bundle.endpoint_study_records}
|
||||
self.assertIn("Biodegradation in soil - Test Pathway (Benzene)", esr_names)
|
||||
self.assertIn("Biodegradation in soil - Test Pathway (Phenol)", esr_names)
|
||||
|
||||
def test_mapper_builds_root_specific_transformations_for_disjoint_subgraphs(self):
|
||||
compounds = [
|
||||
PathwayCompoundDTO(pk=1, name="Root A", smiles="CC"),
|
||||
PathwayCompoundDTO(pk=2, name="Root B", smiles="CCC"),
|
||||
PathwayCompoundDTO(pk=3, name="A Child", smiles="CCCC"),
|
||||
PathwayCompoundDTO(pk=4, name="B Child", smiles="CCCCC"),
|
||||
]
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="Disjoint Pathway",
|
||||
compounds=compounds,
|
||||
edges=[
|
||||
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1], end_compound_pks=[3]),
|
||||
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[2], end_compound_pks=[4]),
|
||||
],
|
||||
root_compound_pks=[1, 2],
|
||||
)
|
||||
|
||||
bundle = PathwayMapper().map(export)
|
||||
|
||||
substance_name_by_uuid = {sub.uuid: sub.name for sub in bundle.substances}
|
||||
reference_name_by_uuid = {ref.uuid: ref.name for ref in bundle.reference_substances}
|
||||
|
||||
products_by_root: dict[str, set[str]] = {}
|
||||
for esr in bundle.endpoint_study_records:
|
||||
root_name = substance_name_by_uuid[esr.substance_uuid]
|
||||
products_by_root[root_name] = {
|
||||
reference_name_by_uuid[tp.product_reference_uuid]
|
||||
for tp in esr.transformation_products
|
||||
}
|
||||
|
||||
self.assertEqual(products_by_root["Root A"], {"A Child"})
|
||||
self.assertEqual(products_by_root["Root B"], {"B Child"})
|
||||
|
||||
def test_mapper_requires_all_edge_parents_to_be_reachable(self):
|
||||
compounds = [
|
||||
PathwayCompoundDTO(pk=1, name="Root", smiles="CC"),
|
||||
PathwayCompoundDTO(pk=2, name="Co-reactant", smiles="CCC"),
|
||||
PathwayCompoundDTO(pk=3, name="Product", smiles="CCCC"),
|
||||
]
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="Multi Parent Pathway",
|
||||
compounds=compounds,
|
||||
edges=[
|
||||
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1, 2], end_compound_pks=[3]),
|
||||
],
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
|
||||
bundle = PathwayMapper().map(export)
|
||||
esr = bundle.endpoint_study_records[0]
|
||||
|
||||
self.assertEqual(len(esr.transformation_products), 0)
|
||||
|
||||
def test_mapper_resolves_multi_parent_transformations_after_intermediate_is_reachable(self):
|
||||
compounds = [
|
||||
PathwayCompoundDTO(pk=1, name="Root", smiles="CC"),
|
||||
PathwayCompoundDTO(pk=2, name="Intermediate", smiles="CCC"),
|
||||
PathwayCompoundDTO(pk=3, name="Product", smiles="CCCC"),
|
||||
]
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="Closure Pathway",
|
||||
compounds=compounds,
|
||||
edges=[
|
||||
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1], end_compound_pks=[2]),
|
||||
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1, 2], end_compound_pks=[3]),
|
||||
],
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
|
||||
bundle = PathwayMapper().map(export)
|
||||
esr = bundle.endpoint_study_records[0]
|
||||
reference_name_by_uuid = {ref.uuid: ref.name for ref in bundle.reference_substances}
|
||||
product_names = {
|
||||
reference_name_by_uuid[tp.product_reference_uuid] for tp in esr.transformation_products
|
||||
}
|
||||
|
||||
self.assertEqual(product_names, {"Intermediate", "Product"})
|
||||
|
||||
def test_mapper_populates_half_lives_from_root_node_ai(self):
|
||||
"""HalfLife AI on root node → ESR.half_lives."""
|
||||
from envipy_additional_information.information import HalfLife, Interval
|
||||
|
||||
hl = HalfLife(
|
||||
model="SFO", fit="ok", comment="", dt50=Interval(start=5.0, end=10.0), source="test"
|
||||
)
|
||||
root_node = PathwayNodeDTO(
|
||||
node_uuid=uuid4(),
|
||||
compound_pk=1,
|
||||
name="Root",
|
||||
depth=0,
|
||||
smiles="CC",
|
||||
additional_info=[hl],
|
||||
)
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="P",
|
||||
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||
nodes=[root_node],
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
bundle = PathwayMapper().map(export)
|
||||
esr = bundle.endpoint_study_records[0]
|
||||
self.assertEqual(len(esr.half_lives), 1)
|
||||
self.assertEqual(esr.half_lives[0].dt50_start, 5.0)
|
||||
self.assertEqual(esr.half_lives[0].dt50_end, 10.0)
|
||||
|
||||
def test_mapper_populates_temperature_from_root_node_ai(self):
|
||||
"""Temperature AI on root node → ESR.temperature as tuple."""
|
||||
from envipy_additional_information.information import Temperature, Interval
|
||||
|
||||
temp = Temperature(interval=Interval(start=20.0, end=25.0))
|
||||
root_node = PathwayNodeDTO(
|
||||
node_uuid=uuid4(),
|
||||
compound_pk=1,
|
||||
name="Root",
|
||||
depth=0,
|
||||
smiles="CC",
|
||||
additional_info=[temp],
|
||||
)
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="P",
|
||||
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||
nodes=[root_node],
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
bundle = PathwayMapper().map(export)
|
||||
esr = bundle.endpoint_study_records[0]
|
||||
self.assertEqual(esr.temperature, (20.0, 25.0))
|
||||
|
||||
def test_mapper_ignores_ai_on_non_root_nodes(self):
|
||||
"""AI from non-root nodes (depth > 0) should not appear in ESR."""
|
||||
from envipy_additional_information.information import HalfLife, Interval
|
||||
|
||||
hl = HalfLife(
|
||||
model="SFO", fit="ok", comment="", dt50=Interval(start=5.0, end=10.0), source="test"
|
||||
)
|
||||
non_root_node = PathwayNodeDTO(
|
||||
node_uuid=uuid4(),
|
||||
compound_pk=2,
|
||||
name="Product",
|
||||
depth=1,
|
||||
smiles="CCC",
|
||||
additional_info=[hl],
|
||||
)
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="P",
|
||||
compounds=[
|
||||
PathwayCompoundDTO(pk=1, name="Root", smiles="CC"),
|
||||
PathwayCompoundDTO(pk=2, name="Product", smiles="CCC"),
|
||||
],
|
||||
nodes=[non_root_node],
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
bundle = PathwayMapper().map(export)
|
||||
esr = bundle.endpoint_study_records[0]
|
||||
self.assertEqual(len(esr.half_lives), 0)
|
||||
|
||||
def test_extracts_soil_texture2_from_root_node_ai(self):
|
||||
"""SoilTexture2 AI on root node → ESR.soil_properties.sand/silt/clay."""
|
||||
from envipy_additional_information.information import SoilTexture2
|
||||
|
||||
texture = SoilTexture2(sand=65.0, silt=25.0, clay=10.0)
|
||||
root_node = PathwayNodeDTO(
|
||||
node_uuid=uuid4(),
|
||||
compound_pk=1,
|
||||
name="Root",
|
||||
depth=0,
|
||||
smiles="CC",
|
||||
additional_info=[texture],
|
||||
)
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="P",
|
||||
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||
nodes=[root_node],
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
bundle = PathwayMapper().map(export)
|
||||
esr = bundle.endpoint_study_records[0]
|
||||
self.assertIsNotNone(esr.soil_properties)
|
||||
self.assertEqual(esr.soil_properties.sand, 65.0)
|
||||
self.assertEqual(esr.soil_properties.silt, 25.0)
|
||||
self.assertEqual(esr.soil_properties.clay, 10.0)
|
||||
|
||||
def test_extracts_ph_from_root_node_ai(self):
|
||||
"""Acidity AI on root node → ESR.soil_properties.ph_lower/ph_upper/ph_method."""
|
||||
from envipy_additional_information.information import Acidity, Interval
|
||||
|
||||
acidity = Acidity(interval=Interval(start=6.5, end=7.2), method="CaCl2")
|
||||
root_node = PathwayNodeDTO(
|
||||
node_uuid=uuid4(),
|
||||
compound_pk=1,
|
||||
name="Root",
|
||||
depth=0,
|
||||
smiles="CC",
|
||||
additional_info=[acidity],
|
||||
)
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="P",
|
||||
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||
nodes=[root_node],
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
bundle = PathwayMapper().map(export)
|
||||
esr = bundle.endpoint_study_records[0]
|
||||
self.assertIsNotNone(esr.soil_properties)
|
||||
self.assertEqual(esr.soil_properties.ph_lower, 6.5)
|
||||
self.assertEqual(esr.soil_properties.ph_upper, 7.2)
|
||||
self.assertEqual(esr.soil_properties.ph_method, "CaCl2")
|
||||
|
||||
def test_normalizes_blank_ph_method_to_none(self):
|
||||
"""Blank Acidity method should not produce an empty PHMeasuredIn XML node."""
|
||||
from envipy_additional_information.information import Acidity, Interval
|
||||
|
||||
acidity = Acidity(interval=Interval(start=6.5, end=7.2), method=" ")
|
||||
root_node = PathwayNodeDTO(
|
||||
node_uuid=uuid4(),
|
||||
compound_pk=1,
|
||||
name="Root",
|
||||
depth=0,
|
||||
smiles="CC",
|
||||
additional_info=[acidity],
|
||||
)
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="P",
|
||||
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||
nodes=[root_node],
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
bundle = PathwayMapper().map(export)
|
||||
esr = bundle.endpoint_study_records[0]
|
||||
|
||||
self.assertIsNotNone(esr.soil_properties)
|
||||
self.assertIsNone(esr.soil_properties.ph_method)
|
||||
|
||||
def test_extracts_cec_and_org_carbon(self):
|
||||
"""CEC and OMContent AI on root node → ESR.soil_properties.cec/org_carbon."""
|
||||
from envipy_additional_information.information import CEC, OMContent
|
||||
|
||||
cec = CEC(capacity=15.3)
|
||||
om = OMContent(in_oc=2.1)
|
||||
root_node = PathwayNodeDTO(
|
||||
node_uuid=uuid4(),
|
||||
compound_pk=1,
|
||||
name="Root",
|
||||
depth=0,
|
||||
smiles="CC",
|
||||
additional_info=[cec, om],
|
||||
)
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="P",
|
||||
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||
nodes=[root_node],
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
bundle = PathwayMapper().map(export)
|
||||
esr = bundle.endpoint_study_records[0]
|
||||
self.assertIsNotNone(esr.soil_properties)
|
||||
self.assertEqual(esr.soil_properties.cec, 15.3)
|
||||
self.assertEqual(esr.soil_properties.org_carbon, 2.1)
|
||||
|
||||
def test_soil_properties_none_when_no_soil_ai(self):
|
||||
"""No soil AI → soil_properties is None."""
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="P",
|
||||
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
bundle = PathwayMapper().map(export)
|
||||
esr = bundle.endpoint_study_records[0]
|
||||
self.assertIsNone(esr.soil_properties)
|
||||
|
||||
def test_ignores_soil_ai_on_non_root_nodes(self):
|
||||
"""Soil AI on non-root nodes (depth > 0) is not extracted."""
|
||||
from envipy_additional_information.information import SoilTexture2
|
||||
|
||||
texture = SoilTexture2(sand=60.0, silt=30.0, clay=10.0)
|
||||
non_root_node = PathwayNodeDTO(
|
||||
node_uuid=uuid4(),
|
||||
compound_pk=2,
|
||||
name="Product",
|
||||
depth=1,
|
||||
smiles="CCC",
|
||||
additional_info=[texture],
|
||||
)
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="P",
|
||||
compounds=[
|
||||
PathwayCompoundDTO(pk=1, name="Root", smiles="CC"),
|
||||
PathwayCompoundDTO(pk=2, name="Product", smiles="CCC"),
|
||||
],
|
||||
nodes=[non_root_node],
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
bundle = PathwayMapper().map(export)
|
||||
esr = bundle.endpoint_study_records[0]
|
||||
self.assertIsNone(esr.soil_properties)
|
||||
|
||||
def test_mapper_merges_root_scenarios_into_single_esr_with_soil_numbers(self):
|
||||
"""Scenario-aware root export should merge scenarios into one ESR linked by SoilNo."""
|
||||
from envipy_additional_information.information import HalfLife, Interval, SoilTexture2
|
||||
|
||||
scenario_a = PathwayScenarioDTO(
|
||||
scenario_uuid=uuid4(),
|
||||
name="Scenario A",
|
||||
additional_info=[
|
||||
HalfLife(
|
||||
model="SFO",
|
||||
fit="ok",
|
||||
comment="",
|
||||
dt50=Interval(start=2.0, end=2.0),
|
||||
source="A",
|
||||
),
|
||||
SoilTexture2(sand=70.0, silt=20.0, clay=10.0),
|
||||
],
|
||||
)
|
||||
scenario_b = PathwayScenarioDTO(
|
||||
scenario_uuid=uuid4(),
|
||||
name="Scenario B",
|
||||
additional_info=[
|
||||
HalfLife(
|
||||
model="SFO",
|
||||
fit="ok",
|
||||
comment="",
|
||||
dt50=Interval(start=5.0, end=5.0),
|
||||
source="B",
|
||||
),
|
||||
SoilTexture2(sand=40.0, silt=40.0, clay=20.0),
|
||||
],
|
||||
)
|
||||
root_node = PathwayNodeDTO(
|
||||
node_uuid=uuid4(),
|
||||
compound_pk=1,
|
||||
name="Root",
|
||||
depth=0,
|
||||
smiles="CC",
|
||||
scenarios=[scenario_a, scenario_b],
|
||||
)
|
||||
export = PathwayExportDTO(
|
||||
pathway_uuid=uuid4(),
|
||||
pathway_name="P",
|
||||
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||
nodes=[root_node],
|
||||
root_compound_pks=[1],
|
||||
)
|
||||
|
||||
bundle = PathwayMapper().map(export)
|
||||
|
||||
self.assertEqual(len(bundle.endpoint_study_records), 1)
|
||||
esr = bundle.endpoint_study_records[0]
|
||||
|
||||
self.assertEqual(esr.name, "Biodegradation in soil - P")
|
||||
self.assertEqual(len(esr.half_lives), 2)
|
||||
self.assertEqual(len(esr.soil_properties_entries), 2)
|
||||
|
||||
by_dt50 = {hl.dt50_start: hl for hl in esr.half_lives}
|
||||
self.assertEqual(by_dt50[2.0].soil_no_code, "2")
|
||||
self.assertEqual(by_dt50[5.0].soil_no_code, "4")
|
||||
self.assertEqual(by_dt50[2.0].temperature, None)
|
||||
|
||||
by_soil_no = {soil.soil_no_code: soil for soil in esr.soil_properties_entries}
|
||||
self.assertEqual(by_soil_no["2"].sand, 70.0)
|
||||
self.assertEqual(by_soil_no["4"].sand, 40.0)
|
||||
148
epiuclid/tests/test_xsd_validation.py
Normal file
148
epiuclid/tests/test_xsd_validation.py
Normal file
@ -0,0 +1,148 @@
|
||||
"""XSD validation tests for IUCLID XML builders — no DB required."""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from django.test import SimpleTestCase, tag
|
||||
|
||||
from epiuclid.builders.base import NS_PLATFORM_CONTAINER
|
||||
from epiuclid.builders.endpoint_study import EndpointStudyRecordBuilder
|
||||
from epiuclid.builders.reference_substance import ReferenceSubstanceBuilder
|
||||
from epiuclid.builders.substance import SubstanceBuilder
|
||||
from epiuclid.schemas.loader import get_content_schema, get_document_schema
|
||||
|
||||
from .factories import (
|
||||
make_endpoint_study_record_data,
|
||||
make_half_life_entry,
|
||||
make_reference_substance_data,
|
||||
make_soil_properties_data,
|
||||
make_substance_data,
|
||||
make_transformation_entry,
|
||||
)
|
||||
|
||||
|
||||
def _content_element(xml_str: str) -> ET.Element:
|
||||
"""Extract the first child of <Content> from a full i6d XML string."""
|
||||
root = ET.fromstring(xml_str)
|
||||
content = root.find(f"{{{NS_PLATFORM_CONTAINER}}}Content")
|
||||
assert content is not None and len(content) > 0
|
||||
return list(content)[0]
|
||||
|
||||
|
||||
def _assert_content_valid(xml_str: str, doc_type: str, subtype: str | None = None) -> None:
|
||||
schema = get_content_schema(doc_type, subtype)
|
||||
schema.validate(_content_element(xml_str))
|
||||
|
||||
|
||||
@tag("iuclid")
|
||||
class SubstanceXSDValidationTest(SimpleTestCase):
|
||||
def test_substance_validates_against_xsd(self):
|
||||
data = make_substance_data()
|
||||
xml_str = SubstanceBuilder().build(data)
|
||||
_assert_content_valid(xml_str, "SUBSTANCE")
|
||||
|
||||
def test_minimal_substance_validates_against_xsd(self):
|
||||
data = make_substance_data(name="Unknown compound", reference_substance_uuid=None)
|
||||
xml_str = SubstanceBuilder().build(data)
|
||||
_assert_content_valid(xml_str, "SUBSTANCE")
|
||||
|
||||
|
||||
@tag("iuclid")
|
||||
class ReferenceSubstanceXSDValidationTest(SimpleTestCase):
|
||||
def test_reference_substance_validates_against_xsd(self):
|
||||
data = make_reference_substance_data()
|
||||
xml_str = ReferenceSubstanceBuilder().build(data)
|
||||
_assert_content_valid(xml_str, "REFERENCE_SUBSTANCE")
|
||||
|
||||
def test_reference_substance_minimal_validates_against_xsd(self):
|
||||
data = make_reference_substance_data(
|
||||
name="Minimal compound",
|
||||
smiles="CC",
|
||||
cas_number=None,
|
||||
molecular_formula=None,
|
||||
molecular_weight=None,
|
||||
inchi=None,
|
||||
inchi_key=None,
|
||||
)
|
||||
xml_str = ReferenceSubstanceBuilder().build(data)
|
||||
_assert_content_valid(xml_str, "REFERENCE_SUBSTANCE")
|
||||
|
||||
|
||||
@tag("iuclid")
|
||||
class EndpointStudyRecordXSDValidationTest(SimpleTestCase):
|
||||
def test_endpoint_study_record_validates_against_xsd(self):
|
||||
data = make_endpoint_study_record_data(
|
||||
name="Biodegradation study with data",
|
||||
half_lives=[
|
||||
make_half_life_entry(),
|
||||
],
|
||||
temperature=(20.0, 20.0),
|
||||
transformation_products=[
|
||||
make_transformation_entry(),
|
||||
],
|
||||
model_name_and_version=["Test model 1.0"],
|
||||
software_name_and_version=["enviPath"],
|
||||
model_remarks=["Model UUID: 00000000-0000-0000-0000-000000000000"],
|
||||
)
|
||||
xml_str = EndpointStudyRecordBuilder().build(data)
|
||||
_assert_content_valid(xml_str, "ENDPOINT_STUDY_RECORD", "BiodegradationInSoil")
|
||||
|
||||
def test_temperature_only_esr_validates_against_xsd(self):
|
||||
data = make_endpoint_study_record_data(
|
||||
name="Biodegradation study with temperature only", temperature=(21.0, 21.0)
|
||||
)
|
||||
xml_str = EndpointStudyRecordBuilder().build(data)
|
||||
_assert_content_valid(xml_str, "ENDPOINT_STUDY_RECORD", "BiodegradationInSoil")
|
||||
|
||||
def test_skeleton_esr_validates_against_xsd(self):
|
||||
data = make_endpoint_study_record_data(name="Biodegradation study")
|
||||
xml_str = EndpointStudyRecordBuilder().build(data)
|
||||
_assert_content_valid(xml_str, "ENDPOINT_STUDY_RECORD", "BiodegradationInSoil")
|
||||
|
||||
def test_esr_with_soil_properties_validates_against_xsd(self):
|
||||
"""ESR with full soil properties validates against BiodegradationInSoil XSD."""
|
||||
data = make_endpoint_study_record_data(
|
||||
name="Biodegradation study with soil properties",
|
||||
soil_properties=make_soil_properties_data(),
|
||||
)
|
||||
xml_str = EndpointStudyRecordBuilder().build(data)
|
||||
_assert_content_valid(xml_str, "ENDPOINT_STUDY_RECORD", "BiodegradationInSoil")
|
||||
|
||||
def test_esr_with_multiple_soils_and_linked_dt_validates_against_xsd(self):
|
||||
data = make_endpoint_study_record_data(
|
||||
name="Biodegradation study with multiple soils",
|
||||
soil_properties_entries=[
|
||||
make_soil_properties_data(soil_no_code="2", soil_type="LOAMY_SAND"),
|
||||
make_soil_properties_data(soil_no_code="4", soil_type="CLAY_LOAM"),
|
||||
],
|
||||
half_lives=[
|
||||
make_half_life_entry(dt50_start=1.0, dt50_end=1.0, soil_no_code="2"),
|
||||
make_half_life_entry(dt50_start=2.0, dt50_end=2.0, soil_no_code="4"),
|
||||
],
|
||||
)
|
||||
xml_str = EndpointStudyRecordBuilder().build(data)
|
||||
_assert_content_valid(xml_str, "ENDPOINT_STUDY_RECORD", "BiodegradationInSoil")
|
||||
|
||||
|
||||
@tag("iuclid")
|
||||
class DocumentWrapperXSDValidationTest(SimpleTestCase):
|
||||
def test_full_i6d_document_validates_against_container_xsd(self):
|
||||
"""Validate the Document wrapper (PlatformMetadata + Content + Attachments + ModificationHistory).
|
||||
|
||||
The container schema uses processContents="strict" for xs:any in Content,
|
||||
so we need the content schema loaded into the validator too.
|
||||
"""
|
||||
data = make_substance_data()
|
||||
xml_str = SubstanceBuilder().build(data)
|
||||
root = ET.fromstring(xml_str)
|
||||
|
||||
doc_schema = get_document_schema()
|
||||
content_schema = get_content_schema("SUBSTANCE")
|
||||
|
||||
# This is a xmlschema quirk and happens because there are children of the Content element not defined in the Content schema.
|
||||
errors = [
|
||||
e for e in doc_schema.iter_errors(root) if "unavailable namespace" not in str(e.reason)
|
||||
]
|
||||
self.assertEqual(errors, [], msg=f"Document wrapper errors: {errors}")
|
||||
|
||||
content_el = _content_element(xml_str)
|
||||
content_schema.validate(content_el)
|
||||
22
epiuclid/tests/xml_assertions.py
Normal file
22
epiuclid/tests/xml_assertions.py
Normal file
@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
|
||||
def assert_xpath_text(
|
||||
case: SimpleTestCase,
|
||||
root: ET.Element,
|
||||
path: str,
|
||||
expected: str,
|
||||
) -> ET.Element:
|
||||
element = root.find(path)
|
||||
case.assertIsNotNone(element, msg=f"Missing element at xpath: {path}")
|
||||
assert element is not None
|
||||
case.assertEqual(element.text, expected)
|
||||
return element
|
||||
|
||||
|
||||
def assert_xpath_absent(case: SimpleTestCase, root: ET.Element, path: str) -> None:
|
||||
case.assertIsNone(root.find(path), msg=f"Element should be absent at xpath: {path}")
|
||||
Reference in New Issue
Block a user