Initial Commit

This commit is contained in:
Eric Gullickson
2025-09-17 16:09:15 -05:00
parent 0cdb9803de
commit a052040e3a
373 changed files with 437090 additions and 6773 deletions

View File

@@ -0,0 +1,381 @@
"""
Unit Tests for EngineSpecParser
Tests the engine specification parsing functionality including:
- Standard engine format parsing (displacement, configuration, cylinders)
- CRITICAL: L→I normalization (L3 becomes I3)
- Hybrid and electric vehicle detection
- Fuel type and aspiration parsing
- Electric motor creation for empty engines arrays
- Error handling for unparseable engines
"""
import unittest
from unittest.mock import patch
# Import the class we're testing
from ..utils.engine_spec_parser import EngineSpecParser, EngineSpec
class TestEngineSpecParser(unittest.TestCase):
"""Test cases for EngineSpecParser utility"""
def setUp(self):
"""Set up test environment before each test"""
self.parser = EngineSpecParser()
def test_parse_standard_engines(self):
"""Test parsing of standard engine formats"""
test_cases = [
# Format: (input, expected_displacement, expected_config, expected_cylinders)
("2.0L I4", 2.0, "I", 4),
("3.5L V6", 3.5, "V", 6),
("5.6L V8", 5.6, "V", 8),
("1.6L I4", 1.6, "I", 4),
("6.2L V8", 6.2, "V", 8),
]
for engine_str, expected_disp, expected_config, expected_cyl in test_cases:
with self.subTest(engine_str=engine_str):
spec = self.parser.parse_engine_string(engine_str)
self.assertEqual(spec.displacement_l, expected_disp,
f"Displacement: expected {expected_disp}, got {spec.displacement_l}")
self.assertEqual(spec.configuration, expected_config,
f"Configuration: expected {expected_config}, got {spec.configuration}")
self.assertEqual(spec.cylinders, expected_cyl,
f"Cylinders: expected {expected_cyl}, got {spec.cylinders}")
self.assertEqual(spec.fuel_type, "Gasoline") # Default
self.assertEqual(spec.aspiration, "Natural") # Default
def test_l_to_i_normalization(self):
"""Test CRITICAL L→I configuration normalization"""
test_cases = [
# L-configuration should become I (Inline)
("1.5L L3", "I", 3),
("2.0L L4", "I", 4),
("1.2L L3", "I", 3),
]
for engine_str, expected_config, expected_cyl in test_cases:
with self.subTest(engine_str=engine_str):
spec = self.parser.parse_engine_string(engine_str)
self.assertEqual(spec.configuration, expected_config,
f"L→I normalization failed: '{engine_str}' should become I{expected_cyl}")
self.assertEqual(spec.cylinders, expected_cyl)
self.assertEqual(spec.raw_string, engine_str) # Original preserved
def test_subaru_boxer_engines(self):
"""Test Subaru Boxer (H-configuration) engines"""
test_cases = [
("2.4L H4", 2.4, "H", 4),
("2.0L H4", 2.0, "H", 4),
("2.5L H4", 2.5, "H", 4),
]
for engine_str, expected_disp, expected_config, expected_cyl in test_cases:
with self.subTest(engine_str=engine_str):
spec = self.parser.parse_engine_string(engine_str)
self.assertEqual(spec.displacement_l, expected_disp)
self.assertEqual(spec.configuration, expected_config) # Should remain H
self.assertEqual(spec.cylinders, expected_cyl)
def test_w_configuration_engines(self):
"""Test W-configuration engines (VW Group, Bentley)"""
test_cases = [
("6.0L W12", 6.0, "W", 12),
("4.0L W8", 4.0, "W", 8),
("8.0L W16", 8.0, "W", 16), # Theoretical case
]
for engine_str, expected_disp, expected_config, expected_cyl in test_cases:
with self.subTest(engine_str=engine_str):
spec = self.parser.parse_engine_string(engine_str)
self.assertEqual(spec.displacement_l, expected_disp)
self.assertEqual(spec.configuration, expected_config) # Should remain W
self.assertEqual(spec.cylinders, expected_cyl)
def test_hybrid_detection(self):
"""Test hybrid engine detection patterns"""
test_cases = [
# Format: (input, expected_fuel_type)
("2.5L I4 FULL HYBRID EV- (FHEV)", "Full Hybrid"),
("1.5L L3 PLUG-IN HYBRID EV- (PHEV)", "Plug-in Hybrid"),
("2.0L I4 FULL HYBRID EV- (FHEV)", "Full Hybrid"),
("1.8L I4 HYBRID", "Hybrid"), # Generic hybrid
]
for engine_str, expected_fuel_type in test_cases:
with self.subTest(engine_str=engine_str):
spec = self.parser.parse_engine_string(engine_str)
self.assertEqual(spec.fuel_type, expected_fuel_type,
f"Expected fuel type '{expected_fuel_type}' for '{engine_str}'")
def test_l_to_i_with_hybrid(self):
"""Test L→I normalization combined with hybrid detection"""
test_cases = [
("1.5L L3 PLUG-IN HYBRID EV- (PHEV)", "I", 3, "Plug-in Hybrid"),
("1.2L L3 FULL HYBRID EV- (FHEV)", "I", 3, "Full Hybrid"),
]
for engine_str, expected_config, expected_cyl, expected_fuel in test_cases:
with self.subTest(engine_str=engine_str):
spec = self.parser.parse_engine_string(engine_str)
# Test both L→I normalization AND hybrid detection
self.assertEqual(spec.configuration, expected_config,
f"L→I normalization failed for hybrid: '{engine_str}'")
self.assertEqual(spec.cylinders, expected_cyl)
self.assertEqual(spec.fuel_type, expected_fuel,
f"Hybrid detection failed: '{engine_str}'")
def test_flex_fuel_detection(self):
"""Test flex fuel detection"""
test_cases = [
("5.6L V8 FLEX", "Flex Fuel"),
("4.0L V6 FLEX", "Flex Fuel"),
]
for engine_str, expected_fuel_type in test_cases:
with self.subTest(engine_str=engine_str):
spec = self.parser.parse_engine_string(engine_str)
self.assertEqual(spec.fuel_type, expected_fuel_type)
def test_electric_detection(self):
"""Test electric engine detection"""
test_cases = [
("1.8L I4 ELECTRIC", "Electric"),
]
for engine_str, expected_fuel_type in test_cases:
with self.subTest(engine_str=engine_str):
spec = self.parser.parse_engine_string(engine_str)
self.assertEqual(spec.fuel_type, expected_fuel_type)
def test_aspiration_detection(self):
"""Test turbo/supercharged detection"""
# Note: These patterns are less common in current JSON data
test_cases = [
("2.0L I4 TURBO", "Turbocharged"),
("6.2L V8 SUPERCHARGED", "Supercharged"),
("3.0L V6 SC", "Supercharged"), # SC abbreviation
]
for engine_str, expected_aspiration in test_cases:
with self.subTest(engine_str=engine_str):
spec = self.parser.parse_engine_string(engine_str)
self.assertEqual(spec.aspiration, expected_aspiration)
def test_create_electric_motor(self):
"""Test electric motor creation for empty engines arrays"""
spec = self.parser.create_electric_motor()
self.assertIsNone(spec.displacement_l)
self.assertEqual(spec.configuration, "Electric")
self.assertIsNone(spec.cylinders)
self.assertEqual(spec.fuel_type, "Electric")
self.assertIsNone(spec.aspiration)
self.assertEqual(spec.raw_string, "Electric Motor")
def test_parse_multiple_engines_empty_array(self):
"""Test handling of empty engines array (electric vehicles)"""
# Empty array should create electric motor
specs = self.parser.parse_multiple_engines([])
self.assertEqual(len(specs), 1)
self.assertEqual(specs[0].raw_string, "Electric Motor")
self.assertEqual(specs[0].fuel_type, "Electric")
def test_parse_multiple_engines_normal(self):
"""Test parsing multiple engine specifications"""
engine_strings = [
"2.0L I4",
"3.5L V6",
"1.5L L3 PLUG-IN HYBRID EV- (PHEV)" # Includes L→I normalization
]
specs = self.parser.parse_multiple_engines(engine_strings)
self.assertEqual(len(specs), 3)
# Check first engine
self.assertEqual(specs[0].displacement_l, 2.0)
self.assertEqual(specs[0].configuration, "I")
# Check third engine (L→I normalization + hybrid)
self.assertEqual(specs[2].configuration, "I") # L normalized to I
self.assertEqual(specs[2].fuel_type, "Plug-in Hybrid")
def test_unparseable_engines(self):
"""Test handling of unparseable engine strings"""
unparseable_cases = [
"Custom Hybrid System",
"V12 Twin-Turbo Custom",
"V10 Plus",
"Unknown Engine Type",
"", # Empty string
]
for engine_str in unparseable_cases:
with self.subTest(engine_str=engine_str):
spec = self.parser.parse_engine_string(engine_str)
# Should create fallback engine
self.assertEqual(spec.configuration, "Unknown")
self.assertEqual(spec.fuel_type, "Unknown")
self.assertEqual(spec.aspiration, "Natural")
self.assertIsNone(spec.displacement_l)
self.assertIsNone(spec.cylinders)
self.assertEqual(spec.raw_string, engine_str or "Empty Engine String")
def test_case_insensitive_parsing(self):
"""Test that parsing is case insensitive"""
test_cases = [
("2.0l i4", 2.0, "I", 4), # Lowercase
("3.5L v6", 3.5, "V", 6), # Mixed case
("1.5L l3 plug-in hybrid ev- (phev)", "I", 3, "Plug-in Hybrid"), # Lowercase with hybrid
]
for engine_str, expected_disp, expected_config, expected_cyl in test_cases[:2]:
with self.subTest(engine_str=engine_str):
spec = self.parser.parse_engine_string(engine_str)
self.assertEqual(spec.displacement_l, expected_disp)
self.assertEqual(spec.configuration, expected_config)
self.assertEqual(spec.cylinders, expected_cyl)
# Test hybrid case separately
spec = self.parser.parse_engine_string("1.5L l3 plug-in hybrid ev- (phev)")
self.assertEqual(spec.configuration, "I") # L→I normalization
self.assertEqual(spec.fuel_type, "Plug-in Hybrid")
def test_get_unique_engines(self):
"""Test deduplication of engine specifications"""
# Create list with duplicates
engine_specs = [
self.parser.parse_engine_string("2.0L I4"),
self.parser.parse_engine_string("2.0L I4"), # Duplicate
self.parser.parse_engine_string("3.5L V6"),
self.parser.parse_engine_string("2.0L I4"), # Another duplicate
]
unique_specs = self.parser.get_unique_engines(engine_specs)
# Should have only 2 unique engines
self.assertEqual(len(unique_specs), 2)
# Check that we have the expected unique engines
displacement_configs = [(spec.displacement_l, spec.configuration, spec.cylinders)
for spec in unique_specs]
self.assertIn((2.0, "I", 4), displacement_configs)
self.assertIn((3.5, "V", 6), displacement_configs)
def test_validate_engine_spec(self):
"""Test engine specification validation"""
# Valid engine
valid_spec = self.parser.parse_engine_string("2.0L I4")
warnings = self.parser.validate_engine_spec(valid_spec)
self.assertEqual(len(warnings), 0)
# Invalid displacement
invalid_spec = EngineSpec(
displacement_l=-1.0, # Invalid
configuration="I",
cylinders=4,
fuel_type="Gasoline",
aspiration="Natural",
raw_string="Invalid Engine"
)
warnings = self.parser.validate_engine_spec(invalid_spec)
self.assertGreater(len(warnings), 0)
self.assertTrue(any("displacement" in w for w in warnings))
# Electric motor should be valid
electric_spec = self.parser.create_electric_motor()
warnings = self.parser.validate_engine_spec(electric_spec)
self.assertEqual(len(warnings), 0)
def test_normalize_configuration_directly(self):
"""Test the normalize_configuration method directly"""
# Test L→I normalization
self.assertEqual(self.parser.normalize_configuration('L'), 'I')
self.assertEqual(self.parser.normalize_configuration('l'), 'I') # Case insensitive
# Test other configurations remain unchanged
self.assertEqual(self.parser.normalize_configuration('I'), 'I')
self.assertEqual(self.parser.normalize_configuration('V'), 'V')
self.assertEqual(self.parser.normalize_configuration('H'), 'H')
self.assertEqual(self.parser.normalize_configuration('i'), 'I') # Uppercase
def test_decimal_displacement(self):
"""Test engines with decimal displacements"""
test_cases = [
("1.5L I4", 1.5),
("2.3L I4", 2.3),
("3.7L V6", 3.7),
]
for engine_str, expected_disp in test_cases:
with self.subTest(engine_str=engine_str):
spec = self.parser.parse_engine_string(engine_str)
self.assertEqual(spec.displacement_l, expected_disp)
def test_edge_case_patterns(self):
"""Test edge cases and boundary conditions"""
# Large engines
spec = self.parser.parse_engine_string("8.4L V10")
self.assertEqual(spec.displacement_l, 8.4)
self.assertEqual(spec.cylinders, 10)
# Small engines
spec = self.parser.parse_engine_string("1.0L I3")
self.assertEqual(spec.displacement_l, 1.0)
self.assertEqual(spec.cylinders, 3)
# Very small displacement
spec = self.parser.parse_engine_string("0.7L I3")
self.assertEqual(spec.displacement_l, 0.7)
class TestEngineSpec(unittest.TestCase):
"""Test cases for EngineSpec dataclass"""
def test_engine_spec_creation(self):
"""Test EngineSpec creation and string representation"""
spec = EngineSpec(
displacement_l=2.0,
configuration="I",
cylinders=4,
fuel_type="Gasoline",
aspiration="Natural",
raw_string="2.0L I4"
)
self.assertEqual(spec.displacement_l, 2.0)
self.assertEqual(spec.configuration, "I")
self.assertEqual(spec.cylinders, 4)
self.assertEqual(spec.fuel_type, "Gasoline")
self.assertEqual(spec.aspiration, "Natural")
self.assertEqual(spec.raw_string, "2.0L I4")
# Test string representation
str_repr = str(spec)
self.assertIn("2.0L I4", str_repr)
self.assertIn("Gasoline", str_repr)
if __name__ == '__main__':
# Run specific test for L→I normalization if needed
if len(unittest.sys.argv) > 1 and 'l_to_i' in unittest.sys.argv[1].lower():
suite = unittest.TestSuite()
suite.addTest(TestEngineSpecParser('test_l_to_i_normalization'))
suite.addTest(TestEngineSpecParser('test_l_to_i_with_hybrid'))
runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite)
else:
unittest.main(verbosity=2)

