Files
TK-BOM-Project/backend/tests/test_revision_comparison.py
Hyungi Ahn f16bc662ad 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
2026-01-09 09:36:40 +09:00

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"