- Improved RevisionComparator with fuzzy matching (RapidFuzz) and dynamic DB material loading - Enhanced regex patterns for better size/material extraction - Initialized Alembic for schema migrations and created baseline migration - Added entrypoint.sh for automated migrations in Docker - Fixed SyntaxError in fitting_classifier.py - Updated test suite with new functionality tests
200 lines
7.9 KiB
Python
200 lines
7.9 KiB
Python
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"
|
|
|