from __future__ import annotations import json from pathlib import Path import unittest from unittest.mock import patch from app.simulation.components.experimental.library import LIBRARY from app.simulation.core.base import AlgebraicComponent from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec from app.simulation.core.metadata import ParameterDefinition from app.simulation.core.ports import PortDefinition from app.simulation.registry import ( COMPONENT_MODEL_REGISTRY, ComponentModelSpec, validate_component_model_class, ) from app.system_xml import validate_system_xml_document GAS_REFERENCE_PARAMETER = ParameterDefinition( "gi", 0.0, label="气体类型索引", minimum=0.0, maximum=99.0, editor="amesimGasReference", ) class GasReferenceFixture(AlgebraicComponent): MODEL_TYPE = "gas_reference_fixture" MODEL_VERSION = "1.0.0" PORTS = () PARAMETERS = (GAS_REFERENCE_PARAMETER,) RESULT_VARIABLES = () DISPLAY = ComponentDisplaySpec( label="气体引用测试组件", library_id="experimental", category_id="flow", symbol="generic", ports=(), ) def __init__(self, name: str, gi: float) -> None: super().__init__(name) self.set_parameter_values({"gi": gi}) @classmethod def create(cls, *, name, medium, parameters): del medium return cls(name, parameters["gi"]) class GasMediumDefinitionFixture(AlgebraicComponent): MODEL_TYPE = "gas_medium_definition_fixture" MODEL_VERSION = "1.0.0" PORTS = () PARAMETERS = ( ParameterDefinition( "gi", 1.0, label="介质索引", minimum=1.0, maximum=99.0, ), ) RESULT_VARIABLES = () DISPLAY = ComponentDisplaySpec( label="气体介质定义测试组件", library_id="experimental", category_id="flow", symbol="generic", ports=(), role="amesimGasMediumDefinition", ) def __init__(self, name: str, gi: float) -> None: super().__init__(name) self.set_parameter_values({"gi": gi}) @classmethod def create(cls, *, name, medium, parameters): del medium return cls(name, parameters["gi"]) class ConnectedGasReferenceFixture(AlgebraicComponent): MODEL_TYPE = "connected_gas_reference_fixture" MODEL_VERSION = "1.0.0" PORTS = ( PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), ) PARAMETERS = (GAS_REFERENCE_PARAMETER,) RESULT_VARIABLES = () DISPLAY = ComponentDisplaySpec( label="相连气体引用测试组件", library_id="experimental", category_id="flow", symbol="generic", ports=(PortDisplaySpec("port_1", "right"),), ) def __init__(self, name: str, gi: float) -> None: super().__init__(name) self.register_declared_port("port_1") self.set_parameter_values({"gi": gi}) @classmethod def create(cls, *, name, medium, parameters): del medium return cls(name, parameters["gi"]) GAS_REFERENCE_SPEC = ComponentModelSpec( component_class=GasReferenceFixture, library=LIBRARY, ) GAS_MEDIUM_DEFINITION_SPEC = ComponentModelSpec( component_class=GasMediumDefinitionFixture, library=LIBRARY, ) CONNECTED_GAS_REFERENCE_SPEC = ComponentModelSpec( component_class=ConnectedGasReferenceFixture, library=LIBRARY, ) def system_xml( *, gi: float | None, medium_reference_version: str | None = None, medium_gas_indices: tuple[float, ...] = (), ) -> bytes: version_attribute = ( f' mediumReferenceVersion="{medium_reference_version}"' if medium_reference_version is not None else "" ) reference_parameter = ( f'' if gi is not None else "" ) medium_components = "".join( f""" """ for index, gas_index in enumerate(medium_gas_indices, start=1) ) return f""" {reference_parameter} {medium_components} """.encode() def connected_system_xml( *, first_gi: float, second_gi: float, medium_gas_indices: tuple[float, ...], ) -> bytes: medium_components = "".join( f""" """ for index, gas_index in enumerate(medium_gas_indices, start=1) ) return f""" {medium_components} """.encode() def air_medium_system_xml(*, property_model: float | None) -> bytes: property_parameter = ( f'' if property_model is not None else "" ) return f""" {property_parameter} """.encode() class MediumReferenceCatalogContractTests(unittest.TestCase): def test_registry_emits_parameter_editor_and_component_role(self) -> None: validate_component_model_class(GasReferenceFixture, library=LIBRARY) validate_component_model_class(GasMediumDefinitionFixture, library=LIBRARY) reference_payload = GAS_REFERENCE_SPEC.as_catalog_dict() definition_payload = GAS_MEDIUM_DEFINITION_SPEC.as_catalog_dict() self.assertEqual( reference_payload["parameters"][0]["editor"], "amesimGasReference", ) self.assertEqual( definition_payload["role"], "amesimGasMediumDefinition", ) def test_committed_catalog_schema_constrains_the_extension_values(self) -> None: schema_path = ( Path(__file__).resolve().parent.parent / "schemas" / "component-catalog-v1.schema.json" ) schema = json.loads(schema_path.read_text(encoding="utf-8")) self.assertEqual( schema["$defs"]["parameter"]["properties"]["editor"]["enum"], ["amesimGasReference", "amesimGasPropertyModel"], ) self.assertEqual( schema["$defs"]["parameter"]["properties"]["options"]["items"][ "$ref" ], "#/$defs/parameterOption", ) self.assertEqual( schema["$defs"]["component"]["properties"]["role"]["enum"], ["amesimGasMediumDefinition"], ) class MediumReferenceSystemXmlContractTests(unittest.TestCase): def setUp(self) -> None: self.registry_patch = patch.dict( COMPONENT_MODEL_REGISTRY, { GasReferenceFixture.MODEL_TYPE: GAS_REFERENCE_SPEC, GasMediumDefinitionFixture.MODEL_TYPE: GAS_MEDIUM_DEFINITION_SPEC, ConnectedGasReferenceFixture.MODEL_TYPE: CONNECTED_GAS_REFERENCE_SPEC, }, ) self.registry_patch.start() self.addCleanup(self.registry_patch.stop) def test_legacy_gi_one_without_definition_maps_to_builtin_air(self) -> None: report = validate_system_xml_document(system_xml(gi=1.0)) self.assertTrue(report.valid) self.assertEqual( {issue.code for issue in report.issues}, {"AMESIM_GI_LEGACY_AIR_MAPPED"}, ) assert report.document is not None self.assertEqual(report.document.medium_reference_version, "1") self.assertEqual( report.document.as_project_data()["mediumReferenceVersion"], 1, ) self.assertEqual( report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"], 0.0, ) def test_legacy_missing_gi_injects_builtin_air(self) -> None: report = validate_system_xml_document(system_xml(gi=None)) self.assertTrue(report.valid) self.assertEqual( {issue.code for issue in report.issues}, {"AMESIM_GI_DEFAULT_INJECTED"}, ) assert report.document is not None self.assertEqual( report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"], 0.0, ) def test_versioned_gi_one_is_not_reinterpreted(self) -> None: report = validate_system_xml_document( system_xml(gi=1.0, medium_reference_version="1") ) self.assertFalse(report.valid) self.assertEqual( {issue.code for issue in report.issues}, {"AMESIM_GAS_REFERENCE_UNDEFINED"}, ) assert report.document is not None self.assertEqual(report.document.medium_reference_version, "1") self.assertEqual( report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"], 1.0, ) def test_legacy_document_with_definition_is_not_reinterpreted(self) -> None: report = validate_system_xml_document( system_xml(gi=1.0, medium_gas_indices=(1.0,)) ) self.assertTrue(report.valid) self.assertEqual(report.issues, ()) assert report.document is not None self.assertEqual( report.document.as_project_data()["nodes"][0]["data"]["parameters"]["gi"], 1.0, ) def test_versioned_positive_reference_requires_matching_definition(self) -> None: report = validate_system_xml_document( system_xml( gi=2.0, medium_reference_version="1", medium_gas_indices=(2.0,), ) ) self.assertTrue(report.valid) self.assertEqual(report.issues, ()) def test_missing_property_model_is_defaulted_for_versioned_medium(self) -> None: report = validate_system_xml_document( air_medium_system_xml(property_model=None) ) self.assertTrue(report.valid) self.assertEqual( {issue.code for issue in report.issues}, {"AMESIM_GAS_PROPERTY_MODEL_DEFAULTED"}, ) assert report.document is not None parameters = report.document.as_project_data()["nodes"][0]["data"][ "parameters" ] self.assertEqual(parameters["gi"], 1.0) self.assertEqual(parameters["property_model"], 0.0) def test_explicit_supported_property_model_round_trips_without_warning(self) -> None: report = validate_system_xml_document( air_medium_system_xml(property_model=0.0) ) self.assertTrue(report.valid) self.assertEqual(report.issues, ()) def test_unsupported_property_model_is_rejected_semantically(self) -> None: for property_model in (1.0, 99.0): with self.subTest(property_model=property_model): report = validate_system_xml_document( air_medium_system_xml(property_model=property_model) ) self.assertFalse(report.valid) self.assertEqual( {issue.code for issue in report.issues}, {"PARAMETER_VALUE_INVALID"}, ) def test_medium_definition_index_must_be_integer_in_range(self) -> None: fractional = validate_system_xml_document( system_xml( gi=0.0, medium_reference_version="1", medium_gas_indices=(1.5,), ) ) zero = validate_system_xml_document( system_xml( gi=0.0, medium_reference_version="1", medium_gas_indices=(0.0,), ) ) self.assertIn( "AMESIM_GAS_MEDIUM_INDEX_INVALID", {issue.code for issue in fractional.issues}, ) self.assertIn( "AMESIM_GAS_MEDIUM_INDEX_INVALID", {issue.code for issue in zero.issues}, ) def test_medium_definition_indices_must_be_unique(self) -> None: report = validate_system_xml_document( system_xml( gi=1.0, medium_reference_version="1", medium_gas_indices=(1.0, 1.0), ) ) self.assertFalse(report.valid) self.assertIn( "AMESIM_GAS_MEDIUM_INDEX_DUPLICATE", {issue.code for issue in report.issues}, ) def test_gas_reference_index_must_be_integer_in_range(self) -> None: fractional = validate_system_xml_document( system_xml(gi=1.5, medium_reference_version="1") ) out_of_range = validate_system_xml_document( system_xml(gi=100.0, medium_reference_version="1") ) self.assertIn( "AMESIM_GAS_REFERENCE_INDEX_INVALID", {issue.code for issue in fractional.issues}, ) self.assertIn( "AMESIM_GAS_REFERENCE_INDEX_INVALID", {issue.code for issue in out_of_range.issues}, ) def test_connected_pneumatic_references_cannot_conflict(self) -> None: report = validate_system_xml_document( connected_system_xml( first_gi=1.0, second_gi=2.0, medium_gas_indices=(1.0, 2.0), ) ) self.assertFalse(report.valid) self.assertEqual( {issue.code for issue in report.issues}, {"AMESIM_GAS_REFERENCE_CONFLICT"}, ) def test_connected_pneumatic_references_may_share_one_medium(self) -> None: report = validate_system_xml_document( connected_system_xml( first_gi=1.0, second_gi=1.0, medium_gas_indices=(1.0,), ) ) self.assertTrue(report.valid) self.assertEqual(report.issues, ()) def test_unknown_medium_reference_version_is_rejected_by_xsd(self) -> None: report = validate_system_xml_document( system_xml(gi=0.0, medium_reference_version="2") ) self.assertFalse(report.valid) self.assertIsNone(report.document) self.assertEqual({issue.code for issue in report.issues}, {"XSD_VALIDATION_ERROR"}) if __name__ == "__main__": unittest.main()