"""Property-based tests for skill name validation.
Uses Hypothesis to generate test cases that verify:
1. Valid name patterns always pass pattern validation
2. Invalid name patterns always fail
3. Category normalization is idempotent
"""
import string
from hypothesis import assume, given, settings
from hypothesis import strategies as st
from skillport.modules.skills.internal.validation import (
NAME_MAX_LENGTH,
_validate_name_chars,
validate_skill_record,
)
# Strategy for valid skill names: lowercase letters, digits, hyphens
valid_name_chars = st.sampled_from(string.ascii_lowercase + string.digits + "-")
valid_name_strategy = st.text(
alphabet=valid_name_chars,
min_size=1,
max_size=NAME_MAX_LENGTH,
).filter(lambda s: _validate_name_chars(s) and len(s) > 0)
# Strategy for names with invalid characters
invalid_char_strategy = st.sampled_from(
string.ascii_uppercase # uppercase
+ "_" # underscore
+ " " # space
+ "." # dot
+ "/" # slash
+ "@#$%^&*()+=[]{}|\\:;\"'<>,?" # special chars
)
class TestNamePatternProperty:
"""Property-based tests for name character validation."""
@given(name=valid_name_strategy)
@settings(max_examples=200)
def test_valid_names_pass_validation(self, name: str):
"""Any string of lowercase letters, digits, hyphens should pass validation."""
assert _validate_name_chars(name), f"'{name}' should pass validation"
@given(
prefix=st.text(alphabet=string.ascii_lowercase, min_size=0, max_size=10),
invalid_char=invalid_char_strategy,
suffix=st.text(alphabet=string.ascii_lowercase, min_size=0, max_size=10),
)
@settings(max_examples=200)
def test_invalid_chars_fail_validation(self, prefix: str, invalid_char: str, suffix: str):
"""Names with invalid characters should fail validation."""
name = prefix + invalid_char + suffix
assume(len(name) > 0)
# The validation should fail because of invalid char
assert not _validate_name_chars(name), f"'{name}' should NOT pass validation"
class TestNameValidationProperty:
"""Property-based tests for validate_skill_record name rules."""
@given(name=valid_name_strategy)
@settings(max_examples=100)
def test_valid_names_pass_validation(self, name: str):
"""Valid names should pass pattern validation."""
assume(len(name) <= NAME_MAX_LENGTH)
issues = validate_skill_record(
{
"name": name,
"description": "test description",
"path": f"/skills/{name}", # path matches name
}
)
# Should have no pattern-related fatal issues
pattern_issues = [
i for i in issues if i.severity == "fatal" and "invalid" in i.message.lower()
]
assert len(pattern_issues) == 0, f"'{name}' should pass pattern validation"
@given(
base=st.text(alphabet=string.ascii_lowercase, min_size=1, max_size=20),
invalid_char=invalid_char_strategy,
)
@settings(max_examples=100)
def test_invalid_chars_fail_validation(self, base: str, invalid_char: str):
"""Names with invalid characters should fail validation."""
name = base + invalid_char
assume(len(name) <= NAME_MAX_LENGTH)
issues = validate_skill_record(
{
"name": name,
"description": "test",
"path": f"/skills/{name}",
}
)
# Should have pattern-related fatal issue
pattern_issues = [
i for i in issues if i.severity == "fatal" and "invalid" in i.message.lower()
]
assert len(pattern_issues) > 0, f"'{name}' should fail pattern validation"
@given(length=st.integers(min_value=NAME_MAX_LENGTH + 1, max_value=NAME_MAX_LENGTH + 50))
@settings(max_examples=50)
def test_long_names_fail_validation(self, length: int):
"""Names exceeding max length should fail validation."""
name = "a" * length
issues = validate_skill_record(
{
"name": name,
"description": "test",
"path": f"/skills/{name}",
}
)
# Should have length-related fatal issue
length_issues = [
i for i in issues if i.severity == "fatal" and "chars" in i.message.lower()
]
assert len(length_issues) > 0, f"Name of length {length} should fail"
class TestCategoryNormalizationProperty:
"""Property-based tests for category normalization."""
@given(category=st.text(min_size=0, max_size=50))
@settings(max_examples=200)
def test_normalization_is_idempotent(self, category: str):
"""Normalizing a category twice should yield same result."""
# Simulate normalization: strip + lower
normalized_once = category.strip().lower()
normalized_twice = normalized_once.strip().lower()
assert normalized_once == normalized_twice, (
f"Normalization should be idempotent: '{category}'"
)
@given(category=st.text(min_size=0, max_size=50))
@settings(max_examples=200)
def test_normalization_removes_leading_trailing_whitespace(self, category: str):
"""Normalization should remove leading/trailing whitespace."""
normalized = category.strip().lower()
assert normalized == normalized.strip(), (
"Normalized category should have no leading/trailing whitespace"
)
@given(category=st.text(alphabet=string.ascii_letters, min_size=1, max_size=20))
@settings(max_examples=100)
def test_normalization_is_lowercase(self, category: str):
"""Normalization should produce lowercase output."""
normalized = category.strip().lower()
assert normalized == normalized.lower(), "Normalized category should be lowercase"
@given(
leading=st.text(alphabet=" \t\n", min_size=0, max_size=5),
content=st.text(alphabet=string.ascii_lowercase, min_size=1, max_size=10),
trailing=st.text(alphabet=" \t\n", min_size=0, max_size=5),
)
@settings(max_examples=100)
def test_whitespace_variations_normalize_same(self, leading: str, content: str, trailing: str):
"""Different whitespace variations should normalize to same value."""
variant1 = leading + content + trailing
variant2 = content
assert variant1.strip().lower() == variant2.strip().lower(), (
"Whitespace variations should normalize to same value"
)