Search by

rasuvaeff / property-testing-names

rasuvaeff

Localised person-name generators (en, ru) for rasuvaeff/property-testing-core: first, last, patronymic and gender-consistent full names

Package info

github.com/rasuvaeff/property-testing-names

pkg:composer/rasuvaeff/property-testing-names

Statistics

Installs: 26

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-09-20 09:17 UTC

README

Latest Stable Version Total Downloads Build Static analysis Psalm level PHP License

Русская версия

Person-name generators for the property-testing engine: given names, surnames and patronymics for en and ru, with grammatical gender kept consistent across the parts of one name. Names shrink toward the shortest entries of their dataset, so a counterexample minimises into a plain name instead of turning into random text.

Using an AI coding assistant? llms.txt contains a compact API reference you can share with the model.

Why a separate package

The core Gen facade holds format-derived primitives — Gen::email(), Gen::uuid(), Gen::ipv4() — things a formula produces. Name lists are versioned data with their own update policy, so they live here, behind their own Names:: facade. There is no Gen::name(): a core method that silently depends on an optional data package would pass composer-require-checker in your project and fail at runtime.

Package Use it when
rasuvaeff/property-testing-core The engine itself: arbitraries, shrinking, corpus
rasuvaeff/property-testing-testo You test with Testo — the #[Property] attribute
rasuvaeff/property-testing-phpunit You test with PHPUnit — the forAll()->check() trait
rasuvaeff/property-testing-names (this package) Your inputs are people: forms, profiles, auth, validators, reports

Requirements

  • PHP 8.3 – 8.5
  • ext-mbstring
  • rasuvaeff/property-testing-core ^1.0

Installation

composer require --dev rasuvaeff/property-testing-names

Usage

Every factory returns an ArbitraryInterface — a recipe for values, not a value. The comments below show what each one generates:

use Rasuvaeff\PropertyTesting\Names\Names;
use Rasuvaeff\PropertyTesting\Names\Gender;

$firstNames = Names::first();                     // 'Ian', 'Emma', …
$surnames = Names::last(locale: 'ru');            // 'Попов', 'Иванова', …
$femaleNames = Names::first('ru', Gender::Female); // 'Мария', 'Ольга', …
$patronymics = Names::middle('ru');               // 'Ивановна', 'Петрович', …
$displayNames = Names::full('ru', middle: true);  // 'Иван Иванович Иванов'
$people = Names::person('ru', middle: true);      // PersonName objects

The runner draws from them; nothing is generated until it does. To see values while debugging, sample explicitly:

Gen::sample($displayNames, 3, 6);   // fixed seed → the same three names every time

Inside a property test the factories go into the generators method, exactly like the core ones:

use Rasuvaeff\PropertyTesting\ArbitraryInterface;
use Rasuvaeff\PropertyTesting\Names\Names;
use Rasuvaeff\PropertyTesting\Property;
use Testo\Assert;

#[Property(runs: 300)]
public function displayNameFitsTheColumn(string $first, string $last): void
{
    Assert::true(mb_strlen((new Profile($first, $last))->displayName()) <= 255);
}

/** @return array<string, ArbitraryInterface> */
public static function displayNameFitsTheColumnGenerators(): array
{
    return [
        'first' => Names::first(locale: 'ru'),
        'last' => Names::last(locale: 'ru'),
    ];
}

API

Factory Returns Notes
Names::first(string $locale = 'en', ?Gender $gender = null) ArbitraryInterface<non-empty-string> Without a gender the male and female lists are merged
Names::last(string $locale = 'en', ?Gender $gender = null) ArbitraryInterface<non-empty-string> Inflected per gender where the locale requires it
Names::middle(string $locale, ?Gender $gender = null) ArbitraryInterface<non-empty-string> Patronymics; the locale is required because the dataset is not universal
Names::full(string $locale = 'en', ?Gender $gender = null, bool $middle = false) ArbitraryInterface<non-empty-string> First [Middle] Last, rendered from person()
Names::person(string $locale = 'en', ?Gender $gender = null, bool $middle = false) ArbitraryInterface<PersonName> The parts, kept together
Names::locales() non-empty-list<non-empty-string> The locale codes the factories above accept — not an arbitrary

PersonName is a final readonly class with $first, $middle (nullable), $last, $gender and three display forms. It is Stringable(string) $p is full() — and json_encode() renders it as plain data, because Gender is a string-backed enum ('male', 'female'). The constructor rejects a part that is empty or whitespace-only with InvalidArgumentException naming the part (First name must not be empty); $middle is null for "no middle name", never ''.

Method en ru
full() John Smith Иван Иванович Иванов
initialLast() J. Smith И. Иванов
lastInitials() Smith J. Иванов И. И.

Any other form is one Gen::map() away:

Gen::map(Names::person(), static fn (PersonName $p): string => $p->last . ', ' . $p->first);

Objects that hold a name

Gen::forClass(PersonName::class) does not use the datasets: it reads the constructor's non-empty-string annotations and builds names from arbitrary text — including whitespace-only strings, which the constructor refuses. When a class under test holds a PersonName, pass Names::person() as the override for that parameter so the value comes from the lists and shrinks toward a plain name:

Gen::forClass(Profile::class, ['name' => Names::person('ru', middle: true)]);

Locales

Locale Given names Surnames Patronymics
en 50 male + 50 female 100, shared by both genders
ru 50 male + 50 female 50 + 50, index-aligned pairs 40 + 40, the same stems in both lists

Omitting the gender draws from the two lists merged, and the merge drops what they share: Names::last('en') picks from 100 surnames, not from the same 100 listed twice. Uniform picking makes a duplicated entry a doubled probability.

An unregistered locale raises InvalidArgumentException when the arbitrary is built, not when it first generates a value; the same is true for asking en for middle names. Locale tags are matched literally: 'EN', 'en-US' and 'en ' are all unknown.

Names::locales() returns the registered codes, so a matrix over the supported locales does not have to hardcode them and keeps covering one added later:

foreach (Names::locales() as $locale) {
    // one property run per supported locale
}

There is no registration API by design: a mutable registry would make generated data depend on test execution order.

Gender consistency

Names::first() and Names::last() are independent draws — combining them by hand can produce Мария Иванов, which no Russian form renders. When the parts must agree, draw them together:

$person = Names::person('ru', middle: true);   // Мария Ивановна Иванова

Gender has two cases, Male ('male') and Female ('female'), declared in that order because shrinking walks toward the first case.

Security

The lists are synthetic test data: they are not a register of real people and make no claim of cultural completeness. Generated values are printable UTF-8 without control characters, so they are safe to embed in test reports and failure messages — but they are still generated input, and code under test should validate them like any other user data.

Dataset changes alter the values a given seed produces, so they ship as minor releases and are listed in CHANGELOG.md.

That matters for the regression corpus. A counterexample whose argument is a PersonName is stored as a seed entry — the core codec has no data representation for objects other than enums — and a dataset minor makes that seed replay a different person, so the recorded failure is silently no longer the one that was found. A counterexample made of strings (Names::full(), first(), last()) is stored as values and replays through any release; if the corpus has to survive upgrades, write the property over those. For the same reason CounterExample::toExamplesCode() throws on a PersonName argument.

Examples

Runnable scripts live in examples/.

Development

make build          # validate + normalize + require-checker + cs + psalm + test
make cs-fix
make psalm
make test
make test-coverage
make mutation
make release-check

No PHP on the host is required — every target runs in the composer:2 Docker image.

License

BSD-3-Clause. See LICENSE.md.