# -*- coding: utf-8 -*- """赛道匹配内核金标准用例(离线、确定性)。""" from __future__ import annotations import json import os import tempfile 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_coverage_only_below_try_band(self) -> None: result = self._match(_teacher(researchTags="钙钛矿,光伏", recentPapers="")) entry = next(e for e in result["top3"] if e["track"] == "面上项目·光伏材料") # 仅覆盖率 2/3 → 0.7×0.6667=46.7% → 47%,低于 50% 可尝试档 → 不建议 self.assertEqual(entry["match_pct"], 47) self.assertEqual(entry["tier"], "不建议") def test_t1_try_band_with_supplement_hint(self) -> None: result = self._match(_teacher(researchTags="钙钛矿,光伏,界面调控")) entry = next(e for e in result["top3"] if e["track"] == "面上项目·光伏材料") # 覆盖率 2/3 → 0.7×0.6667=46.7% + 优先方向 1 命中 0.15 = 61.7% → 62%,落在可尝试档 self.assertEqual(entry["match_pct"], 62) self.assertEqual(entry["tier"], "可尝试") self.assertTrue(any("建议补充" in r for r in entry["reasons"])) def test_zero_match_reason_is_factual(self) -> None: result = self._match(_teacher(researchTags="量子计算", recentPapers="", projectHistory="")) entry = next(e for e in result["top3"] if e["track"] == "面上项目·光伏材料") reasons_text = "".join(entry["reasons"]) self.assertEqual(entry["match_pct"], 0) self.assertIn("暂无重合", reasons_text) self.assertNotIn("部分相关性", reasons_text) 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) class TestTrackYearScope(MatchRuleTestBase): """缺省取赛道库最新年度(actions.json 契约);显式年度不受影响。""" def _seed_two_years(self) -> None: from service.profile_service import upsert_track upsert_track({"year": 2026, "track_name": "旧方向", "program_name": "面上项目", "track_tags": "钙钛矿,光伏"}) upsert_track({"year": 2027, "track_name": "新方向", "program_name": "面上项目", "track_tags": "量子计算"}) def test_load_tracks_defaults_to_latest_year(self) -> None: from service.match_service import load_tracks self._seed_two_years() self.assertEqual({t["year"] for t in load_tracks()}, {2027}) def test_load_tracks_explicit_year(self) -> None: from service.match_service import load_tracks self._seed_two_years() self.assertEqual({t["year"] for t in load_tracks(year=2026)}, {2026}) def test_load_tracks_empty_library(self) -> None: from service.match_service import load_tracks self.assertEqual(load_tracks(), []) def test_kernel_default_matches_latest_year_only(self) -> None: from service.match_service import load_teachers, load_tracks, run_match from service.profile_service import upsert_teacher self._seed_two_years() upsert_teacher({"teacher_name": "张教授", "title": "教授", "research_tags": "量子计算,量子信息"}) payload = run_match(load_teachers(), load_tracks(), "2027") report = payload["reports"][0] all_tracks = [e["track"] for entries in report["tiers"].values() for e in entries] all_tracks += [e["track"] for e in report["top3"]] self.assertIn("面上项目·新方向", all_tracks) self.assertNotIn("面上项目·旧方向", all_tracks) class TestBatchInputCollection(MatchRuleTestBase): """input/ 目录收集:坏文件跳过并记录原因,不中断整批。""" def test_collect_input_teachers_skips_bad_files(self) -> None: from service.match_service import collect_input_teachers with tempfile.TemporaryDirectory() as tmp: with open(os.path.join(tmp, "a.json"), "w", encoding="utf-8") as f: json.dump([_teacher(), _teacher(teacherName="李教授")], f, ensure_ascii=False) with open(os.path.join(tmp, "single.json"), "w", encoding="utf-8") as f: json.dump({"teacherName": "王教授"}, f, ensure_ascii=False) with open(os.path.join(tmp, "bad.json"), "w", encoding="utf-8") as f: f.write("{not-json") with open(os.path.join(tmp, "scalar.json"), "w", encoding="utf-8") as f: json.dump("just-a-string", f, ensure_ascii=False) teachers, skipped = collect_input_teachers(tmp) names = [t.get("teacherName") for t in teachers] self.assertEqual(names, ["张教授", "李教授", "王教授"]) self.assertEqual({e["file"] for e in skipped}, {"bad.json", "scalar.json"}) self.assertTrue(all(e["reason"] for e in skipped)) def test_collect_input_teachers_empty_dir(self) -> None: from service.match_service import collect_input_teachers with tempfile.TemporaryDirectory() as tmp: teachers, skipped = collect_input_teachers(tmp) self.assertEqual(teachers, []) self.assertEqual(skipped, []) if __name__ == "__main__": unittest.main()