Files
rendercv/tests/renderer/conftest.py
Sina Atalay 5cc5fbdf9e Massive Refactor: Architecture Redesign and Technical Debt Cleanup (#528)
* Rename `data` folder with schema

* Start refactoring data models

* Work on entry models

* Keep working on entries

* Keep working on data models

* Push old data files

* Keep working on data models

* First draft of schema.cv

* Keep working on schema

* Keep working on schema

* Improve schema.models

* Keep working on rendercv.schema

* Work on schema.design

* Keep working on rendercv.schema

* Complete variant_class_generator

* Keep working rendercv.schema

* Keep working on rendercv.schema

* Final touches to rendercv.schema

* Improve json schema descriptions in rendercv.schema

* Start working on rendercv.schema tests

* Keep implementing rendercv.schema tests

* Add more tests for rendercv.schema

* Improve rendercv.schema

* Improve docstrings and comments in rendercv.schema

* Implement better pydantic error handling in `rendercv.schema`

* Improve variant class system

* Fix rendercv.schema tests

* Start working on rendercv.templater

* Update template names

* Switching to new rendercv typst template soon

* Work on new templater

* Rename renderer with renderer_old

* Don't use utils in rendercv.schema

* Complete connections

* Update renderer folder structure

* Work on new renderer

* Work on new renderer

* Date processing on new renderer

* Improve date processing, support multiple emails, phones, and websites

* Improve markdown to Typst

* Complete entry template processing

* Time span computation in new renderer

* Better entry templates

* Setup new templates

* Improve rendercv.schema

* Start adding tests for rendercv.renderer

* New markdown parser!

* Improve markdown to typst conversion

* Finalize markdown parser

* Add new test files for rendercv.renderer

* Fix cv and connections

* Add connections test

* Improve connection tests

* Improve entry templates

* Add model processor tests

* Improve templater

* Rename old folders

* Improve schema

* Add file generation logic to renderer

* Fix naming issues

* Fix schema tests

* Add path type tests

* Add font family and typst dimension type tests

* Rename old tests

* Fix design tests

* Start integration testing of renderer

* Improve entry tempates

* Handle nested highlights properly

* Finalize Typst preamble template

* Start working on new CLI

* Remove old test files

* Implement override dictionary in new schema

* Start working on new CLI

* Better prints on render command

* New structure

* New render printer

* Add all the commands to new CLI

* Work on new command in new cli

* Improve new command

* Add error handler to new cli

* Work on create theme command

* Complete create theme command

* Remove old source files

* Improve exceptions

* Create new docs

* Add writing tests guide

* Fix cli printer and write tests

* Test copy templates

* Add app tests

* Bring back accidentally removed files

* Imporve cli and tests

* Fix path issues

* Improve

* Improve

* Add reference file comparison tests

* Fix path resolver

* Start working on test_pdf_png

* Implement comparison of multiple files (png)

* Start testing typst

* Fix templating issues

* Fix header and entry templates issues

* Implement short second rows

* Fix date issues

* Fix nested bullets and add summary

* Update testdata

* Implement footer

* Update testdata

* Reimagined design and locale schema, first iteration

* Reimagined design and locale second iteration

* Update design and locale schemas

* Adapt templater to the new design and locale

* Fix tests

* Update lib.typ and testdata for the new locale and design

* Implement proper tests with all combinations of entries

* Remove some docstrings

* fix connections logic

* Improve

* Start working on examples

* Update testdata

* Fix long second row issue

* fix templating issues

* Fix lib.typ issues

* Update testdata

* Fix clean_trailing_parts

* Update test cv

* update test cv

* Update theme defaults

* update schema and fix moderncv

* Fix moderncv issues

* Update testdata

* Update testdata and examples

* Fix issues about photo

* Fix typst photo path issues

* improve entry templates from yaml

* add new locale

* Rename writing tests doc

* Update writing tests

* Improve tests

* Add more cli tests

* Increase test coverage

* Rename variant pydantic model generator

* Improve tests

* Update testdata and improve tests

* Format, fix pre-commit errors

* Fix scripts and update entry figures

* Improve tests

* Write docstrings of schema

* Write schema docstrings

* Setup api reference

* Start working on new docs

* Work on docs

* Improve progress panel of render command

* Finalize new docs index

* Complete CLI docs

* Work on YAML input structure page

* Finalize user guide

* Start working on developer guide

* Improve api reference

* Improve developer guide

* Improve developer guide

* Improve developer gide

* Improve developer guide

* Improve developer guide

* Update developer guide

* Improve developer guide

* Improve developer guide

* Improve developer guide

* Developer guide first draft

* update developer guide

* Update examples

* Update testdata

* Handle wrong installation (rendercv instead of rendercv[full])

* Remove unnecessary files

* Write set up vs code page

* Update README.md

* Change docs description

* Compress design options gif

* minor updates

* Polish all the json schema descriptions

* Update testdata and examples

* Remove some emdashed from docs

* Add whatsapp support

* Add TestEscapeTypstCharacters to tests

* Implement custom connections

* Add page break before sections feature

* Revert page break before sections feature

* Rebase to main

* Fix social network tests, update schema
2025-12-09 17:03:56 +03:00

381 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"),
],
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