View File

@@ -0,0 +1,427 @@
"""
Unit Tests for JsonExtractor
Tests the JSON extraction functionality including:
- JSON structure validation
- Make/model/year/trim/engine extraction
- Electric vehicle handling (empty engines arrays)
- Data normalization and quality assurance
- Error handling and reporting
- Integration with MakeNameMapper and EngineSpecParser
"""
import unittest
import tempfile
import json
import os
from unittest.mock import patch, MagicMock
# Import the classes we're testing
from ..extractors.json_extractor import (
JsonExtractor, MakeData, ModelData, ExtractionResult, ValidationResult
)
from ..utils.make_name_mapper import MakeNameMapper
from ..utils.engine_spec_parser import EngineSpecParser, EngineSpec
class TestJsonExtractor(unittest.TestCase):
"""Test cases for JsonExtractor functionality"""
def setUp(self):
"""Set up test environment before each test"""
self.make_mapper = MakeNameMapper()
self.engine_parser = EngineSpecParser()
self.extractor = JsonExtractor(self.make_mapper, self.engine_parser)
def create_test_json_file(self, filename: str, content: dict) -> str:
"""Create a temporary JSON file for testing"""
temp_dir = tempfile.mkdtemp()
file_path = os.path.join(temp_dir, filename)
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(content, f)
return file_path
def test_validate_json_structure_valid(self):
"""Test JSON structure validation with valid data"""
valid_json = {
"toyota": [
{
"year": "2024",
"models": [
{
"name": "camry",
"engines": ["2.5L I4", "3.5L V6"],
"submodels": ["LE", "XLE", "XSE"]
}
]
}
]
}
result = self.extractor.validate_json_structure(valid_json, "toyota.json")
self.assertTrue(result.is_valid)
self.assertEqual(len(result.errors), 0)
def test_validate_json_structure_invalid_top_level(self):
"""Test JSON validation with invalid top-level structure"""
invalid_json = ["not", "a", "dict"]
result = self.extractor.validate_json_structure(invalid_json, "test.json")
self.assertFalse(result.is_valid)
self.assertGreater(len(result.errors), 0)
self.assertIn("must be a dictionary", result.errors[0])
def test_validate_json_structure_multiple_keys(self):
"""Test JSON validation with multiple top-level keys"""
invalid_json = {
"toyota": [],
"honda": []
}
result = self.extractor.validate_json_structure(invalid_json, "test.json")
self.assertFalse(result.is_valid)
self.assertIn("exactly one top-level key", result.errors[0])
def test_validate_json_structure_missing_required_fields(self):
"""Test JSON validation with missing required fields"""
invalid_json = {
"toyota": [
{
# Missing 'year' field
"models": [
{
# Missing 'name' field
"engines": ["2.5L I4"]
}
]
}
]
}
result = self.extractor.validate_json_structure(invalid_json, "test.json")
self.assertFalse(result.is_valid)
self.assertTrue(any("missing 'year' field" in error for error in result.errors))
self.assertTrue(any("missing 'name' field" in error for error in result.errors))
def test_extract_make_data_simple(self):
"""Test extraction of simple make data"""
test_json = {
"toyota": [
{
"year": "2024",
"models": [
{
"name": "camry",
"engines": ["2.5L I4", "3.5L V6"],
"submodels": ["LE", "XLE"]
}
]
}
]
}
json_file = self.create_test_json_file("toyota.json", test_json)
try:
make_data = self.extractor.extract_make_data(json_file)
self.assertEqual(make_data.name, "Toyota")
self.assertEqual(make_data.filename, "toyota.json")
self.assertEqual(len(make_data.models), 1)
self.assertEqual(len(make_data.processing_errors), 0)
# Check model data
model = make_data.models[0]
self.assertEqual(model.name, "camry")
self.assertEqual(model.years, [2024])
self.assertEqual(len(model.engines), 2)
self.assertEqual(len(model.trims), 2)
self.assertFalse(model.is_electric)
finally:
os.unlink(json_file)
def test_extract_make_data_electric_vehicle(self):
"""Test extraction with electric vehicle (empty engines array)"""
test_json = {
"tesla": [
{
"year": "2024",
"models": [
{
"name": "model s",
"engines": [], # Empty engines - electric vehicle
"submodels": ["Base", "Plaid"]
}
]
}
]
}
json_file = self.create_test_json_file("tesla.json", test_json)
try:
make_data = self.extractor.extract_make_data(json_file)
self.assertEqual(make_data.name, "Tesla")
self.assertEqual(len(make_data.models), 1)
model = make_data.models[0]
self.assertTrue(model.is_electric)
self.assertEqual(len(model.engines), 1) # Should get default electric motor
self.assertEqual(model.engines[0].fuel_type, "Electric")
self.assertEqual(model.engines[0].configuration, "Electric")
finally:
os.unlink(json_file)
def test_extract_make_data_multiple_years(self):
"""Test extraction with model appearing across multiple years"""
test_json = {
"honda": [
{
"year": "2023",
"models": [
{
"name": "civic",
"engines": ["1.5L I4"],
"submodels": ["LX", "EX"]
}
]
},
{
"year": "2024",
"models": [
{
"name": "civic",
"engines": ["1.5L I4", "2.0L I4"],
"submodels": ["LX", "EX", "Type R"]
}
]
}
]
}
json_file = self.create_test_json_file("honda.json", test_json)
try:
make_data = self.extractor.extract_make_data(json_file)
self.assertEqual(len(make_data.models), 1) # Should merge into one model
model = make_data.models[0]
self.assertEqual(model.name, "civic")
self.assertEqual(sorted(model.years), [2023, 2024])
self.assertEqual(len(model.engines), 2) # Should have both engines
self.assertEqual(len(model.trims), 3) # Should have unique trims
finally:
os.unlink(json_file)
def test_extract_make_data_l_to_i_normalization(self):
"""Test that L→I normalization is applied during extraction"""
test_json = {
"geo": [
{
"year": "1995",
"models": [
{
"name": "metro",
"engines": ["1.0L L3", "1.3L I4"], # L3 should become I3
"submodels": ["Base", "LSi"]
}
]
}
]
}
json_file = self.create_test_json_file("geo.json", test_json)
try:
make_data = self.extractor.extract_make_data(json_file)
model = make_data.models[0]
# Find the L3 engine (should be normalized to I3)
l3_engine = None
for engine in model.engines:
if engine.displacement_l == 1.0 and engine.cylinders == 3:
l3_engine = engine
break
self.assertIsNotNone(l3_engine)
self.assertEqual(l3_engine.configuration, "I") # Should be normalized from L
finally:
os.unlink(json_file)
def test_extract_make_data_invalid_json(self):
"""Test extraction with invalid JSON file"""
json_file = self.create_test_json_file("invalid.json", {"invalid": "structure"})
try:
make_data = self.extractor.extract_make_data(json_file)
# Should return make data with errors
self.assertEqual(make_data.name, "Invalid")
self.assertEqual(len(make_data.models), 0)
self.assertGreater(len(make_data.processing_errors), 0)
finally:
os.unlink(json_file)
def test_extract_all_makes_multiple_files(self):
"""Test extraction of multiple make files"""
# Create temporary directory with multiple JSON files
temp_dir = tempfile.mkdtemp()
try:
# Create test files
toyota_json = {"toyota": [{"year": "2024", "models": [{"name": "camry", "engines": ["2.5L I4"], "submodels": ["LE"]}]}]}
tesla_json = {"tesla": [{"year": "2024", "models": [{"name": "model s", "engines": [], "submodels": ["Base"]}]}]}
toyota_file = os.path.join(temp_dir, "toyota.json")
tesla_file = os.path.join(temp_dir, "tesla.json")
with open(toyota_file, 'w') as f:
json.dump(toyota_json, f)
with open(tesla_file, 'w') as f:
json.dump(tesla_json, f)
# Extract all makes
result = self.extractor.extract_all_makes(temp_dir)
self.assertEqual(result.total_files_processed, 2)
self.assertEqual(result.successful_extractions, 2)
self.assertEqual(result.failed_extractions, 0)
self.assertEqual(len(result.makes), 2)
self.assertEqual(result.total_models, 2)
self.assertEqual(result.total_engines, 2) # Toyota: 1, Tesla: 1 (electric)
self.assertEqual(result.total_electric_models, 1) # Tesla
# Check make names
make_names = [make.name for make in result.makes]
self.assertIn("Toyota", make_names)
self.assertIn("Tesla", make_names)
finally:
# Clean up
for file in os.listdir(temp_dir):
os.unlink(os.path.join(temp_dir, file))
os.rmdir(temp_dir)
def test_extract_all_makes_empty_directory(self):
"""Test extraction from empty directory"""
temp_dir = tempfile.mkdtemp()
try:
result = self.extractor.extract_all_makes(temp_dir)
self.assertEqual(result.total_files_processed, 0)
self.assertEqual(result.successful_extractions, 0)
self.assertEqual(result.failed_extractions, 0)
self.assertEqual(len(result.makes), 0)
finally:
os.rmdir(temp_dir)
def test_get_extraction_statistics(self):
"""Test extraction statistics generation"""
# Create mock extraction result
make1 = MakeData("Toyota", "toyota.json", [], [], [])
make1.models = [ModelData("camry", [2024], [], [], False)]
make2 = MakeData("Tesla", "tesla.json", [], [], [])
make2.models = [ModelData("model s", [2024], [], [], True)]
result = ExtractionResult(
makes=[make1, make2],
total_files_processed=2,
successful_extractions=2,
failed_extractions=0,
total_models=2,
total_engines=2,
total_electric_models=1
)
stats = self.extractor.get_extraction_statistics(result)
self.assertEqual(stats['files']['total_processed'], 2)
self.assertEqual(stats['files']['successful'], 2)
self.assertEqual(stats['files']['success_rate'], 1.0)
self.assertEqual(stats['data']['total_makes'], 2)
self.assertEqual(stats['data']['total_models'], 2)
self.assertEqual(stats['data']['electric_models'], 1)
self.assertEqual(len(stats['makes']), 2)
class TestDataStructures(unittest.TestCase):
"""Test cases for data structure classes"""
def test_validation_result(self):
"""Test ValidationResult properties"""
result = ValidationResult(True, [], ["warning"])
self.assertTrue(result.is_valid)
self.assertFalse(result.has_errors)
self.assertTrue(result.has_warnings)
def test_model_data_properties(self):
"""Test ModelData calculated properties"""
# Create mock engine specs
engines = [
EngineSpec(2.5, "I", 4, "Gasoline", "Natural", "2.5L I4"),
EngineSpec(3.5, "V", 6, "Gasoline", "Natural", "3.5L V6")
]
model = ModelData(
name="camry",
years=[2023, 2024],
engines=engines,
trims=["LE", "XLE", "XSE"],
is_electric=False
)
self.assertEqual(model.total_trims, 3)
self.assertEqual(model.total_engines, 2)
self.assertEqual(model.year_range, "2023-2024")
def test_model_data_single_year(self):
"""Test ModelData with single year"""
model = ModelData("camry", [2024], [], ["LE"])
self.assertEqual(model.year_range, "2024")
def test_make_data_properties(self):
"""Test MakeData calculated properties"""
model1 = ModelData("camry", [2024], [], ["LE", "XLE"], False)
model2 = ModelData("prius", [2024], [], ["L", "LE"], True) # Electric
make = MakeData("Toyota", "toyota.json", [model1, model2], [], [])
self.assertEqual(make.total_models, 2)
self.assertEqual(make.total_trims, 4)
self.assertEqual(make.electric_models_count, 1)
def test_extraction_result_properties(self):
"""Test ExtractionResult calculated properties"""
result = ExtractionResult(
makes=[],
total_files_processed=10,
successful_extractions=8,
failed_extractions=2,
total_models=100,
total_engines=500,
total_electric_models=25
)
self.assertEqual(result.success_rate, 0.8)
if __name__ == '__main__':
unittest.main(verbosity=2)

