"""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