Increase test coverage

This commit is contained in:
Sina Atalay
2026-03-03 16:39:23 +03:00
parent 0ebcf36275
commit da2e657416
12 changed files with 487 additions and 2 deletions

View File

@@ -1,6 +1,7 @@
import os
import pathlib
import sys
from unittest.mock import patch
import pytest
import typer
@@ -11,6 +12,7 @@ from rendercv.cli.render_command.run_rendercv import (
run_rendercv,
timed_step,
)
from rendercv.exception import RenderCVUserError
class TestTimedStep:
@@ -162,6 +164,24 @@ design:
# Restore permissions for cleanup
yaml_file.chmod(original_mode)
def test_user_error_during_rendering(self, tmp_path):
yaml_file = tmp_path / "test.yaml"
yaml_file.write_text("cv:\n name: John Doe\n", encoding="utf-8")
progress = ProgressPanel(quiet=True)
with (
patch(
"rendercv.cli.render_command.run_rendercv"
".build_rendercv_dictionary_and_model",
side_effect=RenderCVUserError(message="test error"),
),
pytest.raises(typer.Exit) as exc_info,
progress,
):
run_rendercv(yaml_file, progress)
assert exc_info.value.exit_code == 1
class TestCollectInputFilePaths:
def test_returns_only_input_file_by_default(self, tmp_path):
@@ -212,6 +232,33 @@ class TestCollectInputFilePaths:
assert result == {"input": yaml_file}
def test_includes_cli_provided_locale_file(self, tmp_path):
yaml_file = tmp_path / "cv.yaml"
yaml_file.write_text("cv:\n name: John Doe\n", encoding="utf-8")
locale_file = tmp_path / "locale.yaml"
locale_file.touch()
result = collect_input_file_paths(yaml_file, locale=locale_file)
assert result["input"] == yaml_file
assert result["locale"] == locale_file
def test_includes_yaml_referenced_locale_file(self, tmp_path):
locale_file = tmp_path / "my_locale.yaml"
locale_file.touch()
yaml_file = tmp_path / "cv.yaml"
yaml_file.write_text(
"cv:\n name: John Doe\n"
"settings:\n render_command:\n locale: my_locale.yaml\n",
encoding="utf-8",
)
result = collect_input_file_paths(yaml_file)
assert result["input"] == yaml_file
assert result["locale"] == locale_file.resolve()
def test_cli_flags_take_precedence_over_yaml_references(self, tmp_path):
yaml_ref_design = tmp_path / "yaml_design.yaml"
yaml_ref_design.touch()

View File

@@ -3,8 +3,10 @@ import time
from unittest.mock import MagicMock, patch
import typer
import watchdog.events
from rendercv.cli.render_command import watcher
from rendercv.cli.render_command.watcher import EventHandler
class TestRunFunctionIfFilesChange:
@@ -108,3 +110,32 @@ class TestRunFunctionIfFilesChange:
time.sleep(0.2)
assert call_count > count_after_exit
class TestEventHandler:
def test_ignores_events_for_unwatched_files(self):
mock_fn = MagicMock()
handler = EventHandler(mock_fn, watched_files={"/watched/file.yaml"})
event = watchdog.events.FileModifiedEvent("/other/file.yaml")
handler.on_modified(event)
mock_fn.assert_not_called()
def test_calls_function_for_watched_file(self):
mock_fn = MagicMock()
handler = EventHandler(mock_fn, watched_files={"/watched/file.yaml"})
event = watchdog.events.FileModifiedEvent("/watched/file.yaml")
handler.on_modified(event)
mock_fn.assert_called_once()
def test_suppresses_typer_exit(self):
mock_fn = MagicMock(side_effect=typer.Exit(code=1))
handler = EventHandler(mock_fn, watched_files={"/watched/file.yaml"})
event = watchdog.events.FileModifiedEvent("/watched/file.yaml")
handler.on_modified(event)
mock_fn.assert_called_once()

View File

