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 def test_blank_strings_normalise_to_none(self) -> None: result = score(candidate_name=" ", job_title="", current_company="\n\t") assert result.candidate_name is None assert result.job_title is None assert result.current_company 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) 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."