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
This commit is contained in:
Sina Atalay
2026-03-25 04:18:20 +03:00
parent 141ea9fe92
commit 2df9d2262b
4 changed files with 104 additions and 3 deletions

View File

@@ -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(

View File

@@ -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):

View File

@@ -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")

View File

@@ -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: