Files
rendercv/tests/renderer/templater/test_model_processor.py
Sina Atalay 5e95eee87d Distribute Hypothesis tests into their respective unit test files
Move all 55 property-based tests from tests/test_hypothesis.py into
the existing test files for each module they test:

- test_string_processor.py: keyword bolding, placeholder, URL, pattern
- test_markdown_parser.py: Typst escaping, markdown-to-typst
- test_date.py: date parsing, placeholders, time spans
- test_override_dictionary.py: immutability, path traversal
- test_path_resolver.py: name variants, OUTPUT_FOLDER resolution
- test_classic_theme.py: Typst dimension validation
- test_social_network.py: username format validation

Reusable Hypothesis strategies live in tests/strategies.py. Added
pythonpath=["."] to pyproject.toml so tests can import the strategies
module.
2026-03-25 16:28:20 +03:00

299 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from collections.abc import Callable
from datetime import date as Date
from unittest.mock import MagicMock, patch
import pydantic
import pytest
from rendercv.exception import RenderCVUserError
from rendercv.renderer.templater.model_processor import (
download_photo_from_url,
process_fields,
process_model,
)
from rendercv.schema.models.cv.cv import Cv
from rendercv.schema.models.cv.entries.normal import NormalEntry
from rendercv.schema.models.rendercv_model import RenderCVModel
from rendercv.schema.models.settings.settings import Settings
@pytest.fixture
def recorder():
"""Return a processor function and a list tracking all values it has processed.
Used to verify which fields get processed and in what order.
"""
seen = []
def fn(v: str) -> str:
seen.append(v)
return f"processed-{v}"
return fn, seen
class TestProcessFields:
def test_applies_processors_in_order(self):
processors: list[Callable[[str], str]] = [
lambda s: s.upper(),
lambda s: f"{s}!",
]
result = process_fields("content", processors)
assert result == "CONTENT!"
def test_processes_fields_and_mutates_entry(self, recorder):
fn, seen = recorder
entry = NormalEntry.model_validate(
{
"name": "Entry",
"summary": "hello",
"highlights": ["a", "b"],
"start_date": "2020-01-01",
"end_date": "2020-02-01",
"location": "Remote",
}
)
process_fields(entry, [fn])
assert "hello" in seen
assert "a" in seen
assert "b" in seen
assert "Remote" in seen
assert "2020-01-01" not in seen
assert "2020-02-01" not in seen
assert entry.summary == "processed-hello"
assert entry.highlights == ["processed-a", "processed-b"]
assert entry.location == "processed-Remote"
assert entry.start_date == "2020-01-01"
assert entry.end_date == "2020-02-01"
def test_converts_non_string_non_list_fields_to_string(self, recorder):
class EntryWithInt(pydantic.BaseModel):
name: str
count: int
fn, seen = recorder
entry = EntryWithInt(name="Test", count=42)
process_fields(entry, [fn]) # ty: ignore[invalid-argument-type]
assert "Test" in seen
assert "42" in seen
assert entry.name == "processed-Test"
assert entry.count == "processed-42"
@pytest.fixture(params=[["Python", "Remote"], []])
def model(request: pytest.FixtureRequest) -> RenderCVModel:
"""Return a test RenderCVModel with keywords either set or empty.
Parametrized to test both with and without bold keywords.
"""
cv_data = {
# Order matters for connections
"name": "Jane Doe @",
"headline": "Software Engineer @",
"email": "jane@example.com",
"website": "https://janedoe.dev",
"sections": {
"Professional Experience": [
{
"name": "Backend Work",
"summary": "Built Python services with *markdown* emphasis.",
"highlights": ["Improved Python performance"],
"start_date": "2022-01-01",
"end_date": "2023-02-01",
"location": "Remote",
}
]
},
}
cv = Cv.model_validate(cv_data)
rendercv_model = RenderCVModel(
cv=cv, settings=Settings(current_date=Date(2024, 2, 1))
)
rendercv_model.settings.bold_keywords = request.param
return rendercv_model
class TestProcessModel:
def test_markdown_output_has_correct_structure(self, model):
result = process_model(model, "markdown")
assert result.cv.name == "Jane Doe @"
assert result.cv.headline == "Software Engineer @"
# Connections and last updated date are added to cv
assert result.cv._connections == [
"[jane@example.com](mailto:jane@example.com)",
"[janedoe.dev](https://janedoe.dev/)",
]
assert result.cv._top_note == "*Last updated in Feb 2024*"
entry = result.cv.rendercv_sections[0].entries[0]
assert entry.main_column.startswith("**Backend Work**")
if model.settings.bold_keywords:
assert (
"Built **Python** services with *markdown* emphasis."
in entry.main_column
)
assert "- Improved **Python** performance" in entry.main_column
# DATE placeholder removed because it's not provided; location remains
assert entry.date_and_location_column == "**Remote**\nJan 2022 Feb 2023"
else:
assert (
"Built Python services with *markdown* emphasis." in entry.main_column
)
assert "- Improved Python performance" in entry.main_column
assert entry.date_and_location_column == "Remote\nJan 2022 Feb 2023"
def test_typst_output_escapes_special_characters(self, model):
result = process_model(model, "typst")
assert result.cv.name == "Jane Doe \\@"
assert result.cv.headline == "Software Engineer \\@"
entry = result.cv.rendercv_sections[0].entries[0]
assert entry.main_column.startswith("#strong[Backend Work]")
if model.settings.bold_keywords:
assert "- Improved #strong[Python] performance" in entry.main_column
assert (
entry.date_and_location_column == "#strong[Remote]\nJan 2022 Feb 2023"
)
# Connections rendered as Typst links with icons by default
assert result.cv._connections[0].startswith("#link(")
assert "#connection-with-icon" in result.cv._connections[0]
else:
assert "- Improved Python performance" in entry.main_column
assert entry.date_and_location_column == "Remote\nJan 2022 Feb 2023"
assert result.cv._connections[0].startswith("#link(")
assert "jane@example.com" in result.cv._connections[0]
def test_handles_cv_with_no_sections(self):
cv_data = {
"name": "Jane Doe",
"headline": "Software Engineer",
}
cv = Cv.model_validate(cv_data)
rendercv_model = RenderCVModel(cv=cv)
result = process_model(rendercv_model, "markdown")
assert result.cv.name == "Jane Doe"
assert result.cv.headline == "Software Engineer"
def test_pdf_title_default_placeholder_resolution(self, model):
result = process_model(model, "typst")
assert result.settings.pdf_title == "Jane Doe @ - CV"
def test_pdf_title_custom_placeholder_resolution(self):
cv = Cv.model_validate({"name": "John Doe"})
rendercv_model = RenderCVModel(
cv=cv, settings=Settings(current_date=Date(2024, 3, 15))
)
rendercv_model.settings.pdf_title = "NAME - Resume YEAR"
result = process_model(rendercv_model, "typst")
assert result.settings.pdf_title == "John Doe - Resume 2024"
class TestDownloadPhotoFromUrl:
def test_skips_when_photo_is_none(self):
cv = Cv.model_validate({"name": "John Doe"})
model = RenderCVModel(cv=cv)
download_photo_from_url(model)
assert model.cv.photo is None
def test_skips_when_photo_is_local_path(self, tmp_path):
cv = Cv.model_validate({"name": "John Doe"})
model = RenderCVModel(cv=cv)
model.cv.photo = tmp_path / "photo.jpg"
download_photo_from_url(model)
assert model.cv.photo == tmp_path / "photo.jpg"
def test_downloads_photo_from_url(self, tmp_path):
cv = Cv.model_validate({"name": "John Doe"})
model = RenderCVModel(cv=cv)
model.cv.photo = pydantic.HttpUrl("https://example.com/photo.jpg")
model.settings.render_command.output_folder = tmp_path / "output"
mock_response = MagicMock()
mock_response.read.return_value = b"photo data"
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with patch(
"rendercv.renderer.templater.model_processor.urllib.request.urlopen",
return_value=mock_response,
) as mock_urlopen:
download_photo_from_url(model)
mock_urlopen.assert_called_once()
assert model.cv.photo == tmp_path / "output" / "photo.jpg"
def test_uses_photo_jpg_fallback_when_no_filename_in_url(self, tmp_path):
cv = Cv.model_validate({"name": "John Doe"})
model = RenderCVModel(cv=cv)
model.cv.photo = pydantic.HttpUrl("https://example.com/")
model.settings.render_command.output_folder = tmp_path / "output"
mock_response = MagicMock()
mock_response.read.return_value = b"photo data"
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with patch(
"rendercv.renderer.templater.model_processor.urllib.request.urlopen",
return_value=mock_response,
):
download_photo_from_url(model)
assert model.cv.photo == tmp_path / "output" / "photo.jpg"
def test_skips_download_when_file_already_exists(self, tmp_path):
cv = Cv.model_validate({"name": "John Doe"})
model = RenderCVModel(cv=cv)
model.cv.photo = pydantic.HttpUrl("https://example.com/photo.jpg")
output_dir = tmp_path / "output"
output_dir.mkdir()
(output_dir / "photo.jpg").write_bytes(b"existing")
model.settings.render_command.output_folder = output_dir
with patch(
"rendercv.renderer.templater.model_processor.urllib.request.urlopen"
) as mock_urlopen:
download_photo_from_url(model)
mock_urlopen.assert_not_called()
assert model.cv.photo == output_dir / "photo.jpg"
def test_raises_user_error_on_download_failure(self, tmp_path):
cv = Cv.model_validate({"name": "John Doe"})
model = RenderCVModel(cv=cv)
model.cv.photo = pydantic.HttpUrl("https://example.com/photo.jpg")
model.settings.render_command.output_folder = tmp_path / "output"
with (
patch(
"rendercv.renderer.templater.model_processor.urllib.request.urlopen",
side_effect=OSError("network error"),
),
pytest.raises(RenderCVUserError) as exc_info,
):
download_photo_from_url(model)
assert exc_info.value.message is not None
assert "Failed to download photo" in exc_info.value.message