Files
gaokao-volunteer-system/data/crowd_db/tests/test_crowd_detector.py
Hermes Agent 2baa8c2422 fix(crowd_db): 27省全部usable+后测试期望同步 + source_url http→https
- 山西/天津 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省口径)
2026-06-24 22:29:59 +08:00

789 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""扎堆检测算法测试 (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 是 mediumfrequency >= 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=0detect 时应跳过不报告"""
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 跳过 → 无命中