378 lines
13 KiB
Python
378 lines
13 KiB
Python
"""Unit tests for match scoring algorithm.
|
|
|
|
These tests exercise calculate_match_score and the individual _*_score
|
|
helper functions from match_service.py. No database is needed — we
|
|
construct lightweight User-like objects directly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app.services.match_service import (
|
|
WEIGHTS,
|
|
_activity_score,
|
|
_age_score,
|
|
_education_score,
|
|
_height_score,
|
|
_hobby_score,
|
|
_location_score,
|
|
_value_score,
|
|
calculate_match_score,
|
|
)
|
|
|
|
|
|
def _user(**kwargs) -> SimpleNamespace:
|
|
"""Create a minimal User-like object for scoring tests."""
|
|
defaults = {
|
|
"id": 1,
|
|
"nickname": "test",
|
|
"gender": 1,
|
|
"birth_year": 1995,
|
|
"city": "广州市",
|
|
"height": 175,
|
|
"education": 4,
|
|
"personality_tags": [],
|
|
"hobbies": [],
|
|
"value_answers": {},
|
|
"prefer_age_min": None,
|
|
"prefer_age_max": None,
|
|
"prefer_education": None,
|
|
"prefer_city": None,
|
|
"updated_at": datetime.now(),
|
|
"avatar_url": None,
|
|
"avatar_blur_url": None,
|
|
"profile_images": [],
|
|
}
|
|
defaults.update(kwargs)
|
|
return SimpleNamespace(**defaults)
|
|
|
|
|
|
# ============================================================================
|
|
# Weight sanity
|
|
# ============================================================================
|
|
|
|
class TestWeights:
|
|
def test_weights_sum_to_one(self):
|
|
total = sum(WEIGHTS.values())
|
|
assert abs(total - 1.0) < 1e-9
|
|
|
|
def test_seven_dimensions(self):
|
|
assert len(WEIGHTS) == 7
|
|
assert set(WEIGHTS) == {"age", "hobbies", "values", "education", "activity", "location", "height"}
|
|
|
|
|
|
# ============================================================================
|
|
# Age score
|
|
# ============================================================================
|
|
|
|
class TestAgeScore:
|
|
def test_no_birth_year_returns_50(self):
|
|
a = _user(prefer_age_min=1990, prefer_age_max=2000)
|
|
b = _user(birth_year=None)
|
|
assert _age_score(a, b) == 50.0
|
|
|
|
def test_below_min_returns_0(self):
|
|
a = _user(prefer_age_min=1990, prefer_age_max=2000)
|
|
b = _user(birth_year=1985)
|
|
assert _age_score(a, b) == 0.0
|
|
|
|
def test_above_max_returns_0(self):
|
|
a = _user(prefer_age_min=1990, prefer_age_max=2000)
|
|
b = _user(birth_year=2005)
|
|
assert _age_score(a, b) == 0.0
|
|
|
|
def test_at_midpoint_returns_max(self):
|
|
a = _user(prefer_age_min=1990, prefer_age_max=2000)
|
|
b = _user(birth_year=1995) # midpoint
|
|
score = _age_score(a, b)
|
|
assert score == 100.0
|
|
|
|
def test_at_edge_returns_75(self):
|
|
a = _user(prefer_age_min=1990, prefer_age_max=2000)
|
|
# half_range = (2000-1990)/2 = 5; diff from mid = 5 → 100 - (5/5)*50 = 50
|
|
b = _user(birth_year=2000)
|
|
score = _age_score(a, b)
|
|
assert score == 50.0
|
|
|
|
def test_no_preference_returns_80(self):
|
|
a = _user(prefer_age_min=None, prefer_age_max=None)
|
|
b = _user(birth_year=1995)
|
|
assert _age_score(a, b) == 80.0
|
|
|
|
|
|
# ============================================================================
|
|
# Hobby score
|
|
# ============================================================================
|
|
|
|
class TestHobbyScore:
|
|
def test_identical_hobbies(self):
|
|
a = _user(hobbies=["篮球", "电影"])
|
|
b = _user(hobbies=["篮球", "电影"])
|
|
score, overlap = _hobby_score(a, b)
|
|
assert score == 100.0
|
|
assert set(overlap) == {"篮球", "电影"}
|
|
|
|
def test_no_overlap(self):
|
|
a = _user(hobbies=["篮球"])
|
|
b = _user(hobbies=["阅读"])
|
|
score, overlap = _hobby_score(a, b)
|
|
assert score == 0.0
|
|
assert overlap == []
|
|
|
|
def test_partial_overlap(self):
|
|
a = _user(hobbies=["篮球", "电影", "旅行"])
|
|
b = _user(hobbies=["电影", "阅读"])
|
|
score, overlap = _hobby_score(a, b)
|
|
# intersection=1, union=4 → 25.0
|
|
assert score == 25.0
|
|
assert overlap == ["电影"]
|
|
|
|
def test_empty_hobbies_returns_30(self):
|
|
a = _user(hobbies=[])
|
|
b = _user(hobbies=["篮球"])
|
|
score, _ = _hobby_score(a, b)
|
|
assert score == 30.0
|
|
|
|
def test_none_hobbies_returns_30(self):
|
|
a = _user(hobbies=None)
|
|
b = _user(hobbies=None)
|
|
score, _ = _hobby_score(a, b)
|
|
assert score == 30.0
|
|
|
|
|
|
# ============================================================================
|
|
# Value score
|
|
# ============================================================================
|
|
|
|
class TestValueScore:
|
|
def test_all_match(self):
|
|
a = _user(value_answers={"q1": "A", "q2": "B", "q3": "C", "q4": "D"})
|
|
b = _user(value_answers={"q1": "A", "q2": "B", "q3": "C", "q4": "D"})
|
|
score, reason = _value_score(a, b)
|
|
assert score == 100.0
|
|
assert reason == "生活节奏相近"
|
|
|
|
def test_three_match(self):
|
|
a = _user(value_answers={"q1": "A", "q2": "B", "q3": "C", "q4": "D"})
|
|
b = _user(value_answers={"q1": "A", "q2": "B", "q3": "C", "q4": "X"})
|
|
score, reason = _value_score(a, b)
|
|
assert score == 75.0
|
|
assert reason == "生活节奏相近"
|
|
|
|
def test_two_match(self):
|
|
a = _user(value_answers={"q1": "A", "q2": "B", "q3": "C", "q4": "D"})
|
|
b = _user(value_answers={"q1": "A", "q2": "B", "q3": "X", "q4": "Y"})
|
|
score, reason = _value_score(a, b)
|
|
assert score == 50.0
|
|
assert reason == "价值观较为一致"
|
|
|
|
def test_one_match(self):
|
|
a = _user(value_answers={"q1": "A", "q2": "B", "q3": "C", "q4": "D"})
|
|
b = _user(value_answers={"q1": "A", "q2": "X", "q3": "Y", "q4": "Z"})
|
|
score, reason = _value_score(a, b)
|
|
assert score == 25.0
|
|
assert reason is None
|
|
|
|
def test_no_answers_returns_50(self):
|
|
a = _user(value_answers={})
|
|
b = _user(value_answers={})
|
|
score, reason = _value_score(a, b)
|
|
assert score == 50.0
|
|
assert reason is None
|
|
|
|
|
|
# ============================================================================
|
|
# Education score
|
|
# ============================================================================
|
|
|
|
class TestEducationScore:
|
|
def test_no_preference_returns_80(self):
|
|
a = _user(prefer_education=None)
|
|
b = _user(education=4)
|
|
assert _education_score(a, b) == 80.0
|
|
|
|
def test_no_education_returns_40(self):
|
|
a = _user(prefer_education=4)
|
|
b = _user(education=None)
|
|
assert _education_score(a, b) == 40.0
|
|
|
|
def test_meets_preference(self):
|
|
a = _user(prefer_education=3)
|
|
b = _user(education=4)
|
|
assert _education_score(a, b) == 100.0
|
|
|
|
def test_exceeds_preference(self):
|
|
a = _user(prefer_education=3)
|
|
b = _user(education=5)
|
|
assert _education_score(a, b) == 100.0
|
|
|
|
def test_below_preference(self):
|
|
a = _user(prefer_education=4)
|
|
b = _user(education=2)
|
|
# 100 - (4-2)*25 = 50
|
|
assert _education_score(a, b) == 50.0
|
|
|
|
|
|
# ============================================================================
|
|
# Activity score
|
|
# ============================================================================
|
|
|
|
class TestActivityScore:
|
|
def test_recent_update(self):
|
|
b = _user(updated_at=datetime.now())
|
|
assert _activity_score(b) == 100.0
|
|
|
|
def test_one_week_inactive(self):
|
|
b = _user(updated_at=datetime.now() - timedelta(days=6))
|
|
assert _activity_score(b) == 80.0
|
|
|
|
def test_one_month_inactive(self):
|
|
b = _user(updated_at=datetime.now() - timedelta(days=20))
|
|
assert _activity_score(b) == 50.0
|
|
|
|
def test_long_inactive(self):
|
|
b = _user(updated_at=datetime.now() - timedelta(days=60))
|
|
assert _activity_score(b) == 20.0
|
|
|
|
def test_no_updated_at(self):
|
|
b = _user(updated_at=None)
|
|
assert _activity_score(b) == 30.0
|
|
|
|
|
|
# ============================================================================
|
|
# Location score
|
|
# ============================================================================
|
|
|
|
class TestLocationScore:
|
|
def test_same_city(self):
|
|
a = _user(city="广州市")
|
|
b = _user(city="广州市")
|
|
assert _location_score(a, b) == 100.0
|
|
|
|
def test_same_prefix_different_city(self):
|
|
a = _user(city="广州市")
|
|
b = _user(city="广州区")
|
|
# "广州" == "广州" → same first 2 chars
|
|
assert _location_score(a, b) == 60.0
|
|
|
|
def test_different_province(self):
|
|
a = _user(city="广州市")
|
|
b = _user(city="北京市")
|
|
assert _location_score(a, b) == 20.0
|
|
|
|
def test_no_city_returns_50(self):
|
|
a = _user(city=None)
|
|
b = _user(city="广州市")
|
|
assert _location_score(a, b) == 50.0
|
|
|
|
|
|
# ============================================================================
|
|
# Height score
|
|
# ============================================================================
|
|
|
|
class TestHeightScore:
|
|
def test_female_rates_tall_male(self):
|
|
a = _user(gender=2) # female evaluator
|
|
b = _user(gender=1, height=178)
|
|
assert _height_score(a, b) == 100.0
|
|
|
|
def test_female_rates_medium_male(self):
|
|
a = _user(gender=2)
|
|
b = _user(gender=1, height=172)
|
|
assert _height_score(a, b) == 70.0
|
|
|
|
def test_female_rates_short_male(self):
|
|
a = _user(gender=2)
|
|
b = _user(gender=1, height=165)
|
|
assert _height_score(a, b) == 40.0
|
|
|
|
def test_non_female_evaluator_returns_70(self):
|
|
a = _user(gender=1)
|
|
b = _user(gender=2, height=165)
|
|
assert _height_score(a, b) == 70.0
|
|
|
|
def test_no_height_returns_50(self):
|
|
a = _user(gender=2)
|
|
b = _user(gender=1, height=None)
|
|
assert _height_score(a, b) == 50.0
|
|
|
|
|
|
# ============================================================================
|
|
# calculate_match_score integration
|
|
# ============================================================================
|
|
|
|
class TestCalculateMatchScore:
|
|
def test_returns_score_and_reasons(self):
|
|
a = _user(
|
|
id=1, gender=1, birth_year=1995, city="广州市", height=178, education=4,
|
|
hobbies=["篮球", "电影"], value_answers={"q1": "A", "q2": "B", "q3": "A", "q4": "C"},
|
|
prefer_age_min=1993, prefer_age_max=2000, prefer_education=3,
|
|
updated_at=datetime.now(),
|
|
)
|
|
b = _user(
|
|
id=2, gender=2, birth_year=1997, city="广州市", height=165, education=4,
|
|
hobbies=["阅读", "电影", "瑜伽"], value_answers={"q1": "A", "q2": "B", "q3": "B", "q4": "C"},
|
|
prefer_age_min=1990, prefer_age_max=1998, prefer_education=3,
|
|
updated_at=datetime.now(),
|
|
)
|
|
result = calculate_match_score(a, b)
|
|
assert "score" in result
|
|
assert "reasons" in result
|
|
assert isinstance(result["score"], float)
|
|
assert 0 <= result["score"] <= 100
|
|
|
|
def test_score_is_weighted_sum(self):
|
|
a = _user(
|
|
id=1, gender=1, birth_year=1995, city="广州市", height=178, education=4,
|
|
hobbies=["篮球"], value_answers={"q1": "A", "q2": "A", "q3": "A", "q4": "A"},
|
|
prefer_age_min=1990, prefer_age_max=2000, prefer_education=3,
|
|
updated_at=datetime.now(),
|
|
)
|
|
b = _user(
|
|
id=2, gender=2, birth_year=1995, city="广州市", height=165, education=4,
|
|
hobbies=["篮球"], value_answers={"q1": "A", "q2": "A", "q3": "A", "q4": "A"},
|
|
updated_at=datetime.now(),
|
|
)
|
|
result = calculate_match_score(a, b)
|
|
# Manually compute expected
|
|
expected = (
|
|
_age_score(a, b) * 0.20
|
|
+ _hobby_score(a, b)[0] * 0.18
|
|
+ _value_score(a, b)[0] * 0.18
|
|
+ _education_score(a, b) * 0.14
|
|
+ _activity_score(b) * 0.12
|
|
+ _location_score(a, b) * 0.10
|
|
+ _height_score(a, b) * 0.08
|
|
)
|
|
assert abs(result["score"] - round(expected, 1)) < 0.05
|
|
|
|
def test_reasons_include_common_hobby(self):
|
|
a = _user(hobbies=["篮球", "电影"], city="广州市", updated_at=datetime.now())
|
|
b = _user(hobbies=["篮球", "阅读"], city="广州市", updated_at=datetime.now())
|
|
result = calculate_match_score(a, b)
|
|
assert any("篮球" in r for r in result["reasons"])
|
|
|
|
def test_reasons_include_same_city(self):
|
|
a = _user(city="广州市", updated_at=datetime.now())
|
|
b = _user(city="广州市", updated_at=datetime.now())
|
|
result = calculate_match_score(a, b)
|
|
assert "同城" in result["reasons"]
|
|
|
|
def test_reasons_limited_to_three(self):
|
|
a = _user(
|
|
city="广州市", hobbies=["篮球"],
|
|
value_answers={"q1": "A", "q2": "A", "q3": "A", "q4": "A"},
|
|
updated_at=datetime.now(),
|
|
)
|
|
b = _user(
|
|
city="广州市", hobbies=["篮球"],
|
|
value_answers={"q1": "A", "q2": "A", "q3": "A", "q4": "A"},
|
|
updated_at=datetime.now(),
|
|
)
|
|
result = calculate_match_score(a, b)
|
|
assert len(result["reasons"]) <= 3
|