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_gas_indices: tuple[float, ...] = (), ) -> bytes: 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", "choice"], ) 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_positive_reference_without_definition_is_not_reinterpreted(self) -> None: report = validate_system_xml_document(system_xml(gi=1.0)) 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.as_model_data()["components"][0]["parameters"]["gi"], 1.0, ) def test_missing_gi_is_not_defaulted(self) -> None: report = validate_system_xml_document(system_xml(gi=None)) self.assertFalse(report.valid) self.assertEqual( {issue.code for issue in report.issues}, {"PARAMETER_REQUIRED_MISSING"}, ) def test_builtin_air_reference_zero_is_valid_canonical_data(self) -> None: report = validate_system_xml_document(system_xml(gi=0.0)) self.assertTrue(report.valid) self.assertEqual(report.issues, ()) assert report.document is not None self.assertEqual( report.document.as_model_data()["components"][0]["parameters"]["gi"], 0.0, ) def test_positive_reference_with_definition_is_preserved(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_model_data()["components"][0]["parameters"]["gi"], 1.0, ) def test_positive_reference_accepts_matching_definition(self) -> None: report = validate_system_xml_document( system_xml( gi=2.0, medium_gas_indices=(2.0,), ) ) self.assertTrue(report.valid) self.assertEqual(report.issues, ()) def test_missing_property_model_is_not_defaulted(self) -> None: report = validate_system_xml_document( air_medium_system_xml(property_model=None) ) self.assertFalse(report.valid) self.assertEqual( {issue.code for issue in report.issues}, {"PARAMETER_REQUIRED_MISSING"}, ) 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_gas_indices=(1.5,), ) ) zero = validate_system_xml_document( system_xml( gi=0.0, 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_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) ) out_of_range = validate_system_xml_document( system_xml(gi=100.0) ) 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_removed_medium_reference_version_is_rejected_by_xsd(self) -> None: removed_marker_xml = system_xml(gi=0.0).replace( b'