Files
rendercv/tests/renderer/conftest.py
Jordan Bradford 17114e03b2 Add Bluesky (#560)
2025-12-21 23:18:13 +03:00

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