Files
rendercv/tests/renderer/templater/test_string_processor.py
Sina Atalay 6956f39835 Address code review feedback on Hypothesis and classic_theme changes
- Update hypothesis to latest version (>=6.151.9)
- Remove pythonpath pytest config (was only needed for tests.strategies)
- Consolidate classic_theme.py into single file with all design models
- Move Hypothesis strategies from strategies.py into their test files
- Add noqa: ARG001 to unused yaml_field_override CLI parameter
- Fix lint and type errors across the codebase
2026-03-25 16:28:44 +03:00

227 lines
7.8 KiB
Python

import pytest
from hypothesis import assume, given, settings
from hypothesis import strategies as st
from rendercv.exception import RenderCVInternalError
from rendercv.renderer.templater.string_processor import (
build_keyword_matcher_pattern,
clean_url,
make_keywords_bold,
substitute_placeholders,
)
keyword_lists = st.lists(
st.text(
alphabet=st.characters(categories=("L", "N", "Zs")),
min_size=1,
max_size=30,
).filter(lambda s: s.strip()),
min_size=0,
max_size=10,
)
@st.composite
def placeholder_dicts(draw: st.DrawFn) -> dict[str, str]:
"""Generate placeholder dicts with UPPERCASE keys."""
keys = draw(
st.lists(
st.from_regex(r"[A-Z]{1,15}", fullmatch=True),
min_size=0,
max_size=5,
unique=True,
)
)
values = draw(
st.lists(
st.text(
alphabet=st.characters(categories=("L", "N", "Zs")),
min_size=0,
max_size=20,
),
min_size=len(keys),
max_size=len(keys),
)
)
return dict(zip(keys, values, strict=True))
@st.composite
def urls(draw: st.DrawFn) -> str:
"""Generate realistic URL strings with http/https protocol."""
protocol = draw(st.sampled_from(["https://", "http://"]))
domain = draw(st.from_regex(r"[a-z]{2,10}\.[a-z]{2,4}", fullmatch=True))
path = draw(st.from_regex(r"[a-z0-9_-]{0,20}", fullmatch=True))
trailing_slash = draw(st.sampled_from(["", "/"]))
if path:
return f"{protocol}{domain}/{path}{trailing_slash}"
return f"{protocol}{domain}{trailing_slash}"
class TestMakeKeywordsBold:
@pytest.mark.parametrize(
("text", "keywords", "expected"),
[
(
"This is a test string with some keywords.",
["test", "keywords"],
"This is a **test** string with some **keywords**.",
),
("No matches here.", ["test", "keywords"], "No matches here."),
("Python and python", ["Python"], "**Python** and python"),
("", ["test"], ""),
("Test word", [], "Test word"),
("I can read well", ["re"], "I can read well"),
# Issue #706: keywords with non-word boundary characters
(
"Tech stack: Python, Java",
["Tech stack:"],
"**Tech stack:** Python, Java",
),
("Use C++ and C#", ["C++", "C#"], "Use **C++** and **C#**"),
],
)
def test_returns_expected_output(self, text, keywords, expected):
assert make_keywords_bold(text, keywords) == expected
@settings(deadline=None)
@given(text=st.text(max_size=100))
def test_empty_keywords_is_identity(self, text: str) -> None:
assert make_keywords_bold(text, []) == text
@settings(deadline=None)
@given(text=st.text(max_size=100), keywords=keyword_lists)
def test_never_produces_double_bolding(
self, text: str, keywords: list[str]
) -> None:
result = make_keywords_bold(text, keywords)
assert "****" not in result
@settings(deadline=None)
@given(text=st.text(max_size=100), keywords=keyword_lists)
def test_output_length_never_shrinks(self, text: str, keywords: list[str]) -> None:
result = make_keywords_bold(text, keywords)
assert len(result) >= len(text)
@settings(deadline=None)
@given(
keyword=st.text(
alphabet=st.characters(categories=("Lu",)),
min_size=2,
max_size=10,
)
)
def test_is_case_sensitive(self, keyword: str) -> None:
text = f"before {keyword.lower()} after"
result = make_keywords_bold(text, [keyword])
assert f"**{keyword.lower()}**" not in result
@settings(deadline=None)
@given(
keyword=st.text(min_size=1, max_size=20).filter(lambda s: s.strip()),
)
def test_keyword_in_text_always_gets_bolded(self, keyword: str) -> None:
text = f"before {keyword} after"
result = make_keywords_bold(text, [keyword])
assert f"**{keyword}**" in result
class TestSubstitutePlaceholders:
@pytest.mark.parametrize(
("string", "placeholders", "expected_string"),
[
("Hello, NAME!", {"NAME": "World"}, "Hello, World!"),
("Hello, NAME!", {"NAME": None}, "Hello, !"),
("No placeholders here.", {}, "No placeholders here."),
],
)
def test_returns_expected_output(self, string, placeholders, expected_string):
assert substitute_placeholders(string, placeholders) == expected_string
@settings(deadline=None)
@given(text=st.text(max_size=100))
def test_empty_placeholders_preserves_content(self, text: str) -> None:
assert substitute_placeholders(text, {}) == text
@settings(deadline=None)
@given(placeholders=placeholder_dicts()) # ty: ignore[missing-argument]
def test_all_keys_absent_from_output(self, placeholders: dict[str, str]) -> None:
assume(placeholders)
keys = set(placeholders.keys())
assume(not any(k in v for k in keys for v in placeholders.values()))
template = " ".join(placeholders.keys())
result = substitute_placeholders(template, placeholders)
for key in placeholders:
assert key not in result
class TestCleanUrl:
@pytest.mark.parametrize(
("url", "expected_clean_url"),
[
("https://example.com", "example.com"),
("https://example.com/", "example.com"),
("https://example.com/test", "example.com/test"),
("https://example.com/test/", "example.com/test"),
("https://www.example.com/test/", "www.example.com/test"),
],
)
def test_returns_expected_output(self, url, expected_clean_url):
assert clean_url(url) == expected_clean_url
@settings(deadline=None)
@given(url=urls()) # ty: ignore[missing-argument]
def test_is_idempotent(self, url: str) -> None:
assert clean_url(clean_url(url)) == clean_url(url)
@settings(deadline=None)
@given(url=urls()) # ty: ignore[missing-argument]
def test_removes_protocol(self, url: str) -> None:
result = clean_url(url)
assert "https://" not in result
assert "http://" not in result
@settings(deadline=None)
@given(url=urls()) # ty: ignore[missing-argument]
def test_removes_trailing_slashes(self, url: str) -> None:
result = clean_url(url)
if result:
assert not result.endswith("/")
class TestBuildKeywordMatcherPattern:
def test_raises_error_for_empty_keywords(self):
with pytest.raises(RenderCVInternalError) as exc_info:
build_keyword_matcher_pattern(frozenset())
assert "Keywords cannot be empty" in str(exc_info.value)
@settings(deadline=None)
@given(
keywords=st.frozensets(
st.text(min_size=1, max_size=20).filter(lambda s: s.strip()),
min_size=1,
max_size=10,
)
)
def test_matches_all_input_keywords(self, keywords: frozenset[str]) -> None:
pattern = build_keyword_matcher_pattern(keywords)
for keyword in keywords:
assert pattern.search(keyword) is not None
build_keyword_matcher_pattern.cache_clear()
@settings(deadline=None)
@given(
base=st.from_regex(r"[a-zA-Z]{3,8}", fullmatch=True),
extension=st.from_regex(r"[a-zA-Z]{1,5}", fullmatch=True),
)
def test_matches_longest_keyword_first(self, base: str, extension: str) -> None:
short = base
long = base + extension
pattern = build_keyword_matcher_pattern(frozenset({short, long}))
match = pattern.search(long)
assert match is not None
assert match.group(0) == long
build_keyword_matcher_pattern.cache_clear()