xiangqinxiaochengxu/backend/tests/test_match_score.py
2026-06-18 23:06:40 +08:00

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