# SPDX-License-Identifier: MIT OR GPL-3.0-or-later """ Standalone property-based tests for UV edge matching functionality. Feature: vrm-uv-edge-merger Property 5: UV Edge Matching Precision These tests can run without Blender dependencies. """ from dataclasses import dataclass from typing import Optional from hypothesis import given, settings from hypothesis import strategies as st # UV坐标精度(小数位数)- copied from utils.py UV_PRECISION = 6 def normalize_uv_pair( uv1: tuple[float, float], uv2: tuple[float, float] ) -> tuple[tuple[float, float], tuple[float, float]]: """ 标准化UV坐标对(较小的在前) 通过将较小的UV坐标放在前面,确保相同的边无论顶点顺序如何都能匹配。 使用6位小数精度进行比较和存储。 Args: uv1: 第一个UV坐标 (u, v) uv2: 第二个UV坐标 (u, v) Returns: 标准化后的UV对 ((u1,v1), (u2,v2)),较小的坐标在前 """ # 四舍五入到指定精度 uv1_rounded = (round(uv1[0], UV_PRECISION), round(uv1[1], UV_PRECISION)) uv2_rounded = (round(uv2[0], UV_PRECISION), round(uv2[1], UV_PRECISION)) # 比较并排序,较小的在前 if uv1_rounded <= uv2_rounded: return (uv1_rounded, uv2_rounded) return (uv2_rounded, uv1_rounded) class TestUvEdgeMatchingPrecision: """ Property 5: UV边匹配精度 *For any* edge with UV coordinates, the matching function SHALL correctly identify the edge if its UV coordinates (rounded to 6 decimal places) exist in the target UV set. **Validates: Requirements 4.2, 4.3, 4.4** """ @given( st.tuples( st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), ), st.tuples( st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), ), st.floats(min_value=-1e-7, max_value=1e-7, allow_nan=False, allow_infinity=False), ) @settings(max_examples=100) def test_uv_matching_precision_property( self, uv1: tuple[float, float], uv2: tuple[float, float], noise: float, ) -> None: """ Feature: vrm-uv-edge-merger, Property 5: UV Edge Matching Precision For any UV coordinate pair, adding noise smaller than 6 decimal precision should still result in a match after normalization. """ # Create the normalized reference UV pair normalized_ref = normalize_uv_pair(uv1, uv2) # Add small noise (within 6 decimal precision tolerance) uv1_noisy = (uv1[0] + noise, uv1[1] + noise) uv2_noisy = (uv2[0] + noise, uv2[1] + noise) # Normalize the noisy pair normalized_noisy = normalize_uv_pair(uv1_noisy, uv2_noisy) # Create target UV set with the reference target_uvs = {normalized_ref} # The noisy normalized pair should match the reference # because the noise is within 6 decimal precision matches = normalized_noisy in target_uvs # Calculate expected match based on rounding behavior # If noise is small enough, rounding should produce same result expected_match = normalized_ref == normalized_noisy assert matches == expected_match, ( f"UV matching inconsistency: " f"normalized_ref={normalized_ref}, normalized_noisy={normalized_noisy}, " f"noise={noise}, matches={matches}, expected={expected_match}" ) @given( st.tuples( st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), ), st.tuples( st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), ), ) @settings(max_examples=100) def test_uv_matching_set_membership_property( self, uv1: tuple[float, float], uv2: tuple[float, float], ) -> None: """ Feature: vrm-uv-edge-merger, Property 5: UV Edge Matching Precision For any UV coordinate pair, the normalized pair should be found in a target set containing that same normalized pair, regardless of original order. """ # Normalize in both orders normalized_forward = normalize_uv_pair(uv1, uv2) normalized_reverse = normalize_uv_pair(uv2, uv1) # Create target set with forward normalization target_uvs = {normalized_forward} # Both should match (symmetry property ensures this) assert normalized_forward in target_uvs, ( f"Forward normalized pair {normalized_forward} not found in target set" ) assert normalized_reverse in target_uvs, ( f"Reverse normalized pair {normalized_reverse} not found in target set " f"(forward was {normalized_forward})" ) @given( st.tuples( st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), ), st.tuples( st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), ), ) @settings(max_examples=100) def test_uv_precision_rounding_property( self, uv1: tuple[float, float], uv2: tuple[float, float], ) -> None: """ Feature: vrm-uv-edge-merger, Property 5: UV Edge Matching Precision For any UV coordinate pair, the normalized result should have coordinates rounded to exactly 6 decimal places. """ normalized = normalize_uv_pair(uv1, uv2) # Check that all coordinates are rounded to 6 decimal places for uv in normalized: for coord in uv: # Verify rounding by checking round(coord, 6) == coord assert round(coord, UV_PRECISION) == coord, ( f"Coordinate {coord} is not rounded to {UV_PRECISION} decimal places" ) def select_material_faces_impl( polygons_material_indices: list[int], material_index: int, ) -> set[int]: """ 获取指定材质索引的所有面的索引集合 这是一个纯函数实现,用于测试材质面选择的完整性。 Args: polygons_material_indices: 每个面的材质索引列表 material_index: 目标材质索引 Returns: 使用该材质的面索引集合 """ result: set[int] = set() for i, mat_idx in enumerate(polygons_material_indices): if mat_idx == material_index: result.add(i) return result class TestMaterialFaceSelectionCompleteness: """ Property 6: Material Face Selection Completeness *For any* mesh and material index, the face selection function SHALL select exactly all faces that have the specified material index, and no others. **Validates: Requirements 3.2** """ @given( st.lists( st.integers(min_value=0, max_value=5), min_size=1, max_size=50, ), st.integers(min_value=0, max_value=5), ) @settings(max_examples=100) def test_material_face_selection_completeness_property( self, material_indices: list[int], target_material_index: int, ) -> None: """ Feature: vrm-uv-edge-merger, Property 6: Material Face Selection Completeness For any mesh with faces having various material indices, select_material_faces returns exactly the set of face indices that have the specified material index. """ # Call the function under test result = select_material_faces_impl(material_indices, target_material_index) # Calculate expected result: indices of faces with the target material expected = { i for i, mat_idx in enumerate(material_indices) if mat_idx == target_material_index } # Verify completeness: result should contain exactly the expected faces assert result == expected, ( f"select_material_faces returned {result}, expected {expected}. " f"Material indices: {material_indices}, target: {target_material_index}" ) @given( st.lists( st.integers(min_value=0, max_value=5), min_size=0, max_size=50, ), ) @settings(max_examples=100) def test_material_face_selection_no_false_positives_property( self, material_indices: list[int], ) -> None: """ Feature: vrm-uv-edge-merger, Property 6: Material Face Selection Completeness For any mesh, selecting faces for a material index that doesn't exist should return an empty set. """ # Use a material index that definitely doesn't exist non_existent_index = max(material_indices, default=-1) + 10 # Call the function under test result = select_material_faces_impl(material_indices, non_existent_index) # Should return empty set for non-existent material assert result == set(), ( f"select_material_faces returned {result} for non-existent material " f"index {non_existent_index}, expected empty set" ) @given( st.lists( st.integers(min_value=0, max_value=5), min_size=1, max_size=50, ), st.integers(min_value=0, max_value=5), ) @settings(max_examples=100) def test_material_face_selection_no_duplicates_property( self, material_indices: list[int], target_material_index: int, ) -> None: """ Feature: vrm-uv-edge-merger, Property 6: Material Face Selection Completeness For any mesh, the selected face indices should be unique (no duplicates). """ result = select_material_faces_impl(material_indices, target_material_index) # Since result is a set, it inherently has no duplicates # But we verify the count matches expected expected_count = sum( 1 for mat_idx in material_indices if mat_idx == target_material_index ) assert len(result) == expected_count, ( f"Expected {expected_count} faces, got {len(result)}. " f"Material indices: {material_indices}, target: {target_material_index}" ) @given( st.lists( st.integers(min_value=0, max_value=5), min_size=1, max_size=50, ), st.integers(min_value=0, max_value=5), ) @settings(max_examples=100) def test_material_face_selection_valid_indices_property( self, material_indices: list[int], target_material_index: int, ) -> None: """ Feature: vrm-uv-edge-merger, Property 6: Material Face Selection Completeness For any mesh, all returned face indices should be valid (within bounds). """ result = select_material_faces_impl(material_indices, target_material_index) # All indices should be valid for face_idx in result: assert 0 <= face_idx < len(material_indices), ( f"Invalid face index {face_idx} for mesh with " f"{len(material_indices)} faces" ) def find_body_mesh_impl(children: list[tuple[str, str]]) -> bool: """ 检查子对象列表中是否存在名为"Body"的MESH对象 Args: children: 子对象列表,每个元素为 (name, type) 元组 Returns: 如果存在名为"Body"的MESH对象返回True,否则返回False """ for name, obj_type in children: if name == "Body" and obj_type == "MESH": return True return False def find_face_mesh_impl(children: list[tuple[str, str]]) -> bool: """ 检查子对象列表中是否存在名为"Face"的MESH对象 Args: children: 子对象列表,每个元素为 (name, type) 元组 Returns: 如果存在名为"Face"的MESH对象返回True,否则返回False """ for name, obj_type in children: if name == "Face" and obj_type == "MESH": return True return False def is_valid_vrm_structure(children: list[tuple[str, str]]) -> bool: """ 检查子对象列表是否构成有效的VRM结构 Args: children: 子对象列表,每个元素为 (name, type) 元组 Returns: 如果同时存在Body和Face MESH对象返回True,否则返回False """ return find_body_mesh_impl(children) and find_face_mesh_impl(children) class TestVRMModelDetectionCorrectness: """ Property 1: VRM Model Detection Correctness *For any* Blender object hierarchy, the VRM detection function SHALL return True if and only if the hierarchy contains an Armature with both "Body" and "Face" mesh children, regardless of the current Blender mode. **Validates: Requirements 1.1, 1.2, 1.3, 1.4** """ @given( st.lists( st.tuples( st.text(min_size=1, max_size=20).filter(lambda x: x.strip()), st.sampled_from(["MESH", "ARMATURE", "EMPTY", "LIGHT", "CAMERA"]), ), min_size=0, max_size=10, ), ) @settings(max_examples=100) def test_vrm_detection_requires_both_body_and_face( self, children_specs: list[tuple[str, str]], ) -> None: """ Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness For any set of child objects, VRM detection returns True if and only if both "Body" and "Face" mesh children exist. """ # Check if Body and Face meshes exist has_body_mesh = any( name == "Body" and obj_type == "MESH" for name, obj_type in children_specs ) has_face_mesh = any( name == "Face" and obj_type == "MESH" for name, obj_type in children_specs ) # Test find_body_mesh_impl body_found = find_body_mesh_impl(children_specs) assert body_found == has_body_mesh, ( f"find_body_mesh_impl mismatch: expected {has_body_mesh}, " f"got {body_found} for {children_specs}" ) # Test find_face_mesh_impl face_found = find_face_mesh_impl(children_specs) assert face_found == has_face_mesh, ( f"find_face_mesh_impl mismatch: expected {has_face_mesh}, " f"got {face_found} for {children_specs}" ) # VRM detection should be True only if both exist expected_vrm_valid = has_body_mesh and has_face_mesh actual_vrm_valid = is_valid_vrm_structure(children_specs) assert actual_vrm_valid == expected_vrm_valid, ( f"VRM detection mismatch: expected {expected_vrm_valid}, " f"got {actual_vrm_valid} for {children_specs}" ) def test_vrm_detection_with_valid_vrm_structure(self) -> None: """ Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness A valid VRM structure with both Body and Face meshes should be detected. """ children = [ ("Body", "MESH"), ("Face", "MESH"), ("Hair", "MESH"), ] assert find_body_mesh_impl(children), "Body mesh should be found" assert find_face_mesh_impl(children), "Face mesh should be found" assert is_valid_vrm_structure(children), "VRM structure should be valid" def test_vrm_detection_with_missing_body(self) -> None: """ Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness Missing Body mesh should result in invalid VRM detection. """ children = [ ("Face", "MESH"), ("Hair", "MESH"), ] assert not find_body_mesh_impl(children), "Body mesh should not be found" assert find_face_mesh_impl(children), "Face mesh should be found" assert not is_valid_vrm_structure(children), "VRM structure should be invalid" def test_vrm_detection_with_missing_face(self) -> None: """ Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness Missing Face mesh should result in invalid VRM detection. """ children = [ ("Body", "MESH"), ("Hair", "MESH"), ] assert find_body_mesh_impl(children), "Body mesh should be found" assert not find_face_mesh_impl(children), "Face mesh should not be found" assert not is_valid_vrm_structure(children), "VRM structure should be invalid" def test_vrm_detection_with_non_mesh_body(self) -> None: """ Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness Body object that is not a MESH should not be detected as Body mesh. """ children = [ ("Body", "EMPTY"), # Not a mesh ("Face", "MESH"), ] assert not find_body_mesh_impl(children), "Non-mesh Body should not be found" assert find_face_mesh_impl(children), "Face mesh should be found" assert not is_valid_vrm_structure(children), "VRM structure should be invalid" def test_vrm_detection_with_non_mesh_face(self) -> None: """ Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness Face object that is not a MESH should not be detected as Face mesh. """ children = [ ("Body", "MESH"), ("Face", "ARMATURE"), # Not a mesh ] assert find_body_mesh_impl(children), "Body mesh should be found" assert not find_face_mesh_impl(children), "Non-mesh Face should not be found" assert not is_valid_vrm_structure(children), "VRM structure should be invalid" def test_vrm_detection_with_empty_children(self) -> None: """ Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness Empty children list should result in invalid VRM detection. """ children: list[tuple[str, str]] = [] assert not find_body_mesh_impl(children), "Body should not be found in empty list" assert not find_face_mesh_impl(children), "Face should not be found in empty list" assert not is_valid_vrm_structure(children), "VRM structure should be invalid" @dataclass class MockOperationState: """Mock operation state for testing state restoration.""" original_mode: str original_active_object: Optional[str] # Object name original_selected_objects: list[str] # Object names processed_materials: list[str] def save_state_impl( current_mode: str, active_object_name: Optional[str], selected_object_names: list[str], ) -> MockOperationState: """ 保存当前状态的纯函数实现 Args: current_mode: 当前Blender模式 active_object_name: 当前活动对象名称 selected_object_names: 当前选中对象名称列表 Returns: 保存的状态对象 """ return MockOperationState( original_mode=current_mode, original_active_object=active_object_name, original_selected_objects=list(selected_object_names), processed_materials=[], ) def restore_state_impl( state: MockOperationState, available_objects: set[str], ) -> tuple[str, Optional[str], list[str]]: """ 恢复状态的纯函数实现 Args: state: 保存的状态 available_objects: 当前可用的对象名称集合 Returns: (恢复的模式, 恢复的活动对象, 恢复的选中对象列表) """ # 恢复选中对象(只恢复仍然存在的对象) restored_selected = [ name for name in state.original_selected_objects if name in available_objects ] # 恢复活动对象(只有在对象仍然存在时才恢复) restored_active = None if state.original_active_object and state.original_active_object in available_objects: restored_active = state.original_active_object # 恢复模式 restored_mode = state.original_mode return (restored_mode, restored_active, restored_selected) class TestStateRestorationOnError: """ Property 7: State Restoration on Error *For any* error during processing, the system SHALL restore the original Blender state (mode, active object, selection) before reporting the error. **Validates: Requirements 7.4** """ @given( st.sampled_from(["OBJECT", "EDIT", "SCULPT", "VERTEX_PAINT", "WEIGHT_PAINT"]), st.text(min_size=1, max_size=20).filter(lambda x: x.strip()) | st.none(), st.lists( st.text(min_size=1, max_size=20).filter(lambda x: x.strip()), min_size=0, max_size=5, ), ) @settings(max_examples=100) def test_state_save_and_restore_property( self, original_mode: str, active_object: Optional[str], selected_objects: list[str], ) -> None: """ Feature: vrm-uv-edge-merger, Property 7: State Restoration on Error For any initial state, saving and then restoring should return to the original state (assuming all objects still exist). """ # Save state state = save_state_impl(original_mode, active_object, selected_objects) # Verify state was saved correctly assert state.original_mode == original_mode assert state.original_active_object == active_object assert state.original_selected_objects == selected_objects # Create available objects set (all objects exist) available_objects = set(selected_objects) if active_object: available_objects.add(active_object) # Restore state restored_mode, restored_active, restored_selected = restore_state_impl( state, available_objects ) # Verify restoration assert restored_mode == original_mode, ( f"Mode not restored: expected {original_mode}, got {restored_mode}" ) assert restored_active == active_object, ( f"Active object not restored: expected {active_object}, got {restored_active}" ) assert set(restored_selected) == set(selected_objects), ( f"Selected objects not restored: expected {selected_objects}, " f"got {restored_selected}" ) @given( st.sampled_from(["OBJECT", "EDIT", "SCULPT"]), st.text(min_size=1, max_size=20).filter(lambda x: x.strip()), st.lists( st.text(min_size=1, max_size=20).filter(lambda x: x.strip()), min_size=1, max_size=5, ), st.sets( st.text(min_size=1, max_size=20).filter(lambda x: x.strip()), min_size=0, max_size=3, ), ) @settings(max_examples=100) def test_state_restore_with_deleted_objects_property( self, original_mode: str, active_object: str, selected_objects: list[str], deleted_objects: set[str], ) -> None: """ Feature: vrm-uv-edge-merger, Property 7: State Restoration on Error When some objects have been deleted, restoration should only restore objects that still exist. """ # Save state state = save_state_impl(original_mode, active_object, selected_objects) # Create available objects (some may have been deleted) all_objects = set(selected_objects) all_objects.add(active_object) available_objects = all_objects - deleted_objects # Restore state restored_mode, restored_active, restored_selected = restore_state_impl( state, available_objects ) # Mode should always be restored assert restored_mode == original_mode # Active object should only be restored if it still exists if active_object in deleted_objects: assert restored_active is None, ( f"Deleted active object should not be restored: {active_object}" ) else: assert restored_active == active_object # Selected objects should only include those that still exist for obj_name in restored_selected: assert obj_name in available_objects, ( f"Restored selected object {obj_name} should be in available objects" ) assert obj_name not in deleted_objects, ( f"Deleted object {obj_name} should not be in restored selection" ) def test_state_restore_with_empty_state(self) -> None: """ Feature: vrm-uv-edge-merger, Property 7: State Restoration on Error Restoring an empty state should work without errors. """ state = save_state_impl("OBJECT", None, []) available_objects: set[str] = set() restored_mode, restored_active, restored_selected = restore_state_impl( state, available_objects ) assert restored_mode == "OBJECT" assert restored_active is None assert restored_selected == [] def test_state_processed_materials_tracking(self) -> None: """ Feature: vrm-uv-edge-merger, Property 7: State Restoration on Error The state should track processed materials for error reporting. """ state = save_state_impl("OBJECT", "Armature", ["Body", "Face"]) # Simulate processing materials state.processed_materials.append("Material1") state.processed_materials.append("Material2") assert len(state.processed_materials) == 2 assert "Material1" in state.processed_materials assert "Material2" in state.processed_materials