Skip to content

Commit 9347848

Browse files
derek73claude
andcommitted
Add property, contract, and benchmark test layers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c7e3fb0 commit 9347848

5 files changed

Lines changed: 207 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ dev = [
4444
"ruff (>=0.15)",
4545
"pytest-timeout>=2.4.0",
4646
"pytest-cov>=6",
47+
"hypothesis",
4748
]
4849

4950
[tool.mypy]

tests/v2/test_benchmark.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Perf smoke (core spec §7 tail): parse cost stays v1-comparable
2+
(microseconds per name). Deliberately generous bound -- guards against
3+
order-of-magnitude regressions, does not gate normal variance."""
4+
import time
5+
6+
from nameparser import parse
7+
8+
9+
def test_parse_thousand_names_under_a_second() -> None:
10+
parse("warm up the default parser cache")
11+
start = time.perf_counter()
12+
for i in range(1000):
13+
parse(f"Dr. Juan{i} de la Vega III")
14+
elapsed = time.perf_counter() - start
15+
assert elapsed < 1.0, f"1000 parses took {elapsed:.2f}s"

tests/v2/test_contracts.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Stable-string contract tests (core spec §7.4): every enum member and
2+
stable tag has a canonical triggering input, parametrized by iterating
3+
the registries -- a new member without an entry here fails loudly."""
4+
import pytest
5+
6+
from nameparser import Parser, Policy, parse
7+
from nameparser._policy import PatronymicRule
8+
from nameparser._types import STABLE_TAGS, AmbiguityKind
9+
10+
_AMBIGUITY_TRIGGERS: dict[AmbiguityKind, str | None] = {
11+
AmbiguityKind.PARTICLE_OR_GIVEN: "Van Johnson",
12+
AmbiguityKind.UNBALANCED_DELIMITER: 'Jon "Nick Smith',
13+
AmbiguityKind.COMMA_STRUCTURE: "Smith, John, Extra, Jr.",
14+
# no emitter yet -- arrives with locale-pack order detection (2.x)
15+
AmbiguityKind.ORDER: None,
16+
# no emitter yet -- arrives with suffix/nickname refinement (2.x)
17+
AmbiguityKind.SUFFIX_OR_NICKNAME: None,
18+
}
19+
20+
21+
@pytest.mark.parametrize("kind", [
22+
pytest.param(k, marks=pytest.mark.xfail(
23+
strict=True, reason=f"{k.value}: emitter not yet implemented"))
24+
if k in _AMBIGUITY_TRIGGERS and _AMBIGUITY_TRIGGERS[k] is None else k
25+
for k in AmbiguityKind
26+
])
27+
def test_every_ambiguity_kind_has_a_registered_trigger(
28+
kind: AmbiguityKind) -> None:
29+
assert kind in _AMBIGUITY_TRIGGERS, (
30+
f"new AmbiguityKind {kind.value!r} needs a canonical trigger "
31+
f"(or an explicit None with its planned emitter)")
32+
trigger = _AMBIGUITY_TRIGGERS[kind]
33+
assert trigger is not None # None triggers are strict-xfail marked
34+
kinds = {a.kind for a in parse(trigger).ambiguities}
35+
assert kind in kinds
36+
37+
38+
_PATRONYMIC_TRIGGERS: dict[PatronymicRule, tuple[str, str]] = {
39+
# rule -> (input, expected given)
40+
PatronymicRule.EAST_SLAVIC: ("Сидоров Иван Петрович", "Иван"),
41+
PatronymicRule.TURKIC: ("Mammadova Aygun Ali kizi", "Aygun"),
42+
}
43+
44+
45+
@pytest.mark.parametrize("rule", list(PatronymicRule))
46+
def test_every_patronymic_rule_has_a_trigger(rule: PatronymicRule) -> None:
47+
assert rule in _PATRONYMIC_TRIGGERS
48+
text, expected_given = _PATRONYMIC_TRIGGERS[rule]
49+
p = Parser(policy=Policy(patronymic_rules=frozenset({rule})))
50+
assert p.parse(text).given == expected_given
51+
52+
53+
_TAG_TRIGGERS: dict[str, tuple[str, str]] = {
54+
# tag -> (input, token text carrying the tag)
55+
"particle": ("Juan de la Vega", "de"),
56+
"conjunction": ("Mr. and Mrs. John Smith", "and"),
57+
"initial": ("John A. Smith", "A."),
58+
}
59+
60+
61+
@pytest.mark.parametrize("tag", sorted(STABLE_TAGS))
62+
def test_every_stable_tag_has_a_trigger(tag: str) -> None:
63+
assert tag in _TAG_TRIGGERS
64+
text, token_text = _TAG_TRIGGERS[tag]
65+
pn = parse(text)
66+
tagged = next(t for t in pn.tokens if t.text == token_text)
67+
assert tag in tagged.tags

tests/v2/test_properties.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Property layer (core spec §7.3). Hypothesis is a dev dependency only.
2+
3+
The alphabet is punctuation-heavy on purpose: plain st.text() spreads
4+
over all of Unicode, so commas, quotes, and delimiters almost never
5+
appear and the interesting planes go unexercised. derandomize=True
6+
keeps runs reproducible on shared CI runners -- this layer guards
7+
against regressions; exploratory fuzzing happened during review.
8+
"""
9+
from hypothesis import given, settings
10+
from hypothesis import strategies as st
11+
12+
from nameparser import parse
13+
14+
_ALPHABET = st.sampled_from(
15+
'abcdefgh ABC .,،,\'"()«»‏‏\U0001f600éñßЖ-')
16+
17+
18+
@given(st.text(alphabet=_ALPHABET, max_size=200))
19+
@settings(max_examples=300, deadline=None, derandomize=True)
20+
def test_parse_never_raises_on_str(text: str) -> None:
21+
parse(text)
22+
23+
24+
@given(st.text(alphabet=_ALPHABET, max_size=200))
25+
@settings(max_examples=300, deadline=None, derandomize=True)
26+
def test_provenance_for_parser_produced_names(text: str) -> None:
27+
pn = parse(text)
28+
for t in pn.tokens:
29+
assert t.span is not None
30+
assert t.text == pn.original[t.span.start:t.span.end]
31+
32+
33+
@given(st.text(alphabet=_ALPHABET, max_size=100))
34+
@settings(max_examples=200, deadline=None, derandomize=True)
35+
def test_capitalized_idempotent(text: str) -> None:
36+
once = parse(text).capitalized()
37+
assert once.capitalized() == once
38+
39+
40+
@given(st.text(alphabet=_ALPHABET, max_size=100))
41+
@settings(max_examples=200, deadline=None, derandomize=True)
42+
def test_render_reparse_reaches_fixpoint(text: str) -> None:
43+
# render/reparse legitimately takes several rounds to stabilize on
44+
# comma-heavy input (each round can re-segment); the invariant is
45+
# BOUNDED CONVERGENCE, not one-step idempotence
46+
s = str(parse(text))
47+
for _ in range(10):
48+
nxt = str(parse(s))
49+
if nxt == s:
50+
break
51+
s = nxt
52+
assert str(parse(s)) == s, f"no fixpoint within 10 rounds: {s!r}"

0 commit comments

Comments
 (0)