# SPDX-License-Identifier: MIT OR GPL-3.0-or-later """ Property-based tests for body_mesh utility functions. Feature: vrm-body-mesh-separator Feature: vrm-uv-edge-merger """ from hypothesis import given, settings from hypothesis import strategies as st from .utils import ( find_body_mesh, find_face_mesh, generate_unique_name, is_skin_material, normalize_uv_pair, select_material_faces, ) class TestIsSkinMaterial: """ Property 2: 材质分类正确性 *For any* 材质名称字符串,`is_skin_material`函数应该返回True当且仅当该名称包含"SKIN"子字符串(不区分大小写)。 **Validates: Requirements 2.2, 2.3** """ @given(st.text()) @settings(max_examples=100) def test_skin_material_classification_property(self, material_name: str) -> None: """ Feature: vrm-body-mesh-separator, Property 2: 材质分类正确性 For any material name string, is_skin_material returns True if and only if the name contains "SKIN" (case-insensitive). """ result = is_skin_material(material_name) expected = "SKIN" in material_name.upper() assert result == expected, ( f"is_skin_material('{material_name}') returned {result}, " f"expected {expected}" ) class TestGenerateUniqueName: """ Property 6: 唯一命名保证 *For any* 基础名称和已存在名称集合,`generate_unique_name`返回的名称不应该在已存在名称集合中。 **Validates: Requirements 4.2** """ @given( st.text(min_size=1, max_size=50).filter(lambda x: x.strip()), st.sets(st.text(min_size=1, max_size=50), max_size=20), ) @settings(max_examples=100) def test_unique_name_generation_property( self, base_name: str, existing_names: set[str] ) -> None: """ Feature: vrm-body-mesh-separator, Property 6: 唯一命名保证 For any base name and set of existing names, generate_unique_name returns a name that is not in the existing names set. """ result = generate_unique_name(base_name, existing_names) assert result not in existing_names, ( f"generate_unique_name('{base_name}', {existing_names}) " f"returned '{result}' which is in existing_names" ) class TestNormalizeUvPair: """ Property 4: UV坐标标准化对称性 *For any* pair of UV coordinates (uv1, uv2), normalizing (uv1, uv2) SHALL produce the same result as normalizing (uv2, uv1), ensuring order-independent matching. **Validates: Requirements 4.5** """ @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_normalization_symmetry_property( self, uv1: tuple[float, float], uv2: tuple[float, float] ) -> None: """ Feature: vrm-uv-edge-merger, Property 4: UV Coordinate Normalization For any pair of UV coordinates, normalize(uv1, uv2) == normalize(uv2, uv1). """ result_forward = normalize_uv_pair(uv1, uv2) result_reverse = normalize_uv_pair(uv2, uv1) assert result_forward == result_reverse, ( f"normalize_uv_pair({uv1}, {uv2}) = {result_forward}, " f"but normalize_uv_pair({uv2}, {uv1}) = {result_reverse}" ) 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})" ) class MockPolygon: """Mock polygon for testing material face selection.""" def __init__(self, material_index: int) -> None: self.material_index = material_index class MockMesh: """Mock mesh data for testing material face selection.""" def __init__(self, polygons: list[MockPolygon]) -> None: self.polygons = polygons class MockMeshObject: """Mock mesh object for testing material face selection.""" def __init__(self, mesh_data: MockMesh) -> None: self.type = "MESH" self.data = mesh_data 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. """ # Create mock polygons with the given material indices polygons = [MockPolygon(idx) for idx in material_indices] mock_mesh = MockMesh(polygons) mock_obj = MockMeshObject(mock_mesh) # Call the function under test result = select_material_faces(mock_obj, 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. """ # Create mock polygons polygons = [MockPolygon(idx) for idx in material_indices] mock_mesh = MockMesh(polygons) mock_obj = MockMeshObject(mock_mesh) # 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(mock_obj, 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" ) def test_material_face_selection_invalid_object(self) -> None: """ Feature: vrm-uv-edge-merger, Property 6: Material Face Selection Completeness Selecting faces from None or non-mesh object should return empty set. """ # Test with None result_none = select_material_faces(None, 0) assert result_none == set(), "Expected empty set for None object" # Test with non-mesh object (mock with wrong type) class NonMeshObject: type = "ARMATURE" data = None result_non_mesh = select_material_faces(NonMeshObject(), 0) assert result_non_mesh == set(), "Expected empty set for non-mesh object" class MockChild: """Mock child object for testing VRM model detection.""" def __init__(self, name: str, obj_type: str = "MESH") -> None: self.name = name self.type = obj_type class MockArmature: """Mock armature object for testing VRM model detection.""" def __init__(self, children: list[MockChild]) -> None: self.type = "ARMATURE" self.children = 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. """ # Create mock children children = [MockChild(name, obj_type) for name, obj_type in children_specs] mock_armature = MockArmature(children) # Check if Body and Face meshes exist has_body_mesh = any( c.name == "Body" and c.type == "MESH" for c in children ) has_face_mesh = any( c.name == "Face" and c.type == "MESH" for c in children ) # Test find_body_mesh body_result = find_body_mesh(mock_armature) if has_body_mesh: assert body_result is not None, ( f"find_body_mesh should find Body mesh in {children_specs}" ) assert body_result.name == "Body", ( f"find_body_mesh returned wrong object: {body_result.name}" ) else: assert body_result is None, ( f"find_body_mesh should return None for {children_specs}" ) # Test find_face_mesh face_result = find_face_mesh(mock_armature) if has_face_mesh: assert face_result is not None, ( f"find_face_mesh should find Face mesh in {children_specs}" ) assert face_result.name == "Face", ( f"find_face_mesh returned wrong object: {face_result.name}" ) else: assert face_result is None, ( f"find_face_mesh should return None 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 = body_result is not None and face_result is not None 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 = [ MockChild("Body", "MESH"), MockChild("Face", "MESH"), MockChild("Hair", "MESH"), ] mock_armature = MockArmature(children) body = find_body_mesh(mock_armature) face = find_face_mesh(mock_armature) assert body is not None, "Body mesh should be found" assert face is not None, "Face mesh should be found" assert body.name == "Body" assert face.name == "Face" 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 = [ MockChild("Face", "MESH"), MockChild("Hair", "MESH"), ] mock_armature = MockArmature(children) body = find_body_mesh(mock_armature) face = find_face_mesh(mock_armature) assert body is None, "Body mesh should not be found" assert face is not None, "Face mesh should be found" 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 = [ MockChild("Body", "MESH"), MockChild("Hair", "MESH"), ] mock_armature = MockArmature(children) body = find_body_mesh(mock_armature) face = find_face_mesh(mock_armature) assert body is not None, "Body mesh should be found" assert face is None, "Face mesh should not be found" 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 = [ MockChild("Body", "EMPTY"), # Not a mesh MockChild("Face", "MESH"), ] mock_armature = MockArmature(children) body = find_body_mesh(mock_armature) face = find_face_mesh(mock_armature) assert body is None, "Non-mesh Body should not be found" assert face is not None, "Face mesh should be found" def test_vrm_detection_with_none_armature(self) -> None: """ Feature: vrm-uv-edge-merger, Property 1: VRM Model Detection Correctness None armature should return None for both meshes. """ body = find_body_mesh(None) face = find_face_mesh(None) assert body is None, "Body should be None for None armature" assert face is None, "Face should be None for None armature"