Files
resume-haness/tests/test_output_constraints.py

361 lines
11 KiB
Python

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)