from __future__ import annotations import json from pathlib import Path import unittest from app.main import get_component_catalog from app.simulation.registry import ( COMPONENT_MODEL_REGISTRY, validate_component_registries, ) from app.simulation.core.metadata import ParameterDefinition class ComponentCatalogTests(unittest.TestCase): def setUp(self) -> None: self.catalog = get_component_catalog() self.libraries = { library["id"]: library for library in self.catalog["libraries"] } self.library = self.libraries["experimental"] self.components = { component["modelType"]: component for library in self.catalog["libraries"] for component in library["components"] } def test_experimental_library_is_exposed_as_temporary(self) -> None: self.assertEqual(self.catalog["schemaVersion"], 1) self.assertEqual(self.library["id"], "experimental") self.assertEqual(self.library["label"], "临时测试组件库") self.assertEqual(self.library["version"], "0.1.0") self.assertTrue(self.library["temporary"]) self.assertEqual( self.library["sourcePackage"], "app.simulation.components.experimental", ) self.assertEqual( [category["id"] for category in self.library["categories"]], ["storage", "flow", "junctions"], ) def test_amesim_library_exposes_pneumatic_models(self) -> None: library = self.libraries["amesim"] components = { component["modelType"]: component for component in library["components"] } self.assertEqual(library["label"], "AMESim 组件库") self.assertEqual([category["id"] for category in library["categories"]], ["media", "storage", "flow", "junctions", "boundary", "signals", "mechanical"]) self.assertEqual(set(components), {"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"}) for model_type, component in components.items(): with self.subTest(model_type=model_type): self.assertEqual(component["symbol"], model_type) self.assertEqual( components["amesim_ideal_air_medium"]["role"], "amesimGasMediumDefinition", ) self.assertEqual( components["amesim_ideal_air_medium"]["category"]["id"], "media", ) self.assertEqual( components["amesim_ideal_air_medium"]["label"], "空气介质定义", ) self.assertEqual( components["amesim_ideal_air_medium"]["modelVersion"], "0.2.0", ) medium_parameters = { parameter["name"]: parameter for parameter in components["amesim_ideal_air_medium"]["parameters"] } self.assertEqual( list(medium_parameters), ["gi", "property_model"], ) self.assertEqual( medium_parameters["property_model"]["editor"], "amesimGasPropertyModel", ) self.assertEqual( medium_parameters["property_model"]["options"], [{"value": 0, "label": "理想气体"}], ) helium_medium = components["amesim_helium_medium"] self.assertEqual(helium_medium["role"], "amesimGasMediumDefinition") self.assertEqual(helium_medium["category"]["id"], "media") self.assertEqual(helium_medium["label"], "氦气介质定义") self.assertEqual(helium_medium["modelVersion"], "0.1.0") helium_parameters = { parameter["name"]: parameter for parameter in helium_medium["parameters"] } self.assertEqual(list(helium_parameters), ["gi", "property_model"]) self.assertEqual( helium_parameters["property_model"]["editor"], "amesimGasPropertyModel", ) self.assertEqual( helium_parameters["property_model"]["options"], [{"value": 0, "label": "Peng–Robinson"}], ) self.assertEqual( [port["name"] for port in components["amesim_p4node2"]["ports"]], ["port_1", "port_2", "port_3", "port_4"], ) self.assertEqual(components["amesim_pnpl01"]["category"]["id"], "boundary") self.assertEqual([port["name"] for port in components["amesim_pnpl01"]["ports"]], ["port_1"]) pnch_parameters = { parameter["name"]: parameter for parameter in components["amesim_pnch023"]["parameters"] } self.assertEqual(components["amesim_pnch023"]["category"]["id"], "storage") self.assertEqual(pnch_parameters["cvol"]["quantity"], "volume") self.assertEqual(pnch_parameters["kth"]["quantity"], "heat_transfer_coefficient") pnch012_parameters = { parameter["name"]: parameter for parameter in components["amesim_pnch012"]["parameters"] } self.assertEqual(components["amesim_pnch012"]["category"]["id"], "storage") self.assertEqual([port["name"] for port in components["amesim_pnch012"]["ports"]], ["port_1", "port_2", "port_3", "port_4"]) self.assertEqual(pnch012_parameters["cvol0"]["quantity"], "volume") self.assertEqual(pnch012_parameters["dvol1"]["unit"], "m3/s") pnor_parameters = { parameter["name"]: parameter for parameter in components["amesim_pnor001"]["parameters"] } self.assertEqual(components["amesim_pnor001"]["category"]["id"], "flow") self.assertEqual(pnor_parameters["area"]["quantity"], "area") self.assertEqual(pnor_parameters["area"]["unit"], "m2") pnvo_parameters = { parameter["name"]: parameter for parameter in components["amesim_pnvo001_fixed"]["parameters"] } self.assertEqual(components["amesim_pnvo001_fixed"]["category"]["id"], "flow") self.assertEqual(pnvo_parameters["opening"]["maximum"], 1.0) self.assertEqual([port["name"] for port in components["amesim_pnvo001_fixed"]["ports"]], ["port_2", "port_3"]) self.assertEqual(components["amesim_step0"]["category"]["id"], "signals") self.assertEqual([port["name"] for port in components["amesim_step0"]["ports"]], ["out"]) ud00_parameters = { parameter["name"]: parameter for parameter in components["amesim_ud00"]["parameters"] } self.assertEqual(components["amesim_ud00"]["category"]["id"], "signals") self.assertEqual([port["name"] for port in components["amesim_ud00"]["ports"]], ["out"]) self.assertEqual(ud00_parameters["tstart"]["quantity"], "time") self.assertEqual(ud00_parameters["nstages"]["maximum"], 8.0) self.assertEqual(components["amesim_f000"]["category"]["id"], "mechanical") self.assertEqual([port["name"] for port in components["amesim_f000"]["ports"]], ["port_1"]) self.assertEqual(components["amesim_f000"]["ports"][0]["domain"], "mechanical") self.assertEqual(components["amesim_forc"]["category"]["id"], "mechanical") self.assertEqual([port["name"] for port in components["amesim_forc"]["ports"]], ["res", "port_2"]) self.assertEqual(components["amesim_forc"]["ports"][1]["domain"], "mechanical") mecmas_parameters = { parameter["name"]: parameter for parameter in components["amesim_mecmas21"]["parameters"] } self.assertEqual(components["amesim_mecmas21"]["category"]["id"], "mechanical") self.assertEqual([port["name"] for port in components["amesim_mecmas21"]["ports"]], ["port_2", "port_1"]) self.assertEqual(components["amesim_mecmas21"]["modelVersion"], "0.2.0") self.assertEqual( components["amesim_mecmas21"]["parameterGroups"], [ { "id": "friction", "label": "摩擦", "parameters": [ "frictionType", "strib", "astrib", "fstick", "fcoul", "rvisc", "wind", "dvel", ], "order": 10, "defaultExpanded": False, }, { "id": "endstops", "label": "限位", "parameters": [ "discContactOption", "xmax", "Kbmax", "Dbmax", "Pdmax", "xmin", "Kbmin", "Dbmin", "Pdmin", "restcoeff", "restdvel", ], "order": 20, "defaultExpanded": False, }, ], ) self.assertNotIn("parameterGroups", components["amesim_f000"]) self.assertEqual(mecmas_parameters["mass"]["unit"], "kg") self.assertEqual(mecmas_parameters["Kbmin"]["unit"], "N/m") self.assertEqual(mecmas_parameters["theta"]["label"], "倾角(度)") self.assertIn("+90°", mecmas_parameters["theta"]["description"]) self.assertIn("-90°", mecmas_parameters["theta"]["description"]) self.assertEqual( { name: ( mecmas_parameters[name]["editor"], [ (option["value"], option["label"]) for option in mecmas_parameters[name]["options"] ], ) for name in ( "useFriction", "stoptype", "discContactOption", "strib", "frictionType", ) }, { "useFriction": ("choice", [(1.0, "否"), (2.0, "是")]), "stoptype": ( "choice", [ (1.0, "理想限位"), (2.0, "弹性限位"), (3.0, "恢复碰撞"), (4.0, "无限位"), ], ), "discContactOption": ( "choice", [ (1.0, "允许负接触力"), (2.0, "不允许负接触力"), ], ), "strib": ("choice", [(1.0, "否"), (2.0, "是")]), "frictionType": ( "choice", [(1.0, "简单"), (2.0, "高级")], ), }, ) self.assertEqual( { name: mecmas_parameters[name]["visibleWhen"] for name in ( "fstick", "fcoul", "rvisc", "wind", "dvel", "restdvel", "restcoeff", "astrib", "xmin", "Kbmin", "Dbmin", "Pdmin", "xmax", "Kbmax", "Dbmax", "Pdmax", "discContactOption", "strib", "frictionType", ) }, { "fstick": [ {"parameter": "stoptype", "values": [1.0, 2.0, 4.0]}, {"parameter": "useFriction", "values": [2.0]}, ], "fcoul": [ {"parameter": "stoptype", "values": [1.0, 2.0, 4.0]}, {"parameter": "useFriction", "values": [2.0]}, ], "rvisc": [ {"parameter": "useFriction", "values": [2.0]}, ], "wind": [ {"parameter": "useFriction", "values": [2.0]}, ], "dvel": [ {"parameter": "stoptype", "values": [1.0, 2.0, 4.0]}, {"parameter": "useFriction", "values": [2.0]}, {"parameter": "frictionType", "values": [2.0]}, ], "restdvel": [ {"parameter": "stoptype", "values": [3.0]}, ], "restcoeff": [ {"parameter": "stoptype", "values": [3.0]}, ], "astrib": [ {"parameter": "stoptype", "values": [1.0, 2.0, 4.0]}, {"parameter": "useFriction", "values": [2.0]}, {"parameter": "frictionType", "values": [2.0]}, {"parameter": "strib", "values": [2.0]}, ], "xmin": [ {"parameter": "stoptype", "values": [1.0, 2.0, 3.0]}, ], "Kbmin": [ {"parameter": "stoptype", "values": [2.0]}, ], "Dbmin": [ {"parameter": "stoptype", "values": [2.0]}, ], "Pdmin": [ {"parameter": "stoptype", "values": [2.0]}, ], "xmax": [ {"parameter": "stoptype", "values": [1.0, 2.0, 3.0]}, ], "Kbmax": [ {"parameter": "stoptype", "values": [2.0]}, ], "Dbmax": [ {"parameter": "stoptype", "values": [2.0]}, ], "Pdmax": [ {"parameter": "stoptype", "values": [2.0]}, ], "discContactOption": [ {"parameter": "stoptype", "values": [2.0]}, ], "strib": [ {"parameter": "stoptype", "values": [1.0, 2.0, 4.0]}, {"parameter": "useFriction", "values": [2.0]}, {"parameter": "frictionType", "values": [2.0]}, ], "frictionType": [ {"parameter": "stoptype", "values": [1.0, 2.0, 4.0]}, {"parameter": "useFriction", "values": [2.0]}, ], }, ) for name in ("mass", "theta", "useFriction", "stoptype", "v0", "x0"): with self.subTest(always_visible=name): self.assertNotIn("visibleWhen", mecmas_parameters[name]) lstp_parameters = { parameter["name"]: parameter for parameter in components["amesim_lstp00a"]["parameters"] } self.assertEqual(components["amesim_lstp00a"]["category"]["id"], "mechanical") self.assertEqual([port["name"] for port in components["amesim_lstp00a"]["ports"]], ["port_1", "port_2"]) self.assertEqual(lstp_parameters["gap0"]["unit"], "m") self.assertEqual(lstp_parameters["kcont"]["unit"], "N/m") lmechn_parameters = { parameter["name"]: parameter for parameter in components["amesim_lmechn1"]["parameters"] } self.assertEqual(components["amesim_lmechn1"]["category"]["id"], "mechanical") self.assertEqual([port["name"] for port in components["amesim_lmechn1"]["ports"]], [f"port_{index}" for index in range(1, 10)]) pnrp_parameters = { parameter["name"]: parameter for parameter in components["amesim_pnrp17"]["parameters"] } self.assertEqual(components["amesim_pnrp17"]["category"]["id"], "mechanical") self.assertEqual([port["name"] for port in components["amesim_pnrp17"]["ports"]], ["port_1", "port_3", "port_2", "port_4", "port_5"]) self.assertEqual(pnrp_parameters["dp"]["unit"], "m") self.assertEqual(lmechn_parameters["v1"]["maximum"], 8.0) self.assertEqual(components["amesim_pnvo001"]["category"]["id"], "flow") self.assertEqual([port["name"] for port in components["amesim_pnvo001"]["ports"]], ["res", "port_2", "port_3"]) pnl_parameters = { parameter["name"]: parameter for parameter in components["amesim_pnl00r"]["parameters"] } self.assertEqual(components["amesim_pnl00r"]["category"]["id"], "flow") self.assertEqual(pnl_parameters["diam"]["quantity"], "length") self.assertEqual(pnl_parameters["le"]["unit"], "m") pnl0001_parameters = { parameter["name"]: parameter for parameter in components["amesim_pnl0001"]["parameters"] } self.assertEqual(components["amesim_pnl0001"]["category"]["id"], "flow") self.assertEqual(pnl0001_parameters["kth"]["quantity"], "heat_transfer_coefficient") self.assertEqual(pnl0001_parameters["p0"]["unit"], "Pa") self.assertEqual(components["amesim_pnl0002"]["category"]["id"], "flow") self.assertEqual(components["amesim_pnl0003"]["category"]["id"], "flow") pnl0003_parameters = { parameter["name"]: parameter for parameter in components["amesim_pnl0003"]["parameters"] } self.assertEqual(pnl0003_parameters["p1_0"]["unit"], "Pa") self.assertEqual(pnl0003_parameters["T2_0"]["quantity"], "temperature") def test_amesim_configurable_parameters_expose_choices_and_groups(self) -> None: def parameters(model_type: str) -> dict[str, dict[str, object]]: return { parameter["name"]: parameter for parameter in self.components[model_type]["parameters"] } flow_options = [ {"value": 1.0, "label": "Cq"}, {"value": 2.0, "label": "Cv"}, {"value": 3.0, "label": "Kv"}, ] for model_type, area_name in ( ("amesim_pnor001", "area"), ("amesim_pnvo001_fixed", "area0"), ("amesim_pnvo001", "area0"), ): with self.subTest(model_type=model_type): component = self.components[model_type] model_parameters = parameters(model_type) self.assertEqual(component["modelVersion"], "0.2.0") self.assertEqual(model_parameters["flowset"]["editor"], "choice") self.assertEqual(model_parameters["flowset"]["options"], flow_options) self.assertEqual( model_parameters["cq"]["visibleWhen"], [{"parameter": "flowset", "values": [1.0]}], ) self.assertEqual( model_parameters[area_name]["visibleWhen"], [{"parameter": "flowset", "values": [1.0]}], ) self.assertEqual( model_parameters["Cv"]["visibleWhen"], [{"parameter": "flowset", "values": [2.0]}], ) self.assertEqual( model_parameters["Kv"]["visibleWhen"], [{"parameter": "flowset", "values": [3.0]}], ) self.assertEqual( component["parameterGroups"], [ { "id": "flow_coefficient", "label": "流量系数", "parameters": ["cq", area_name, "Cv", "Kv"], "order": 10, "defaultExpanded": False, } ], ) thermal_options = [ {"value": 1.0, "label": "多方过程"}, {"value": 2.0, "label": "带换热"}, ] for model_type in ("amesim_pnl0001", "amesim_pnl0002", "amesim_pnl0003"): with self.subTest(model_type=model_type): component = self.components[model_type] model_parameters = parameters(model_type) self.assertEqual(component["modelVersion"], "0.2.0") self.assertEqual(model_parameters["mode"]["editor"], "choice") self.assertEqual(model_parameters["mode"]["options"], thermal_options) self.assertEqual( model_parameters["k"]["visibleWhen"], [{"parameter": "mode", "values": [1.0]}], ) for name in ("kth", "extemp"): self.assertEqual( model_parameters[name]["visibleWhen"], [{"parameter": "mode", "values": [2.0]}], ) self.assertEqual( component["parameterGroups"], [ { "id": "thermodynamics", "label": "热力学", "parameters": ["k", "kth", "extemp"], "order": 10, "defaultExpanded": False, } ], ) ud00 = self.components["amesim_ud00"] ud00_parameters = parameters("amesim_ud00") self.assertEqual(ud00["modelVersion"], "0.2.0") self.assertEqual(ud00_parameters["nstages"]["editor"], "choice") self.assertEqual( ud00_parameters["nstages"]["options"], [ {"value": float(stage_count), "label": str(stage_count)} for stage_count in range(1, 9) ], ) self.assertEqual( ud00_parameters["iscyclic"]["options"], [{"value": 0.0, "label": "否"}, {"value": 1.0, "label": "是"}], ) self.assertNotIn("visibleWhen", ud00_parameters["start1"]) self.assertEqual( ud00_parameters["start2"]["visibleWhen"], [{"parameter": "nstages", "values": [2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]}], ) self.assertEqual( ud00_parameters["t8"]["visibleWhen"], [{"parameter": "nstages", "values": [8.0]}], ) self.assertEqual( ud00["parameterGroups"][0]["parameters"], [ name for stage_index in range(1, 9) for name in ( f"start{stage_index}", f"end{stage_index}", f"t{stage_index}", ) ], ) lstp = self.components["amesim_lstp00a"] lstp_parameters = parameters("amesim_lstp00a") self.assertEqual(lstp["modelVersion"], "0.2.0") self.assertEqual( lstp_parameters["stiffmode"]["options"], [ {"value": 1.0, "label": "数值刚度"}, {"value": 2.0, "label": "几何参数"}, ], ) self.assertEqual( lstp_parameters["discContactOption"]["options"], [ {"value": 1.0, "label": "允许负接触力"}, {"value": 2.0, "label": "不允许负接触力"}, ], ) self.assertEqual( lstp_parameters["kcont"]["visibleWhen"], [{"parameter": "stiffmode", "values": [1.0]}], ) for name in ("na", "G", "sdiam", "wdiam"): self.assertEqual( lstp_parameters[name]["visibleWhen"], [{"parameter": "stiffmode", "values": [2.0]}], ) self.assertEqual( lstp["parameterGroups"], [ { "id": "stiffness", "label": "刚度", "parameters": ["kcont", "G", "sdiam", "wdiam", "na"], "order": 10, "defaultExpanded": False, }, { "id": "contact", "label": "接触", "parameters": ["gap0", "rcont", "Pdis"], "order": 20, "defaultExpanded": False, }, ], ) def test_every_registered_component_is_in_the_catalog(self) -> None: self.assertEqual( set(self.components), set(COMPONENT_MODEL_REGISTRY), ) def test_catalog_reuses_model_parameter_contracts(self) -> None: cylinder = self.components["cylinder"] parameters = { parameter["name"]: parameter for parameter in cylinder["parameters"] } self.assertEqual(cylinder["label"], "气瓶") self.assertEqual(cylinder["modelVersion"], "1.0.0") self.assertEqual(parameters["volume"]["default"], 0.01) self.assertEqual(parameters["volume"]["quantity"], "volume") self.assertEqual(parameters["p0"]["unit"], "Pa") self.assertTrue(parameters["p0"]["minimumExclusive"]) def test_parameter_description_is_optional_interface_metadata(self) -> None: described = ParameterDefinition( "diam", 0.01, label="管径", quantity="length", unit="m", minimum=0.0, minimum_exclusive=True, description="用于计算流通面积。", ).as_interface_dict() undescribed = ParameterDefinition( "gain", 1.0, label="增益", ).as_interface_dict() self.assertEqual(described["description"], "用于计算流通面积。") self.assertEqual(described["default"], 0.01) self.assertEqual(described["unit"], "m") self.assertEqual(described["minimum"], 0.0) self.assertTrue(described["minimumExclusive"]) self.assertNotIn("description", undescribed) def test_catalog_exposes_descriptions_for_core_amesim_pneumatic_parameters( self, ) -> None: medium_parameters = { parameter["name"]: parameter for parameter in self.components["amesim_helium_medium"]["parameters"] } chamber_parameters = { parameter["name"]: parameter for parameter in self.components["amesim_pnch023"]["parameters"] } pipe_parameters = { parameter["name"]: parameter for parameter in self.components["amesim_pnl00r"]["parameters"] } orifice_parameters = { parameter["name"]: parameter for parameter in self.components["amesim_pnor001"]["parameters"] } self.assertIn("自动分配", medium_parameters["gi"]["description"]) self.assertIn( "Peng–Robinson", medium_parameters["property_model"]["description"], ) self.assertIn("固定有效容积", chamber_parameters["cvol"]["description"]) self.assertIn("Darcy", pipe_parameters["rr"]["description"]) self.assertIn("有效孔口面积", orifice_parameters["area"]["description"]) self.assertEqual(chamber_parameters["cvol"]["default"], 0.057) self.assertEqual(pipe_parameters["diam"]["unit"], "m") def test_catalog_adds_port_layout_without_changing_physical_contract(self) -> None: tee_ports = { port["name"]: port for port in self.components["tee"]["ports"] } self.assertEqual(tee_ports["port_in"]["side"], "left") self.assertEqual(tee_ports["port_out1"]["side"], "right") self.assertEqual(tee_ports["port_out2"]["side"], "right") self.assertEqual(tee_ports["port_in"]["kind"], "physical") self.assertEqual(tee_ports["port_in"]["domain"], "pneumatic") self.assertEqual(tee_ports["port_in"]["nominalRole"], "bidirectional") def test_registry_can_repeat_discovery_and_validation(self) -> None: validate_component_registries() def test_catalog_schema_is_committed_and_targets_version_one(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["properties"]["schemaVersion"]["const"], 1) self.assertIn("modelVersion", schema["$defs"]["component"]["required"]) self.assertIn("version", schema["$defs"]["library"]["required"]) parameter_schema = schema["$defs"]["parameter"] parameter_group_schema = schema["$defs"]["parameterGroup"] self.assertEqual( parameter_schema["properties"]["description"], {"type": "string", "minLength": 1}, ) self.assertNotIn("description", parameter_schema["required"]) self.assertFalse(parameter_schema["additionalProperties"]) self.assertNotIn("unknownField", parameter_schema["properties"]) self.assertEqual( parameter_group_schema["required"], ["id", "label", "parameters", "order", "defaultExpanded"], ) self.assertTrue( parameter_group_schema["properties"]["parameters"]["uniqueItems"] ) self.assertIn( "parameterGroups", schema["$defs"]["component"]["properties"], ) self.assertNotIn( "parameterGroups", schema["$defs"]["component"]["required"], ) if __name__ == "__main__": unittest.main()