Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_

**Adding a flag-gated post-parse transform** (reorder/adjust) — add a `Constants` boolean (default `False`), implement a `handle_*()` method, and call it in `post_process()` after `handle_firstnames()` and before `handle_capitalization()`, gated on the flag. Default-off keeps existing parses byte-for-byte unchanged. Two shipped examples: `patronymic_name_order` gates both `handle_east_slavic_patronymic_name_order()` (#85) and `handle_turkic_patronymic_name_order()` (#185) — one flag driving two independent handlers, added in the same `post_process()` slot; `middle_name_as_last` gates `handle_middle_name_as_last()` (#133), which folds `middle_list` into `last_list`.

**Validating a new parsing rule** — before implementing, simulate it in a throwaway script against `TEST_NAMES` (plus a few target-language examples) to catch regressions/false-positives early. E.g. this surfaced the `last_base` `do`/`st`/`mc` empties and the patronymic `"David Michael Abramovich"` false-reorder. After the change, `tests/v2/test_snapshot.py` lists every corpus name it moved, grouped by changed field -- read them, and re-record with `uv run python tools/differential/snapshot.py --record` once every move is intended, so the PR diff of `snapshot.jsonl` shows the reviewer the same list. `uv run python tools/differential/cldr_agreement.py` reports how the tree's agreement with CLDR's stated name fields moved; `tests/v2/test_cldr_agreement.py` fails on any move, and `tools/differential/cldr_agreement.json` doubles as a measured list of names the parser and CLDR read differently.
**Validating a new parsing rule** — before implementing, simulate it in a throwaway script against `TEST_NAMES` (plus a few target-language examples) to catch regressions/false-positives early. E.g. this surfaced the `last_base` `do`/`st`/`mc` empties and the patronymic `"David Michael Abramovich"` false-reorder. After the change, `tests/v2/test_snapshot.py` lists every corpus name it moved, grouped by changed field -- read them, and re-record with `uv run python tools/differential/snapshot.py --record` once every move is intended, so the PR diff of `snapshot.jsonl` shows the reviewer the same list. `uv run python tools/differential/cldr_agreement.py` reports how the tree's agreement with CLDR's stated name fields moved; `tests/v2/test_cldr_agreement.py` fails on any move, and `tools/differential/cldr_agreement.json` doubles as a measured list of names the parser and CLDR read differently. A locale-pack change reads the second view the same command prints, per pack (`cldr_agreement_locales.json`, #672); its `JA` rows run only with the `ja` extra installed.

## Gotchas

Expand Down Expand Up @@ -331,7 +331,7 @@ Don't use the bare `python3 -m doctest <file>.rst` CLI (no `optionflags`) to che

A parse-behavior test can still be a disguised constant-content test if it only exercises one or two hardcoded entries from a large list (e.g. guarding a specific missing-comma typo in `SUFFIX_ACRONYMS`) — it documents that one instance without catching the same bug class elsewhere in the list. Prefer a test on the structural property itself, or skip a dedicated test for narrow, unrepeatable typo fixes.

**A guard that decides its own scope can stop guarding without failing.** Two shapes hit in #329, both in live code. `_POLICY_SHAPES` in `test_benchmark.py` had one entry, and pytest turns an **empty parametrize into a skip, not a failure**, so deleting it would have dissolved the quadratic guard into the skip count. And `test_facade_cases.py::_constants_for` decides which case rows it can express: its gate had been structurally unsatisfiable since the maiden-wins canonicalization landed, silently skipping seven rows, while an unenumerated `Policy` field would instead let a row run MISTRANSLATED. Neither could fail, because a skip is indistinguishable from "correctly declined". Assert the table is non-empty, give each shape a reachability probe, and tie any hand-written field enumeration to `dataclasses.fields(...)` the way `test_policy.py`'s `test_policy_patch_mirrors_policy_field_names` does. **A component that filters its own input needs a test on the filter, not only on what survives it.**
**A guard that decides its own scope can stop guarding without failing.** Two shapes hit in #329, both in live code. `_POLICY_SHAPES` in `test_benchmark.py` had one entry, and pytest turns an **empty parametrize into a skip, not a failure**, so deleting it would have dissolved the quadratic guard into the skip count. And `test_facade_cases.py::_constants_for` decides which case rows it can express: its gate had been structurally unsatisfiable since the maiden-wins canonicalization landed, silently skipping seven rows, while an unenumerated `Policy` field would instead let a row run MISTRANSLATED. Neither could fail, because a skip is indistinguishable from "correctly declined". Assert the table is non-empty, give each shape a reachability probe, and tie any hand-written field enumeration to `dataclasses.fields(...)` the way `test_policy.py`'s `test_policy_patch_mirrors_policy_field_names` does. **A component that filters its own input needs a test on the filter, not only on what survives it.** The same holds for a record split by scope: a check that reads each scope's share of a record never sees an entry that falls in no scope, so test that every entry has one — #672's per-pack CLDR ratchet missed record entries for a file dropped from `LOCALE_FILES`, and its re-record carried them over as if their pack were merely unmeasured.

**Deleting a test module: sort its tests by the code they exercise, not by their names.** A module named for a retired tool also holds tests of code that survives it. #664 deleted `test_ledger_guards.py` and rewrote `test_differential.py`, triaging both by test name, and review found five tests guarding live code in the discard pile: four of `build_issues_corpus.py`'s harvester, and the check that `compare.py` reports the entries it skips. For each test, grep its module-level handles (`load_tool("...")`, imports) and ask whether that code is also being deleted.

Expand Down
71 changes: 70 additions & 1 deletion tests/v2/test_cldr_agreement.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import pytest

from nameparser import parse
from nameparser import locales, parse

_TOOLS = Path(__file__).parents[2] / "tools" / "differential"

Expand Down Expand Up @@ -206,3 +206,72 @@ def test_an_unspaced_role_is_expected_in_text_order() -> None:
{"given": "セシリア", "given2": "ローズ", "surname": "ブラウン"},
"ブラウン・ローズ・セシリア")
assert agreement.same(roles["given_names"], "ローズ セシリア", "ブラウン・ローズ・セシリア")


# -- the locale view (#672) --------------------------------------------------

def test_every_locale_pack_is_measured_or_listed_as_having_no_cldr_file() -> None:
# A new pack must add its CLDR files to LOCALE_FILES or say it has
# none; otherwise it goes unmeasured by omission.
mapped = set(agreement.LOCALE_FILES.values())
assert not mapped & agreement.NO_CLDR
assert mapped | agreement.NO_CLDR == {code.upper() for code in locales.available()}


def test_every_mapped_cldr_file_has_renderings() -> None:
# A misspelled file in the table would measure nothing and pass.
files = {row["file"] for row in agreement.load_rows()}
assert set(agreement.LOCALE_FILES) <= files


def test_the_locale_view_parses_with_the_pack() -> None:
# A view that fell back to the default parser would record the
# default's disagreements and still pass its own ratchet. 王小明 is
# one unspaced Han token: only ZH segments it.
row = {"file": "zh", "label": "nativeG", "order": "surnameFirst",
"text": "王小明", "shape": 4, "fields": {"surname": "王", "given": "小明"}}
assert agreement.disagreements([row], locale=True) == {}
assert agreement.disagreements([row]) != {}


def test_a_pack_parser_keeps_the_family_first_order_of_its_shape() -> None:
# Latin text: no script decides the order, so only the FAMILY_FIRST
# base the pack is applied over can read Ivanov as the family.
name = agreement.parse_rendering("Ivanov Ivan", 4, "RU")
assert (name.family, name.given) == ("Ivanov", "Ivan")


def test_a_recorded_entry_whose_file_left_the_table_is_reported_gone() -> None:
# No pack's check reads such an entry, so without this it would sit
# in the record unguarded -- and survive every re-record.
[message] = agreement.unmapped({"zh/nativeG/givenFirst": {}, "xx/nativeG/sorting": {}})
assert message.startswith("gone: xx/nativeG/sorting")
assert agreement.unmapped(agreement.read_record(agreement.LOCALE_RECORD)) == []


def test_a_re_record_drops_entries_whose_file_left_the_table(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# The carry-over that keeps an unmeasured pack's entries must not
# keep an unmapped file's too.
record = tmp_path / "locales.json"
agreement.write_record({"xx/nativeG/sorting": {"text": "X"}}, record)
monkeypatch.setattr(agreement, "LOCALE_RECORD", record)
monkeypatch.setattr(agreement, "RECORD", tmp_path / "default.json")
assert agreement.main(["--record"]) == 0
assert "xx/nativeG/sorting" not in agreement.read_record(record)


@pytest.mark.parametrize("pack", [
pytest.param(pack, marks=pytest.mark.skipif(
not agreement.pack_available(pack),
reason=f"needs {agreement._EXTRAS.get(pack)}; CI's ja-extra job runs it"))
for pack in sorted(set(agreement.LOCALE_FILES.values()))])
def test_each_pack_still_agrees_with_cldr_where_it_did(pack: str) -> None:
problems = agreement.locale_changes(
agreement.load_rows(), pack, agreement.read_record(agreement.LOCALE_RECORD))
assert not problems, "\n".join([
*problems[:40],
f"{len(problems)} change(s) against "
f"tools/differential/cldr_agreement_locales.json for {pack}.",
f"If every one is intended, re-record with `{agreement.RECORD_COMMAND}` "
"and say why in the commit."])
4 changes: 4 additions & 0 deletions tools/differential/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,4 +397,8 @@ data can.

and say why in the commit. Run it without `--record` for per-role agreement counts. Nothing is special-cased: an unnatural rendering or a modelling difference (CLDR's `さん` title is a suffix here) simply stays a recorded disagreement, which makes the record a measured worklist as well as a guard.

A second view measures the locale packs (#672). `LOCALE_FILES` in `cldr_agreement.py` maps each CLDR file to the pack for its language (`zh`, `zh_Hant`, `yue`, `yue_Hans` → `ZH`; `ja` → `JA`; `ru` → `RU`; `tr`, `az` → `TR_AZ`); those renderings are parsed with `parser_for(pack)`, over the same `FAMILY_FIRST` base where CLDR's order is surnameFirst, and their disagreements go to `cldr_agreement_locales.json`, ratcheted per pack by the same test. The summary prints, per pack, how many of its renderings agree in every role under the default parser and under the pack, so a pack PR shows what it moved on its own language. A new pack adds its files to `LOCALE_FILES`, or its name to `NO_CLDR`; the test fails on a pack in neither. The modelling differences stay recorded here too: a pack is not meant to agree with CLDR where the models differ by design.

`--record` rewrites both records. `JA` needs the `ja` extra (`namedivider-python`), which only CI's `ja-extra` job installs: without it the `JA` ratchet skips, the summary says `not measured`, and `--record` keeps the recorded `ja` entries unchanged. Re-record with the extra installed (`uv sync --group dev --extra ja`) when a change moves Japanese.

`_CORPUS_NAMES` in `tests/v2/_differential_fixtures.py` leaves the file out (2026-10-12): the tests reading it carry counts measured over the names the other corpora held. `differential_corpus()` in `tests/v2/conftest.py`, which feeds the property tests, includes it since #652, so the decomposed-twin test walks its names.
Loading
Loading