415 lines
11 KiB
Python
415 lines
11 KiB
Python
from textwrap import dedent
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from job_research.profile.cv_extractor import extract_cv_signals
|
||
from job_research.profile.cv_extractor import extract_pdf_text
|
||
|
||
|
||
def test_extract_cv_signals_reads_basic_fields_from_text() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Location: France
|
||
Languages: French, English
|
||
Skills: Python, SQL, Terraform, GCP, BigQuery
|
||
Data Engineer at Company A
|
||
Analytics Engineer at Company B
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["name"] == "Tonio"
|
||
assert extracted["location"] == "France"
|
||
assert extracted["languages"] == ["French", "English"]
|
||
assert extracted["skills"] == ["Python", "SQL", "Terraform", "GCP", "BigQuery"]
|
||
assert extracted["experience_entries"][0]["title"] == "Data Engineer"
|
||
assert len(extracted["experience_entries"]) == 2
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("line", "expected"),
|
||
[
|
||
("Years of experience: 2", 2),
|
||
("Années d'expérience : 3", 3),
|
||
],
|
||
)
|
||
def test_extract_cv_signals_extracts_years_of_experience_from_explicit_line(
|
||
line: str, expected: int
|
||
) -> None:
|
||
text = dedent(
|
||
f"""
|
||
Tonio
|
||
Location: France
|
||
{line}
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["years_of_experience"] == expected
|
||
|
||
|
||
def test_extract_cv_signals_allows_single_word_titles() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Location: France
|
||
Consultant at Accenture
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["experience_entries"] == [
|
||
{"title": "Consultant", "company": "Accenture"}
|
||
]
|
||
|
||
|
||
def test_extract_cv_signals_allows_lowercase_company_names() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Location: France
|
||
Data Engineer at leboncoin
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["experience_entries"] == [
|
||
{"title": "Data Engineer", "company": "leboncoin"}
|
||
]
|
||
|
||
|
||
def test_extract_cv_signals_ignores_prose_after_company_name() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Location: France
|
||
Senior engineer at Microsoft before moving to Paris.
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["experience_entries"] == []
|
||
|
||
|
||
def test_extract_cv_signals_normalizes_bullet_prefixed_fields_and_experience() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
- Location: France
|
||
- Data Engineer at Company A
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["location"] == "France"
|
||
assert extracted["experience_entries"] == [
|
||
{"title": "Data Engineer", "company": "Company A"}
|
||
]
|
||
|
||
|
||
def test_extract_cv_signals_normalizes_en_dash_bullet_prefixed_fields_and_experience() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
– Location: France
|
||
— Data Engineer at Company A
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["location"] == "France"
|
||
assert extracted["experience_entries"] == [
|
||
{"title": "Data Engineer", "company": "Company A"}
|
||
]
|
||
|
||
|
||
def test_extract_cv_signals_recognizes_french_field_labels() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Formation:
|
||
M.Sc. in Data Engineering at EPITA
|
||
Langues: French, English
|
||
Compétences: Python, SQL
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["languages"] == ["French", "English"]
|
||
assert extracted["skills"] == ["Python", "SQL"]
|
||
assert extracted["education_entries"] == [
|
||
{"credential": "M.Sc. in Data Engineering", "institution": "EPITA"}
|
||
]
|
||
|
||
|
||
def test_extract_cv_signals_parses_french_experience_connectors() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Location: France
|
||
Ingénieur chez Dassault Systèmes
|
||
Développeur au CNRS
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["experience_entries"] == [
|
||
{"title": "Ingénieur", "company": "Dassault Systèmes"},
|
||
{"title": "Développeur", "company": "CNRS"},
|
||
]
|
||
|
||
|
||
def test_extract_cv_signals_parses_clear_titles_with_french_and_english_connectors() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Location: France
|
||
Ingénieur à Thales
|
||
Ingénieur Data chez BNP Paribas
|
||
Consultant BI chez Accenture
|
||
Head of Data at Qonto
|
||
Product Owner at Qonto
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["experience_entries"] == [
|
||
{"title": "Ingénieur", "company": "Thales"},
|
||
{"title": "Ingénieur Data", "company": "BNP Paribas"},
|
||
{"title": "Consultant BI", "company": "Accenture"},
|
||
{"title": "Head of Data", "company": "Qonto"},
|
||
{"title": "Product Owner", "company": "Qonto"},
|
||
]
|
||
|
||
|
||
def test_extract_cv_signals_accepts_lowercase_short_and_real_company_titles() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Location: France
|
||
data engineer at Company A
|
||
iOS Engineer at Company A
|
||
3D Artist at Studio
|
||
R Developer at Company A
|
||
Data Engineer at Made in Design
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["experience_entries"] == [
|
||
{"title": "data engineer", "company": "Company A"},
|
||
{"title": "iOS Engineer", "company": "Company A"},
|
||
{"title": "3D Artist", "company": "Studio"},
|
||
{"title": "R Developer", "company": "Company A"},
|
||
{"title": "Data Engineer", "company": "Made in Design"},
|
||
]
|
||
|
||
|
||
def test_extract_cv_signals_ignores_french_prose_continuations() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Location: France
|
||
Ingénieur au sein de BNP Paribas
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["experience_entries"] == []
|
||
|
||
|
||
def test_extract_cv_signals_rejects_label_like_lines_without_colons() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Location at Paris
|
||
Summary at a glance
|
||
Profile at LinkedIn
|
||
Education at EPITA
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["experience_entries"] == []
|
||
|
||
|
||
def test_extract_cv_signals_rejects_narrative_bullet_experience_lines() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Location: France
|
||
Implemented data pipelines at Airbnb
|
||
Designed dashboards at Company A
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["experience_entries"] == []
|
||
|
||
|
||
def test_extract_cv_signals_ignores_in_paris_prose_tail() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Location: France
|
||
Data Engineer at Microsoft in Paris
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["experience_entries"] == []
|
||
|
||
|
||
def test_extract_cv_signals_extracts_education_entries_after_heading() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Location: France
|
||
Education:
|
||
- Master of Science in Data Science at University of Paris
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["education_entries"] == [
|
||
{
|
||
"credential": "Master of Science in Data Science",
|
||
"institution": "University of Paris",
|
||
}
|
||
]
|
||
|
||
|
||
def test_extract_cv_signals_extracts_common_french_education_entries() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Location: France
|
||
Education:
|
||
Diplôme d'ingénieur
|
||
CentraleSupélec
|
||
MSc
|
||
University of Paris
|
||
BSc
|
||
University of Oxford
|
||
Bac+5
|
||
École Polytechnique
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["education_entries"] == [
|
||
{
|
||
"credential": "Diplôme d'ingénieur",
|
||
"institution": "CentraleSupélec",
|
||
},
|
||
{"credential": "MSc", "institution": "University of Paris"},
|
||
{"credential": "BSc", "institution": "University of Oxford"},
|
||
{"credential": "Bac+5", "institution": "École Polytechnique"},
|
||
]
|
||
|
||
|
||
def test_extract_cv_signals_extracts_dotted_degree_variants() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Location: France
|
||
Education:
|
||
M.Sc. in Data Engineering at EPITA
|
||
B.Sc. in Computer Science at University X
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["education_entries"] == [
|
||
{"credential": "M.Sc. in Data Engineering", "institution": "EPITA"},
|
||
{
|
||
"credential": "B.Sc. in Computer Science",
|
||
"institution": "University X",
|
||
},
|
||
]
|
||
|
||
|
||
def test_extract_cv_signals_ignores_before_moving_prose_tail() -> None:
|
||
text = dedent(
|
||
"""
|
||
Tonio
|
||
Location: France
|
||
Senior Engineer at Microsoft Before moving to Paris.
|
||
"""
|
||
).strip()
|
||
|
||
extracted = extract_cv_signals(text)
|
||
|
||
assert extracted["experience_entries"] == []
|
||
|
||
|
||
def test_extract_pdf_text_skips_blank_pages(monkeypatch) -> None:
|
||
class FakePage:
|
||
def __init__(self, text: str | None) -> None:
|
||
self._text = text
|
||
|
||
def extract_text(self) -> str | None:
|
||
return self._text
|
||
|
||
class FakePdfReader:
|
||
def __init__(self, path: str) -> None:
|
||
self.path = path
|
||
self.pages = [
|
||
FakePage(None),
|
||
FakePage("Tonio"),
|
||
FakePage(""),
|
||
FakePage("Data Engineer"),
|
||
]
|
||
|
||
seen_paths: list[str] = []
|
||
|
||
def fake_pdf_reader(path: str) -> FakePdfReader:
|
||
seen_paths.append(path)
|
||
return FakePdfReader(path)
|
||
|
||
monkeypatch.setattr("job_research.profile.cv_extractor.PdfReader", fake_pdf_reader)
|
||
|
||
extracted = extract_pdf_text(Path("cv.pdf"))
|
||
|
||
assert extracted == "Tonio\nData Engineer"
|
||
assert seen_paths == ["cv.pdf"]
|
||
|
||
|
||
def test_extract_pdf_text_rejects_textless_pdfs(monkeypatch) -> None:
|
||
class FakePage:
|
||
def extract_text(self) -> str | None:
|
||
return " "
|
||
|
||
class FakePdfReader:
|
||
def __init__(self, path: str) -> None:
|
||
self.path = path
|
||
self.pages = [FakePage(), FakePage()]
|
||
|
||
def fake_pdf_reader(path: str) -> FakePdfReader:
|
||
return FakePdfReader(path)
|
||
|
||
monkeypatch.setattr("job_research.profile.cv_extractor.PdfReader", fake_pdf_reader)
|
||
|
||
with pytest.raises(ValueError, match="No extractable text"):
|
||
extract_pdf_text(Path("cv.pdf"))
|