242 lines
7.3 KiB
Python
242 lines
7.3 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timezone
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from resume_harness.models import (
|
|
CandidateProfile,
|
|
ContactInfo,
|
|
DraftClaim,
|
|
DraftSection,
|
|
EvidenceCategory,
|
|
EvidenceItem,
|
|
EvidenceSource,
|
|
GenerationConfig,
|
|
OutputMode,
|
|
ResumeDraft,
|
|
ResumeMode,
|
|
SectionType,
|
|
)
|
|
from resume_harness.renderer import MarkdownRenderer, render_markdown
|
|
|
|
|
|
NOW = datetime(2026, 7, 1, 12, tzinfo=timezone.utc)
|
|
|
|
|
|
def _profile() -> CandidateProfile:
|
|
facts = [
|
|
EvidenceItem(
|
|
evidence_id=evidence_id,
|
|
category=EvidenceCategory.PROJECT,
|
|
content=content,
|
|
source=EvidenceSource.PORTFOLIO,
|
|
)
|
|
for evidence_id, content in (
|
|
("ev-latency", "결제 API 응답 시간을 단축했다."),
|
|
("ev-tests", "회귀 테스트를 자동화했다."),
|
|
("ev-python", "Python 서비스를 개발했다."),
|
|
)
|
|
]
|
|
return CandidateProfile(
|
|
candidate_id="candidate-1",
|
|
name="김하네스",
|
|
name_en="Harness Kim",
|
|
contact=ContactInfo(
|
|
email="harness@example.com",
|
|
phone="010-1234-5678",
|
|
city="서울",
|
|
links=["https://example.com/portfolio"],
|
|
),
|
|
facts=facts,
|
|
updated_at=NOW,
|
|
)
|
|
|
|
|
|
def _claim(
|
|
claim_id: str, text: str, evidence_id: str, order: int
|
|
) -> DraftClaim:
|
|
return DraftClaim(
|
|
claim_id=claim_id,
|
|
text=text,
|
|
evidence_ids=[evidence_id],
|
|
order=order,
|
|
)
|
|
|
|
|
|
def _draft(*, mode: ResumeMode = ResumeMode.PRIVATE_MODERN) -> ResumeDraft:
|
|
experience = DraftSection(
|
|
section_id="section-experience",
|
|
section_type=SectionType.EXPERIENCE,
|
|
heading="경력",
|
|
order=0,
|
|
claims=[
|
|
_claim("claim-tests", "회귀 테스트 자동화", "ev-tests", 1),
|
|
_claim("claim-latency", "결제 API 응답 시간 단축", "ev-latency", 0),
|
|
],
|
|
)
|
|
skills = DraftSection(
|
|
section_id="section-skills",
|
|
section_type=SectionType.SKILLS,
|
|
heading="기술",
|
|
order=1,
|
|
claims=[_claim("claim-python", "Python 서비스 개발", "ev-python", 0)],
|
|
)
|
|
return ResumeDraft(
|
|
draft_id="draft-1",
|
|
candidate_id="candidate-1",
|
|
title="백엔드 엔지니어",
|
|
mode=mode,
|
|
# Input order is intentionally different from canonical ``order``.
|
|
sections=[skills, experience],
|
|
generated_at=NOW,
|
|
)
|
|
|
|
|
|
def _config(*, mode: ResumeMode = ResumeMode.PRIVATE_MODERN) -> GenerationConfig:
|
|
return GenerationConfig(resume_mode=mode, as_of_date=date(2026, 7, 1))
|
|
|
|
|
|
def test_private_markdown_renders_identity_first_and_uses_stable_order() -> None:
|
|
output = render_markdown(_draft(), _profile(), _config())
|
|
|
|
assert output.startswith(
|
|
"# 김하네스\n"
|
|
"영문명: Harness Kim\n"
|
|
"지원 분야: 백엔드 엔지니어\n"
|
|
"이메일: harness@example.com\n"
|
|
)
|
|
assert output.index("## 경력") < output.index("## 기술")
|
|
assert output.index("결제 API 응답 시간 단축") < output.index(
|
|
"회귀 테스트 자동화"
|
|
)
|
|
assert re.search(r"^\|", output, re.MULTILINE) is None
|
|
assert "<table" not in output.casefold()
|
|
assert output.endswith("\n")
|
|
|
|
|
|
def test_public_blind_omits_the_complete_identity_and_contact_block() -> None:
|
|
mode = ResumeMode.PUBLIC_BLIND
|
|
output = render_markdown(
|
|
_draft(mode=mode), _profile(), _config(mode=mode)
|
|
)
|
|
|
|
assert output.startswith("# 백엔드 엔지니어\n")
|
|
for private_value in (
|
|
"김하네스",
|
|
"Harness Kim",
|
|
"harness@example.com",
|
|
"010-1234-5678",
|
|
"서울",
|
|
"https://example.com/portfolio",
|
|
):
|
|
assert private_value not in output
|
|
|
|
|
|
def test_public_blind_renderer_fails_closed_on_origin_disclosure() -> None:
|
|
mode = ResumeMode.PUBLIC_BLIND
|
|
draft = _draft(mode=mode)
|
|
origin_claim = next(
|
|
claim
|
|
for section in draft.sections
|
|
for claim in section.claims
|
|
if claim.evidence_ids == ["ev-python"]
|
|
)
|
|
origin_claim.text = "고향은 대전이며 Python 서비스를 개발했다."
|
|
|
|
with pytest.raises(ValueError, match="PRIVACY.BLIND_ORIGIN"):
|
|
render_markdown(draft, _profile(), _config(mode=mode))
|
|
|
|
|
|
def test_evidence_ids_are_hidden_by_default_and_available_only_for_debug() -> None:
|
|
draft = _draft()
|
|
profile = _profile()
|
|
config = _config()
|
|
|
|
normal = MarkdownRenderer().render(draft, profile, config)
|
|
debug = MarkdownRenderer(debug_evidence_ids=True).render(
|
|
draft, profile, config
|
|
)
|
|
alias = render_markdown(
|
|
draft, profile, config, include_evidence_ids=True
|
|
)
|
|
|
|
assert "ev-latency" not in normal
|
|
assert "[근거 ID: ev-latency]" in debug
|
|
assert debug == alias
|
|
|
|
|
|
def test_embedded_line_breaks_cannot_create_markdown_blocks() -> None:
|
|
draft = _draft()
|
|
draft.sections[0].claims[0].text = "Python 개발\r\n## 위조 섹션 | <table>"
|
|
|
|
output = render_markdown(draft, _profile(), _config())
|
|
|
|
assert "Python 개발 ## 위조 섹션 | <table>" in output
|
|
assert "\n## 위조 섹션" not in output
|
|
assert "<table>" not in output
|
|
assert "|" not in output
|
|
|
|
|
|
def test_claim_text_cannot_inject_links_images_or_inline_code() -> None:
|
|
draft = _draft()
|
|
draft.sections[0].claims[0].text = " `숨은 코드` **과장**"
|
|
|
|
output = render_markdown(draft, _profile(), _config())
|
|
|
|
assert " \`숨은 코드\` \*\*과장\*\*" in output
|
|
|
|
|
|
def test_renderer_rejects_non_markdown_output_mode() -> None:
|
|
config = GenerationConfig(
|
|
output_mode=OutputMode.JSON,
|
|
resume_mode=ResumeMode.PRIVATE_MODERN,
|
|
as_of_date=date(2026, 7, 1),
|
|
)
|
|
with pytest.raises(ValueError, match="output_mode"):
|
|
render_markdown(_draft(), _profile(), config)
|
|
|
|
|
|
def test_markdown_renderer_does_not_silently_drop_required_photo() -> None:
|
|
from resume_harness.models import SensitiveDataCategory
|
|
|
|
config = GenerationConfig(
|
|
output_mode=OutputMode.MARKDOWN,
|
|
resume_mode=ResumeMode.EMPLOYER_FORM,
|
|
include_photo=True,
|
|
allowed_sensitive_categories={SensitiveDataCategory.PHOTO},
|
|
employer_required_sensitive_categories={SensitiveDataCategory.PHOTO},
|
|
as_of_date=date(2026, 7, 1),
|
|
)
|
|
draft = _draft(mode=ResumeMode.EMPLOYER_FORM)
|
|
|
|
with pytest.raises(ValueError, match="cannot embed a photo"):
|
|
render_markdown(draft, _profile(), config)
|
|
|
|
|
|
def test_renderer_checks_evidence_references_before_output() -> None:
|
|
draft = _draft()
|
|
draft.sections[0].claims[0].evidence_ids = ["ev-unknown"]
|
|
|
|
with pytest.raises(ValueError, match="unknown evidence"):
|
|
render_markdown(draft, _profile(), _config())
|
|
|
|
|
|
def test_renderer_blocks_claims_backed_by_confidential_evidence() -> None:
|
|
profile = _profile()
|
|
profile = profile.model_copy(
|
|
update={
|
|
"facts": [
|
|
fact.model_copy(update={"confidential": fact.evidence_id == "ev-python"})
|
|
for fact in profile.facts
|
|
]
|
|
}
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="confidential"):
|
|
render_markdown(_draft(), profile, _config())
|