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