361 lines
11 KiB
Python
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)
|