@@ -2,7 +2,7 @@ import json
import pathlib
import sys
import time
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
from typer.testing import CliRunner
@@ -11,7 +11,10 @@ from rendercv import __version__
from rendercv.cli.app import (
VERSION_CHECK_TTL_SECONDS,
app,
fetch_and_cache_latest_version,
fetch_latest_version_from_pypi,
get_cache_dir,
get_version_cache_file,
read_version_cache,
warn_if_new_version_is_available,
write_version_cache,
@@ -75,6 +78,13 @@ class TestGetCacheDir:
assert get_cache_dir() == tmp_path / "rendercv"
def test_get_version_cache_file():
result = get_version_cache_file()
assert result.name == "version_check.json"
assert result.parent == get_cache_dir()
class TestReadVersionCache:
def test_returns_none_for_missing_file(self, tmp_path, monkeypatch):
monkeypatch.setattr(
@@ -133,6 +143,60 @@ class TestWriteVersionCache:
assert data["latest_version"] == "2.0.0"
assert "last_check" in data
def test_silently_ignores_os_error(self, monkeypatch):
monkeypatch.setattr(
"rendercv.cli.app.get_version_cache_file",
lambda: pathlib.Path("/nonexistent/readonly/path/version_check.json"),
)
write_version_cache("2.0.0")
class TestFetchLatestVersionFromPypi:
def test_returns_version_on_success(self, monkeypatch):
response_data = json.dumps({"info": {"version": "3.0.0"}}).encode()
mock_response = MagicMock()
mock_response.read.return_value = response_data
mock_response.info.return_value.get_content_charset.return_value = "utf-8"
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
monkeypatch.setattr(
"rendercv.cli.app.urllib.request.urlopen",
lambda *a, **kw: mock_response,
)
result = fetch_latest_version_from_pypi()
assert result == "3.0.0"
@patch(
"rendercv.cli.app.urllib.request.urlopen",
side_effect=ConnectionError("fail"),
)
def test_returns_none_on_failure(self, mock_urlopen): # NOQA: ARG002
result = fetch_latest_version_from_pypi()
assert result is None
class TestFetchAndCacheLatestVersion:
@patch("rendercv.cli.app.write_version_cache")
@patch("rendercv.cli.app.fetch_latest_version_from_pypi", return_value="3.0.0")
def test_fetches_and_writes_cache(self, mock_fetch, mock_write):
fetch_and_cache_latest_version()
mock_fetch.assert_called_once()
mock_write.assert_called_once_with("3.0.0")
@patch("rendercv.cli.app.write_version_cache")
@patch("rendercv.cli.app.fetch_latest_version_from_pypi", return_value=None)
def test_does_not_write_cache_on_fetch_failure(self, mock_fetch, mock_write):
fetch_and_cache_latest_version()
mock_fetch.assert_called_once()
mock_write.assert_not_called()
def write_cache(tmp_path, version, age_seconds=0):
"""Helper to write a version cache file for testing."""
@@ -240,3 +304,15 @@ class TestWarnIfNewVersionIsAvailable:
data = json.loads(cache_file.read_text(encoding="utf-8"))
assert data["latest_version"] == "99.0.0"
def test_handles_invalid_version_in_cache(self, tmp_path, capsys, monkeypatch):
write_cache(tmp_path, "not.a.version", age_seconds=0)
monkeypatch.setattr(
"rendercv.cli.app.get_version_cache_file",
lambda: tmp_path / "version_check.json",
)
warn_if_new_version_is_available()
captured = capsys.readouterr()
assert "new version" not in captured.out.lower()

View File

@@ -3,6 +3,7 @@ from typing import Literal, get_args
import pydantic
import pytest
from rendercv.exception import RenderCVInternalError
from rendercv.renderer.templater.connections import (
compute_connections,
compute_connections_for_markdown,
@@ -417,3 +418,22 @@ class TestIconMapping:
assert conn_type in fontawesome_icons, (
f"Missing icon for connection type: {conn_type}"
)
class TestParseConnectionsInternalErrors:
"""Test defensive guards when _key_order contains a key but the field is None."""
def _make_model_with_none_field(self, key: str) -> RenderCVModel:
cv = Cv.model_validate({"name": "John Doe"})
cv._key_order = [key]
return create_rendercv_model(cv)
@pytest.mark.parametrize(
"key",
["phone", "website", "social_networks", "custom_connections"],
)
def test_raises_for_none_field_in_key_order(self, key):
model = self._make_model_with_none_field(key)
with pytest.raises(RenderCVInternalError):
parse_connections(model)

View File

@@ -1,4 +1,5 @@
from datetime import date as Date
from unittest.mock import patch
import pydantic
import pytest
@@ -489,6 +490,95 @@ def test_remove_not_provided_placeholders(entry_templates, entry_fields, expecte
assert result == expected
class TestRenderEntryTemplatesInternalErrors:
"""Test defensive guards when model_dump includes a key but the attribute is None."""
@pytest.mark.parametrize(
("entry_type", "field_name"),
[
("education", "highlights"),
("publication", "authors"),
],
)
def test_raises_when_field_in_dump_but_attribute_is_none(
self, entry_type, field_name
):
if entry_type == "education":
entry = EducationEntry.model_validate(
{
"institution": "MIT",
"area": "CS",
"highlights": ["A"],
"start_date": "2020-01",
"end_date": "2021-01",
}
)
else:
entry = PublicationEntry.model_validate(
{
"title": "Paper",
"authors": ["John"],
"date": "2024-01",
}
)
# Set the attribute to None while model_dump still includes it
original_model_dump = entry.model_dump
def patched_model_dump(**kwargs):
result = original_model_dump(**kwargs)
result[field_name] = "placeholder"
return result
entry.model_dump = patched_model_dump # ty: ignore[invalid-assignment]
setattr(entry, field_name, None)
with pytest.raises(RenderCVInternalError):
render_entry_templates(
entry,
templates=Templates(),
locale=EnglishLocale(),
show_time_span=False,
current_date=Date(2024, 1, 1),
)
@pytest.mark.parametrize("field_name", ["start_date", "end_date"])
def test_raises_when_date_field_in_dump_but_attribute_is_none(self, field_name):
entry = EducationEntry.model_validate(
{
"institution": "MIT",
"area": "CS",
"start_date": "2020-01",
"end_date": "2021-01",
}
)
original_model_dump = entry.model_dump
def patched_model_dump(**kwargs):
result = original_model_dump(**kwargs)
result[field_name] = "placeholder"
return result
entry.model_dump = patched_model_dump # ty: ignore[invalid-assignment]
setattr(entry, field_name, None)
with (
patch(
"rendercv.renderer.templater.entry_templates_from_input.process_date",
return_value="Jan 2020 Jan 2021",
),
pytest.raises(RenderCVInternalError),
):
render_entry_templates(
entry,
templates=Templates(),
locale=EnglishLocale(),
show_time_span=False,
current_date=Date(2024, 1, 1),
)
@pytest.mark.parametrize(
("input_text", "expected"),
[

View File

@@ -1,9 +1,15 @@
from datetime import date as Date
from unittest.mock import patch
import pydantic
import pytest
from rendercv.renderer.templater.model_processor import process_fields, process_model
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
@@ -193,3 +199,85 @@ class TestProcessModel:
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"
with patch(
"rendercv.renderer.templater.model_processor.urllib.request.urlretrieve"
) as mock_retrieve:
download_photo_from_url(model)
mock_retrieve.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"
with patch(
"rendercv.renderer.templater.model_processor.urllib.request.urlretrieve"
):
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.urlretrieve"
) as mock_retrieve:
download_photo_from_url(model)
mock_retrieve.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.urlretrieve",
side_effect=ConnectionError("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

View File

@@ -1,7 +1,9 @@
import pathlib
from unittest.mock import MagicMock, patch
import pytest
from rendercv.exception import RenderCVInternalError
from rendercv.renderer.pdf_png import generate_pdf, generate_png
from rendercv.renderer.typst import generate_typst
from rendercv.schema.models.design.built_in_design import available_themes
@@ -135,3 +137,32 @@ class TestGeneratePngCleansUpOldFiles:
assert result is not None
assert len(result) >= 1
def test_raises_error_when_typst_returns_none_bytes(
tmp_path: pathlib.Path,
minimal_rendercv_model: RenderCVModel,
):
model = RenderCVModel(
cv=minimal_rendercv_model.cv,
design={"theme": "classic"},
locale=minimal_rendercv_model.locale,
settings=minimal_rendercv_model.settings,
)
output_dir = tmp_path / "output"
output_dir.mkdir()
model.settings.render_command.typst_path = output_dir / "John_Doe_CV.typ"
typst_path = generate_typst(model)
model.settings.render_command.png_path = output_dir / "John_Doe_CV.png"
mock_compiler = MagicMock()
mock_compiler.compile.return_value = [None]
with (
patch(
"rendercv.renderer.pdf_png.get_typst_compiler", return_value=mock_compiler
),
pytest.raises(RenderCVInternalError, match="Typst compiler returned None"),
):
generate_png(model, typst_path)

View File

@@ -1,8 +1,10 @@
from typing import Any
from unittest.mock import MagicMock
import pydantic
import pytest
from rendercv.exception import RenderCVInternalError
from rendercv.schema.models.cv.cv import Cv
from rendercv.schema.models.cv.section import available_entry_type_names
@@ -87,3 +89,10 @@ class TestCv:
assert "tel:" not in serialized["phone"]
assert serialized["phone"] == "+90-541-999-99-99"
def test_raises_internal_error_when_field_name_is_none(self):
mock_info = MagicMock(spec=pydantic.ValidationInfo)
mock_info.field_name = None
with pytest.raises(RenderCVInternalError, match="field_name is None"):
Cv.validate_list_or_scalar_fields("test@example.com", mock_info)

View File

@@ -1,9 +1,11 @@
import os
import pathlib
from unittest.mock import MagicMock, patch
import pydantic
import pytest
from rendercv.exception import RenderCVInternalError
from rendercv.schema.models.design.design import Design
from rendercv.schema.models.validation_context import ValidationContext
@@ -140,3 +142,56 @@ class SomeOtherClass(BaseModel):
design_adapter.validate_python(
{"theme": "classic", "colors": "invalid_value_not_a_dict"}
)
def test_raises_internal_error_when_spec_is_none(self, design_adapter, tmp_path):
custom_theme_path = tmp_path / "mytheme"
custom_theme_path.mkdir()
(custom_theme_path / "EducationEntry.j2.typ").touch()
(custom_theme_path / "__init__.py").write_text("# empty", encoding="utf-8")
with (
patch(
"rendercv.schema.models.design.design.importlib.util"
".spec_from_file_location",
return_value=None,
),
pytest.raises(RenderCVInternalError, match="Failed to load spec"),
):
design_adapter.validate_python(
{"theme": "mytheme"},
context={
"context": ValidationContext(
input_file_path=tmp_path / "input.yaml"
)
},
)
def test_raises_internal_error_when_spec_loader_is_none(
self, design_adapter, tmp_path
):
custom_theme_path = tmp_path / "mytheme"
custom_theme_path.mkdir()
(custom_theme_path / "EducationEntry.j2.typ").touch()
(custom_theme_path / "__init__.py").write_text("# empty", encoding="utf-8")
mock_spec = MagicMock()
mock_spec.name = "theme"
mock_spec.loader = None
mock_spec.submodule_search_locations = None
with (
patch(
"rendercv.schema.models.design.design.importlib.util"
".spec_from_file_location",
return_value=mock_spec,
),
pytest.raises(RenderCVInternalError, match=r"spec\.loader is None"),
):
design_adapter.validate_python(
{"theme": "mytheme"},
context={
"context": ValidationContext(
input_file_path=tmp_path / "input.yaml"
)
},
)

View File

@@ -1,9 +1,11 @@
from dataclasses import asdict
from unittest.mock import MagicMock
import pydantic
import pytest
from rendercv.exception import RenderCVInternalError
from rendercv.schema.models.custom_error_types import CustomPydanticErrorTypes
from rendercv.schema.models.rendercv_model import RenderCVModel
from rendercv.schema.models.validation_context import ValidationContext
from rendercv.schema.pydantic_error_handling import (
@@ -184,6 +186,26 @@ class TestParseValidationErrorsWithOverlaySources:
assert err.yaml_source == "design_yaml_file"
class TestParseValidationErrorsInternalErrors:
def test_raises_for_entry_validation_error_missing_ctx(self):
mock_exception = MagicMock(spec=pydantic.ValidationError)
mock_exception.errors.return_value = [
{
"type": CustomPydanticErrorTypes.entry_validation.value,
"loc": ("cv",),
"msg": "entry validation failed",
"input": {},
}
]
input_dict = read_yaml("cv:\n name: John Doe")
with pytest.raises(
RenderCVInternalError, match="entry_validation error missing"
):
parse_validation_errors(mock_exception, input_dict)
class TestGetInnerYamlObjectFromItsKey:
def test_returns_object_and_coordinates_for_valid_key(self):
yaml_content = "name: John\nage: 30"

View File

@@ -11,6 +11,7 @@ from rendercv.schema.rendercv_model_builder import (
build_rendercv_dictionary,
build_rendercv_dictionary_and_model,
build_rendercv_model_from_commented_map,
get_yaml_error_location,
)
from rendercv.schema.sample_generator import dictionary_to_yaml
@@ -712,3 +713,12 @@ class TestBuildRendercvModel:
_, model = build_rendercv_dictionary_and_model(main_yaml, **kwargs) # ty: ignore[invalid-argument-type]
assert check(model)
class TestGetYamlErrorLocation:
def test_returns_none_when_no_marks(self):
error = ruamel.yaml.YAMLError()
result = get_yaml_error_location(error)
assert result is None

View File

@@ -36,3 +36,9 @@ class TestReadYaml:
with pytest.raises(RenderCVUserError, match="empty"):
read_yaml(empty_file_path)
def test_treats_asterisk_as_plain_text(self):
result = read_yaml("key: *not_an_alias")
assert isinstance(result, CommentedMap)
assert result["key"] == "*not_an_alias"