- libs/ai-services: new POST /neighborhood/score router computing weighted 6-axis livability score from per-category POI counts; algorithm versioned for future iteration (sigmoid curves, percentile thresholds). - apps/api: HttpNeighborhoodScoreService proxies to Python first, falls back to PrismaNeighborhoodScoreService when AI service unavailable. Mirrors the HttpAVMService pattern. Existing GET /analytics/neighborhoods/:district/score endpoint and CQRS handler now flow through the proxy. - AnalyticsModule binds Http variant by default, retains Prisma variant as injectable fallback. - Tests: 5 pytest cases for Python heuristic, 4 vitest cases for HTTP proxy fallback behaviour. Co-Authored-By: Paperclip <noreply@paperclip.ing>
120 lines
3.2 KiB
Python
120 lines
3.2 KiB
Python
from fastapi.testclient import TestClient
|
|
|
|
from app.main import app
|
|
|
|
client = TestClient(app)
|
|
|
|
|
|
def test_zero_counts_yields_zero_score():
|
|
resp = client.post(
|
|
"/neighborhood/score",
|
|
json={
|
|
"district": "Quận 7",
|
|
"city": "Hồ Chí Minh",
|
|
"poi_counts": {
|
|
"education": 0,
|
|
"healthcare": 0,
|
|
"transport": 0,
|
|
"shopping": 0,
|
|
"greenery": 0,
|
|
"safety": 0,
|
|
},
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total_score"] == 0
|
|
assert data["education_score"] == 0
|
|
assert data["algorithm_version"].startswith("neighborhood-heuristic")
|
|
|
|
|
|
def test_saturated_counts_yields_one_hundred():
|
|
resp = client.post(
|
|
"/neighborhood/score",
|
|
json={
|
|
"district": "Quận 1",
|
|
"city": "Hồ Chí Minh",
|
|
"poi_counts": {
|
|
"education": 50,
|
|
"healthcare": 50,
|
|
"transport": 50,
|
|
"shopping": 50,
|
|
"greenery": 50,
|
|
"safety": 50,
|
|
},
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total_score"] == 100.0
|
|
assert data["education_score"] == 10
|
|
assert data["safety_score"] == 10
|
|
|
|
|
|
def test_partial_counts_apply_weighted_average():
|
|
# Hit the linear cap on transport+greenery only; others 0.
|
|
# transport weight 20 + greenery 15 = 35 → expect total 35.0.
|
|
resp = client.post(
|
|
"/neighborhood/score",
|
|
json={
|
|
"district": "Bình Thạnh",
|
|
"city": "Hồ Chí Minh",
|
|
"poi_counts": {
|
|
"education": 0,
|
|
"healthcare": 0,
|
|
"transport": 12,
|
|
"shopping": 0,
|
|
"greenery": 6,
|
|
"safety": 0,
|
|
},
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["transport_score"] == 10
|
|
assert data["greenery_score"] == 10
|
|
assert data["total_score"] == 35.0
|
|
assert data["poi_counts"]["transport"] == 12
|
|
|
|
|
|
def test_below_max_uses_linear_scale():
|
|
# education max=15, count=3 → 2.0; weight 20 → contributes 4.0
|
|
resp = client.post(
|
|
"/neighborhood/score",
|
|
json={
|
|
"district": "Quận 3",
|
|
"city": "Hồ Chí Minh",
|
|
"poi_counts": {
|
|
"education": 3,
|
|
"healthcare": 0,
|
|
"transport": 0,
|
|
"shopping": 0,
|
|
"greenery": 0,
|
|
"safety": 0,
|
|
},
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["education_score"] == 2.0
|
|
assert data["total_score"] == 4.0
|
|
|
|
|
|
def test_validation_rejects_negative_count():
|
|
resp = client.post(
|
|
"/neighborhood/score",
|
|
json={
|
|
"district": "Quận 1",
|
|
"city": "Hồ Chí Minh",
|
|
"poi_counts": {
|
|
"education": -1,
|
|
"healthcare": 0,
|
|
"transport": 0,
|
|
"shopping": 0,
|
|
"greenery": 0,
|
|
"safety": 0,
|
|
},
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|