from __future__ import annotations from datetime import date, datetime, timezone import pytest from resume_harness.models import ( CandidateProfile, ConstraintKind, ContactInfo, DraftClaim, DraftSection, EvidenceCategory, EvidenceItem, EvidenceSource, GenerationConfig, JobAnalysis, JobRequirement, OutputMode, PostingConstraint, RequirementCategory, RequirementKind, ResumeDraft, SectionType, ) from resume_harness.output_constraints import ( OutputConstraintError, as_quality_findings, count_section_characters, validate_output_constraints, ) from resume_harness.renderer import render_markdown NOW = datetime(2026, 7, 1, 12, tzinfo=timezone.utc) def _claim(claim_id: str, text: str, order: int = 0) -> DraftClaim: return DraftClaim( claim_id=claim_id, text=text, evidence_ids=["ev-1"], requirement_ids=["req-1"], order=order, ) def _section( section_id: str, section_type: SectionType, heading: str, order: int, *texts: str, ) -> DraftSection: return DraftSection( section_id=section_id, section_type=section_type, heading=heading, claims=[ _claim(f"claim-{section_id}-{index}", text, index) for index, text in enumerate(texts or ("근거 기반 내용",)) ], order=order, ) def _draft(*sections: DraftSection) -> ResumeDraft: return ResumeDraft( draft_id="draft-1", candidate_id="candidate-1", posting_id="posting-1", title="백엔드 엔지니어", sections=list(sections), generated_at=NOW, ) def _analysis(*constraints: PostingConstraint) -> JobAnalysis: return JobAnalysis( analysis_id="analysis-1", posting_id="posting-1", target_role="백엔드 엔지니어", summary="API 개발 경험을 확인한다.", requirements=[ JobRequirement( requirement_id="req-1", text="API 개발 경험", kind=RequirementKind.REQUIRED, category=RequirementCategory.SKILL, source_quote="API 개발 경험", classification_quote="필수\nAPI 개발 경험", ) ], constraints=list(constraints), analysed_at=NOW, ) def _config(**updates: object) -> GenerationConfig: values = {"as_of_date": date(2026, 7, 1), **updates} return GenerationConfig(**values) def test_configured_section_order_is_a_blocking_contract() -> None: draft = _draft( _section("skills", SectionType.SKILLS, "기술", 0), _section("experience", SectionType.EXPERIENCE, "경력", 1), ) issues = validate_output_constraints(draft, _config()) assert [issue.code for issue in issues] == ["OUTPUT.SECTION_ORDER"] assert issues[0].blocking def test_unlisted_optional_section_does_not_disturb_relative_order() -> None: draft = _draft( _section("other", SectionType.OTHER, "기타", 0), _section("experience", SectionType.EXPERIENCE, "경력", 1), _section("skills", SectionType.SKILLS, "기술", 2), ) assert validate_output_constraints(draft, _config()) == [] def test_required_section_accepts_korean_alias_and_rejects_missing_section() -> None: constraint = PostingConstraint( constraint_id="required-career", kind=ConstraintKind.REQUIRED_SECTION, description="경력사항 필수", source_quote="경력사항 필수", section="경력사항", ) present = _draft(_section("experience", SectionType.EXPERIENCE, "경력", 0)) missing = _draft(_section("skills", SectionType.SKILLS, "기술", 0)) assert validate_output_constraints( present, _config(), analysis=_analysis(constraint) ) == [] issues = validate_output_constraints( missing, _config(), analysis=_analysis(constraint) ) assert [issue.code for issue in issues] == ["OUTPUT.REQUIRED_SECTION"] def test_required_section_without_a_reference_fails_closed() -> None: constraint = PostingConstraint( constraint_id="required-unknown", kind=ConstraintKind.REQUIRED_SECTION, description="지정 항목 필수", source_quote="지정 항목 필수", ) draft = _draft(_section("skills", SectionType.SKILLS, "기술", 0)) issues = validate_output_constraints( draft, _config(), analysis=_analysis(constraint) ) assert [issue.code for issue in issues] == ["OUTPUT.CONSTRAINT_MALFORMED"] def test_character_limit_has_a_documented_deterministic_count() -> None: section = _section("intro", SectionType.OTHER, "자기소개", 0, "가 나", "다") constraint = PostingConstraint( constraint_id="intro-limit", kind=ConstraintKind.CHARACTER_LIMIT, description="자기소개 4자 이내", source_quote="자기소개 4자 이내", section="자기소개", max_characters=4, ) assert count_section_characters([section]) == 5 issues = validate_output_constraints( _draft(section), _config(), analysis=_analysis(constraint) ) assert [issue.code for issue in issues] == ["OUTPUT.CHARACTER_LIMIT"] assert "5자" in issues[0].message assert issues[0].claim_id == "claim-intro-1" @pytest.mark.parametrize("allowed", [[".md"], ["Markdown"], ["text/markdown"]]) def test_file_format_recognises_markdown_aliases(allowed: list[str]) -> None: constraint = PostingConstraint( constraint_id="format", kind=ConstraintKind.FILE_FORMAT, description="마크다운 제출", source_quote="마크다운 제출", formats=allowed, ) draft = _draft(_section("skills", SectionType.SKILLS, "기술", 0)) assert validate_output_constraints( draft, _config(output_mode=OutputMode.MARKDOWN), analysis=_analysis(constraint), ) == [] def test_disallowed_file_format_is_blocking() -> None: constraint = PostingConstraint( constraint_id="format", kind=ConstraintKind.FILE_FORMAT, description="PDF 또는 DOCX 제출", source_quote="PDF 또는 DOCX 제출", formats=["PDF", "DOCX"], ) draft = _draft(_section("skills", SectionType.SKILLS, "기술", 0)) issues = validate_output_constraints( draft, _config(), analysis=_analysis(constraint) ) assert [issue.code for issue in issues] == ["OUTPUT.FILE_FORMAT"] def test_unknown_blocking_constraint_fails_closed() -> None: constraint = PostingConstraint( constraint_id="language-only", kind=ConstraintKind.OTHER, description="영문으로만 작성", source_quote="영문으로만 작성", blocking=True, ) draft = _draft(_section("skills", SectionType.SKILLS, "기술", 0)) issues = validate_output_constraints( draft, _config(), analysis=_analysis(constraint) ) assert [issue.code for issue in issues] == [ "OUTPUT.UNSUPPORTED_BLOCKING_CONSTRAINT" ] assert issues[0].blocking @pytest.mark.parametrize( ("configured_format", "text", "expected_issue"), [ ("YYYY.MM", "재직 기간 2024.03~2025.07", False), ("YYYY.MM", "재직 기간 2024-3~2025-7", True), ("YYYY.MM.DD", "자격 취득 2025.07.01", False), ("YYYY.MM.DD", "자격 취득 2025년 7월 1일", True), ], ) def test_date_format_is_enforced_in_rendered_content( configured_format: str, text: str, expected_issue: bool ) -> None: draft = _draft(_section("experience", SectionType.EXPERIENCE, "경력", 0, text)) issues = validate_output_constraints( draft, _config(date_format=configured_format) ) assert ("OUTPUT.DATE_FORMAT" in {issue.code for issue in issues}) is expected_issue def test_nonblocking_posting_rule_stays_a_warning_in_quality_contract() -> None: constraint = PostingConstraint( constraint_id="optional-format", kind=ConstraintKind.FILE_FORMAT, description="PDF 권장", source_quote="PDF 권장", formats=["PDF"], blocking=False, ) issues = validate_output_constraints( _draft(_section("skills", SectionType.SKILLS, "기술", 0)), _config(), analysis=_analysis(constraint), ) finding = as_quality_findings(issues)[0] assert not finding.blocking assert finding.code == "OUTPUT.FILE_FORMAT" def test_markdown_renderer_rechecks_posting_constraints() -> None: constraint = PostingConstraint( constraint_id="format", kind=ConstraintKind.FILE_FORMAT, description="PDF 제출", source_quote="PDF 제출", formats=["PDF"], ) draft = _draft(_section("skills", SectionType.SKILLS, "기술", 0)) profile = CandidateProfile( candidate_id="candidate-1", name="김지원", contact=ContactInfo(email="apply@example.com"), facts=[ EvidenceItem( evidence_id="ev-1", category=EvidenceCategory.SKILL, content="API 개발 경험", source=EvidenceSource.PORTFOLIO, ) ], updated_at=NOW, ) with pytest.raises(OutputConstraintError, match="OUTPUT.FILE_FORMAT"): render_markdown( draft, profile, _config(), analysis=_analysis(constraint), ) def test_markdown_renderer_rechecks_posting_blind_fields() -> None: constraint = PostingConstraint( constraint_id="blind-school", kind=ConstraintKind.BLIND_FIELD, description="학교명 기재 금지", source_quote="학교명 기재 금지", fields=["학교명"], ) draft = _draft( _section( "education", SectionType.EDUCATION, "교육", 0, "학교명: 합성대학교에서 API 과목을 이수", ) ) profile = CandidateProfile( candidate_id="candidate-1", name="김지원", contact=ContactInfo(email="apply@example.com"), facts=[ EvidenceItem( evidence_id="ev-1", category=EvidenceCategory.EDUCATION, content="API 과목을 이수했다.", source=EvidenceSource.DOCUMENT, ) ], updated_at=NOW, ) with pytest.raises(ValueError, match="PRIVACY.POSTING_FIELD_LEAK"): render_markdown( draft, profile, _config(), analysis=_analysis(constraint), ) def test_markdown_does_not_pretend_to_measure_physical_pages() -> None: draft = _draft( _section("experience", SectionType.EXPERIENCE, "경력", 0, "가" * 10_000) ) issues = validate_output_constraints(draft, _config(max_pages=1)) assert all("PAGE" not in issue.code for issue in issues)