View File

@@ -0,0 +1,443 @@
"""
Unit Tests for JsonManualLoader
Tests the database loading functionality including:
- Clear/Append mode operations
- Referential integrity maintenance
- Duplicate handling and conflict resolution
- Load statistics and reporting
- Error handling and rollback scenarios
"""
import unittest
from unittest.mock import Mock, patch, MagicMock
from typing import List
# Import the classes we're testing
from ..loaders.json_manual_loader import (
JsonManualLoader, LoadMode, LoadResult, LoadStatistics
)
from ..extractors.json_extractor import MakeData, ModelData
from ..utils.engine_spec_parser import EngineSpec
class TestJsonManualLoader(unittest.TestCase):
"""Test cases for JsonManualLoader functionality"""
def setUp(self):
"""Set up test environment before each test"""
self.mock_postgres_loader = Mock()
self.loader = JsonManualLoader(self.mock_postgres_loader)
def create_test_engine_spec(self, displacement: float, config: str, cylinders: int, fuel_type: str = "Gasoline") -> EngineSpec:
"""Create a test engine specification"""
return EngineSpec(
displacement_l=displacement,
configuration=config,
cylinders=cylinders,
fuel_type=fuel_type,
aspiration="Natural",
raw_string=f"{displacement}L {config}{cylinders}"
)
def create_test_model_data(self, name: str, years: List[int], engine_count: int = 1, trim_count: int = 2, is_electric: bool = False) -> ModelData:
"""Create test model data"""
engines = []
if is_electric:
engines = [EngineSpec(None, "Electric", None, "Electric", None, "Electric Motor")]
else:
for i in range(engine_count):
engines.append(self.create_test_engine_spec(2.0 + i, "I", 4))
trims = [f"Trim_{i}" for i in range(trim_count)]
return ModelData(
name=name,
years=years,
engines=engines,
trims=trims,
is_electric=is_electric
)
def create_test_make_data(self, name: str, model_count: int = 2) -> MakeData:
"""Create test make data"""
models = []
for i in range(model_count):
models.append(self.create_test_model_data(f"Model_{i}", [2024], 1, 2))
return MakeData(
name=name,
filename=f"{name.lower()}.json",
models=models,
processing_errors=[],
processing_warnings=[]
)
@patch('loaders.json_manual_loader.db_connections.postgres_connection')
def test_load_make_append_mode_new_make(self, mock_conn_context):
"""Test loading a new make in append mode"""
# Mock database connection and cursor
mock_conn = Mock()
mock_cursor = Mock()
mock_conn_context.return_value.__enter__.return_value = mock_conn
mock_conn.cursor.return_value = mock_cursor
# Mock make doesn't exist (returns None)
mock_cursor.fetchone.return_value = None
# Mock make insert returns ID
mock_cursor.fetchone.side_effect = [10] # make ID
# Create test data
make_data = self.create_test_make_data("Toyota", 1)
stats = LoadStatistics()
# Test loading
make_id = self.loader.load_make(make_data, LoadMode.APPEND, stats)
self.assertEqual(make_id, 10)
self.assertEqual(stats.makes_processed, 1)
# Verify make insert was called
mock_cursor.execute.assert_any_call(
"INSERT INTO vehicles.make (name) VALUES (%s) RETURNING id",
("Toyota",)
)
@patch('loaders.json_manual_loader.db_connections.postgres_connection')
def test_load_make_append_mode_existing_make(self, mock_conn_context):
"""Test loading an existing make in append mode"""
# Mock database connection and cursor
mock_conn = Mock()
mock_cursor = Mock()
mock_conn_context.return_value.__enter__.return_value = mock_conn
mock_conn.cursor.return_value = mock_cursor
# Mock make exists (returns ID)
mock_cursor.fetchone.return_value = (5,) # existing make ID
# Create test data
make_data = self.create_test_make_data("Honda", 1)
stats = LoadStatistics()
# Test loading
make_id = self.loader.load_make(make_data, LoadMode.APPEND, stats)
self.assertEqual(make_id, 5)
self.assertEqual(stats.duplicate_makes, 1)
# Verify make select was called, but not insert
mock_cursor.execute.assert_any_call(
"SELECT id FROM vehicles.make WHERE name = %s",
("Honda",)
)
@patch('loaders.json_manual_loader.db_connections.postgres_connection')
def test_load_engine_with_full_spec(self, mock_conn_context):
"""Test loading engine with complete specification"""
# Mock database cursor
mock_cursor = Mock()
# Mock engine doesn't exist
mock_cursor.fetchone.side_effect = [None, 100] # check, then insert
# Create test engine
engine_spec = self.create_test_engine_spec(2.5, "V", 6, "Gasoline")
stats = LoadStatistics()
# Test loading
engine_id = self.loader.load_engine(mock_cursor, engine_spec, LoadMode.APPEND, stats)
self.assertEqual(engine_id, 100)
self.assertEqual(stats.engines_inserted, 1)
# Verify engine insert with correct parameters
expected_call = mock_cursor.execute.call_args_list[-1]
args = expected_call[0]
self.assertIn("INSERT INTO vehicles.engine", args[0])
self.assertEqual(args[1][0], "2.5L V6") # engine name
self.assertEqual(args[1][1], "2.5lv6") # engine code
self.assertEqual(args[1][2], 2.5) # displacement
self.assertEqual(args[1][3], 6) # cylinders
self.assertEqual(args[1][4], "Gasoline") # fuel type
@patch('loaders.json_manual_loader.db_connections.postgres_connection')
def test_load_engine_electric(self, mock_conn_context):
"""Test loading electric motor"""
# Mock database cursor
mock_cursor = Mock()
# Mock engine doesn't exist
mock_cursor.fetchone.side_effect = [None, 200] # check, then insert
# Create electric engine
engine_spec = EngineSpec(None, "Electric", None, "Electric", None, "Electric Motor")
stats = LoadStatistics()
# Test loading
engine_id = self.loader.load_engine(mock_cursor, engine_spec, LoadMode.APPEND, stats)
self.assertEqual(engine_id, 200)
self.assertEqual(stats.engines_inserted, 1)
# Verify electric motor was inserted correctly
expected_call = mock_cursor.execute.call_args_list[-1]
args = expected_call[0]
self.assertIn("INSERT INTO vehicles.engine", args[0])
self.assertEqual(args[1][0], "Electric Motor") # engine name
self.assertEqual(args[1][4], "Electric") # fuel type
def test_load_mode_enum(self):
"""Test LoadMode enum values"""
self.assertEqual(LoadMode.CLEAR.value, "clear")
self.assertEqual(LoadMode.APPEND.value, "append")
def test_load_result_properties(self):
"""Test LoadResult calculated properties"""
result = LoadResult(
total_makes=10,
total_models=50,
total_model_years=100,
total_trims=200,
total_engines=75,
total_trim_engine_mappings=400,
failed_makes=["BMW", "Audi"],
warnings=["Warning 1"],
load_mode=LoadMode.APPEND
)
self.assertEqual(result.success_count, 8) # 10 - 2 failed
self.assertEqual(result.success_rate, 0.8) # 8/10
def test_load_statistics_initialization(self):
"""Test LoadStatistics initialization"""
stats = LoadStatistics()
self.assertEqual(stats.makes_processed, 0)
self.assertEqual(stats.makes_skipped, 0)
self.assertEqual(len(stats.errors), 0)
self.assertEqual(len(stats.warnings), 0)
@patch('loaders.json_manual_loader.db_connections.postgres_connection')
def test_clear_all_tables(self, mock_conn_context):
"""Test clearing all tables in correct order"""
# Mock database connection and cursor
mock_conn = Mock()
mock_cursor = Mock()
mock_conn_context.return_value.__enter__.return_value = mock_conn
mock_conn.cursor.return_value = mock_cursor
# Test clearing
self.loader.clear_all_tables()
# Verify truncate calls were made
truncate_calls = [call for call in mock_cursor.execute.call_args_list
if 'TRUNCATE' in str(call)]
self.assertGreater(len(truncate_calls), 0)
# Verify commit was called
mock_conn.commit.assert_called()
@patch('loaders.json_manual_loader.db_connections.postgres_connection')
def test_get_database_statistics(self, mock_conn_context):
"""Test getting database statistics"""
# Mock database connection and cursor
mock_conn = Mock()
mock_cursor = Mock()
mock_conn_context.return_value.__enter__.return_value = mock_conn
mock_conn.cursor.return_value = mock_cursor
# Mock count queries return values
mock_cursor.fetchone.side_effect = [(10,), (50,), (100,), (200,), (75,), (400,)]
# Test getting statistics
stats = self.loader.get_database_statistics()
expected_stats = {
'make': 10,
'model': 50,
'model_year': 100,
'trim': 200,
'engine': 75,
'trim_engine': 400
}
self.assertEqual(stats, expected_stats)
@patch('loaders.json_manual_loader.db_connections.postgres_connection')
def test_validate_referential_integrity_clean(self, mock_conn_context):
"""Test referential integrity validation with clean data"""
# Mock database connection and cursor
mock_conn = Mock()
mock_cursor = Mock()
mock_conn_context.return_value.__enter__.return_value = mock_conn
mock_conn.cursor.return_value = mock_cursor
# Mock all integrity checks return 0 (no issues)
mock_cursor.fetchone.side_effect = [(0,), (0,), (0,), (0,)]
# Test validation
issues = self.loader.validate_referential_integrity()
self.assertEqual(len(issues), 0)
@patch('loaders.json_manual_loader.db_connections.postgres_connection')
def test_validate_referential_integrity_issues(self, mock_conn_context):
"""Test referential integrity validation with issues found"""
# Mock database connection and cursor
mock_conn = Mock()
mock_cursor = Mock()
mock_conn_context.return_value.__enter__.return_value = mock_conn
mock_conn.cursor.return_value = mock_cursor
# Mock integrity checks return issues
mock_cursor.fetchone.side_effect = [(2,), (0,), (1,), (0,)] # 2 orphaned models, 1 orphaned trim
# Test validation
issues = self.loader.validate_referential_integrity()
self.assertEqual(len(issues), 2)
self.assertIn("orphaned models", issues[0])
self.assertIn("orphaned trims", issues[1])
def test_load_all_makes_with_errors(self):
"""Test loading makes where some have processing errors"""
# Create test data with errors
good_make = self.create_test_make_data("Toyota", 1)
bad_make = MakeData(
name="ErrorMake",
filename="error.json",
models=[],
processing_errors=["JSON parse error"],
processing_warnings=[]
)
makes_data = [good_make, bad_make]
# Mock load_make to work for good make
with patch.object(self.loader, 'load_make') as mock_load_make:
mock_load_make.return_value = 1
# Test loading
result = self.loader.load_all_makes(makes_data, LoadMode.APPEND)
self.assertEqual(result.total_makes, 2)
self.assertEqual(result.success_count, 1) # Only good make succeeded
self.assertIn("ErrorMake", result.failed_makes)
@patch('loaders.json_manual_loader.db_connections.postgres_connection')
def test_load_model_new_model(self, mock_conn_context):
"""Test loading a new model"""
# Mock database cursor
mock_cursor = Mock()
# Mock model doesn't exist, then return new ID
mock_cursor.fetchone.side_effect = [None, 25] # check, then insert
# Create test data
model_data = self.create_test_model_data("Camry", [2024], 1, 2)
stats = LoadStatistics()
# Mock load_model_year
with patch.object(self.loader, 'load_model_year') as mock_load_model_year:
mock_load_model_year.return_value = 100
# Test loading
model_id = self.loader.load_model(mock_cursor, 1, model_data, LoadMode.APPEND, stats)
self.assertEqual(model_id, 25)
self.assertEqual(stats.models_inserted, 1)
# Verify model_year was loaded for each year
mock_load_model_year.assert_called_once_with(mock_cursor, 25, 2024, model_data, LoadMode.APPEND, stats)
@patch('loaders.json_manual_loader.db_connections.postgres_connection')
def test_load_trim_with_engines(self, mock_conn_context):
"""Test loading trim and connecting to engines"""
# Mock database cursor
mock_cursor = Mock()
# Mock trim doesn't exist, then return new ID
mock_cursor.fetchone.side_effect = [None, 50, None] # trim check, insert, mapping check
# Test data
stats = LoadStatistics()
engine_ids = [100, 101]
# Test loading
trim_id = self.loader.load_trim(mock_cursor, 1, "Sport", engine_ids, LoadMode.APPEND, stats)
self.assertEqual(trim_id, 50)
self.assertEqual(stats.trims_inserted, 1)
self.assertEqual(stats.trim_engine_mappings_inserted, 2) # One for each engine
class TestLoadModeAndResults(unittest.TestCase):
"""Test LoadMode enum and result classes"""
def test_load_mode_values(self):
"""Test LoadMode enum has correct values"""
self.assertEqual(LoadMode.CLEAR.value, "clear")
self.assertEqual(LoadMode.APPEND.value, "append")
def test_load_result_with_no_failures(self):
"""Test LoadResult with perfect success"""
result = LoadResult(
total_makes=5,
total_models=25,
total_model_years=50,
total_trims=100,
total_engines=40,
total_trim_engine_mappings=200,
failed_makes=[],
warnings=[],
load_mode=LoadMode.APPEND
)
self.assertEqual(result.success_count, 5)
self.assertEqual(result.success_rate, 1.0)
def test_load_result_with_failures(self):
"""Test LoadResult with some failures"""
result = LoadResult(
total_makes=10,
total_models=40,
total_model_years=80,
total_trims=160,
total_engines=60,
total_trim_engine_mappings=320,
failed_makes=["BMW", "Audi", "Mercedes"],
warnings=["Warning 1", "Warning 2"],
load_mode=LoadMode.CLEAR
)
self.assertEqual(result.success_count, 7) # 10 - 3 failed
self.assertEqual(result.success_rate, 0.7)
def test_load_statistics_defaults(self):
"""Test LoadStatistics default values"""
stats = LoadStatistics()
# Check all counters start at 0
self.assertEqual(stats.makes_processed, 0)
self.assertEqual(stats.makes_skipped, 0)
self.assertEqual(stats.models_inserted, 0)
self.assertEqual(stats.model_years_inserted, 0)
self.assertEqual(stats.trims_inserted, 0)
self.assertEqual(stats.engines_inserted, 0)
self.assertEqual(stats.trim_engine_mappings_inserted, 0)
self.assertEqual(stats.duplicate_makes, 0)
self.assertEqual(stats.duplicate_models, 0)
self.assertEqual(stats.duplicate_engines, 0)
# Check lists are initialized
self.assertIsInstance(stats.errors, list)
self.assertIsInstance(stats.warnings, list)
self.assertEqual(len(stats.errors), 0)
self.assertEqual(len(stats.warnings), 0)
if __name__ == '__main__':
unittest.main(verbosity=2)

