HR-ATS-Portal/tests/unit/test_models.py

196 lines
7.3 KiB
Python

from __future__ import annotations
import pytest
from pydantic import TypeAdapter, ValidationError
from app.models.scoring import ATSScore, CandidateResult, CompletedCandidate, FailedCandidate
def score(**overrides: object) -> ATSScore:
payload: dict[str, object] = {
"match_score": 50,
"matched_keywords": [],
"missing_keywords": [],
"summary_critique": "Adequate.",
}
payload.update(overrides)
return ATSScore(**payload) # type: ignore[arg-type]
class TestScoreBounds:
@pytest.mark.parametrize("value", [0, 1, 50, 99, 100])
def test_accepts_the_inclusive_range(self, value: int) -> None:
assert score(match_score=value).match_score == value
@pytest.mark.parametrize("value", [-1, 101, 1000])
def test_rejects_out_of_range(self, value: int) -> None:
with pytest.raises(ValidationError):
score(match_score=value)
class TestStrictness:
def test_unknown_fields_are_rejected(self) -> None:
with pytest.raises(ValidationError):
score(confidence=0.9)
def test_unknown_fields_rejected_on_failed_candidate(self) -> None:
with pytest.raises(ValidationError):
FailedCandidate(
filename="a.pdf",
error_code="INVALID_PDF",
error_message="bad",
retryable=True, # type: ignore[call-arg]
)
class TestKeywordNormalisation:
def test_trims_and_drops_empties(self) -> None:
result = score(matched_keywords=[" Python ", "", " ", "FastAPI"])
assert result.matched_keywords == ["Python", "FastAPI"]
def test_collapses_internal_whitespace(self) -> None:
result = score(matched_keywords=["REST APIs"])
assert result.matched_keywords == ["REST APIs"]
def test_deduplicates_case_insensitively_keeping_first_spelling(self) -> None:
result = score(matched_keywords=["Python", "python", "PYTHON", "Docker"])
assert result.matched_keywords == ["Python", "Docker"]
def test_preserves_model_ordering(self) -> None:
result = score(missing_keywords=["Kubernetes", "AWS", "Terraform"])
assert result.missing_keywords == ["Kubernetes", "AWS", "Terraform"]
def test_deduplication_runs_before_the_length_ceiling(self) -> None:
"""A chatty model returning near-duplicates should not fail the candidate."""
noisy = ["Python"] * 20 + [f"Skill {i}" for i in range(15)]
result = score(matched_keywords=noisy)
assert len(result.matched_keywords) == 16
def test_more_than_thirty_distinct_keywords_is_still_rejected(self) -> None:
with pytest.raises(ValidationError):
score(matched_keywords=[f"Skill {i}" for i in range(31)])
def test_non_string_entries_are_dropped(self) -> None:
result = score(matched_keywords=["Python", 42, None, "Docker"])
assert result.matched_keywords == ["Python", "Docker"]
class TestProfileFields:
def test_all_default_to_none(self) -> None:
result = score()
assert result.candidate_name is None
assert result.job_title is None
assert result.current_company is None
assert result.years_experience is None
assert result.professional_summary is None
def test_blank_strings_normalise_to_none(self) -> None:
result = score(candidate_name=" ", job_title="", current_company="\n\t", professional_summary=" ")
assert result.candidate_name is None
assert result.job_title is None
assert result.current_company is None
assert result.professional_summary is None
def test_whitespace_is_collapsed(self) -> None:
result = score(candidate_name=" Ada Lovelace ", job_title="Senior\nEngineer")
assert result.candidate_name == "Ada Lovelace"
assert result.job_title == "Senior Engineer"
@pytest.mark.parametrize("value", [0, 60])
def test_years_bounds_are_inclusive(self, value: int) -> None:
assert score(years_experience=value).years_experience == value
@pytest.mark.parametrize("value", [-1, 61])
def test_years_outside_bounds_rejected(self, value: int) -> None:
with pytest.raises(ValidationError):
score(years_experience=value)
def test_over_length_name_rejected(self) -> None:
with pytest.raises(ValidationError):
score(candidate_name="x" * 121)
def test_professional_summary_whitespace_is_collapsed(self) -> None:
result = score(professional_summary=" Python backend\n in Engineering. ")
assert result.professional_summary == "Python backend in Engineering."
def test_over_length_professional_summary_rejected(self) -> None:
with pytest.raises(ValidationError):
score(professional_summary="x" * 501)
class TestCritique:
def test_whitespace_is_collapsed(self) -> None:
result = score(summary_critique=" Strong backend\n fit. ")
assert result.summary_critique == "Strong backend fit."
def test_empty_is_rejected(self) -> None:
with pytest.raises(ValidationError):
score(summary_critique=" ")
def test_over_length_is_rejected(self) -> None:
with pytest.raises(ValidationError):
score(summary_critique="x" * 501)
class TestDiscriminatedUnion:
adapter = TypeAdapter(CandidateResult)
def test_completed_payload_resolves_to_completed(self) -> None:
parsed = self.adapter.validate_python(
{
"filename": "a.pdf",
"status": "completed",
"match_score": 80,
"matched_keywords": ["Python"],
"missing_keywords": [],
"summary_critique": "Good.",
}
)
assert isinstance(parsed, CompletedCandidate)
def test_failed_payload_resolves_to_failed(self) -> None:
parsed = self.adapter.validate_python(
{
"filename": "b.pdf",
"status": "failed",
"error_code": "PDF_ENCRYPTED",
"error_message": "locked",
}
)
assert isinstance(parsed, FailedCandidate)
def test_a_completed_result_cannot_carry_failure_fields(self) -> None:
with pytest.raises(ValidationError):
self.adapter.validate_python(
{
"filename": "a.pdf",
"status": "completed",
"match_score": 80,
"summary_critique": "Good.",
"error_code": "INVALID_PDF",
}
)
def test_a_failed_result_cannot_carry_a_score(self) -> None:
with pytest.raises(ValidationError):
self.adapter.validate_python(
{
"filename": "b.pdf",
"status": "failed",
"error_code": "INVALID_PDF",
"error_message": "bad",
"match_score": 80,
}
)
def test_completed_candidate_inherits_normalisation(self) -> None:
parsed = CompletedCandidate(
filename="a.pdf",
match_score=70,
matched_keywords=["Python", "python"],
missing_keywords=[],
summary_critique=" Fine. ",
)
assert parsed.matched_keywords == ["Python"]
assert parsed.summary_critique == "Fine."