Skip to content

Commit ddd0416

Browse files
derek73claude
andcommitted
Add the review's test-coverage batch (T1-T4, T6)
- T1: three-piece FAMILY_FIRST_GIVEN_LAST pins given-from-the-END semantics (not a rotation of FAMILY_FIRST) - T2: reverse-coverage property -- every input char lies in a token span, a masked delimited span, or is individually ignorable; no character silently vanishes - T3: case row for post-comma non-suffix extras ('Smith, John, Extra, Jr.' -> suffix 'Extra, Jr.' + COMMA_STRUCTURE; v1 parity pinned live) - T4: multiple unbalanced delimiters are each reported; the scan does not stop at the first unmatched opener - T6: digits join the Hypothesis stress alphabet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e7ccd67 commit ddd0416

3 files changed

Lines changed: 57 additions & 3 deletions

File tree

tests/v2/cases.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ class Case:
4040
{"given": "John", "family": "Smith"}),
4141
Case("suffix_comma", "John Smith, PhD",
4242
{"given": "John", "family": "Smith", "suffix": "PhD"}),
43+
Case("comma_extras_become_suffixes", "Smith, John, Extra, Jr.",
44+
{"given": "John", "family": "Smith", "suffix": "Extra, Jr."},
45+
ambiguities=("comma-structure",),
46+
notes="post-comma segments land in suffix even when not "
47+
"suffix-shaped; the ambiguity flags the guess (v1 "
48+
"parity, pinned live 2026-07-13)"),
4349
Case("delavega", "Dr. Juan de la Vega III",
4450
{"title": "Dr.", "given": "Juan", "family": "de la Vega",
4551
"suffix": "III"}),

tests/v2/test_parser.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import pytest
44

55
from nameparser import Lexicon, Locale, Parser, Policy, PolicyPatch, parse, parser_for
6-
from nameparser._policy import FAMILY_FIRST, PatronymicRule
6+
from nameparser._policy import (
7+
FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, PatronymicRule,
8+
)
79
from nameparser._types import AmbiguityKind
810

911

@@ -128,6 +130,26 @@ def test_matches_component_wise_case_insensitive() -> None:
128130
pn.matches(42) # type: ignore[arg-type]
129131

130132

133+
def test_family_first_given_last_places_middle_between() -> None:
134+
# T1: the three-piece FAMILY_FIRST_GIVEN_LAST assignment -- family
135+
# from the front, given from the END, middle between (not a rotation
136+
# of FAMILY_FIRST)
137+
p = Parser(policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST))
138+
pn = p.parse("Zeng Xiao Long")
139+
assert (pn.family, pn.middle, pn.given) == ("Zeng", "Xiao", "Long")
140+
141+
142+
def test_multiple_unbalanced_delimiters_each_reported() -> None:
143+
# T4: the extract scan continues past the first unmatched opener;
144+
# each one is reported and treated as literal text
145+
pn = parse('John "Jack (Smith')
146+
unbalanced = [a for a in pn.ambiguities
147+
if a.kind is AmbiguityKind.UNBALANCED_DELIMITER]
148+
assert len(unbalanced) == 2
149+
assert pn.given == "John" and pn.family == "(Smith"
150+
assert not pn.nickname
151+
152+
131153
def test_matches_accepts_explicit_parser() -> None:
132154
family_first = Parser(policy=Policy(name_order=FAMILY_FIRST))
133155
pn = family_first.parse("Yamada Taro")

tests/v2/test_properties.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
from hypothesis import given, settings
1010
from hypothesis import strategies as st
1111

12-
from nameparser import parse
12+
from nameparser import Lexicon, Policy, parse
13+
from nameparser._pipeline import run
14+
from nameparser._pipeline._state import ParseState
1315

1416
_ALPHABET = st.sampled_from(
15-
'abcdefgh ABC .,،,\'"()«»‏‏\U0001f600éñßЖ-')
17+
'abcdefgh ABC 12 .,،,\'"()«»‏‏\U0001f600éñßЖ-')
1618

1719

1820
@given(st.text(alphabet=_ALPHABET, max_size=200))
@@ -50,3 +52,27 @@ def test_render_reparse_reaches_fixpoint(text: str) -> None:
5052
break
5153
s = nxt
5254
assert str(parse(s)) == s, f"no fixpoint within 10 rounds: {s!r}"
55+
56+
57+
@given(st.text(alphabet=_ALPHABET, max_size=100))
58+
@settings(max_examples=300, deadline=None, derandomize=True)
59+
def test_every_original_char_is_accounted_for(text: str) -> None:
60+
# Reverse coverage (the dual of provenance): no character of the
61+
# input silently vanishes. Every char lies in a token span, a
62+
# masked delimited span, or is individually ignorable -- whitespace,
63+
# a structural comma, or a char the strip options remove. Checked on
64+
# the pre-assembly state because dropped/extracted tokens keep their
65+
# spans there.
66+
state = run(ParseState(original=text, lexicon=Lexicon.default(),
67+
policy=Policy()))
68+
covered: set[int] = set()
69+
for tok in state.tokens:
70+
covered.update(range(tok.span.start, tok.span.end))
71+
for span in state.masked:
72+
covered.update(range(span.start, span.end))
73+
ignorable = {",", "،", ",", "\U0001f600", "‏"}
74+
for i, ch in enumerate(text):
75+
if i in covered or ch.isspace() or ch in ignorable:
76+
continue
77+
raise AssertionError(
78+
f"char {ch!r} at {i} in {text!r} is unaccounted for")

0 commit comments

Comments
 (0)