from __future__ import annotations import unittest from app.simulation.components.experimental.library import LIBRARY from app.simulation.components.amesim.library import LIBRARY as AMESIM_LIBRARY from app.simulation.core.base import AlgebraicComponent from app.simulation.core.catalog import ( ComponentDisplaySpec, ParameterGroupDisplaySpec, PortDisplaySpec, ) from app.simulation.core.metadata import ( ParameterCondition, ParameterDefinition, ParameterOption, ) from app.simulation.core.medium import IdealGasMedium from app.simulation.core.ports import PortDefinition from app.simulation.registry import ( COMPONENT_LIBRARY_REGISTRY, COMPONENT_MODEL_REGISTRY, discover_component_registries, validate_component_model_class, ) class ComponentRegistryTests(unittest.TestCase): def test_manifest_is_the_only_public_model_source(self) -> None: libraries, models = discover_component_registries() self.assertEqual(libraries, COMPONENT_LIBRARY_REGISTRY) self.assertEqual(models, COMPONENT_MODEL_REGISTRY) self.assertEqual( tuple(models), ( "cylinder", "tank", "pipe", "orifice", "tee", "amesim_ideal_air_medium", "amesim_helium_medium", "amesim_pnpl01", "amesim_step0", "amesim_ud00", "amesim_f000", "amesim_forc", "amesim_mecmas21", "amesim_lstp00a", "amesim_lmechn1", "amesim_pnrp17", "amesim_pnch023", "amesim_pnch012", "amesim_pnor001", "amesim_pnvo001_fixed", "amesim_pnvo001", "amesim_pnl00r", "amesim_pnl0001", "amesim_pnl0002", "amesim_pnl0003", "amesim_pn3node2", "amesim_p4node2", ), ) self.assertNotIn( "app.simulation.components.experimental.flow.pipe:Pipe", LIBRARY.models, ) def test_amesim_library_is_enabled_as_separate_public_boundary(self) -> None: libraries, models = discover_component_registries() self.assertIn("amesim", libraries) self.assertEqual(libraries["amesim"], AMESIM_LIBRARY) self.assertEqual( models["amesim_ideal_air_medium"].library.id, "amesim", ) self.assertEqual(models["amesim_helium_medium"].library.id, "amesim") self.assertEqual(models["amesim_pnpl01"].library.id, "amesim") self.assertEqual(models["amesim_step0"].library.id, "amesim") self.assertEqual(models["amesim_ud00"].library.id, "amesim") self.assertEqual(models["amesim_f000"].library.id, "amesim") self.assertEqual(models["amesim_forc"].library.id, "amesim") self.assertEqual(models["amesim_mecmas21"].library.id, "amesim") self.assertEqual(models["amesim_lstp00a"].library.id, "amesim") self.assertEqual(models["amesim_lmechn1"].library.id, "amesim") self.assertEqual(models["amesim_pnrp17"].library.id, "amesim") self.assertEqual(models["amesim_pnch023"].library.id, "amesim") self.assertEqual(models["amesim_pnch012"].library.id, "amesim") self.assertEqual(models["amesim_pnor001"].library.id, "amesim") self.assertEqual(models["amesim_pnvo001_fixed"].library.id, "amesim") self.assertEqual(models["amesim_pnvo001"].library.id, "amesim") self.assertEqual(models["amesim_pnl00r"].library.id, "amesim") self.assertEqual(models["amesim_pnl0001"].library.id, "amesim") self.assertEqual(models["amesim_pnl0002"].library.id, "amesim") self.assertEqual(models["amesim_pnl0003"].library.id, "amesim") self.assertEqual(models["amesim_pn3node2"].library.id, "amesim") self.assertEqual(models["amesim_p4node2"].library.id, "amesim") def test_every_public_model_owns_its_catalog_contract(self) -> None: for model_type, spec in COMPONENT_MODEL_REGISTRY.items(): with self.subTest(model_type=model_type): declarations = spec.component_class.__dict__ self.assertIn("MODEL_VERSION", declarations) self.assertIn("DISPLAY", declarations) self.assertIn("create", declarations) self.assertEqual(spec.display.library_id, spec.library.id) self.assertIn( spec.display.category_id, spec.library.category_by_id, ) def test_display_ports_must_match_physical_ports(self) -> None: class BrokenDisplayComponent(AlgebraicComponent): MODEL_TYPE = "broken_display" MODEL_VERSION = "1.0.0" PORTS = ( PortDefinition.pneumatic( "port_a", nominal_role="bidirectional", ), ) PARAMETERS = () RESULT_VARIABLES = () DISPLAY = ComponentDisplaySpec( label="错误显示模型", library_id="experimental", category_id="flow", symbol="generic", ports=(PortDisplaySpec("port_b", "right"),), ) @classmethod def create( cls, *, name: str, medium: IdealGasMedium, parameters, ): return cls(name) with self.assertRaisesRegex( ValueError, "display ports must exactly match", ): validate_component_model_class( BrokenDisplayComponent, library=LIBRARY, ) def test_parameter_groups_are_strictly_validated(self) -> None: parameters = ( ParameterDefinition("mode", 1.0, label="模式"), ParameterDefinition("gain", 2.0, label="增益"), ParameterDefinition("offset", 0.0, label="偏置"), ) def validate_groups(parameter_groups: object) -> None: class GroupedComponent(AlgebraicComponent): MODEL_TYPE = "grouped_component" MODEL_VERSION = "1.0.0" PORTS = () PARAMETERS = parameters RESULT_VARIABLES = () DISPLAY = ComponentDisplaySpec( label="参数分组测试组件", library_id="experimental", category_id="flow", symbol="generic", ports=(), parameter_groups=parameter_groups, # type: ignore[arg-type] ) @classmethod def create(cls, *, name, medium, parameters): del medium, parameters return cls(name) validate_component_model_class(GroupedComponent, library=LIBRARY) valid_groups = ( ParameterGroupDisplaySpec( id="advanced", label="高级参数", parameters=("mode", "gain"), order=10, ), ) validate_groups(valid_groups) self.assertEqual( valid_groups[0].as_catalog_dict(), { "id": "advanced", "label": "高级参数", "parameters": ["mode", "gain"], "order": 10, "defaultExpanded": False, }, ) with self.assertRaisesRegex(ValueError, "must be a tuple"): validate_groups(list(valid_groups)) with self.assertRaisesRegex(ValueError, "ParameterGroupDisplaySpec"): validate_groups((object(),)) with self.assertRaisesRegex(ValueError, "must start with a lowercase"): validate_groups( ( ParameterGroupDisplaySpec( id="Advanced", label="高级参数", parameters=("mode",), ), ) ) with self.assertRaisesRegex(ValueError, "label must not be empty"): validate_groups( ( ParameterGroupDisplaySpec( id="advanced", label=" ", parameters=("mode",), ), ) ) with self.assertRaisesRegex(ValueError, "order must be an integer"): validate_groups( ( ParameterGroupDisplaySpec( id="advanced", label="高级参数", parameters=("mode",), order=1.5, # type: ignore[arg-type] ), ) ) with self.assertRaisesRegex(ValueError, "must be a boolean"): validate_groups( ( ParameterGroupDisplaySpec( id="advanced", label="高级参数", parameters=("mode",), default_expanded=1, # type: ignore[arg-type] ), ) ) with self.assertRaisesRegex(ValueError, "non-empty tuple"): validate_groups( ( ParameterGroupDisplaySpec( id="advanced", label="高级参数", parameters=(), ), ) ) with self.assertRaisesRegex(ValueError, "unknown parameters: missing"): validate_groups( ( ParameterGroupDisplaySpec( id="advanced", label="高级参数", parameters=("missing",), ), ) ) with self.assertRaisesRegex(ValueError, "contains duplicate names: mode"): validate_groups( ( ParameterGroupDisplaySpec( id="advanced", label="高级参数", parameters=("mode", "mode"), ), ) ) with self.assertRaisesRegex(ValueError, "contains duplicate names: advanced"): validate_groups( ( ParameterGroupDisplaySpec( id="advanced", label="高级参数", parameters=("mode",), ), ParameterGroupDisplaySpec( id="advanced", label="其他参数", parameters=("gain",), ), ) ) with self.assertRaisesRegex( ValueError, "assign parameters to multiple groups: mode", ): validate_groups( ( ParameterGroupDisplaySpec( id="advanced", label="高级参数", parameters=("mode", "gain"), ), ParameterGroupDisplaySpec( id="secondary", label="其他参数", parameters=("mode", "offset"), ), ) ) def test_property_model_parameter_options_are_strictly_validated(self) -> None: def validate_parameter(parameter: ParameterDefinition) -> None: class PropertyModelComponent(AlgebraicComponent): MODEL_TYPE = "property_model_component" MODEL_VERSION = "1.0.0" PORTS = () PARAMETERS = (parameter,) RESULT_VARIABLES = () DISPLAY = ComponentDisplaySpec( label="物性模型测试组件", library_id="experimental", category_id="flow", symbol="generic", ports=(), ) @classmethod def create(cls, *, name, medium, parameters): del medium, parameters return cls(name) validate_component_model_class( PropertyModelComponent, library=LIBRARY, ) def property_parameter( *, default: float = 0.0, minimum: float | None = None, maximum: float | None = None, options: tuple[ParameterOption, ...] = (), description: object = "", ) -> ParameterDefinition: return ParameterDefinition( "property_model", default, label="物性计算模型", quantity="dimensionless", unit="", minimum=minimum, maximum=maximum, editor="amesimGasPropertyModel", options=options, description=description, # type: ignore[arg-type] ) with self.assertRaisesRegex(ValueError, "description must be a string"): validate_parameter(property_parameter(description=None)) with self.assertRaisesRegex(ValueError, "description must not be blank"): validate_parameter(property_parameter(description=" ")) with self.assertRaisesRegex(ValueError, "must declare options"): validate_parameter(property_parameter()) with self.assertRaisesRegex(ValueError, "duplicate option values"): validate_parameter( property_parameter( options=( ParameterOption(0, "理想气体"), ParameterOption(0, "重复模型"), ) ) ) with self.assertRaisesRegex(ValueError, "finite numbers"): validate_parameter( property_parameter( options=(ParameterOption(float("nan"), "非法模型"),) ) ) with self.assertRaisesRegex(ValueError, "default value must be one of 0"): validate_parameter( property_parameter( default=1.0, options=(ParameterOption(0, "理想气体"),), ) ) with self.assertRaisesRegex(ValueError, "option 1 must be at most 0"): validate_parameter( property_parameter( minimum=0.0, maximum=0.0, options=(ParameterOption(1, "越界模型"),), ) ) with self.assertRaisesRegex(ValueError, "option values must be integers"): validate_parameter( property_parameter( default=0.5, options=(ParameterOption(0.5, "非整数模型"),), ) ) def test_choice_parameters_and_visibility_conditions_are_strictly_validated( self, ) -> None: def validate_parameters( parameters: tuple[ParameterDefinition, ...], ) -> None: class ChoiceComponent(AlgebraicComponent): MODEL_TYPE = "choice_component" MODEL_VERSION = "1.0.0" PORTS = () PARAMETERS = parameters RESULT_VARIABLES = () DISPLAY = ComponentDisplaySpec( label="离散参数测试组件", library_id="experimental", category_id="flow", symbol="generic", ports=(), ) @classmethod def create(cls, *, name, medium, parameters): del medium, parameters return cls(name) validate_component_model_class(ChoiceComponent, library=LIBRARY) def choice_parameter( name: str, default: float = 0.0, *, visible_when: tuple[ParameterCondition, ...] = (), ) -> ParameterDefinition: return ParameterDefinition( name, default, label=name, editor="choice", options=( ParameterOption(0.0, "关闭"), ParameterOption(1.0, "开启"), ), visible_when=visible_when, ) controller = choice_parameter("mode", 1.0) dependent = ParameterDefinition( "gain", 2.0, label="增益", visible_when=(ParameterCondition("mode", (0.0, 1.0)),), ) validate_parameters((controller, dependent)) self.assertEqual( dependent.as_interface_dict()["visibleWhen"], [{"parameter": "mode", "values": [0.0, 1.0]}], ) with self.assertRaisesRegex(ValueError, "choice editor must declare options"): validate_parameters( ( ParameterDefinition( "mode", 0.0, label="模式", editor="choice", ), ) ) with self.assertRaisesRegex( ValueError, "options require the 'choice' or 'amesimGasPropertyModel' editor", ): validate_parameters( ( ParameterDefinition( "mode", 0.0, label="模式", options=(ParameterOption(0.0, "关闭"),), ), ) ) with self.assertRaisesRegex(ValueError, "unknown parameter 'missing'"): validate_parameters( ( controller, ParameterDefinition( "gain", 2.0, label="增益", visible_when=( ParameterCondition("missing", (1.0,)), ), ), ) ) with self.assertRaisesRegex(ValueError, "cannot reference itself"): validate_parameters( ( choice_parameter( "mode", visible_when=(ParameterCondition("mode", (1.0,)),), ), ) ) with self.assertRaisesRegex( ValueError, "visibility controller 'continuous' must declare options", ): validate_parameters( ( ParameterDefinition("continuous", 0.0, label="连续量"), ParameterDefinition( "gain", 2.0, label="增益", visible_when=( ParameterCondition("continuous", (0.0,)), ), ), ) ) with self.assertRaisesRegex( ValueError, "duplicate visibility controller 'mode'", ): validate_parameters( ( controller, ParameterDefinition( "gain", 2.0, label="增益", visible_when=( ParameterCondition("mode", (0.0,)), ParameterCondition("mode", (1.0,)), ), ), ) ) with self.assertRaisesRegex(ValueError, "unsupported: 2"): validate_parameters( ( controller, ParameterDefinition( "gain", 2.0, label="增益", visible_when=(ParameterCondition("mode", (2.0,)),), ), ) ) with self.assertRaisesRegex(ValueError, "contain a cycle"): validate_parameters( ( choice_parameter( "first", visible_when=(ParameterCondition("second", (1.0,)),), ), choice_parameter( "second", visible_when=(ParameterCondition("first", (1.0,)),), ), ) ) def test_visibility_condition_value_shape_is_strictly_validated(self) -> None: def validate_condition(condition: object) -> None: class VisibilityComponent(AlgebraicComponent): MODEL_TYPE = "visibility_component" MODEL_VERSION = "1.0.0" PORTS = () PARAMETERS = ( ParameterDefinition( "mode", 0.0, label="模式", editor="choice", options=(ParameterOption(0.0, "关闭"),), ), ParameterDefinition( "gain", 1.0, label="增益", visible_when=(condition,), # type: ignore[arg-type] ), ) RESULT_VARIABLES = () DISPLAY = ComponentDisplaySpec( label="可见性测试组件", library_id="experimental", category_id="flow", symbol="generic", ports=(), ) @classmethod def create(cls, *, name, medium, parameters): del medium, parameters return cls(name) validate_component_model_class(VisibilityComponent, library=LIBRARY) with self.assertRaisesRegex(ValueError, "must use ParameterCondition"): validate_condition(object()) with self.assertRaisesRegex(ValueError, "must use a non-empty tuple"): validate_condition(ParameterCondition("mode", ())) with self.assertRaisesRegex(ValueError, "must be finite numbers"): validate_condition(ParameterCondition("mode", (float("nan"),))) with self.assertRaisesRegex(ValueError, "contains duplicate values"): validate_condition(ParameterCondition("mode", (0.0, 0.0))) malformed_visibility = ParameterDefinition( "gain", 1.0, label="增益", visible_when=[], # type: ignore[arg-type] ) class MalformedVisibilityComponent(AlgebraicComponent): MODEL_TYPE = "malformed_visibility_component" MODEL_VERSION = "1.0.0" PORTS = () PARAMETERS = (malformed_visibility,) RESULT_VARIABLES = () DISPLAY = ComponentDisplaySpec( label="错误可见性测试组件", library_id="experimental", category_id="flow", symbol="generic", ports=(), ) @classmethod def create(cls, *, name, medium, parameters): del medium, parameters return cls(name) with self.assertRaisesRegex(ValueError, "visible_when must use a tuple"): validate_component_model_class( MalformedVisibilityComponent, library=LIBRARY, ) if __name__ == "__main__": unittest.main()