mirror of
https://github.com/rendercv/rendercv.git
synced 2026-01-30 08:01:34 -05:00
382 lines
15 KiB
Python
382 lines
15 KiB
Python
import filecmp
|
|
import itertools
|
|
import pathlib
|
|
import shutil
|
|
import types
|
|
import typing
|
|
from datetime import date as Date
|
|
|
|
import pydantic
|
|
import pydantic_extra_types.phone_numbers as pydantic_phone_numbers
|
|
import pytest
|
|
|
|
from rendercv.schema.models.cv.cv import Cv
|
|
from rendercv.schema.models.cv.entries.bullet import BulletEntry
|
|
from rendercv.schema.models.cv.entries.education import EducationEntry
|
|
from rendercv.schema.models.cv.entries.experience import ExperienceEntry
|
|
from rendercv.schema.models.cv.entries.normal import NormalEntry
|
|
from rendercv.schema.models.cv.entries.numbered import NumberedEntry
|
|
from rendercv.schema.models.cv.entries.one_line import OneLineEntry
|
|
from rendercv.schema.models.cv.entries.publication import PublicationEntry
|
|
from rendercv.schema.models.cv.entries.reversed_numbered import ReversedNumberedEntry
|
|
from rendercv.schema.models.cv.social_network import SocialNetwork
|
|
from rendercv.schema.models.rendercv_model import RenderCVModel
|
|
from rendercv.schema.models.settings.settings import Settings
|
|
|
|
|
|
@pytest.fixture
|
|
def compare_file_with_reference(
|
|
tmp_path: pathlib.Path, testdata_dir: pathlib.Path, update_testdata: bool
|
|
):
|
|
"""Generic fixture for comparing generated files with reference files.
|
|
|
|
This fixture works with any file type (typst, markdown, PDF, etc.) and supports
|
|
the --update-testdata flag to regenerate reference files.
|
|
|
|
Usage:
|
|
def test_something(compare_file_with_reference):
|
|
def generate_file(output_path):
|
|
# Generate your file at output_path
|
|
pass
|
|
|
|
assert compare_file_with_reference(
|
|
generate_file,
|
|
"reference.typ"
|
|
)
|
|
|
|
Args:
|
|
tmp_path: Pytest fixture providing temporary directory.
|
|
testdata_dir: Fixture providing path to testdata directory.
|
|
update_testdata: Fixture indicating whether to update reference files.
|
|
|
|
Returns:
|
|
A callable that takes (callable_func, reference_filename)
|
|
and returns True if files match, False otherwise.
|
|
"""
|
|
|
|
def compare(callable_func, reference_filename: str) -> bool:
|
|
output_dir = tmp_path / "output"
|
|
output_dir.mkdir()
|
|
|
|
output_path_input = output_dir / reference_filename
|
|
callable_func(output_path_input)
|
|
|
|
reference_paths = list(
|
|
testdata_dir.glob(f"{output_path_input.stem}*{output_path_input.suffix}")
|
|
)
|
|
generated_paths = list(
|
|
output_dir.glob(f"{output_path_input.stem}*{output_path_input.suffix}")
|
|
)
|
|
|
|
if len(generated_paths) == 0:
|
|
msg = f"Output file not found: {output_path_input}"
|
|
raise FileNotFoundError(msg)
|
|
|
|
if update_testdata:
|
|
testdata_dir.mkdir(parents=True, exist_ok=True)
|
|
for generated_path in generated_paths:
|
|
shutil.copy(generated_path, testdata_dir / generated_path.name)
|
|
return True
|
|
|
|
if not any(reference_paths):
|
|
msg = (
|
|
f"Reference file not found: {reference_filename}. Run with"
|
|
" --update-testdata to generate it."
|
|
)
|
|
raise FileNotFoundError(msg)
|
|
|
|
if len(reference_paths) != len(generated_paths):
|
|
msg = (
|
|
f"Number of generated files ({len(generated_paths)}) does not match"
|
|
f" number of reference files ({len(reference_paths)}). Run with"
|
|
" `--update-testdata` to generate the missing files."
|
|
)
|
|
raise FileNotFoundError(msg)
|
|
|
|
return any(
|
|
filecmp.cmp(generated_path, reference_path, shallow=False)
|
|
for generated_path, reference_path in zip(
|
|
generated_paths, reference_paths, strict=True
|
|
)
|
|
)
|
|
|
|
return compare
|
|
|
|
|
|
@pytest.fixture
|
|
def minimal_rendercv_model() -> RenderCVModel:
|
|
"""Create a minimal RenderCVModel for testing.
|
|
|
|
Returns:
|
|
A RenderCVModel with minimal CV data (name + one section).
|
|
"""
|
|
cv = Cv(
|
|
name="John Doe",
|
|
sections={
|
|
"Experience": [
|
|
"Software Engineer at Company X, 2020-2023",
|
|
]
|
|
},
|
|
)
|
|
return RenderCVModel(cv=cv, settings=Settings(current_date=Date(2025, 11, 30)))
|
|
|
|
|
|
@pytest.fixture
|
|
def full_rendercv_model(testdata_dir: pathlib.Path) -> RenderCVModel:
|
|
"""Create a comprehensive RenderCVModel with all entry combinations.
|
|
|
|
Generates all possible combinations of entry types with different
|
|
optional fields set/unset for comprehensive rendering tests.
|
|
|
|
Returns:
|
|
A RenderCVModel with ~400 entry instances across all entry types.
|
|
"""
|
|
# Build CV with all sections
|
|
cv = Cv(
|
|
name="John Doe",
|
|
headline="AI Researcher and Entrepreneur",
|
|
location="Istanbul, Turkey",
|
|
email="john_doe@example.com",
|
|
photo=testdata_dir.parent / "profile_picture.jpg",
|
|
phone=pydantic_phone_numbers.PhoneNumber("+905419999999"),
|
|
website=pydantic.HttpUrl("https://example.com"),
|
|
social_networks=[
|
|
SocialNetwork(network="LinkedIn", username="johndoe"),
|
|
SocialNetwork(network="GitHub", username="johndoe"),
|
|
SocialNetwork(network="IMDB", username="nm0000001"),
|
|
SocialNetwork(network="Instagram", username="johndoe"),
|
|
SocialNetwork(network="ORCID", username="0000-0000-0000-0000"),
|
|
SocialNetwork(network="Google Scholar", username="F8IyYrQAAAAJ"),
|
|
SocialNetwork(network="Mastodon", username="@johndoe@example.com"),
|
|
SocialNetwork(network="StackOverflow", username="12323/johndoe"),
|
|
SocialNetwork(network="GitLab", username="johndoe"),
|
|
SocialNetwork(network="ResearchGate", username="johndoe"),
|
|
SocialNetwork(network="YouTube", username="johndoe"),
|
|
SocialNetwork(network="Telegram", username="johndoe"),
|
|
SocialNetwork(network="WhatsApp", username="+14155552671"),
|
|
SocialNetwork(network="X", username="johndoe"),
|
|
SocialNetwork(network="Bluesky", username="johndoe.bsky.social"),
|
|
],
|
|
sections={
|
|
"Text Entries": [
|
|
(
|
|
"This is a *TextEntry*. It is only a text and can be useful for"
|
|
" sections like **Summary**. To showcase the TextEntry completely,"
|
|
" this sentence is added, but it doesn't contain any information."
|
|
),
|
|
(
|
|
"Another text entry with *markdown* and **bold** text. This is the"
|
|
" second text entry."
|
|
),
|
|
"Third text with [link](https://example.com) and more content.",
|
|
],
|
|
"Publication Entries": create_combinations_of_entry_type(PublicationEntry),
|
|
"Experience Entries": create_combinations_of_entry_type(ExperienceEntry),
|
|
"Education Entries": create_combinations_of_entry_type(EducationEntry),
|
|
"Normal Entries": create_combinations_of_entry_type(NormalEntry),
|
|
"One Line Entries": [
|
|
OneLineEntry(
|
|
label="Programming", details="Python, C++, JavaScript, MATLAB"
|
|
),
|
|
OneLineEntry(
|
|
label="Programming", details="Python, C++, JavaScript, MATLAB"
|
|
),
|
|
],
|
|
"Bullet Entries": [
|
|
BulletEntry(bullet="This is a bullet entry."),
|
|
BulletEntry(bullet="This is a bullet entry."),
|
|
],
|
|
"Numbered Entries": [
|
|
NumberedEntry(number="This is a numbered entry."),
|
|
NumberedEntry(number="This is a numbered entry."),
|
|
],
|
|
"Reversed Numbered Entries": [
|
|
ReversedNumberedEntry(
|
|
reversed_number="This is a reversed numbered entry."
|
|
),
|
|
ReversedNumberedEntry(
|
|
reversed_number="This is a reversed numbered entry."
|
|
),
|
|
ReversedNumberedEntry(
|
|
reversed_number="This is a reversed numbered entry."
|
|
),
|
|
],
|
|
"A Section & with % Special Characters": [
|
|
NormalEntry(name="A Section & with % Special Characters")
|
|
],
|
|
},
|
|
)
|
|
|
|
return RenderCVModel(cv=cv, settings=Settings(current_date=Date(2025, 11, 30)))
|
|
|
|
|
|
def return_value_for_field(field_name: str, field_type: typing.Any) -> typing.Any:
|
|
"""Generate test values for Pydantic model fields based on name and type.
|
|
|
|
Returns appropriate test data for a field by checking field name first,
|
|
then handling complex types (Union, Literal, list), and finally falling
|
|
back to type-based defaults.
|
|
"""
|
|
field_dictionary = {
|
|
"institution": "Boğaziçi University",
|
|
"location": "Istanbul, Turkey",
|
|
"degree": "BS",
|
|
"area": "Mechanical Engineering",
|
|
"start_date": "2015-09",
|
|
"end_date": "2020-06",
|
|
"date": "2021-09",
|
|
"summary": (
|
|
"Did *this* and this is a **bold** [link](https://example.com). But I must"
|
|
" explain to you how all this mistaken idea of denouncing pleasure and"
|
|
" praising pain was born and I will give you a complete account of the"
|
|
" system, and expound the actual teachings of the great explorer of the"
|
|
" truth, the master-builder of human happiness."
|
|
),
|
|
"highlights": [
|
|
(
|
|
"Did *this* and this is a **bold** [link](https://example.com). But I"
|
|
" must explain to you how all this mistaken idea of denouncing pleasure"
|
|
" and praising pain was born and I will give you a complete account of"
|
|
" the system, and expound the actual teachings of the great explorer of"
|
|
" the truth, the master-builder of human happiness."
|
|
),
|
|
(
|
|
"Did that. Nor again is there anyone who loves or pursues or desires to"
|
|
" obtain pain of itself, because it is pain, but because occasionally"
|
|
" circumstances occur in which toil and pain can procure him some great"
|
|
" pleasure. - Nor again is there anyone who loves or pursues or desires"
|
|
" to obtain pain of itself, because it is pain, but because"
|
|
" occasionally circumstances occur in which toil and pain can procure"
|
|
" him some great pleasure. - Nor again is there anyone who loves or"
|
|
" pursues or desires to obtain pain of itself, because it is pain, but"
|
|
" because occasionally circumstances occur in which toil and pain can"
|
|
" procure him some great pleasure."
|
|
),
|
|
(
|
|
"Did that. Nor again is there anyone who loves or pursues or desires to"
|
|
" obtain pain of itself, because it is pain, but because occasionally"
|
|
" circumstances occur in which toil and pain can procure him some great"
|
|
" pleasure."
|
|
),
|
|
],
|
|
"company": "Some Company",
|
|
"position": "Software Engineer",
|
|
"name": "My Project",
|
|
"label": "Pro**gram**ming",
|
|
"details": "Python, C++, JavaScript, MATLAB",
|
|
"authors": [
|
|
"J. Doe",
|
|
"***H. Tom***",
|
|
"S. Doe",
|
|
"A. Andsurname",
|
|
"S. Doe",
|
|
"A. Andsurname",
|
|
],
|
|
"title": (
|
|
"Magneto-Thermal Thin Shell Approximation for 3D Finite Element Analysis of"
|
|
" No-Insulation Coils"
|
|
),
|
|
"journal": "IEEE Transactions on Applied Superconductivity",
|
|
"doi": "10.1007/978-3-319-69626-3_101-1",
|
|
"url": "https://example.com",
|
|
"bullet": "This is a bullet entry.",
|
|
"number": "This is a numbered entry.",
|
|
"reversed_number": "This is a reversed numbered entry.",
|
|
}
|
|
|
|
field_type_dictionary = {
|
|
pydantic.HttpUrl: "https://example.com",
|
|
pydantic_phone_numbers.PhoneNumber: "+905419999999",
|
|
str: "A test string",
|
|
int: 1,
|
|
float: 1.0,
|
|
bool: True,
|
|
}
|
|
|
|
# 1. Check field name first
|
|
if field_name in field_dictionary:
|
|
return field_dictionary[field_name]
|
|
|
|
# 2. Get type info
|
|
origin = typing.get_origin(field_type)
|
|
args = typing.get_args(field_type)
|
|
|
|
# 3. Handle Union types (including Optional = str | None)
|
|
# Check for both typing.Union and the new union syntax (str | None)
|
|
is_union = origin is typing.Union
|
|
if not is_union and hasattr(typing, "UnionType"):
|
|
is_union = isinstance(field_type, types.UnionType)
|
|
|
|
if is_union:
|
|
# Filter out None and recursively handle first non-None type
|
|
non_none_args = [arg for arg in args if arg is not type(None)]
|
|
if non_none_args:
|
|
return return_value_for_field(field_name, non_none_args[0])
|
|
|
|
# 4. Handle Literal types (e.g., Literal['present'])
|
|
if origin is typing.Literal:
|
|
return args[0] # Return first literal value
|
|
|
|
# 5. Handle list types
|
|
if origin is list:
|
|
element_type = args[0] if args else str
|
|
if element_type is str:
|
|
return ["Item 1", "Item 2"]
|
|
# For other types, create list with one element
|
|
return [return_value_for_field(field_name, element_type)]
|
|
|
|
# 6. Check against type dictionary
|
|
for field_type, value in field_type_dictionary.items():
|
|
if field_type is field_type or field_type == field_type:
|
|
return value
|
|
# Also check if type is in Union args
|
|
if args and field_type in args:
|
|
return value
|
|
|
|
message = f"No value found for field {field_name} of type {field_type}"
|
|
raise ValueError(message)
|
|
|
|
|
|
def create_combinations_of_entry_type(
|
|
model: type[pydantic.BaseModel],
|
|
ignore_fields: set[str] | None = None,
|
|
) -> list[pydantic.BaseModel]:
|
|
"""Generate all combinations of a Pydantic model with different optional fields.
|
|
|
|
Creates instances with: only required fields, then all combinations of optional
|
|
fields being set/unset. Used to test rendering with various field combinations.
|
|
"""
|
|
ignore_fields = ignore_fields or set()
|
|
|
|
required_fields = {}
|
|
optional_fields = {}
|
|
|
|
# 1. Categorize fields as required or optional
|
|
for field_name, field_info in model.model_fields.items():
|
|
if field_name in ignore_fields:
|
|
continue
|
|
|
|
value = return_value_for_field(field_name, field_info.annotation)
|
|
|
|
if field_info.is_required():
|
|
required_fields[field_name] = value
|
|
else:
|
|
optional_fields[field_name] = value
|
|
|
|
# 2. Create base instance with only required fields
|
|
model_with_only_required_fields = model(**required_fields)
|
|
all_combinations = [model_with_only_required_fields]
|
|
|
|
# 3. Generate all combinations of optional fields
|
|
for i in range(1, len(optional_fields) + 1):
|
|
for combination in itertools.combinations(optional_fields, i):
|
|
# Build kwargs with required + selected optional fields
|
|
kwargs = required_fields.copy()
|
|
kwargs.update({k: optional_fields[k] for k in combination})
|
|
|
|
# Create fresh instance (don't mutate existing ones)
|
|
model_instance = model(**kwargs)
|
|
all_combinations.append(model_instance)
|
|
|
|
return all_combinations
|