feat: enhance revision logic with fuzzy matching, dynamic material loading, and schema automation
- 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
This commit is contained in:
199
backend/tests/test_revision_comparison.py
Normal file
199
backend/tests/test_revision_comparison.py
Normal file
@@ -0,0 +1,199 @@
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user