"""匹配引擎测试:评分权重、差距清单、可迁移技能、学习路径拓扑序、简历脱敏。""" from __future__ import annotations from app.extract.base import load_lexicon from app.match.engine import MatchEngine, topological_stages from app.match.resume import mask_text, parse_resume from app.models import JobDefinition, ResumeProfile JOB = JobDefinition( job_id="job_X", title="X工程师", category="后端开发", required_skills=["Java", "Spring Boot", "MySQL", "Redis"], preferred_skills=["Docker", "Kubernetes"], scenarios=["电商", "金融"], expected_years=3, ) def _engine(**weights) -> MatchEngine: return MatchEngine(lexicon=load_lexicon(), weights=weights or None) def test_scoring_weights_and_dimensions(): """四维加权:必备50/加分15/经验20/场景15。""" profile = ResumeProfile( skills=["Java", "Spring Boot", "Docker"], # 必备 2/4,加分 1/2 years=1.5, # 经验 1.5/3 = 0.5 scenarios=["电商"], # 场景 1/2 ) r = _engine().match(profile, JOB) assert r.dimension_scores == {"required": 50.0, "preferred": 50.0, "experience": 50.0, "scenario": 50.0} # 总分 = 0.5*50 + 0.15*50 + 0.2*50 + 0.15*50 = 50 assert r.total_score == 50.0 assert not r.is_match # < 60 阈值 def test_custom_weights_override(): profile = ResumeProfile(skills=["Java", "Spring Boot", "MySQL", "Redis"], years=3, scenarios=["电商", "金融"]) r = _engine(required=1.0, preferred=0.0, experience=0.0, scenario=0.0).match(profile, JOB) assert r.total_score == 100.0 and r.is_match def test_gap_list_and_transferable(): profile = ResumeProfile(skills=["Java", "Spring", "MySQL"], years=3, scenarios=["电商", "金融"]) r = _engine().match(profile, JOB) assert r.missing_required == ["Spring Boot", "Redis"] assert r.matched_required == ["Java", "MySQL"] # Spring 与缺失的 Spring Boot 同属“后端框架”→ 可迁移 assert "Spring" in r.transferable_skills def test_learning_path_topological_order(): """缺失技能按前置关系拓扑分层:Spring → Spring Boot → Spring Cloud。""" lexicon = load_lexicon() stages = topological_stages(["Spring Cloud", "Spring Boot", "Spring"], lexicon) assert stages[0]["skills"] == ["Spring"] assert stages[1]["skills"] == ["Spring Boot"] assert stages[2]["skills"] == ["Spring Cloud"] # 全部缺失时拓扑序稳定且不漏项 stages2 = topological_stages(["Kubernetes", "Docker", "Linux"], lexicon) flat = [s for st in stages2 for s in st["skills"]] assert flat == ["Linux", "Docker", "Kubernetes"] def test_learning_path_acceptance(): """每个阶段附带结构化验收标准:产出物/量化验收项/自测问答,且按技能类别差异化。""" lexicon = load_lexicon() stages = topological_stages(["Spring Cloud", "Spring Boot", "Spring"], lexicon) for st in stages: acc = st["acceptance"] assert acc["deliverable"] and len(acc["criteria"]) >= 3 and len(acc["self_check"]) >= 2 assert all("{" not in c for c in acc["criteria"] + acc["self_check"]) # 占位符全部替换 assert any(s in acc["deliverable"] for s in st["skills"]) # 产出物点名本阶段技能 # 类别差异化:后端框架类与产品类的验收内容应不同 be = topological_stages(["Spring"], lexicon)[0]["acceptance"] pm = topological_stages(["A/B测试"], lexicon)[0]["acceptance"] assert be["category"] != pm["category"] or be["criteria"] != pm["criteria"] def test_resume_masking(): text = "姓名:王小明\n手机:13812345678\n邮箱:wxm@example.com\n3年开发经验" masked = mask_text(text) assert "王小明" not in masked and "王**" in masked assert "13812345678" not in masked and "138****78" in masked assert "wxm@example.com" not in masked and "w***@example.com" in masked def test_resume_parse_skills_and_years(): profile = parse_resume("姓名:李四\n5年后端开发经验,熟悉Java、MySQL与Docker。") assert profile.years == 5.0 assert set(profile.skills) == {"Java", "MySQL", "Docker"} assert "138" not in profile.raw_masked_text