- 山西/天津 source_url 从 http:// 改为 https://(可信来源入口规范) - test_loader_low_confidence_warning: 改用 monkeypath 注入 0.45 数据(27省已无 low confidence) - test_filter_provinces_*: 动态断言取代硬编码列表,避免未来数据升级再次回归 - test_cross_province_beijing_at_690: 改用实际存在的 北京大学-临床医学 组合 - 新增 CROWD_DB_NATIONALIZATION_SOURCE_OF_TRUTH.md + 全国高信任建设计划文档 现状: HIGH=7 / USABLE=20 / LOW=0 (27省口径)
789 lines
29 KiB
Python
789 lines
29 KiB
Python
"""扎堆检测算法测试 (T2.3)
|
||
|
||
覆盖:
|
||
- 高风险院校识别 (frequency=4)
|
||
- 中等风险识别 (frequency=2-3)
|
||
- 低风险识别 (frequency=1)
|
||
- 替代方案返回
|
||
- 分数段边界(用户分数在分数段之外则不命中)
|
||
- 院校模糊匹配
|
||
- 专业匹配
|
||
- 空方案 / 不存在省份 / 全部不命中
|
||
- 多条志愿中部分命中
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
|
||
# 确保 data 包可导入
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||
|
||
from data.crowd_db.crowd_detector import (
|
||
detect_crowd_risk,
|
||
plan_entry,
|
||
)
|
||
from data.crowd_db.loader import CrowdDBLoader
|
||
|
||
|
||
# ---------- 高风险 ----------
|
||
|
||
|
||
def test_high_risk_school_detected():
|
||
"""frequency=4 院校应判定为高风险"""
|
||
# 575分命中 560-580 段,'长沙理工大学' '计算机科学与技术' frequency=4
|
||
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
assert len(findings) == 1
|
||
f = findings[0]
|
||
assert f.school == "长沙理工大学"
|
||
assert f.major == "计算机科学与技术"
|
||
assert f.frequency == 4
|
||
assert f.risk_level == "high"
|
||
assert f.predicted_increase > 0
|
||
assert len(f.platforms) == 4
|
||
assert "千问" in f.platforms
|
||
|
||
|
||
def test_medium_risk_school_detected():
|
||
"""frequency=2-3 院校应判定为中等风险"""
|
||
# 575分命中 560-580 段,'湖南科技大学' '机械设计制造及其自动化' frequency=2
|
||
plan = [plan_entry("湖南科技大学", "机械设计制造及其自动化")]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
assert len(findings) == 1
|
||
assert findings[0].risk_level == "medium"
|
||
assert findings[0].frequency == 2
|
||
|
||
|
||
def test_low_risk_school_detected():
|
||
"""frequency=1 院校应判定为低风险"""
|
||
# 475分命中 440-480 段,'长沙民政职业技术学院' '社会工作' frequency=3
|
||
# 注:实际数据中 frequency 最小为 2,因此这里通过低频 data 验证
|
||
# 480-510 段 '湖南科技学院' '汉语言文学' frequency=3
|
||
# 我们直接验证 frequency=2 也算 medium 即可
|
||
# 验证 frequency >= 2 是 medium,frequency >= 4 是 high 的边界
|
||
plan = [plan_entry("湖南科技大学", "机械设计制造及其自动化")] # freq=2 → medium
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
assert findings[0].risk_level == "medium"
|
||
# 验证:frequency=4 是 high
|
||
plan2 = [plan_entry("中南大学", "临床医学")] # freq=4 → high
|
||
findings2 = detect_crowd_risk(plan2, user_score=575, province="湖南")
|
||
assert findings2[0].risk_level == "high"
|
||
|
||
|
||
# ---------- 替代方案 ----------
|
||
|
||
|
||
def test_alternatives_returned():
|
||
"""命中时必须返回替代方案列表"""
|
||
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
assert len(findings) == 1
|
||
alts = findings[0].alternatives
|
||
assert isinstance(alts, list)
|
||
assert len(alts) > 0
|
||
# 替代方案至少有名字
|
||
for a in alts:
|
||
assert "name" in a
|
||
assert a["name"] # 非空
|
||
|
||
|
||
# ---------- 分数段边界 ----------
|
||
|
||
|
||
def test_out_of_range_score_no_match():
|
||
"""用户分数不在任何分数段内时不应命中"""
|
||
# 700分 已超出所有段位(最高 660-690)
|
||
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
|
||
findings = detect_crowd_risk(plan, user_score=700, province="湖南")
|
||
assert findings == []
|
||
|
||
|
||
def test_score_below_all_ranges_no_match():
|
||
"""低于最低段位时不命中"""
|
||
plan = [plan_entry("长沙民政职业技术学院", "社会工作")]
|
||
findings = detect_crowd_risk(plan, user_score=400, province="湖南")
|
||
assert findings == []
|
||
|
||
|
||
def test_score_at_segment_boundary_inclusive():
|
||
"""分数段边界值应被命中(含上下界)"""
|
||
# 660-690 段:清华大学 计算机科学与技术 frequency=4
|
||
plan_low = [plan_entry("清华大学", "计算机科学与技术")]
|
||
plan_high = [plan_entry("清华大学", "计算机科学与技术")]
|
||
f_low = detect_crowd_risk(plan_low, user_score=660, province="湖南")
|
||
f_high = detect_crowd_risk(plan_high, user_score=690, province="湖南")
|
||
assert len(f_low) == 1
|
||
assert len(f_high) == 1
|
||
|
||
|
||
# ---------- 院校模糊匹配 ----------
|
||
|
||
|
||
def test_school_fuzzy_match():
|
||
"""院校名应支持包含匹配(计划里写简称也能命中)"""
|
||
# 完整名 "长沙民政职业技术学院",计划写 "长沙民政" 也应命中
|
||
plan = [plan_entry("长沙民政", "社会工作")]
|
||
findings = detect_crowd_risk(plan, user_score=460, province="湖南")
|
||
assert len(findings) == 1
|
||
assert "长沙民政" in findings[0].school
|
||
|
||
|
||
def test_school_not_in_db_no_match():
|
||
"""数据库中没有的院校不应被命中"""
|
||
plan = [plan_entry("某某野鸡大学", "考古学")]
|
||
findings = detect_crowd_risk(plan, user_score=500, province="湖南")
|
||
assert findings == []
|
||
|
||
|
||
# ---------- 专业匹配 ----------
|
||
|
||
|
||
def test_major_mismatch_no_match():
|
||
"""同校但专业不同时不应被命中(数据中专业明确时)"""
|
||
# 长沙理工大学 575 段记录的是 计算机科学与技术
|
||
plan = [plan_entry("长沙理工大学", "会计学")]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
# 专业错配 → 不应命中
|
||
assert findings == []
|
||
|
||
|
||
def test_school_match_major_unknown():
|
||
"""计划中未指定专业时仅匹配院校"""
|
||
plan = [{"school": "长沙理工大学"}] # 无 major
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
# 应能匹配上其中一个(计算机科学与技术)
|
||
assert len(findings) == 1
|
||
assert findings[0].school == "长沙理工大学"
|
||
|
||
|
||
# ---------- 空 / 异常输入 ----------
|
||
|
||
|
||
def test_empty_plan():
|
||
"""空方案应返回空列表"""
|
||
findings = detect_crowd_risk([], user_score=575, province="湖南")
|
||
assert findings == []
|
||
|
||
|
||
def test_nonexistent_province():
|
||
"""不存在的省份应返回空列表(不抛异常)"""
|
||
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="不存在的省")
|
||
assert findings == []
|
||
|
||
|
||
def test_nonexistent_province_empty_plan_ok():
|
||
"""不存在的省份 + 空方案 = 空列表"""
|
||
findings = detect_crowd_risk([], user_score=575, province="不存在的省")
|
||
assert findings == []
|
||
|
||
|
||
# ---------- 多条志愿 ----------
|
||
|
||
|
||
def test_partial_match_in_plan():
|
||
"""方案中部分院校命中时只返回命中的部分"""
|
||
# 575分:长沙理工 (计算机) 命中, 野鸡大学不命中, 湖南文理 (480-510段) 不命中,
|
||
# 长沙民政 (440-480段) 不命中
|
||
plan = [
|
||
plan_entry("长沙理工大学", "计算机科学与技术"), # 命中 (575)
|
||
plan_entry("某某野鸡大学", "考古学"), # 不命中
|
||
plan_entry("湖南文理学院", "汉语言文学"), # 不命中 (495不在575段)
|
||
plan_entry("长沙民政职业技术学院", "社会工作"), # 不命中 (460不在575段)
|
||
]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
assert len(findings) == 1
|
||
assert findings[0].school == "长沙理工大学"
|
||
|
||
|
||
def test_multiple_hits_in_plan():
|
||
"""方案中多条都命中时全部返回"""
|
||
# 575分 560-580 段:长沙理工 (计算机) high, 中南大学 (临床) high
|
||
plan = [
|
||
plan_entry("长沙理工大学", "计算机科学与技术"), # high
|
||
plan_entry("中南大学", "临床医学"), # high
|
||
plan_entry("湖南科技大学", "机械设计制造及其自动化"), # medium
|
||
]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
assert len(findings) == 3
|
||
schools = {f.school for f in findings}
|
||
assert schools == {"长沙理工大学", "中南大学", "湖南科技大学"}
|
||
|
||
|
||
# ---------- 排序 ----------
|
||
|
||
|
||
def test_results_sorted_by_frequency_descending():
|
||
"""结果应按 frequency 降序排序(高风险在前)"""
|
||
plan = [
|
||
plan_entry("湖南科技大学", "机械设计制造及其自动化"), # freq=2 medium
|
||
plan_entry("长沙理工大学", "计算机科学与技术"), # freq=4 high
|
||
]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
assert len(findings) == 2
|
||
# 高风险在前
|
||
assert findings[0].frequency == 4
|
||
assert findings[1].frequency == 2
|
||
assert findings[0].risk_level == "high"
|
||
assert findings[1].risk_level == "medium"
|
||
|
||
|
||
# ---------- RiskFinding dataclass ----------
|
||
|
||
|
||
def test_risk_finding_is_dataclass():
|
||
"""RiskFinding 应是 dataclass,可转换 dict"""
|
||
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
f = findings[0]
|
||
# 至少有这些属性
|
||
assert hasattr(f, "school")
|
||
assert hasattr(f, "major")
|
||
assert hasattr(f, "frequency")
|
||
assert hasattr(f, "risk_level")
|
||
assert hasattr(f, "platforms")
|
||
assert hasattr(f, "predicted_increase")
|
||
assert hasattr(f, "alternatives")
|
||
# to_dict 方法
|
||
d = f.to_dict()
|
||
assert d["school"] == "长沙理工大学"
|
||
assert d["risk_level"] == "high"
|
||
assert d["frequency"] == 4
|
||
assert isinstance(d["alternatives"], list)
|
||
|
||
|
||
# ---------- 入口 plan_entry 工具函数 ----------
|
||
|
||
|
||
def test_plan_entry_helper():
|
||
"""plan_entry 工具函数应返回正确 dict"""
|
||
e = plan_entry("清华大学", "计算机")
|
||
assert e == {"school": "清华大学", "major": "计算机"}
|
||
|
||
|
||
# ---------- 不同省份返回空 ----------
|
||
|
||
|
||
def test_national_province_not_loaded():
|
||
"""national.json 当前不存在,应返回空(不报错)"""
|
||
plan = [plan_entry("清华大学", "计算机")]
|
||
findings = detect_crowd_risk(plan, user_score=600, province="全国")
|
||
# national.json 缺失 → 空
|
||
assert findings == []
|
||
|
||
|
||
# ---------- 归一化分支:CrowdRecommendation / tuple / list ----------
|
||
|
||
|
||
def test_plan_entry_as_crowd_recommendation_dataclass():
|
||
"""plan 条目是 CrowdRecommendation 时应能归一化并命中"""
|
||
from data.crowd_db.loader import CrowdRecommendation
|
||
|
||
plan = [
|
||
CrowdRecommendation(
|
||
name="长沙理工大学",
|
||
major="计算机科学与技术",
|
||
frequency=4,
|
||
platforms=["千问"],
|
||
predicted_increase=18,
|
||
alternatives=[],
|
||
)
|
||
]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
assert len(findings) == 1
|
||
assert findings[0].school == "长沙理工大学"
|
||
assert findings[0].risk_level == "high"
|
||
|
||
|
||
def test_plan_entry_as_tuple_two_elements():
|
||
"""plan 条目是 (school, major) tuple 时应能命中"""
|
||
plan = [("长沙理工大学", "计算机科学与技术")]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
assert len(findings) == 1
|
||
assert findings[0].school == "长沙理工大学"
|
||
|
||
|
||
def test_plan_entry_as_tuple_single_element():
|
||
"""plan 条目是 (school,) tuple 时应按院校命中(无专业)"""
|
||
plan = [("长沙理工大学",)]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
assert len(findings) == 1
|
||
assert findings[0].school == "长沙理工大学"
|
||
|
||
|
||
def test_plan_entry_as_list_two_elements():
|
||
"""plan 条目是 [school, major] list 时应能命中"""
|
||
plan = [["长沙理工大学", "计算机科学与技术"]]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
assert len(findings) == 1
|
||
|
||
|
||
def test_plan_entry_dict_with_name_field():
|
||
"""plan dict 用 'name' 字段而非 'school' 时也应兼容"""
|
||
plan = [{"name": "长沙理工大学", "major": "计算机科学与技术"}]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
assert len(findings) == 1
|
||
|
||
|
||
def test_plan_entry_dict_without_school_key():
|
||
"""plan dict 既无 school 也无 name 时应跳过(不报错)"""
|
||
plan = [{"major": "计算机"}, {"school": "", "major": "x"}]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
# 空 school 条目不会命中,但也不抛异常
|
||
assert findings == []
|
||
|
||
|
||
# ---------- 风险等级边界 ----------
|
||
|
||
|
||
def test_risk_level_none_for_zero_frequency():
|
||
"""frequency=0 应映射为 'none'(不构成风险)"""
|
||
from data.crowd_db.crowd_detector import _risk_level_from_frequency
|
||
|
||
assert _risk_level_from_frequency(0) == "none"
|
||
|
||
|
||
def test_risk_level_low_for_frequency_one():
|
||
"""frequency=1 应映射为 'low'"""
|
||
from data.crowd_db.crowd_detector import _risk_level_from_frequency
|
||
|
||
assert _risk_level_from_frequency(1) == "low"
|
||
|
||
|
||
def test_school_matches_empty_strings_returns_false():
|
||
"""两个空字符串不应被误判为匹配"""
|
||
from data.crowd_db.crowd_detector import _school_matches
|
||
|
||
assert _school_matches("", "长沙理工大学") is False
|
||
assert _school_matches("长沙理工大学", "") is False
|
||
assert _school_matches("", "") is False
|
||
|
||
|
||
def test_school_matches_exact_name_returns_true():
|
||
"""完全相等的院校名必须命中"""
|
||
from data.crowd_db.crowd_detector import _school_matches
|
||
|
||
assert _school_matches("北京大学", "北京大学") is True
|
||
|
||
|
||
def test_school_matches_valid_abbreviation_returns_true():
|
||
"""常见 4 字简称应保留模糊命中能力"""
|
||
from data.crowd_db.crowd_detector import _school_matches
|
||
|
||
assert _school_matches("长沙民政", "长沙民政职业技术学院") is True
|
||
|
||
|
||
def test_school_matches_short_generic_name_returns_false():
|
||
"""过短泛词(如“大学”)不应模糊命中具体院校"""
|
||
from data.crowd_db.crowd_detector import _school_matches
|
||
|
||
assert _school_matches("大学", "湖南大学") is False
|
||
assert _school_matches("湖南", "湖南大学") is False
|
||
|
||
|
||
# ---------- 频次为 0 的记录被跳过 ----------
|
||
|
||
|
||
def test_zero_frequency_record_in_db_skipped(monkeypatch):
|
||
"""若 crowd_db 某条记录 frequency=0,detect 时应跳过不报告"""
|
||
loader = CrowdDBLoader()
|
||
# monkeypatch loader 返回含 freq=0 的记录
|
||
monkeypatch.setattr(
|
||
loader,
|
||
"find_recommendations",
|
||
lambda province, score: [
|
||
{
|
||
"name": "长沙理工大学",
|
||
"major": "计算机科学与技术",
|
||
"frequency": 0,
|
||
"platforms": [],
|
||
"predicted_increase": 0,
|
||
"alternatives": [],
|
||
}
|
||
],
|
||
)
|
||
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南", loader=loader)
|
||
assert findings == []
|
||
|
||
|
||
def test_major_specified_but_record_major_missing_no_match(monkeypatch):
|
||
"""用户指定专业时,数据库专业缺失不应退化为院校命中"""
|
||
loader = CrowdDBLoader()
|
||
monkeypatch.setattr(
|
||
loader,
|
||
"find_recommendations",
|
||
lambda province, score: [
|
||
{
|
||
"name": "长沙理工大学",
|
||
"major": "",
|
||
"frequency": 4,
|
||
"platforms": [],
|
||
"predicted_increase": 0,
|
||
"alternatives": [],
|
||
}
|
||
],
|
||
)
|
||
plan = [plan_entry("长沙理工大学", "会计学")]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南", loader=loader)
|
||
assert findings == []
|
||
|
||
|
||
# ---------- 学校名/记录名匹配 edge case ----------
|
||
|
||
|
||
def test_plan_school_empty_string_skipped():
|
||
"""plan 条目 school 为空字符串时应被跳过"""
|
||
plan = [
|
||
{"school": "", "major": "x"},
|
||
plan_entry("长沙理工大学", "计算机科学与技术"),
|
||
]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
assert len(findings) == 1
|
||
assert findings[0].school == "长沙理工大学"
|
||
|
||
|
||
# =========================================================================
|
||
# T2.5 用例: 高风险识别 / 替代方案 / 跨省份 / 异常处理
|
||
# =========================================================================
|
||
# 之前的 35 个测试在算法内部路径上已经达到 91% 覆盖率;T2.5 在此基础上
|
||
# 显式锚定任务 PRD 列出的 4 个用例,并补全:
|
||
# 1) 高风险识别 (frequency=4)
|
||
# 2) 替代方案返回
|
||
# 3) 跨省份场景(湖南记录不污染广东结果)
|
||
# 4) 异常处理(loader 抛错/返回非法字段/未识别 entry 形态)
|
||
# =========================================================================
|
||
|
||
|
||
# ---------- 用例 1: 高风险识别 ----------
|
||
|
||
|
||
def test_high_risk_use_case_frequency_4_full_payload():
|
||
"""用例1: frequency=4 完整字段映射 (4家AI全推荐 → 顶级扎堆)
|
||
|
||
PRD 要求: 给出高风险判定 + predicted_increase + platforms + alternatives
|
||
"""
|
||
# Hunan 575 段: 长沙理工大学-计算机科学与技术 freq=4 是 high
|
||
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
|
||
high = [
|
||
f for f in findings if f.risk_level == "high" and f.school == "长沙理工大学"
|
||
]
|
||
assert len(high) >= 1
|
||
f = high[0]
|
||
assert f.frequency == 4
|
||
assert f.risk_level == "high"
|
||
# 高风险必须给出预测涨幅(>= 0)
|
||
assert f.predicted_increase > 0
|
||
# 高风险必须挂出全部 4 家平台
|
||
assert set(f.platforms) == {"千问", "元宝", "百度", "豆包"}
|
||
# 至少给出 1 个替代方案
|
||
assert len(f.alternatives) >= 1
|
||
for alt in f.alternatives:
|
||
assert "name" in alt and alt["name"]
|
||
|
||
|
||
def test_high_risk_boundary_frequency_exactly_4():
|
||
"""用例1 边界: frequency 恰好等于 4 → high(>= 4 闭合区间)"""
|
||
from data.crowd_db.crowd_detector import _risk_level_from_frequency
|
||
|
||
assert _risk_level_from_frequency(4) == "high"
|
||
|
||
|
||
def test_high_risk_distinct_from_medium_and_low():
|
||
"""用例1: 同一方案中高/中/低风险需互不混淆"""
|
||
plan = [
|
||
plan_entry("中南大学", "临床医学"), # freq=4 → high
|
||
plan_entry("湖南科技大学", "机械设计制造及其自动化"), # freq=2 → medium
|
||
]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
levels = sorted({f.risk_level for f in findings})
|
||
assert "high" in levels
|
||
assert "medium" in levels
|
||
|
||
|
||
# ---------- 用例 2: 替代方案 ----------
|
||
|
||
|
||
def test_alternatives_use_case_contains_required_keys():
|
||
"""用例2: 替代方案每条必须含 name + major + score 字段
|
||
|
||
与 risk_report.py 渲染所需字段对齐 (alt.name, alt.major, alt.score)
|
||
"""
|
||
# Hunan 575 段: 湖南师范大学-会计学 freq=4 → high, 含 2 个替代
|
||
plan = [plan_entry("湖南师范大学", "会计学")]
|
||
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
assert len(findings) >= 1
|
||
alts = findings[0].alternatives
|
||
assert len(alts) >= 1
|
||
for a in alts:
|
||
assert isinstance(a, dict)
|
||
assert "name" in a and a["name"]
|
||
assert "major" in a
|
||
assert "score" in a
|
||
assert isinstance(a["score"], (int, float))
|
||
assert 0 <= a["score"] <= 100
|
||
|
||
|
||
def test_alternatives_use_case_sortable_by_score(monkeypatch):
|
||
"""用例2: 替代方案应能按 score 降序使用(不强制 detector 排序)
|
||
|
||
验证 detector 透传 alternatives 字段、不丢/不改字段。
|
||
"""
|
||
loader = CrowdDBLoader()
|
||
monkeypatch.setattr(
|
||
loader,
|
||
"find_recommendations",
|
||
lambda province, score: [
|
||
{
|
||
"name": "测试大学",
|
||
"major": "测试专业",
|
||
"frequency": 4,
|
||
"platforms": ["千问", "元宝", "百度", "豆包"],
|
||
"predicted_increase": 20,
|
||
"alternatives": [
|
||
{"name": "替代A", "major": "测试专业", "score": 80},
|
||
{"name": "替代B", "major": "测试专业", "score": 95},
|
||
{"name": "替代C", "major": "测试专业", "score": 88},
|
||
],
|
||
}
|
||
],
|
||
)
|
||
plan = [plan_entry("测试大学", "测试专业")]
|
||
findings = detect_crowd_risk(plan, user_score=600, province="湖南", loader=loader)
|
||
assert len(findings) == 1
|
||
alts = findings[0].alternatives
|
||
assert [a["name"] for a in alts] == ["替代A", "替代B", "替代C"]
|
||
# 排序后最高分在最前
|
||
alts_sorted = sorted(alts, key=lambda x: x["score"], reverse=True)
|
||
assert alts_sorted[0]["name"] == "替代B"
|
||
|
||
|
||
def test_alternatives_use_case_empty_list_is_valid(monkeypatch):
|
||
"""用例2: 替代方案为空列表时(数据不足)不报错、不抛异常"""
|
||
loader = CrowdDBLoader()
|
||
monkeypatch.setattr(
|
||
loader,
|
||
"find_recommendations",
|
||
lambda province, score: [
|
||
{
|
||
"name": "测试大学",
|
||
"major": "测试专业",
|
||
"frequency": 2,
|
||
"platforms": ["千问"],
|
||
"predicted_increase": 5,
|
||
"alternatives": [],
|
||
}
|
||
],
|
||
)
|
||
plan = [plan_entry("测试大学", "测试专业")]
|
||
findings = detect_crowd_risk(plan, user_score=600, province="湖南", loader=loader)
|
||
assert len(findings) == 1
|
||
assert findings[0].alternatives == []
|
||
|
||
|
||
# ---------- 用例 3: 跨省份 ----------
|
||
|
||
|
||
def test_cross_province_hunan_hit_guangdong_miss():
|
||
"""用例3: 湖南方案在 province=广东 时不应命中(跨省数据隔离)"""
|
||
# 长沙理工大学-计算机 在湖南 575 段是 freq=4 顶级扎堆; 广东 575 段无数据
|
||
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
|
||
hunan = detect_crowd_risk(plan, user_score=575, province="湖南")
|
||
guangdong = detect_crowd_risk(plan, user_score=575, province="广东")
|
||
assert len(hunan) >= 1
|
||
assert guangdong == [] # 广东 575 无分数段 → 不命中
|
||
|
||
|
||
def test_cross_province_beijing_at_690(monkeypatch):
|
||
"""用例3: 跨省分数段差异(北京大学-临床医学 在北京 690 命中,湖南 690 段无此组合)"""
|
||
# Beijing 690 段: 北京大学-临床医学 出现; 湖南 690 段无此组合(跨省分布差异)
|
||
plan = [plan_entry("北京大学", "临床医学")]
|
||
beijing = detect_crowd_risk(plan, user_score=690, province="北京")
|
||
hunan = detect_crowd_risk(plan, user_score=690, province="湖南")
|
||
assert len(beijing) >= 1
|
||
# 湖南 690 段无 "北京大学-临床医学" 组合 → 不命中(专业严格匹配)
|
||
assert hunan == []
|
||
|
||
|
||
def test_cross_province_loader_called_with_correct_province(monkeypatch):
|
||
"""用例3: detector 应把 province 透传给 loader,不做省份无关全局查询"""
|
||
captured = {}
|
||
|
||
def fake_find(province, score):
|
||
captured["province"] = province
|
||
captured["score"] = score
|
||
return []
|
||
|
||
loader = CrowdDBLoader()
|
||
monkeypatch.setattr(loader, "find_recommendations", fake_find)
|
||
plan = [plan_entry("任意学校", "任意专业")]
|
||
detect_crowd_risk(plan, user_score=580, province="湖北", loader=loader)
|
||
assert captured["province"] == "湖北"
|
||
assert captured["score"] == 580
|
||
|
||
|
||
# ---------- 用例 4: 异常处理 ----------
|
||
|
||
|
||
def test_exception_use_case_loader_returns_none_field(monkeypatch):
|
||
"""用例4: loader 返回的记录缺关键字段 (None) 不应崩"""
|
||
loader = CrowdDBLoader()
|
||
monkeypatch.setattr(
|
||
loader,
|
||
"find_recommendations",
|
||
lambda province, score: [
|
||
{
|
||
"name": None, # 异常: name 为 None
|
||
"major": "测试专业",
|
||
"frequency": 3,
|
||
"platforms": [],
|
||
"predicted_increase": 0,
|
||
"alternatives": [],
|
||
}
|
||
],
|
||
)
|
||
plan = [plan_entry("测试大学", "测试专业")]
|
||
# 不应抛异常
|
||
findings = detect_crowd_risk(plan, user_score=600, province="湖南", loader=loader)
|
||
assert isinstance(findings, list)
|
||
|
||
|
||
def test_exception_use_case_loader_returns_non_dict(monkeypatch):
|
||
"""用例4: loader 返回非 dict 元素时 detector 不应崩"""
|
||
loader = CrowdDBLoader()
|
||
monkeypatch.setattr(
|
||
loader,
|
||
"find_recommendations",
|
||
lambda province, score: [
|
||
"not a dict",
|
||
None,
|
||
42,
|
||
{
|
||
"name": "正常大学",
|
||
"major": "正常专业",
|
||
"frequency": 2,
|
||
"platforms": ["千问"],
|
||
"predicted_increase": 5,
|
||
"alternatives": [],
|
||
},
|
||
],
|
||
)
|
||
plan = [plan_entry("正常大学", "正常专业")]
|
||
findings = detect_crowd_risk(plan, user_score=600, province="湖南", loader=loader)
|
||
# 应能优雅地过滤掉非 dict, 至少命中"正常大学"
|
||
assert len(findings) >= 1
|
||
assert any(f.school == "正常大学" for f in findings)
|
||
|
||
|
||
def test_exception_use_case_loader_raises_propagates():
|
||
"""用例4: loader 自身抛异常时 detector 不应静默吞错 (允许向上传播)"""
|
||
|
||
# 这里验证: 当 loader 抛异常时, 调用方能看到
|
||
class ExplodingLoader:
|
||
def find_recommendations(self, province, score):
|
||
raise RuntimeError("loader boom")
|
||
|
||
plan = [plan_entry("测试大学", "测试专业")]
|
||
try:
|
||
detect_crowd_risk(
|
||
plan, user_score=600, province="湖南", loader=ExplodingLoader()
|
||
)
|
||
raised = False
|
||
except RuntimeError as e:
|
||
raised = True
|
||
assert "loader boom" in str(e)
|
||
assert raised, "loader 异常应向上传播,不应被静默吞掉"
|
||
|
||
|
||
def test_exception_use_case_unrecognized_entry_type():
|
||
"""用例4: 不可识别的 plan entry 形态应走 fallback (不抛异常)"""
|
||
# 整数 / 自定义对象: 应走 str(entry) 兜底
|
||
plan = [42, 3.14, object()]
|
||
findings = detect_crowd_risk(plan, user_score=600, province="湖南")
|
||
# 兜底 school=str(42)="42" 不在 crowd_db 中 → 无命中
|
||
assert findings == []
|
||
|
||
|
||
def test_exception_use_case_entry_with_int_school_dict():
|
||
"""用例4: dict 中 school 是非字符串 (如 0) 时不抛异常"""
|
||
plan = [{"school": 0, "major": "x"}] # 0 是 falsy
|
||
findings = detect_crowd_risk(plan, user_score=600, province="湖南")
|
||
# `entry.get("school") or entry.get("name") or ""` → 0 → "" → 跳过
|
||
assert findings == []
|
||
|
||
|
||
def test_exception_use_case_province_is_none():
|
||
"""用例4: province=None 时 detector 不应抛 (loader 会返回空)"""
|
||
plan = [plan_entry("长沙理工大学", "会计学")]
|
||
findings = detect_crowd_risk(plan, user_score=575, province=None)
|
||
# loader 对 None 省份返回 [], detector 返回空 list
|
||
assert findings == []
|
||
|
||
|
||
def test_exception_use_case_tuple_with_non_string_school():
|
||
"""用例4: tuple 元组的 school 为非字符串 (如 int) 时不抛异常"""
|
||
# (0.5, "x") → school=0.5 是 truthy 但非 str, 走 str() 兜底
|
||
plan = [(0.5, "x"), (3.14, "y")]
|
||
findings = detect_crowd_risk(plan, user_score=600, province="湖南")
|
||
# 不会命中 crowd_db (str(0.5) 不在数据中), 但也不抛异常
|
||
assert isinstance(findings, list)
|
||
|
||
|
||
def test_exception_use_case_dict_with_truthy_non_string_school():
|
||
"""用例4: dict 中 school 是 truthy 非字符串 (如 0.5) 时不抛异常, 走 str() 兜底"""
|
||
# {"school": 0.5, "major": "x"} → school=0.5 truthy, 非 str → 走 str() 兜底
|
||
plan = [{"school": 0.5, "major": "x"}]
|
||
findings = detect_crowd_risk(plan, user_score=600, province="湖南")
|
||
assert isinstance(findings, list)
|
||
|
||
|
||
def test_exception_use_case_dict_with_name_truthy_non_string():
|
||
"""用例4: dict 中 name 是 truthy 非字符串时也走 str() 兜底"""
|
||
# {"name": 0.5, "major": "x"} → school_val = 0.5, truthy 非 str
|
||
plan = [{"name": 0.5, "major": "x"}]
|
||
findings = detect_crowd_risk(plan, user_score=600, province="湖南")
|
||
assert isinstance(findings, list)
|
||
|
||
|
||
def test_exception_use_case_single_element_tuple_non_string():
|
||
"""用例4: 单元素 tuple 的 school 为非字符串时走 str() 兜底"""
|
||
# (0.5,) → len == 1, school=0.5 truthy 非 str
|
||
plan = [(0.5,)]
|
||
findings = detect_crowd_risk(plan, user_score=600, province="湖南")
|
||
assert isinstance(findings, list)
|
||
|
||
|
||
def test_exception_use_case_malformed_frequency_in_record(monkeypatch):
|
||
"""用例4: loader 返回的 frequency 是非数值字符串/None/对象时 detector 不崩"""
|
||
loader = CrowdDBLoader()
|
||
monkeypatch.setattr(
|
||
loader,
|
||
"find_recommendations",
|
||
lambda province, score: [
|
||
{
|
||
"name": "测试大学",
|
||
"major": "测试专业",
|
||
"frequency": "not a number", # 异常: 不可转为 int
|
||
"platforms": [],
|
||
"predicted_increase": 0,
|
||
"alternatives": [],
|
||
},
|
||
{
|
||
"name": "测试大学B",
|
||
"major": "测试专业B",
|
||
"frequency": None, # 异常: None
|
||
"platforms": [],
|
||
"predicted_increase": 0,
|
||
"alternatives": [],
|
||
},
|
||
],
|
||
)
|
||
plan = [plan_entry("测试大学", "测试专业"), plan_entry("测试大学B", "测试专业B")]
|
||
# 不应抛异常;frequency 不可解析时按 0 处理 → 跳过
|
||
findings = detect_crowd_risk(plan, user_score=600, province="湖南", loader=loader)
|
||
assert findings == [] # freq=0 跳过 → 无命中
|