153 lines
5.5 KiB
Python
153 lines
5.5 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 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."
|