# -*- coding: utf-8 -*- """赛道匹配内核金标准用例(离线、确定性)。""" from __future__ import annotations import unittest from _support import IsolatedDataRoot def _teacher(**overrides) -> dict: base = { "teacherName": "张教授", "department": "化材学院", "title": "教授", "researchTags": "钙钛矿,光伏,半导体,界面调控,稳定性", "recentPapers": "钙钛矿电池界面钝化研究", "projectHistory": "主持省自然科学基金", "sciQ1Count": 3, "corePaperCount": 8, } base.update(overrides) return base def _tracks() -> list[dict]: return [ {"year": 2027, "programName": "面上项目", "trackName": "光伏材料", "trackTags": "钙钛矿,光伏,半导体", "priorityKeywords": "界面调控,稳定性", "minTitle": "副高及以上"}, {"year": 2027, "programName": "青年项目", "trackName": "柔性电子", "trackTags": "二维材料,柔性电子,钙钛矿"}, {"year": 2027, "programName": "青年项目", "trackName": "农村经济", "trackTags": "乡村振兴,农村经济"}, {"year": 2027, "programName": "重点项目", "trackName": "量子计算", "trackTags": "量子计算,量子信息", "minTitle": "正高及以上", "minSciQ1": 5}, ] class MatchRuleTestBase(unittest.TestCase): def setUp(self) -> None: self._root = IsolatedDataRoot() self._root.__enter__() from db.connection import init_db init_db() def tearDown(self) -> None: self._root.__exit__(None, None, None) def _match(self, teacher=None, tracks=None): from service.match_service import match_teacher return match_teacher(teacher or _teacher(), tracks if tracks is not None else _tracks(), "2027") class TestHardThresholds(MatchRuleTestBase): def test_h1_title_threshold_excludes(self) -> None: result = self._match(_teacher(title="讲师")) excluded_tracks = {e["track"] for e in result["excluded"]} self.assertIn("重点项目·量子计算", excluded_tracks) self.assertIn("H1", result["excluded"][0]["reason"]) def test_h1_min_title_none_no_filter(self) -> None: result = self._match(_teacher(title="讲师")) # 无门槛赛道不受影响 self.assertGreater(result["eligible_count"], 0) def test_h2_sci_q1_threshold_excludes(self) -> None: result = self._match(_teacher(title="研究员", sciQ1Count=2)) excluded_tracks = {e["track"] for e in result["excluded"]} self.assertIn("重点项目·量子计算", excluded_tracks) self.assertIn("H2", result["excluded"][0]["reason"]) def test_e1_incomplete_profile_raises(self) -> None: with self.assertRaises(ValueError): self._match(_teacher(researchTags="", recentPapers="")) class TestScoring(MatchRuleTestBase): def test_s3_full_coverage_with_priority_hits(self) -> None: result = self._match() top = result["top3"][0] # 光伏材料:coverage 3/3=1.0 → 0.7 + priority 2 hits 0.3 = 1.0 self.assertEqual(top["track"], "面上项目·光伏材料") self.assertEqual(top["match_pct"], 100) self.assertEqual(top["tier"], "强烈推荐") def test_t1_try_band(self) -> None: result = self._match(_teacher(researchTags="钙钛矿,光伏", recentPapers="")) entry = next(e for e in result["top3"] if e["track"] == "面上项目·光伏材料") # coverage 2/3 → 0.7*0.667=0.4667 → 47% 不建议? + 无 priority → 47%;二维材料 coverage 1/3=0.333→23% self.assertEqual(entry["match_pct"], 47) self.assertEqual(entry["tier"], "不建议") def test_t1_strong_boundary(self) -> None: # 恰好 80% 属强烈推荐 result = self._match(_teacher(researchTags="钙钛矿,光伏,半导体", recentPapers="")) entry = next(e for e in result["top3"] if e["track"] == "面上项目·光伏材料") # coverage 1.0 → 0.7 → 70% 可尝试 self.assertEqual(entry["tier"], "可尝试") self.assertEqual(entry["match_pct"], 70) def test_t2_top3_truncation_and_order(self) -> None: result = self._match() self.assertLessEqual(len(result["top3"]), 3) pcts = [e["match_pct"] for e in result["top3"]] self.assertEqual(pcts, sorted(pcts, reverse=True)) def test_reasons_traceable(self) -> None: result = self._match() top = result["top3"][0] self.assertTrue(any("命中需求标签" in r for r in top["reasons"])) self.assertTrue(any("优先资助方向" in r for r in top["reasons"])) class TestKernelAndPersistence(MatchRuleTestBase): def _run(self, teachers=None): from service.match_service import run_match return run_match(teachers or [_teacher()], _tracks(), "2027") def test_run_match_writes_reports_and_brief(self) -> None: result = self._run([_teacher(), _teacher(teacherName="李教授", researchTags="乡村振兴,农村金融", title="教授")]) self.assertEqual(result["count"], 2) self.assertTrue(result["brief_file"].endswith(".html")) for report in result["reports"]: self.assertTrue(report["report_file"].endswith(".html")) with open(report["report_file"], encoding="utf-8") as f: html_text = f.read() self.assertIn("申报策略建议书", html_text) self.assertIn("仅供参考", html_text) def test_empty_tracks_raises(self) -> None: from service.match_service import run_match with self.assertRaises(ValueError): run_match([_teacher()], [], "2027") def test_no_teachers_raises(self) -> None: from service.match_service import run_match with self.assertRaises(ValueError): run_match([], _tracks(), "2027") def test_upsert_teacher_and_track(self) -> None: from service.profile_service import upsert_teacher, upsert_track payload = upsert_teacher({"teacher_name": "张教授", "title": "教授"}) self.assertEqual(payload["action"], "created") payload = upsert_teacher({"teacher_name": "张教授", "sci_q1_count": 5}) self.assertEqual(payload["action"], "updated") with self.assertRaises(ValueError): upsert_teacher({"teacher_name": ""}) payload = upsert_track({"year": 2027, "track_name": "光伏材料", "track_tags": "钙钛矿,光伏,半导体"}) self.assertEqual(payload["action"], "created") with self.assertRaises(ValueError): upsert_track({"year": 2027, "track_name": "光伏材料", "min_title": "院士及以上"}) def test_stats(self) -> None: from service.profile_service import stats, upsert_track for track in _tracks(): upsert_track({ "year": track["year"], "track_name": track["trackName"], "program_name": track.get("programName", ""), "track_tags": track.get("trackTags", ""), }) self._run() payload = stats() self.assertEqual(payload["report_total"], 1) self.assertEqual(payload["tracks_by_year"].get("2027"), 4) class TestBatchSkipIncomplete(MatchRuleTestBase): """批量缺省跳过画像不完整教师(E1),指定单教师保持严格失败。""" def test_batch_skips_incomplete_teacher(self) -> None: from service.match_service import run_match incomplete = _teacher(teacherName="王讲师", researchTags="", recentPapers="") payload = run_match([_teacher(), incomplete], _tracks(), "2027") self.assertEqual(payload["ok"], True) self.assertEqual(payload["count"], 1) self.assertEqual(len(payload["skipped"]), 1) skipped = payload["skipped"][0] self.assertEqual(skipped["teacher_name"], "王讲师") self.assertIn("E1", skipped["reason"]) # 正常教师仍有报告 self.assertEqual(len(payload["reports"]), 1) self.assertEqual(payload["reports"][0]["teacher_name"], "张教授") def test_batch_brief_marks_skipped_teacher(self) -> None: from service.match_service import run_match incomplete = _teacher(teacherName="王讲师", researchTags="", recentPapers="") payload = run_match([_teacher(), incomplete], _tracks(), "2027") with open(payload["brief_file"], encoding="utf-8") as f: html_text = f.read() self.assertIn("画像待补录", html_text) self.assertIn("王讲师", html_text) def test_all_skipped_still_returns_success_payload(self) -> None: from service.match_service import run_match payload = run_match([_teacher(researchTags="", recentPapers="")], _tracks(), "2027") self.assertEqual(payload["ok"], True) self.assertEqual(payload["count"], 0) self.assertEqual(len(payload["skipped"]), 1) def test_strict_mode_raises_for_incomplete(self) -> None: from service.match_service import run_match with self.assertRaises(ValueError): run_match([_teacher(researchTags="", recentPapers="")], _tracks(), "2027", skip_incomplete=False) if __name__ == "__main__": unittest.main()