import pytest from unittest.mock import MagicMock from app.services.revision_comparator import RevisionComparator @pytest.fixture def mock_db(): return MagicMock() @pytest.fixture def comparator(mock_db): return RevisionComparator(mock_db) def test_generate_material_hash(comparator): """해시 생성 로직 테스트""" # 1. 기본 케이스 desc = "PIPE 100A SCH40" size = "100A" mat = "A105" hash1 = comparator._generate_material_hash(desc, size, mat) # 2. 공백/대소문자 차이 (정규화 확인) hash2 = comparator._generate_material_hash(" pipe 100a sch40 ", "100A", "a105") assert hash1 == hash2, "공백과 대소문자는 무시되어야 합니다." # 3. None 값 처리 (Robustness) hash3 = comparator._generate_material_hash(None, None, None) assert isinstance(hash3, str) assert len(hash3) == 32 # MD5 checking # 4. 빈 문자열 처리 hash4 = comparator._generate_material_hash("", "", "") assert hash3 == hash4, "None과 빈 문자열은 동일하게 처리되어야 합니다." def test_extract_size_from_description(comparator): """사이즈 추출 로직 테스트""" # 1. inch patterns assert comparator._extract_size_from_description('PIPE 1/2" SCH40') == '1/2"' assert comparator._extract_size_from_description('ELBOW 1.5inch 90D') == '1.5inch' # 2. mm patterns assert comparator._extract_size_from_description('Plate 100mm') == '100mm' assert comparator._extract_size_from_description('Bar 50.5 MM') == '50.5 MM' # 3. A patterns assert comparator._extract_size_from_description('PIPE 100A') == '100A' assert comparator._extract_size_from_description('FLANGE 50 A') == '50 A' # 4. DN patterns assert comparator._extract_size_from_description('VALVE DN100') == 'DN100' # 5. Dimensions assert comparator._extract_size_from_description('GASKET 10x20') == '10x20' assert comparator._extract_size_from_description('SHEET 10*20') == '10*20' # 6. No match assert comparator._extract_size_from_description('Just Text') == "" assert comparator._extract_size_from_description(None) == "" def test_extract_material_from_description(comparator): """재질 추출 로직 테스트""" # 1. Standard materials assert comparator._extract_material_from_description('PIPE A106 Gr.B') == 'A106 Gr.B' # Should match longer first if implemented correctly assert comparator._extract_material_from_description('FLANGE A105') == 'A105' # 2. Stainless Steel assert comparator._extract_material_from_description('PIPE SUS304L') == 'SUS304L' assert comparator._extract_material_from_description('PIPE SS316') == 'SS316' # 3. Case Insensitivity assert comparator._extract_material_from_description('pipe sus316l') == 'SUS316L' # 4. Partial matches (should prioritize specific) # If "A106" is checked before "A106 Gr.B", it might return "A106". # The implementation list order matters. # In our impl: "A106 Gr.B" is before "A106", so it should work. assert comparator._extract_material_from_description('Material A106 Gr.B Spec') == 'A106 Gr.B' def test_compare_materials_logic_flow(comparator): """비교 로직 통합 테스트""" previous_confirmed = { "revision": "Rev.0", "confirmed_at": "2024-01-01", "items": [ { "specification": "PIPE 100A A106", "size": "100A", "material": "A106", # Extraction will find 'A106' from 'PIPE 100A A106' "bom_quantity": 10.0 } ] } # Case 1: Identical (Normalized) new_materials_same = [{"description": "pipe 100a", "quantity": 10.0}] # Note: extraction logic needs to find size/mat from description to match hash. # "pipe 100a" -> size="100A", mat="" (A106 not in desc) # The hash will be MD5("pipe 100a|100a|") # Previous hash was MD5("pipe 100a|100a|a106") # They WON'T match if extraction fails to find "A106". # Let's provide description that allows extraction to work fully new_materials_full = [{"description": "PIPE 100A A106", "quantity": 10.0}] result = comparator.compare_materials(previous_confirmed, new_materials_full) assert result["unchanged_count"] == 1 # Case 2: Quantity Changed new_materials_qty = [{"description": "PIPE 100A A106", "quantity": 20.0}] result = comparator.compare_materials(previous_confirmed, new_materials_qty) assert result["changed_count"] == 1 assert result["changed_materials"][0]["change_type"] == "QUANTITY_CHANGED" # Case 3: New Item new_materials_new = [{"description": "NEW ITEM SUS304", "quantity": 1.0}] result = comparator.compare_materials(previous_confirmed, new_materials_new) assert result["new_count"] == 1 def test_compare_materials_fuzzy_match(comparator): """Fuzzy Matching 테스트""" previous_confirmed = { "revision": "Rev.0", "confirmed_at": "2024-01-01", "items": [ { "specification": "PIPE 100A SCH40 A106", "size": "100A", "material": "A106", "bom_quantity": 10.0 } ] } # 오타가 포함된 유사 자재 (PIPEE -> PIPE, A106 -> A106) # 정규화/해시는 다르지만 텍스트 유사도는 높음 new_materials_typo = [{ "description": "PIPEE 100A SCH40 A106", "quantity": 10.0 }] # RapidFuzz가 설치되어 있어야 동작 try: import rapidfuzz result = comparator.compare_materials(previous_confirmed, new_materials_typo) # 해시는 다르므로 new_count에 포함되거나 유사 자재로 분류됨 # 구현에 따라 "new_materials" 리스트에 "change_type": "NEW_BUT_SIMILAR" 로 들어감 assert result["new_count"] == 1 new_item = result["new_materials"][0] assert new_item["change_type"] == "NEW_BUT_SIMILAR" assert new_item["similarity_score"] > 85 except ImportError: pytest.skip("rapidfuzz not installed") def test_extract_size_word_boundary(comparator): """Regex Word Boundary 테스트""" # 1. mm boundary check # "100mm" -> ok, "100mm2" -> fail if boundary used assert comparator._extract_size_from_description("100mm") == "100mm" # assert comparator._extract_size_from_description("100mmm") == "" # This depends on implementation strictness # 2. inch boundary assert comparator._extract_size_from_description("1/2 inch") == "1/2 inch" # 3. DN boundary assert comparator._extract_size_from_description("DN100") == "DN100" # "DN100A" should ideally not match DN100 if we want strictness, or it might match 100A. def test_dynamic_material_loading(comparator, mock_db): """DB 기반 동적 자재 로딩 테스트""" # Mocking DB result # fetchall returns list of rows (tuples) mock_db.execute.return_value.fetchall.return_value = [ ("TITANIUM_GR2",), ("INCONEL625",), ] # 1. Check if DB loaded materials are correctly extracted # Test with a material that is ONLY in the mocked DB response, not in default list description = "PIPE 100A TITANIUM_GR2" material = comparator._extract_material_from_description(description) assert material == "TITANIUM_GR2" # Verify DB execute was called assert mock_db.execute.called # 2. Test fallback mechanism (simulate DB connection failure) # mock_db.execute.side_effect = Exception("DB Connection Error") # Reset mock to raise exception on next call mock_db.execute.side_effect = Exception("DB Fail") # SUS316L is in the default fallback list description_fallback = "PIPE 100A SUS316L" material_fallback = comparator._extract_material_from_description(description_fallback) # Should still work using default list assert material_fallback == "SUS316L"