Implementation of phase 4.1
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
"""
|
||||
Comprehensive tests for Pydantic models.
|
||||
|
||||
This module tests all Pydantic model validation, serialization,
|
||||
and computed fields to ensure type safety and correctness.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from pydantic import ValidationError
|
||||
|
||||
from nadlan_mcp.govmap.models import (
|
||||
CoordinatePoint,
|
||||
Address,
|
||||
AutocompleteResult,
|
||||
AutocompleteResponse,
|
||||
Deal,
|
||||
DealStatistics,
|
||||
MarketActivityScore,
|
||||
InvestmentAnalysis,
|
||||
LiquidityMetrics,
|
||||
DealFilters,
|
||||
)
|
||||
|
||||
|
||||
class TestCoordinatePoint:
|
||||
"""Tests for CoordinatePoint model."""
|
||||
|
||||
def test_valid_coordinate(self):
|
||||
"""Test creating valid coordinate point."""
|
||||
coord = CoordinatePoint(longitude=180000.0, latitude=650000.0)
|
||||
assert coord.longitude == 180000.0
|
||||
assert coord.latitude == 650000.0
|
||||
|
||||
def test_coordinate_immutable(self):
|
||||
"""Test that coordinates are immutable (frozen)."""
|
||||
coord = CoordinatePoint(longitude=180000.0, latitude=650000.0)
|
||||
with pytest.raises(ValidationError):
|
||||
coord.longitude = 180001.0
|
||||
|
||||
def test_coordinate_invalid_type(self):
|
||||
"""Test that invalid types raise ValidationError."""
|
||||
with pytest.raises(ValidationError):
|
||||
CoordinatePoint(longitude="invalid", latitude=650000.0)
|
||||
|
||||
|
||||
class TestAddress:
|
||||
"""Tests for Address model."""
|
||||
|
||||
def test_valid_address(self):
|
||||
"""Test creating valid address."""
|
||||
coord = CoordinatePoint(longitude=180000.0, latitude=650000.0)
|
||||
address = Address(
|
||||
text="סוקולוב 38 חולון",
|
||||
id="addr123",
|
||||
type="address",
|
||||
score=95.5,
|
||||
coordinates=coord
|
||||
)
|
||||
assert address.text == "סוקולוב 38 חולון"
|
||||
assert address.score == 95.5
|
||||
assert address.coordinates.longitude == 180000.0
|
||||
|
||||
def test_address_without_coordinates(self):
|
||||
"""Test creating address without coordinates."""
|
||||
address = Address(
|
||||
text="סוקולוב 38 חולון",
|
||||
id="addr123",
|
||||
type="address"
|
||||
)
|
||||
assert address.coordinates is None
|
||||
assert address.score == 0 # Default value
|
||||
|
||||
|
||||
class TestAutocompleteResult:
|
||||
"""Tests for AutocompleteResult model."""
|
||||
|
||||
def test_valid_result_with_coordinates(self):
|
||||
"""Test autocomplete result with parsed coordinates."""
|
||||
coord = CoordinatePoint(longitude=180000.0, latitude=650000.0)
|
||||
result = AutocompleteResult(
|
||||
text="חולון",
|
||||
id="city123",
|
||||
type="city",
|
||||
score=100.0,
|
||||
coordinates=coord,
|
||||
shape="POINT(180000.0 650000.0)"
|
||||
)
|
||||
assert result.text == "חולון"
|
||||
assert result.coordinates.longitude == 180000.0
|
||||
assert result.shape == "POINT(180000.0 650000.0)"
|
||||
|
||||
def test_result_without_shape(self):
|
||||
"""Test result without shape data."""
|
||||
result = AutocompleteResult(
|
||||
text="חולון",
|
||||
id="city123",
|
||||
type="city"
|
||||
)
|
||||
assert result.shape is None
|
||||
assert result.coordinates is None
|
||||
|
||||
|
||||
class TestAutocompleteResponse:
|
||||
"""Tests for AutocompleteResponse model."""
|
||||
|
||||
def test_valid_response(self):
|
||||
"""Test creating valid autocomplete response."""
|
||||
results = [
|
||||
AutocompleteResult(text="חולון", id="city1", type="city"),
|
||||
AutocompleteResult(text="חולון סוקולוב", id="street1", type="street")
|
||||
]
|
||||
response = AutocompleteResponse(resultsCount=2, results=results)
|
||||
assert response.results_count == 2
|
||||
assert len(response.results) == 2
|
||||
|
||||
def test_response_with_alias(self):
|
||||
"""Test that camelCase alias works."""
|
||||
response = AutocompleteResponse.model_validate({
|
||||
"resultsCount": 5,
|
||||
"results": []
|
||||
})
|
||||
assert response.results_count == 5
|
||||
|
||||
def test_empty_response(self):
|
||||
"""Test empty autocomplete response."""
|
||||
response = AutocompleteResponse(resultsCount=0)
|
||||
assert response.results_count == 0
|
||||
assert response.results == []
|
||||
|
||||
|
||||
class TestDeal:
|
||||
"""Tests for Deal model."""
|
||||
|
||||
def test_valid_deal(self):
|
||||
"""Test creating valid deal."""
|
||||
deal = Deal(
|
||||
objectid=12345,
|
||||
deal_amount=1500000.0,
|
||||
deal_date="2024-01-15",
|
||||
asset_area=85.0,
|
||||
settlement_name_heb="חולון",
|
||||
property_type_description="דירה",
|
||||
street_name="סוקולוב",
|
||||
house_number="38",
|
||||
rooms=3.5
|
||||
)
|
||||
assert deal.objectid == 12345
|
||||
assert deal.deal_amount == 1500000.0
|
||||
assert deal.asset_area == 85.0
|
||||
|
||||
def test_deal_with_aliases(self):
|
||||
"""Test creating deal using API camelCase field names."""
|
||||
deal = Deal.model_validate({
|
||||
"objectid": 12345,
|
||||
"dealAmount": 1500000.0,
|
||||
"dealDate": "2024-01-15",
|
||||
"assetArea": 85.0,
|
||||
"propertyTypeDescription": "דירה"
|
||||
})
|
||||
assert deal.deal_amount == 1500000.0
|
||||
assert deal.property_type_description == "דירה"
|
||||
|
||||
def test_deal_price_per_sqm_computed(self):
|
||||
"""Test price_per_sqm computed field."""
|
||||
deal = Deal(
|
||||
objectid=12345,
|
||||
deal_amount=1500000.0,
|
||||
deal_date="2024-01-15",
|
||||
asset_area=85.0
|
||||
)
|
||||
assert deal.price_per_sqm == round(1500000.0 / 85.0, 2)
|
||||
|
||||
def test_deal_price_per_sqm_no_area(self):
|
||||
"""Test price_per_sqm returns None when area is missing."""
|
||||
deal = Deal(
|
||||
objectid=12345,
|
||||
deal_amount=1500000.0,
|
||||
deal_date="2024-01-15"
|
||||
)
|
||||
assert deal.price_per_sqm is None
|
||||
|
||||
def test_deal_price_per_sqm_zero_area(self):
|
||||
"""Test price_per_sqm returns None when area is zero."""
|
||||
deal = Deal(
|
||||
objectid=12345,
|
||||
deal_amount=1500000.0,
|
||||
deal_date="2024-01-15",
|
||||
asset_area=0.0
|
||||
)
|
||||
assert deal.price_per_sqm is None
|
||||
|
||||
def test_deal_extra_fields_allowed(self):
|
||||
"""Test that extra fields are allowed."""
|
||||
deal_data = {
|
||||
"objectid": 12345,
|
||||
"dealAmount": 1500000.0,
|
||||
"dealDate": "2024-01-15",
|
||||
"extra_field": "extra_value",
|
||||
"another_field": 123
|
||||
}
|
||||
deal = Deal.model_validate(deal_data)
|
||||
# Extra fields should be stored
|
||||
assert deal.objectid == 12345
|
||||
|
||||
def test_deal_serialization(self):
|
||||
"""Test deal serialization to dict."""
|
||||
deal = Deal(
|
||||
objectid=12345,
|
||||
deal_amount=1500000.0,
|
||||
deal_date="2024-01-15",
|
||||
asset_area=85.0
|
||||
)
|
||||
deal_dict = deal.model_dump()
|
||||
assert deal_dict["objectid"] == 12345
|
||||
assert deal_dict["deal_amount"] == 1500000.0
|
||||
assert "price_per_sqm" in deal_dict # Computed field included
|
||||
|
||||
def test_deal_serialization_exclude_none(self):
|
||||
"""Test deal serialization excluding None values."""
|
||||
deal = Deal(
|
||||
objectid=12345,
|
||||
deal_amount=1500000.0,
|
||||
deal_date="2024-01-15"
|
||||
)
|
||||
deal_dict = deal.model_dump(exclude_none=True)
|
||||
assert "asset_area" not in deal_dict
|
||||
assert "rooms" not in deal_dict
|
||||
|
||||
def test_deal_required_fields(self):
|
||||
"""Test that required fields are enforced."""
|
||||
with pytest.raises(ValidationError):
|
||||
Deal(objectid=12345) # Missing deal_amount and deal_date
|
||||
|
||||
|
||||
class TestDealStatistics:
|
||||
"""Tests for DealStatistics model."""
|
||||
|
||||
def test_valid_statistics(self):
|
||||
"""Test creating valid deal statistics."""
|
||||
stats = DealStatistics(
|
||||
total_deals=100,
|
||||
price_statistics={
|
||||
"mean": 1500000.0,
|
||||
"median": 1400000.0,
|
||||
"std_dev": 200000.0
|
||||
},
|
||||
area_statistics={
|
||||
"mean": 85.5,
|
||||
"median": 82.0
|
||||
},
|
||||
price_per_sqm_statistics={
|
||||
"mean": 17500.0,
|
||||
"median": 17200.0
|
||||
},
|
||||
property_type_distribution={
|
||||
"דירה": 80,
|
||||
"דירת גן": 15,
|
||||
"פנטהאוז": 5
|
||||
},
|
||||
date_range={
|
||||
"earliest": "2022-01-01",
|
||||
"latest": "2024-12-31"
|
||||
}
|
||||
)
|
||||
assert stats.total_deals == 100
|
||||
assert stats.price_statistics["mean"] == 1500000.0
|
||||
assert stats.property_type_distribution["דירה"] == 80
|
||||
|
||||
def test_empty_statistics(self):
|
||||
"""Test creating empty statistics."""
|
||||
stats = DealStatistics(total_deals=0)
|
||||
assert stats.total_deals == 0
|
||||
assert stats.price_statistics == {}
|
||||
assert stats.date_range is None
|
||||
|
||||
|
||||
class TestMarketActivityScore:
|
||||
"""Tests for MarketActivityScore model."""
|
||||
|
||||
def test_valid_activity_score(self):
|
||||
"""Test creating valid activity score."""
|
||||
score = MarketActivityScore(
|
||||
activity_score=75.5,
|
||||
total_deals=120,
|
||||
deals_per_month=10.0,
|
||||
trend="increasing",
|
||||
time_period_months=12,
|
||||
monthly_distribution={"2024-01": 8, "2024-02": 12}
|
||||
)
|
||||
assert score.activity_score == 75.5
|
||||
assert score.trend == "increasing"
|
||||
assert score.monthly_distribution["2024-02"] == 12
|
||||
|
||||
def test_activity_score_bounds(self):
|
||||
"""Test that activity_score is bounded 0-100."""
|
||||
with pytest.raises(ValidationError):
|
||||
MarketActivityScore(
|
||||
activity_score=150.0, # Invalid: > 100
|
||||
total_deals=100,
|
||||
deals_per_month=8.0,
|
||||
trend="stable",
|
||||
time_period_months=12
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
MarketActivityScore(
|
||||
activity_score=-10.0, # Invalid: < 0
|
||||
total_deals=100,
|
||||
deals_per_month=8.0,
|
||||
trend="stable",
|
||||
time_period_months=12
|
||||
)
|
||||
|
||||
|
||||
class TestInvestmentAnalysis:
|
||||
"""Tests for InvestmentAnalysis model."""
|
||||
|
||||
def test_valid_investment_analysis(self):
|
||||
"""Test creating valid investment analysis."""
|
||||
analysis = InvestmentAnalysis(
|
||||
investment_score=68.5,
|
||||
price_trend="increasing",
|
||||
price_appreciation_rate=5.2,
|
||||
price_volatility=25.3,
|
||||
market_stability="moderate",
|
||||
avg_price_per_sqm=17500.0,
|
||||
price_change_pct=12.5,
|
||||
total_deals=85,
|
||||
data_quality="good"
|
||||
)
|
||||
assert analysis.investment_score == 68.5
|
||||
assert analysis.price_trend == "increasing"
|
||||
assert analysis.data_quality == "good"
|
||||
|
||||
def test_investment_score_bounds(self):
|
||||
"""Test that investment_score is bounded 0-100."""
|
||||
with pytest.raises(ValidationError):
|
||||
InvestmentAnalysis(
|
||||
investment_score=105.0, # Invalid
|
||||
price_trend="stable",
|
||||
price_appreciation_rate=2.0,
|
||||
price_volatility=15.0,
|
||||
market_stability="stable",
|
||||
avg_price_per_sqm=17000.0,
|
||||
price_change_pct=5.0,
|
||||
total_deals=100,
|
||||
data_quality="excellent"
|
||||
)
|
||||
|
||||
|
||||
class TestLiquidityMetrics:
|
||||
"""Tests for LiquidityMetrics model."""
|
||||
|
||||
def test_valid_liquidity_metrics(self):
|
||||
"""Test creating valid liquidity metrics."""
|
||||
metrics = LiquidityMetrics(
|
||||
liquidity_score=82.3,
|
||||
total_deals=150,
|
||||
time_period_months=12,
|
||||
avg_deals_per_month=12.5,
|
||||
deal_velocity=12.5,
|
||||
market_activity_level="high"
|
||||
)
|
||||
assert metrics.liquidity_score == 82.3
|
||||
assert metrics.market_activity_level == "high"
|
||||
assert metrics.deal_velocity == 12.5
|
||||
|
||||
|
||||
class TestDealFilters:
|
||||
"""Tests for DealFilters model."""
|
||||
|
||||
def test_valid_filters(self):
|
||||
"""Test creating valid deal filters."""
|
||||
filters = DealFilters(
|
||||
property_type="דירה",
|
||||
min_rooms=2.0,
|
||||
max_rooms=4.0,
|
||||
min_price=1000000.0,
|
||||
max_price=2000000.0,
|
||||
min_area=60.0,
|
||||
max_area=100.0,
|
||||
min_floor=1,
|
||||
max_floor=5
|
||||
)
|
||||
assert filters.property_type == "דירה"
|
||||
assert filters.min_rooms == 2.0
|
||||
assert filters.max_floor == 5
|
||||
|
||||
def test_empty_filters(self):
|
||||
"""Test creating filters with no criteria."""
|
||||
filters = DealFilters()
|
||||
assert filters.property_type is None
|
||||
assert filters.min_rooms is None
|
||||
|
||||
def test_filter_validation_max_rooms(self):
|
||||
"""Test that max_rooms must be >= min_rooms."""
|
||||
with pytest.raises(ValidationError):
|
||||
DealFilters(min_rooms=4.0, max_rooms=2.0)
|
||||
|
||||
def test_filter_validation_max_price(self):
|
||||
"""Test that max_price must be >= min_price."""
|
||||
with pytest.raises(ValidationError):
|
||||
DealFilters(min_price=2000000.0, max_price=1000000.0)
|
||||
|
||||
def test_filter_validation_max_area(self):
|
||||
"""Test that max_area must be >= min_area."""
|
||||
with pytest.raises(ValidationError):
|
||||
DealFilters(min_area=100.0, max_area=60.0)
|
||||
|
||||
def test_filter_validation_max_floor(self):
|
||||
"""Test that max_floor must be >= min_floor."""
|
||||
with pytest.raises(ValidationError):
|
||||
DealFilters(min_floor=5, max_floor=1)
|
||||
|
||||
def test_filter_negative_values(self):
|
||||
"""Test that negative values are rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
DealFilters(min_rooms=-1.0)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
DealFilters(min_price=-1000.0)
|
||||
|
||||
|
||||
class TestModelIntegration:
|
||||
"""Integration tests for models working together."""
|
||||
|
||||
def test_deal_to_statistics_workflow(self):
|
||||
"""Test creating deals and calculating statistics."""
|
||||
deals = [
|
||||
Deal(
|
||||
objectid=i,
|
||||
deal_amount=1500000.0 + (i * 10000),
|
||||
deal_date=f"2024-01-{i+1:02d}",
|
||||
asset_area=80.0 + i,
|
||||
property_type_description="דירה"
|
||||
)
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
# Verify all deals have price_per_sqm
|
||||
for deal in deals:
|
||||
assert deal.price_per_sqm is not None
|
||||
assert deal.price_per_sqm > 0
|
||||
|
||||
def test_autocomplete_to_deals_workflow(self):
|
||||
"""Test autocomplete response leading to deal search."""
|
||||
# Simulate autocomplete response
|
||||
coord = CoordinatePoint(longitude=180000.0, latitude=650000.0)
|
||||
result = AutocompleteResult(
|
||||
text="סוקולוב 38 חולון",
|
||||
id="addr123",
|
||||
type="address",
|
||||
coordinates=coord
|
||||
)
|
||||
response = AutocompleteResponse(resultsCount=1, results=[result])
|
||||
|
||||
# Verify we can extract coordinates for deal search
|
||||
assert response.results[0].coordinates is not None
|
||||
assert response.results[0].coordinates.longitude == 180000.0
|
||||
|
||||
def test_filter_application(self):
|
||||
"""Test that filters can be created and used."""
|
||||
filters = DealFilters(
|
||||
property_type="דירה",
|
||||
min_rooms=3.0,
|
||||
max_rooms=4.0,
|
||||
min_price=1000000.0,
|
||||
max_price=2000000.0
|
||||
)
|
||||
|
||||
# Create test deals
|
||||
deals = [
|
||||
Deal(objectid=1, deal_amount=1200000.0, deal_date="2024-01-01", rooms=3.0),
|
||||
Deal(objectid=2, deal_amount=2500000.0, deal_date="2024-01-02", rooms=4.0), # Price too high
|
||||
Deal(objectid=3, deal_amount=1500000.0, deal_date="2024-01-03", rooms=2.0), # Too few rooms
|
||||
]
|
||||
|
||||
# Manually check which deals would pass
|
||||
# (actual filtering is done by filter_deals_by_criteria function)
|
||||
assert filters.min_rooms <= deals[0].rooms <= filters.max_rooms
|
||||
assert filters.min_price <= deals[0].deal_amount <= filters.max_price
|
||||
Reference in New Issue
Block a user