"""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 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"))