View File

@@ -0,0 +1,285 @@
"""
Unit Tests for MakeNameMapper
Tests the make name normalization functionality including:
- Standard filename to display name conversion
- Special capitalization cases (BMW, GMC, etc.)
- Multi-word names (underscore to space conversion)
- Validation against authoritative makes list
- Error handling for edge cases
"""
import unittest
import tempfile
import json
import os
from unittest.mock import patch, mock_open
# Import the class we're testing
from ..utils.make_name_mapper import MakeNameMapper, ValidationReport
class TestMakeNameMapper(unittest.TestCase):
"""Test cases for MakeNameMapper utility"""
def setUp(self):
"""Set up test environment before each test"""
self.mapper = MakeNameMapper()
def test_normalize_single_word_makes(self):
"""Test normalization of single-word make names"""
test_cases = [
('toyota.json', 'Toyota'),
('honda.json', 'Honda'),
('ford.json', 'Ford'),
('audi.json', 'Audi'),
('tesla.json', 'Tesla'),
]
for filename, expected in test_cases:
with self.subTest(filename=filename):
result = self.mapper.normalize_make_name(filename)
self.assertEqual(result, expected,
f"Expected '{expected}' for '{filename}', got '{result}'")
def test_normalize_multi_word_makes(self):
"""Test normalization of multi-word make names (underscore to space)"""
test_cases = [
('alfa_romeo.json', 'Alfa Romeo'),
('land_rover.json', 'Land Rover'),
('rolls_royce.json', 'Rolls Royce'),
('aston_martin.json', 'Aston Martin'),
]
for filename, expected in test_cases:
with self.subTest(filename=filename):
result = self.mapper.normalize_make_name(filename)
self.assertEqual(result, expected,
f"Expected '{expected}' for '{filename}', got '{result}'")
def test_normalize_special_cases(self):
"""Test special capitalization cases"""
test_cases = [
('bmw.json', 'BMW'),
('gmc.json', 'GMC'),
('mini.json', 'MINI'),
('mclaren.json', 'McLaren'),
]
for filename, expected in test_cases:
with self.subTest(filename=filename):
result = self.mapper.normalize_make_name(filename)
self.assertEqual(result, expected,
f"Expected '{expected}' for '{filename}', got '{result}'")
def test_normalize_edge_cases(self):
"""Test edge cases and error handling"""
test_cases = [
# Edge cases that should still work
('test.json', 'Test'),
('test_brand.json', 'Test Brand'),
# Error cases that should return "Unknown"
('.json', 'Unknown'),
('', 'Unknown'),
]
for filename, expected in test_cases:
with self.subTest(filename=filename):
result = self.mapper.normalize_make_name(filename)
self.assertEqual(result, expected,
f"Expected '{expected}' for '{filename}', got '{result}'")
def test_validate_mapping_valid_makes(self):
"""Test validation of valid make names"""
# These should be in the authoritative list
valid_cases = [
('toyota.json', 'Toyota'),
('alfa_romeo.json', 'Alfa Romeo'),
('bmw.json', 'BMW'),
('land_rover.json', 'Land Rover'),
]
for filename, display_name in valid_cases:
with self.subTest(filename=filename):
is_valid = self.mapper.validate_mapping(filename, display_name)
self.assertTrue(is_valid, f"'{display_name}' should be valid")
def test_validate_mapping_invalid_makes(self):
"""Test validation of invalid make names"""
invalid_cases = [
('unknown.json', 'Unknown Brand'),
('fake.json', 'Fake'),
('test.json', 'Test Make'),
]
for filename, display_name in invalid_cases:
with self.subTest(filename=filename):
is_valid = self.mapper.validate_mapping(filename, display_name)
self.assertFalse(is_valid, f"'{display_name}' should be invalid")
def test_reverse_lookup(self):
"""Test reverse lookup functionality"""
test_cases = [
('Toyota', 'toyota.json'),
('Alfa Romeo', 'alfa_romeo.json'),
('BMW', 'bmw.json'),
('Land Rover', 'land_rover.json'),
('McLaren', 'mclaren.json'),
]
for display_name, expected_filename in test_cases:
with self.subTest(display_name=display_name):
result = self.mapper.get_filename_for_display_name(display_name)
self.assertEqual(result, expected_filename,
f"Expected '{expected_filename}' for '{display_name}', got '{result}'")
def test_get_all_mappings(self):
"""Test getting all mappings from a directory"""
# Create temporary directory with test files
with tempfile.TemporaryDirectory() as temp_dir:
# Create test JSON files
test_files = ['toyota.json', 'alfa_romeo.json', 'bmw.json']
for filename in test_files:
file_path = os.path.join(temp_dir, filename)
with open(file_path, 'w') as f:
json.dump({"test": "data"}, f)
mappings = self.mapper.get_all_mappings(temp_dir)
# Check that all files were processed
self.assertEqual(len(mappings), len(test_files))
# Check specific mappings
expected_mappings = {
'toyota.json': 'Toyota',
'alfa_romeo.json': 'Alfa Romeo',
'bmw.json': 'BMW'
}
for filename, expected_display in expected_mappings.items():
self.assertIn(filename, mappings)
self.assertEqual(mappings[filename], expected_display)
def test_validation_report(self):
"""Test validation report generation"""
# Create temporary directory with test files
with tempfile.TemporaryDirectory() as temp_dir:
# Mix of valid and invalid files
test_files = {
'toyota.json': {"test": "data"}, # Valid
'alfa_romeo.json': {"test": "data"}, # Valid
'unknown_brand.json': {"test": "data"} # Invalid
}
for filename, content in test_files.items():
file_path = os.path.join(temp_dir, filename)
with open(file_path, 'w') as f:
json.dump(content, f)
report = self.mapper.validate_all_mappings(temp_dir)
# Check report structure
self.assertIsInstance(report, ValidationReport)
self.assertEqual(report.total_files, 3)
self.assertEqual(report.valid_mappings, 2) # toyota and alfa_romeo should be valid
self.assertEqual(len(report.mismatches), 1) # unknown_brand should be invalid
# Check success rate
expected_rate = 2/3 # 2 valid out of 3 total
self.assertAlmostEqual(report.success_rate, expected_rate, places=2)
# Check mismatch details
mismatch = report.mismatches[0]
self.assertEqual(mismatch['filename'], 'unknown_brand.json')
self.assertEqual(mismatch['mapped_name'], 'Unknown Brand')
self.assertEqual(mismatch['status'], 'NOT_FOUND_IN_AUTHORITATIVE')
@patch('builtins.open', mock_open(read_data='{"manufacturers": ["Toyota", "BMW", "Custom Brand"]}'))
def test_load_custom_authoritative_makes(self):
"""Test loading custom authoritative makes from file"""
with patch('os.path.exists', return_value=True):
mapper = MakeNameMapper(sources_dir='test_sources')
# Should have loaded the custom list
self.assertIn('Toyota', mapper.authoritative_makes)
self.assertIn('BMW', mapper.authoritative_makes)
self.assertIn('Custom Brand', mapper.authoritative_makes)
# Test validation with custom list
self.assertTrue(mapper.validate_mapping('custom.json', 'Custom Brand'))
def test_make_statistics(self):
"""Test make statistics calculation"""
with tempfile.TemporaryDirectory() as temp_dir:
# Create test files representing different categories
test_files = [
'toyota.json', # Single word
'honda.json', # Single word
'alfa_romeo.json', # Multi word
'land_rover.json', # Multi word
'bmw.json', # Special case
'gmc.json', # Special case
]
for filename in test_files:
file_path = os.path.join(temp_dir, filename)
with open(file_path, 'w') as f:
json.dump({"test": "data"}, f)
stats = self.mapper.get_make_statistics(temp_dir)
expected_stats = {
'total': 6,
'single_words': 2, # toyota, honda
'multi_words': 2, # alfa_romeo, land_rover
'special_cases': 2 # bmw, gmc
}
self.assertEqual(stats, expected_stats)
def test_error_handling(self):
"""Test error handling for various failure scenarios"""
# Test with non-existent directory
mappings = self.mapper.get_all_mappings('/non/existent/directory')
self.assertEqual(mappings, {})
# Test validation report with non-existent directory
report = self.mapper.validate_all_mappings('/non/existent/directory')
self.assertEqual(report.total_files, 0)
self.assertEqual(report.valid_mappings, 0)
self.assertEqual(len(report.mismatches), 0)
class TestValidationReport(unittest.TestCase):
"""Test cases for ValidationReport dataclass"""
def test_success_rate_calculation(self):
"""Test success rate calculation"""
# Test normal case
report = ValidationReport(
total_files=10,
valid_mappings=8,
mismatches=[]
)
self.assertEqual(report.success_rate, 0.8)
# Test zero division case
report_empty = ValidationReport(
total_files=0,
valid_mappings=0,
mismatches=[]
)
self.assertEqual(report_empty.success_rate, 0.0)
# Test perfect score
report_perfect = ValidationReport(
total_files=5,
valid_mappings=5,
mismatches=[]
)
self.assertEqual(report_perfect.success_rate, 1.0)
if __name__ == '__main__':
unittest.main()