mirror of
https://github.com/rendercv/rendercv.git
synced 2026-04-17 13:33:53 -04:00
- 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
227 lines
7.8 KiB
Python
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()
|