diff --git a/flow360/component/simulation/framework/entity_expansion_utils.py b/flow360/component/simulation/framework/entity_expansion_utils.py index efa262ef8..51a62833c 100644 --- a/flow360/component/simulation/framework/entity_expansion_utils.py +++ b/flow360/component/simulation/framework/entity_expansion_utils.py @@ -18,6 +18,38 @@ from flow360.component.simulation.framework.entity_registry import EntityRegistry +def _register_mirror_entities_in_registry(registry: "EntityRegistry", mirror_status: Any) -> None: + """Register mirror-related entities (planes + derived mirrored entities) into registry. + + This helper is shared by both dict-based and params-based registry builders to ensure + consistent selector expansion coverage. + """ + if not mirror_status: + return + + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.draft_context.mirror import ( + MirrorPlane, + MirrorStatus, + ) + + # Dict path: deserialize to MirrorStatus + if isinstance(mirror_status, dict): + mirror_status = MirrorStatus.model_validate(mirror_status) + + # Object path: MirrorStatus (or compatible) with is_empty() + if hasattr(mirror_status, "is_empty") and mirror_status.is_empty(): + return + + for plane in getattr(mirror_status, "mirror_planes", []) or []: + if isinstance(plane, MirrorPlane): + registry.register(plane) + for mirrored_group in getattr(mirror_status, "mirrored_geometry_body_groups", []) or []: + registry.register(mirrored_group) + for mirrored_surface in getattr(mirror_status, "mirrored_surfaces", []) or []: + registry.register(mirrored_surface) + + def expand_entity_list_in_context( entity_list, params, @@ -129,21 +161,7 @@ def get_registry_from_params(params) -> EntityRegistry: # Register mirror entities from mirror_status so selector expansion can include mirrored types # (e.g. SurfaceSelector can expand to include MirroredSurface). mirror_status = getattr(asset_cache, "mirror_status", None) - if mirror_status is None or mirror_status.is_empty(): - return registry - - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.draft_context.mirror import MirrorPlane - - for plane in mirror_status.mirror_planes: - if isinstance(plane, MirrorPlane): - registry.register(plane) - - for mirrored_group in mirror_status.mirrored_geometry_body_groups: - registry.register(mirrored_group) - - for mirrored_surface in mirror_status.mirrored_surfaces: - registry.register(mirrored_surface) + _register_mirror_entities_in_registry(registry, mirror_status) return registry @@ -227,4 +245,9 @@ def get_entity_info_and_registry_from_dict(params_as_dict: dict) -> tuple: entity_info = parse_entity_info_model(entity_info_dict) registry = EntityRegistry.from_entity_info(entity_info) + # Register mirror entities from mirror_status so selector expansion can include mirrored types + # (e.g. SurfaceSelector can expand to include MirroredSurface) during validation. + mirror_status_dict = asset_cache.get("mirror_status") + _register_mirror_entities_in_registry(registry, mirror_status_dict) + return entity_info, registry diff --git a/tests/simulation/framework/test_entity_dict_database.py b/tests/simulation/framework/test_entity_dict_database.py index 187c09cc0..26a187952 100644 --- a/tests/simulation/framework/test_entity_dict_database.py +++ b/tests/simulation/framework/test_entity_dict_database.py @@ -33,9 +33,10 @@ def _load_simulation_json(relative_path: str) -> dict: class _AssetCache: """Simple object to hold asset cache data.""" - def __init__(self, project_entity_info, selectors): + def __init__(self, project_entity_info, selectors, mirror_status=None): self.project_entity_info = project_entity_info self.selectors = selectors + self.mirror_status = mirror_status class _DummyParams: @@ -52,8 +53,15 @@ def __init__(self, params_dict: dict, entity_info_obj=None): entity_info_obj = parse_entity_info_model(entity_info_dict) selectors = asset_cache_dict.get("selectors") + mirror_status = None + mirror_status_dict = asset_cache_dict.get("mirror_status") + if isinstance(mirror_status_dict, dict) and mirror_status_dict: + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.draft_context.mirror import MirrorStatus + + mirror_status = MirrorStatus.model_validate(mirror_status_dict) self.private_attribute_asset_cache = _AssetCache( - project_entity_info=entity_info_obj, selectors=selectors + project_entity_info=entity_info_obj, selectors=selectors, mirror_status=mirror_status ) def model_dump(self, **kwargs): @@ -89,6 +97,56 @@ def _build_simple_params_dict(): } +def _build_simple_params_dict_with_mirror_status(): + params_as_dict = _build_simple_params_dict() + params_as_dict["private_attribute_asset_cache"]["mirror_status"] = { + "mirror_planes": [ + { + "name": "plane-1", + "normal": [0, 1, 0], + "center": {"value": [0, 0, 0], "units": "m"}, + "private_attribute_entity_type_name": "MirrorPlane", + "private_attribute_id": "mirror-plane-1", + } + ], + "mirrored_geometry_body_groups": [ + { + "name": "body-1_", + "geometry_body_group_id": "body-1", + "mirror_plane_id": "mirror-plane-1", + "private_attribute_entity_type_name": "MirroredGeometryBodyGroup", + "private_attribute_id": "mirrored-body-1", + } + ], + "mirrored_surfaces": [ + { + "name": "wall_", + "surface_id": "wall", + "mirror_plane_id": "mirror-plane-1", + "private_attribute_entity_type_name": "MirroredSurface", + "private_attribute_id": "mirrored-surface-1", + } + ], + } + return params_as_dict + + +def test_get_registry_from_params_matches_dict_with_mirror_status(): + params_as_dict = _build_simple_params_dict_with_mirror_status() + dummy_params = _DummyParams(params_as_dict) + + _, dict_registry = get_entity_info_and_registry_from_dict(params_as_dict) + instance_registry = get_registry_from_params(dummy_params) + + dict_mirrored_surfaces = dict_registry.find_by_type_name("MirroredSurface") + instance_mirrored_surfaces = instance_registry.find_by_type_name("MirroredSurface") + assert _entity_names(dict_mirrored_surfaces) == _entity_names(instance_mirrored_surfaces) + + dict_planes = dict_registry.find_by_type_name("MirrorPlane") + instance_planes = instance_registry.find_by_type_name("MirrorPlane") + assert _entity_names(dict_planes) == _entity_names(instance_planes) + + def test_get_registry_for_geometry_entity_info(): """ Test get_entity_info_and_registry_from_dict with GeometryEntityInfo. diff --git a/tests/simulation/services/test_entity_processing_service.py b/tests/simulation/services/test_entity_processing_service.py index c1a55291b..0baca998e 100644 --- a/tests/simulation/services/test_entity_processing_service.py +++ b/tests/simulation/services/test_entity_processing_service.py @@ -280,6 +280,8 @@ def test_expand_entity_list_in_context_includes_mirrored_entities_from_mirror_st ) ], private_attribute_asset_cache=AssetCache( + use_inhouse_mesher=True, + use_geometry_AI=True, project_entity_info=SurfaceMeshEntityInfo( boundaries=[Surface(name="front", private_attribute_id="s-1")] ), @@ -291,6 +293,17 @@ def test_expand_entity_list_in_context_includes_mirrored_entities_from_mirror_st ), ) + # Validate schema-level correctness (skip contextual validation since this test doesn't + # provide full Case-level required fields like meshing/models/operating_condition). + validated, errors, _warnings = validate_model( + params_as_dict=params.model_dump(exclude_none=True), + validated_by=ValidationCalledBy.LOCAL, + root_item_type=None, + validation_level=None, + ) + assert errors is None + assert validated is not None + expanded = expand_entity_list_in_context(params.outputs[0].entities, params, return_names=False) expanded_type_names = {entity.private_attribute_entity_type_name for entity in expanded} assert "Surface" in expanded_type_names