From 2df9d2262b5bcb8b7beaae0b5cc1ac64fb822f19 Mon Sep 17 00:00:00 2001 From: Sina Atalay <79940989+sinaatalay@users.noreply.github.com> Date: Wed, 25 Mar 2026 04:18:20 +0300 Subject: [PATCH] Fix placeholder removal eating provided placeholders with overlapping names The regex in remove_not_provided_placeholders used a bare alternation without word boundaries, so removing placeholder "AA" would also destroy "AAA" in the same template. Fix: add \b word boundaries and sort longest-first. Also add Hypothesis tests for: - remove_not_provided_placeholders: provided keys survive removal - remove_connectors_of_missing_placeholders: connectors removed/preserved - validate_arbitrary_date: pass-through for valid dates and custom text - Mastodon URL: domain and username appear in generated URL --- .../templater/entry_templates_from_input.py | 6 ++- .../test_entry_templates_from_input.py | 52 ++++++++++++++++++- .../bases/test_entry_with_complex_fields.py | 35 +++++++++++++ tests/schema/models/cv/test_social_network.py | 14 +++++ 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/rendercv/renderer/templater/entry_templates_from_input.py b/src/rendercv/renderer/templater/entry_templates_from_input.py index 5ad47a3c..e449ef06 100644 --- a/src/rendercv/renderer/templater/entry_templates_from_input.py +++ b/src/rendercv/renderer/templater/entry_templates_from_input.py @@ -470,9 +470,11 @@ def remove_not_provided_placeholders( for key, value in entry_templates.items() } - # Then remove the placeholders themselves and adjacent non-space chars: + # Then remove the placeholders themselves and adjacent non-space chars. + # Sort longest-first so e.g. "AAA" matches before "AA": + sorted_placeholders = sorted(not_provided_placeholders, key=len, reverse=True) not_provided_placeholders_pattern = re.compile( - r"\S*(?:" + "|".join(not_provided_placeholders) + r")\S*" + r"\S*\b(?:" + "|".join(sorted_placeholders) + r")\b\S*" ) entry_templates = { key: clean_trailing_parts( diff --git a/tests/renderer/templater/test_entry_templates_from_input.py b/tests/renderer/templater/test_entry_templates_from_input.py index 6d4f7bc8..911b4726 100644 --- a/tests/renderer/templater/test_entry_templates_from_input.py +++ b/tests/renderer/templater/test_entry_templates_from_input.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pydantic import pytest -from hypothesis import given, settings +from hypothesis import assume, given, settings from hypothesis import strategies as st from rendercv.exception import RenderCVInternalError @@ -602,6 +602,34 @@ def test_remove_not_provided_placeholders(entry_templates, entry_fields, expecte assert result == expected +class TestRemoveNotProvidedPlaceholders: + @settings(deadline=None) + @given( + provided_key=st.from_regex(r"[A-Z]{2,10}", fullmatch=True), + missing_key=st.from_regex(r"[A-Z]{2,10}", fullmatch=True), + value=st.from_regex(r"[a-z ]{1,20}", fullmatch=True), + ) + def test_provided_placeholders_survive( + self, provided_key: str, missing_key: str, value: str + ) -> None: + assume(provided_key != missing_key) + templates = {"main": f"{provided_key} {missing_key}"} + fields = {provided_key: value} + result = remove_not_provided_placeholders(templates, fields) + assert provided_key in result["main"] + + @settings(deadline=None) + @given( + missing_key=st.from_regex(r"[A-Z]{2,10}", fullmatch=True), + ) + def test_missing_placeholders_removed(self, missing_key: str) -> None: + templates = {"main": f"PREFIX {missing_key} SUFFIX"} + fields = {"PREFIX": "a", "SUFFIX": "b"} + assume(missing_key not in ("PREFIX", "SUFFIX")) + result = remove_not_provided_placeholders(templates, fields) + assert missing_key not in result["main"] + + class TestRenderEntryTemplatesInternalErrors: """Test defensive guards when model_dump includes a key but the attribute is None.""" @@ -774,6 +802,28 @@ class TestRemoveConnectorsOfMissingPlaceholders: == expected ) + @settings(deadline=None) + @given( + connector=st.from_regex(r"[a-z]{2,8}", fullmatch=True), + ) + def test_connector_removed_when_adjacent_placeholder_missing( + self, connector: str + ) -> None: + template = f"PRESENT {connector} MISSING" + result = remove_connectors_of_missing_placeholders(template, {"MISSING"}) + assert connector not in result + + @settings(deadline=None) + @given( + connector=st.from_regex(r"[a-z]{2,8}", fullmatch=True), + ) + def test_connector_preserved_when_both_placeholders_present( + self, connector: str + ) -> None: + template = f"LEFT {connector} RIGHT" + result = remove_connectors_of_missing_placeholders(template, set()) + assert connector in result + class TestRenderEntryTemplatesWithMissingDegree: def test_no_connector_word_when_degree_is_none(self): diff --git a/tests/schema/models/cv/entries/bases/test_entry_with_complex_fields.py b/tests/schema/models/cv/entries/bases/test_entry_with_complex_fields.py index 3e51d816..2965819f 100644 --- a/tests/schema/models/cv/entries/bases/test_entry_with_complex_fields.py +++ b/tests/schema/models/cv/entries/bases/test_entry_with_complex_fields.py @@ -10,6 +10,9 @@ from rendercv.schema.models.cv.entries.bases.entry_with_complex_fields import ( BaseEntryWithComplexFields, get_date_object, ) +from rendercv.schema.models.cv.entries.bases.entry_with_date import ( + validate_arbitrary_date, +) from tests.strategies import valid_date_strings @@ -91,3 +94,35 @@ class TestBaseEntryWithComplexFields: assert entry.date == end_date assert entry.start_date is None assert entry.end_date is None + + +class TestValidateArbitraryDate: + @settings(deadline=None) + @given(date_str=valid_date_strings()) + def test_valid_date_strings_pass_through(self, date_str: str) -> None: + result = validate_arbitrary_date(date_str) + assert result == date_str + + @settings(deadline=None) + @given(year=st.integers(min_value=1, max_value=9999)) + def test_integer_years_pass_through(self, year: int) -> None: + result = validate_arbitrary_date(year) + assert result == year + + @settings(deadline=None) + @given( + text=st.text(min_size=1, max_size=20).filter( + lambda s: not s.strip().isdigit() and "-" not in s + ) + ) + def test_custom_text_passes_through(self, text: str) -> None: + result = validate_arbitrary_date(text) + assert result == text + + def test_invalid_month_raises(self) -> None: + with pytest.raises(ValueError): + validate_arbitrary_date("2020-13-01") + + def test_invalid_day_raises(self) -> None: + with pytest.raises(ValueError): + validate_arbitrary_date("2020-02-30") diff --git a/tests/schema/models/cv/test_social_network.py b/tests/schema/models/cv/test_social_network.py index 27618094..0ae8f1d3 100644 --- a/tests/schema/models/cv/test_social_network.py +++ b/tests/schema/models/cv/test_social_network.py @@ -109,6 +109,20 @@ class TestSocialNetwork: sn = SocialNetwork(network="Mastodon", username=username) assert sn.username == username + @settings(deadline=None) + @given( + user=st.from_regex(r"[a-zA-Z0-9_]{1,15}", fullmatch=True), + domain=st.from_regex(r"[a-z]{2,10}\.[a-z]{2,4}", fullmatch=True), + ) + def test_mastodon_url_contains_username_and_domain( + self, user: str, domain: str + ) -> None: + username = f"@{user}@{domain}" + sn = SocialNetwork(network="Mastodon", username=username) + assert domain in sn.url + assert f"/@{user}" in sn.url + assert sn.url.startswith("https://") + @settings(deadline=None) @given(username=st.from_regex(r"\d{4}-\d{4}-\d{4}-\d{3}[\dX]", fullmatch=True)) def test_orcid_valid_format_accepted(self, username: str) -> None: