Files
CareerNexus/tests/test_match.py
T

141 lines
7.0 KiB
Python

"""匹配引擎测试:评分权重、差距清单、可迁移技能、学习路径拓扑序、简历脱敏。"""
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
def test_resume_bare_name_and_edu_alias():
"""真实简历排版:首行裸姓名(无"姓名:"前缀)可识别并脱敏;专硕归一为硕士。"""
from app.match.resume import parse_resume
text = (" 王伟航 \n出生年月:2003年10月 现居地:辽宁大连\n"
"联系电话:19293819303 电子邮件:wwh@example.com\n"
"东北财经大学 金融科技(专硕) 2025.09-至今\n"
"职业技能:熟练掌握Python、Hadoop等数据分析技能。")
p = parse_resume(text)
assert p.name == "王**" and "王伟航" not in p.raw_masked_text # 裸姓名识别+脱敏
assert p.education == "硕士" # 专硕→硕士
assert p.address.startswith("辽宁大连")
assert "Python" in p.skills
def test_resume_spaced_labels_and_reading_edu():
"""PDF/Word 排版常见"姓 名 :孙千越"标签字间带空格;年级写法"大二"推断本科在读。"""
from app.match.resume import parse_resume
text = ("姓 名 :孙千越\n学 校:东北财经大学 出生年月:2007 年 12 月 23 日\n"
"年 级:大二 电子邮箱:qianyuesun@outlook.com\n"
"技术能力:Python、Transformers、LLM 推理与评测、RAG、Prompt Engineering、"
"Multi-Agent Workflow。")
p = parse_resume(text)
assert p.name == "孙**" and "孙千越" not in p.raw_masked_text # 空格分隔标签 + 脱敏
assert p.education == "本科(在读)" # 年级写法 → 在读学历
for s in ["大模型应用开发", "智能体开发", "深度学习", "提示词工程", "RAG", "Python"]:
assert s in p.skills, s # 新词表别名命中
def test_resume_reading_degree_explicit():
"""明确"硕士在读"写法:保留在读标记,不降级为已毕业学位。"""
from app.match.resume import parse_resume
p = parse_resume("姓名:张三\n学历:硕士在读\n技能:Python")
assert p.education == "硕士(在读)"
def test_learning_path_stage_chunking():
"""同层技能超 3 个时拆成多个连续阶段;拓扑序不变。"""
stages = topological_stages(["Linux", "Docker", "Kubernetes", "Python", "Java", "Go", "MySQL"],
load_lexicon())
# 每阶段 ≤3 个技能
assert all(len(st["skills"]) <= 3 for st in stages)
flat = [s for st in stages for s in st["skills"]]
# 前置关系仍满足:Linux 在 Docker 前,Docker 在 Kubernetes 前
assert flat.index("Linux") < flat.index("Docker") < flat.index("Kubernetes")
assert len(stages) >= 3 # 7 个技能至少拆成 3 段