Files
gaokao-volunteer-system/data/crowd_db/tests/test_risk_report.py
Hermes Agent 97ea07c231
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
feat(crowd_db): Phase 2 - 2026分数线接入(6省真实数据)
P2级改进:基于真实核实的2026官方分数线更新crowd_db

真实数据来源:
- 湖南:搜狐教育(本科历史类446/物理类400)
- 江苏:微博/教育在线(本科历史类484/物理类456,特控历史类532/物理类513)
- 广东:搜狐/新京报(本科历史类440/物理类425,特控历史类546/物理类539)
- 山东:高考100(一段441,特控521)
- 河北:教育在线(本科历史类485/物理类443,特控历史类542/物理类510)
- 河南:微博(本科历史类459/物理类419,特殊类型历史类534/物理类513)

实现内容:
1. 6省data_year: 2025 -> 2026
2. 更新source_url指向官方公布链接
3. 增加quality_note标注2026官方分数线已接入
4. 调整check_crowd_db_consistency.py:允许多年份共存
5. 修复测试以适应过渡期

验证: pytest 155 passed, 3 skipped; consistency check通过
2026-06-25 12:43:14 +08:00

346 lines
12 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.4)
覆盖:
- build_crowd_risks 转换正确性
- 三色 emoji 标识(🔴/🟡/🟢)随 frequency 变化
- 模板字段名/类型完整school/major/frequency/predicted_increase/
risk_level/risk_level_label/risk_emoji/platforms/alternatives
- alternatives 字段名从 name→school 重映射、score 强制为 int
- group_by_risk 分组稳定
- format_risk_summary 单行汇总
- render_risk_table 含 emoji/院校/替代行
- 空方案 / 不存在省份 / 全部不命中 → 空列表 + 无风险汇总
- 与 crowd_detector.detect_crowd_risk 输出在排序/等级上完全一致
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
from data.crowd_db.crowd_detector import plan_entry # noqa: F401 (re-exported by risk_report)
from data.crowd_db.risk_report import (
RISK_LEVEL_META,
build_crowd_risks,
finding_to_risk_dict,
format_risk_summary,
group_by_risk,
render_risk_table,
)
# ---------- 三色 emoji 标识 ----------
def test_high_risk_emoji_is_red():
"""frequency=4 → 🔴"""
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
assert len(risks) == 1
assert risks[0]["risk_level"] == "high"
assert risks[0]["risk_emoji"] == "🔴"
assert risks[0]["risk_level_label"] == ""
def test_medium_risk_emoji_is_yellow():
"""frequency=2-3 → 🟡"""
plan = [plan_entry("湖南科技大学", "机械设计制造及其自动化")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
assert len(risks) == 1
assert risks[0]["risk_level"] == "medium"
assert risks[0]["risk_emoji"] == "🟡"
assert risks[0]["risk_level_label"] == ""
def test_low_risk_emoji_is_green():
"""frequency=1 → 🟢"""
# 找一个 frequency=1 的院校575 分段里挑低频
# 使用一个不存在的院校则不会命中,所以这里走"全 0 跳过"的对照。
# 改用 480 分数段(专科批临界)找一条 low
plan = [plan_entry("长沙民政职业技术学院", "社会工作")]
risks = build_crowd_risks(plan, user_score=460, province="湖南")
# "长沙民政职业技术学院" 在 440-480 段 frequency=3, 不是 low
# 重新读数据找一个 frequency=1
# 改方案:用 560-580 段,挑 "南华大学" "会计学" (frequency=2)
# 仍然不是 low。我们直接用 mock。
assert all(r["risk_emoji"] in ("🔴", "🟡", "🟢") for r in risks)
def test_low_risk_emoji_via_mock_loader():
"""frequency=1 → 🟢(通过 mock loader 直接构造)"""
from data.crowd_db.crowd_detector import RiskFinding
f = RiskFinding(
school="测试大学A",
major="测试专业",
frequency=1,
risk_level="low",
platforms=["千问"],
predicted_increase=3,
alternatives=[],
)
r = finding_to_risk_dict(f)
assert r["risk_emoji"] == "🟢"
assert r["risk_level"] == "low"
assert r["risk_level_label"] == ""
def test_unknown_risk_level_falls_back_to_low():
"""未知 risk_level 兜底为 low + 🟢(防御性)"""
from data.crowd_db.crowd_detector import RiskFinding
f = RiskFinding(
school="X",
major=None,
frequency=1,
risk_level="mystery",
platforms=[],
predicted_increase=0,
)
r = finding_to_risk_dict(f)
assert r["risk_level"] == "mystery" # 原值保留
assert r["risk_emoji"] == "🟢" # emoji 兜底
assert r["risk_level_label"] == ""
# ---------- 模板字段完整性 ----------
def test_risk_dict_has_all_template_fields():
"""单条 risk 字典必须包含 audit_report.html 模板用到的所有字段"""
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
required = {
"school",
"major",
"frequency",
"predicted_increase",
"risk_level",
"risk_level_label",
"risk_emoji",
"platforms",
"alternatives",
}
for r in risks:
assert required.issubset(r.keys()), f"missing fields: {required - r.keys()}"
def test_risk_dict_field_types():
"""字段类型必须稳定int / str / list"""
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
r = risks[0]
assert isinstance(r["school"], str)
assert isinstance(r["major"], str)
assert isinstance(r["frequency"], int)
assert isinstance(r["predicted_increase"], int)
assert r["risk_level"] in ("high", "medium", "low")
assert isinstance(r["risk_level_label"], str)
assert isinstance(r["risk_emoji"], str)
assert isinstance(r["platforms"], list)
assert isinstance(r["alternatives"], list)
def test_risk_dict_includes_provenance_fields():
"""每条风险必须附带省份级溯源元数据,供报告展示来源/报告/估算标识"""
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
r = risks[0]
for key in (
"source_type",
"raw_source_type",
"source_type_label",
"source_type_icon",
"source",
"source_url",
"confidence",
"quality_level",
"quality_label",
"last_updated",
"data_year",
):
assert key in r, f"missing provenance field: {key}"
assert r["source_type"] == "report"
assert r["raw_source_type"] == "manual_summary"
assert r["source_type_label"] == "报告"
assert r["source_type_icon"] == "⚠️"
assert r["source_url"].startswith("https://")
assert r["last_updated"] # 非空即可,避免数据日期更新导致测试脆弱
assert r["data_year"] in (2025, 2026) # 过渡期:湖南已切到 2026
assert 0 <= r["confidence"] <= 1
assert r["quality_level"] == "high"
assert r["quality_label"] == "A级高置信"
def test_alternatives_remapped_to_school_field():
"""crowd_db 里 alternatives 项的 name 字段必须重映射为模板需要的 school"""
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
for alt in risks[0]["alternatives"]:
assert "school" in alt
assert isinstance(alt["school"], str)
assert "score" in alt
assert isinstance(alt["score"], int)
# ---------- group_by_risk ----------
def test_group_by_risk_structure():
plan = [
plan_entry("长沙理工大学", "计算机科学与技术"), # high
plan_entry("湖南科技大学", "机械设计制造及其自动化"), # medium
plan_entry("某某野鸡大学", "考古学"), # 0 跳过
]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
grouped = group_by_risk(risks)
assert set(grouped.keys()) == {"high", "medium", "low"}
assert all(isinstance(v, list) for v in grouped.values())
# high 至少 1 条
assert len(grouped["high"]) >= 1
# medium 至少 1 条
assert len(grouped["medium"]) >= 1
def test_group_by_risk_empty_input_returns_three_keys():
grouped = group_by_risk([])
assert grouped == {"high": [], "medium": [], "low": []}
# ---------- format_risk_summary ----------
def test_summary_empty_plan():
assert format_risk_summary([]) == "无扎堆风险"
def test_summary_unknown_province():
assert format_risk_summary([]) == "无扎堆风险"
def test_summary_contains_emojis():
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
summary = format_risk_summary(risks)
assert "🔴" in summary
assert "高风险" in summary
# ---------- render_risk_table ----------
def test_render_risk_table_empty():
out = render_risk_table([])
assert "无扎堆风险" in out
def test_render_risk_table_contains_school_and_emoji():
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
out = render_risk_table(risks)
assert "长沙理工大学" in out
assert "🔴" in out
assert "计算机科学与技术" in out
def test_render_risk_table_includes_alternatives_line():
"""长沙理工大学 应该有替代院校行"""
plan = [plan_entry("长沙理工大学", "计算机科学与技术")]
risks = build_crowd_risks(plan, user_score=575, province="湖南")
out = render_risk_table(risks)
if risks[0]["alternatives"]:
assert "替代" in out
# ---------- 端到端:与 crowd_detector 对齐 ----------
def test_build_crowd_risks_matches_detect_crowd_risk_order():
"""build_crowd_risks 的结果与 detect_crowd_risk 的顺序一致frequency 降序)"""
from data.crowd_db.crowd_detector import detect_crowd_risk
plan = [
plan_entry("长沙理工大学", "计算机科学与技术"),
plan_entry("湖南科技大学", "机械设计制造及其自动化"),
]
findings = detect_crowd_risk(plan, user_score=575, province="湖南")
risks = build_crowd_risks(plan, user_score=575, province="湖南")
assert len(findings) == len(risks)
for f, r in zip(findings, risks):
assert f.school == r["school"]
assert f.frequency == r["frequency"]
def test_build_crowd_risks_handles_unknown_province():
"""省份不存在 → 空列表"""
risks = build_crowd_risks(
[plan_entry("长沙理工大学", "计算机科学与技术")],
user_score=575,
province="不存在的省份",
)
assert risks == []
def test_build_crowd_risks_handles_empty_plan():
risks = build_crowd_risks([], user_score=575, province="湖南")
assert risks == []
def test_build_crowd_risks_handles_all_miss():
"""全部不命中(野鸡院校)→ 空列表"""
risks = build_crowd_risks(
[plan_entry("某某野鸡大学A", "X"), plan_entry("某某野鸡大学B", "Y")],
user_score=575,
province="湖南",
)
assert risks == []
# ---------- 自定义 loader 注入 ----------
def test_build_crowd_risks_with_injected_loader():
"""通过注入 loader 覆盖真实数据(确保解耦正确)"""
# 构造一个最小 loader,find_recommendations 返回指定数据
class _StubLoader:
def find_recommendations(self, province, score):
return [
{
"name": "测试高校A",
"major": "测试专业",
"frequency": 4,
"platforms": ["千问", "元宝", "百度", "豆包"],
"predicted_increase": 15,
"alternatives": [
{"name": "替代校A", "major": "替代专业A", "score": 90}
],
}
]
# StubLoader 满足 duck-typing (有 find_recommendations)
risks = build_crowd_risks(
[plan_entry("测试高校A", "测试专业")],
user_score=600,
province="湖南",
loader=_StubLoader(), # type: ignore[arg-type]
)
assert len(risks) == 1
r = risks[0]
assert r["school"] == "测试高校A"
assert r["frequency"] == 4
assert r["risk_emoji"] == "🔴"
assert r["alternatives"][0]["school"] == "替代校A"
assert r["alternatives"][0]["score"] == 90
def test_risk_level_meta_consistency():
"""三个等级的 emoji/label 都在 RISK_LEVEL_META 中"""
assert set(RISK_LEVEL_META.keys()) == {"high", "medium", "low"}
for level, meta in RISK_LEVEL_META.items():
assert "emoji" in meta and "label" in meta and "zh" in meta
assert len(meta["emoji"]) > 0
assert len(meta["label"]) > 0