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
12 changes: 10 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
# Changelog

## Unreleased

## 1.1.0 — 2026-10-02

- **Added:** generators written on the closure's parameters with core's
`#[Generate]` (#61). Without `auto()`, the `forAll()` map and the
attributes together have to cover every parameter — the map may be empty;
under `auto()` the attributes sit between the map and the `@param` type.
Requires `rasuvaeff/property-testing-core` `^1.2`.
- **Changed:** without `auto()`, a parameter neither the map nor `#[Generate]`
covers is refused naming the function and the parameter, instead of the
engine's `No generator for parameter "x"`.
- **Docs:** the READMEs and `llms.txt` now state what follows from driving the
engine through a single PHPUnit test method: `setUp()`/`tearDown()` and
`#[Before]`/`#[After]` run once per property rather than per run, test-double
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,38 @@ Rules worth knowing — verbatim the Testo adapter's (`#[Property(auto: true)]`)
environment dials the suite, while `auto()` changes what one property's
arguments mean.

### Generators on the parameter (`#[Generate]`)

A parameter can carry its generator itself — core's `#[Generate]`
(`rasuvaeff/property-testing-core` 1.2):

```php
use Rasuvaeff\PropertyTesting\Arbitrary\IntArbitrary;
use Rasuvaeff\PropertyTesting\Generate;

$this->forAll()->check(static function (
#[Generate(new IntArbitrary(0, 10_000))]
int $base,
#[Generate([Generators::class, 'attempts'])]
int $attempt,
): void {
// ...
});
```

- It works **without `auto`**: the `forAll()` map can be empty, the attributes
alone are enough — as long as they cover every parameter. The map and the
attributes may also split the parameters between them. A parameter that
neither covers is refused by name; nothing is derived from the types
unless `auto()` says so.
- Under `auto()` it is read after the `forAll()` map and before the `@param`
type and the native type.
- The argument is an arbitrary built with `new`, or a reference to a static
factory returning one (`'method'`, `'Class::method'`,
`[Class::class, 'method']`, an invokable) — the way to use `Gen::map()`,
`Gen::email()`, `Gen::regex()` and other generators built with a closure.
What `new` can express is listed in core's README.

### Naming a property (`id()`)

`id()` names the property:
Expand Down
32 changes: 32 additions & 0 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,38 @@ $this->forAll(['multiplier' => Gen::floatBetween(1.0, 4.0)])
- Переменной окружения `PROPERTY_AUTO` нет намеренно: окружение крутит сьют,
а `auto()` меняет смысл аргументов конкретной property.

### Генераторы на параметре (`#[Generate]`)

Параметр может нести генератор сам — атрибут `#[Generate]` из ядра
(`rasuvaeff/property-testing-core` 1.2):

```php
use Rasuvaeff\PropertyTesting\Arbitrary\IntArbitrary;
use Rasuvaeff\PropertyTesting\Generate;

$this->forAll()->check(static function (
#[Generate(new IntArbitrary(0, 10_000))]
int $base,
#[Generate([Generators::class, 'attempts'])]
int $attempt,
): void {
// ...
});
```

- Работает **без `auto`**: карта `forAll()` может быть пустой, достаточно
одних атрибутов — при условии, что они покрывают каждый параметр. Карта и
атрибуты могут и поделить параметры между собой. Параметр, не покрытый ни тем, ни другим, —
отказ с его именем; из типов ничего не выводится, пока не сказано
`auto()`.
- Под `auto()` атрибут читается после карты `forAll()` и до `@param`-типа и
нативного типа.
- Аргумент — arbitrary, построенный через `new`, или ссылка на статическую
фабрику, которая его возвращает (`'method'`, `'Class::method'`,
`[Class::class, 'method']`, invokable), — так используются `Gen::map()`,
`Gen::email()`, `Gen::regex()` и другие генераторы на замыканиях. Что
выражается через `new` — в README ядра.

### Имя property (`id()`)

`id()` именует property:
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"require": {
"php": "8.3 - 8.5",
"phpunit/phpunit": "^11.5 || ^12.0 || ^13.0",
"rasuvaeff/property-testing-core": "^1.0"
"rasuvaeff/property-testing-core": "^1.2"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.51",
Expand Down
5 changes: 5 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ fully-typed closure needs no map. A map key that is not a parameter is an
error, with auto as without. Strictly opt-in, never the default (bare int/float derive their
full native domain). No PROPERTY_AUTO env variable, deliberately.

`#[Generate(...)]` on a closure parameter (core 1.2) holds its generator: an arbitrary built
with `new`, or a static factory reference ('Class::method', [Class::class, 'method'],
invokable). Works WITHOUT auto(): the forAll() map plus the attributes must cover every
parameter, an uncovered one throws by name. Under auto(): map > #[Generate] > @param > native.

### Example

```php
Expand Down
15 changes: 7 additions & 8 deletions src/PhpUnit/PropertyCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@
*/
public function listeners(PropertyListener ...$listeners): self
{
$this->listeners = array_values($listeners);

Check warning on line 436 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "UnwrapArrayValues": @@ @@ */ public function listeners(PropertyListener ...$listeners): self { - $this->listeners = array_values($listeners); + $this->listeners = $listeners; return $this; }

return $this;
}
Expand Down Expand Up @@ -523,7 +523,7 @@
exhaustive: EnvironmentOverrides::flag(getenv('PROPERTY_EXHAUSTIVE')) ?? $this->exhaustive ?? false,
exhaustiveBudget: $this->exhaustiveBudget,
flakyReplays: $this->flakyReplays,
searchRuns: EnvironmentOverrides::count('PROPERTY_SEARCH_RUNS', getenv('PROPERTY_SEARCH_RUNS')) ?? $this->searchRuns ?? 0,

Check warning on line 526 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "IncrementInteger": @@ @@ exhaustive: EnvironmentOverrides::flag(getenv('PROPERTY_EXHAUSTIVE')) ?? $this->exhaustive ?? false, exhaustiveBudget: $this->exhaustiveBudget, flakyReplays: $this->flakyReplays, - searchRuns: EnvironmentOverrides::count('PROPERTY_SEARCH_RUNS', getenv('PROPERTY_SEARCH_RUNS')) ?? $this->searchRuns ?? 0, + searchRuns: EnvironmentOverrides::count('PROPERTY_SEARCH_RUNS', getenv('PROPERTY_SEARCH_RUNS')) ?? $this->searchRuns ?? 1, ), examples: $this->examples, replayRegressions: $this->seed === null,
),
examples: $this->examples,
replayRegressions: $this->seed === null,
Expand Down Expand Up @@ -590,9 +590,9 @@
}

/**
* The generators the property runs with: the {@see forAll()} map, or —
* under {@see auto()} — that map as overrides with every uncovered
* parameter derived from the closure's signature. The derivation errors are
* The generators the property runs with: the {@see forAll()} map plus any
* `#[Generate]` on the closure's parameters, and — under {@see auto()} —
* every parameter neither covers derived from the closure's signature. The derivation errors are
* {@see Gen::forParameters()}'s own, naming the function and the parameter.
*
* The map is checked here, before the engine sees it, because the engine
Expand All @@ -614,7 +614,7 @@
$generators = [];

foreach ($this->generators as $name => $generator) {
$name = (string) $name;

Check warning on line 617 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "CastString": @@ @@ $generators = []; foreach ($this->generators as $name => $generator) { - $name = (string) $name; + $name = $name; if (!$generator instanceof ArbitraryInterface) { throw new \InvalidArgumentException(sprintf(

if (!$generator instanceof ArbitraryInterface) {
throw new \InvalidArgumentException(sprintf(
Expand All @@ -636,11 +636,10 @@
$generators[$name] = $generator;
}

if (!$this->auto) {
return $generators;
}

return Gen::forParameters($property, $generators);
// Without auto nothing is derived from the declared types, but a
// parameter may still carry its generator as #[Generate]: the map and
// the attributes together have to cover every parameter.
return Gen::forParameters($property, $generators, derive: $this->auto);
}

/**
Expand Down Expand Up @@ -668,7 +667,7 @@
$classifications = $statistics->classifications;
$checks = $statistics->checks;

if ($classifications === [] || $checks <= 0) {

Check warning on line 670 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "LessThanOrEqualTo": @@ @@ $classifications = $statistics->classifications; $checks = $statistics->checks; - if ($classifications === [] || $checks <= 0) { + if ($classifications === [] || $checks < 0) { return; }
return;
}

Expand All @@ -695,14 +694,14 @@
*/
private function reportTables(RunStatistics $statistics): void
{
if ($statistics->checks <= 0) {

Check warning on line 697 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "LessThanOrEqualTo": @@ @@ */ private function reportTables(RunStatistics $statistics): void { - if ($statistics->checks <= 0) { + if ($statistics->checks < 0) { return; }
return;

Check warning on line 698 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ private function reportTables(RunStatistics $statistics): void { if ($statistics->checks <= 0) { - return; + } $report = DistributionReport::of($statistics, coverageAssessed: true);
}

$report = DistributionReport::of($statistics, coverageAssessed: true);

Check warning on line 701 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "TrueValue": @@ @@ return; } - $report = DistributionReport::of($statistics, coverageAssessed: true); + $report = DistributionReport::of($statistics, coverageAssessed: false); foreach ($report->tables as $table => $shares) { $render = static fn(LabelShare $share): string => sprintf('%s %d%% (%d/%d)', $share->label, (int) round($share->percent), $share->count, $report->checks);

foreach ($report->tables as $table => $shares) {
$render = static fn(LabelShare $share): string => sprintf('%s %d%% (%d/%d)', $share->label, (int) round($share->percent), $share->count, $report->checks);

Check warning on line 704 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "RoundingFamily": @@ @@ $report = DistributionReport::of($statistics, coverageAssessed: true); foreach ($report->tables as $table => $shares) { - $render = static fn(LabelShare $share): string => sprintf('%s %d%% (%d/%d)', $share->label, (int) round($share->percent), $share->count, $report->checks); + $render = static fn(LabelShare $share): string => sprintf('%s %d%% (%d/%d)', $share->label, (int) floor($share->percent), $share->count, $report->checks); $parts = array_map($render, $shares); $pairs = array_map($render, $report->intersections[$table] ?? []);

Check warning on line 704 in src/PhpUnit/PropertyCheck.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "RoundingFamily": @@ @@ $report = DistributionReport::of($statistics, coverageAssessed: true); foreach ($report->tables as $table => $shares) { - $render = static fn(LabelShare $share): string => sprintf('%s %d%% (%d/%d)', $share->label, (int) round($share->percent), $share->count, $report->checks); + $render = static fn(LabelShare $share): string => sprintf('%s %d%% (%d/%d)', $share->label, (int) ceil($share->percent), $share->count, $report->checks); $parts = array_map($render, $shares); $pairs = array_map($render, $report->intersections[$table] ?? []);
$parts = array_map($render, $shares);
$pairs = array_map($render, $report->intersections[$table] ?? []);

Expand Down
41 changes: 37 additions & 4 deletions tests/PropertyCheckTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use PHPUnit\Framework\IncompleteTestError;
use PHPUnit\Framework\SkippedWithMessageException;
use PHPUnit\Framework\TestCase;
use Rasuvaeff\PropertyTesting\Arbitrary\IntArbitrary;
use Rasuvaeff\PropertyTesting\Assume;
use Rasuvaeff\PropertyTesting\Classify;
use Rasuvaeff\PropertyTesting\CoverageViolationException;
Expand All @@ -21,6 +22,7 @@
use Rasuvaeff\PropertyTesting\ExampleViolationException;
use Rasuvaeff\PropertyTesting\GaveUpException;
use Rasuvaeff\PropertyTesting\Gen;
use Rasuvaeff\PropertyTesting\Generate;
use Rasuvaeff\PropertyTesting\PhpUnit\PropertyCheck;
use Rasuvaeff\PropertyTesting\PhpUnit\PropertyTesting;
use Rasuvaeff\PropertyTesting\PhpUnit\Tests\Support\Env;
Expand Down Expand Up @@ -1144,7 +1146,7 @@ private function pathOfAFailure(): string
public function testWithoutAutoTheForAllMapIsUsedVerbatim(): void
{
// auto stays opt-in: no derivation happens unless auto() was called,
// so a missing generator surfaces exactly as before.
// so a parameter the map does not cover is refused, not derived.
try {
$this->forAll(['x' => Gen::constant(7)])
->runs(1)
Expand All @@ -1153,12 +1155,43 @@ public function testWithoutAutoTheForAllMapIsUsedVerbatim(): void

self::fail('Expected a failure about the missing generator');
} catch (\InvalidArgumentException $failure) {
// The engine's own refusal for a map that does not cover a
// parameter — not a derivation error, which auto would produce.
self::assertSame('No generator for parameter "missing"', $failure->getMessage());
self::assertStringEndsWith(
'parameter $missing has no generator; pass an override or #[Generate]',
$failure->getMessage(),
);
}
}

public function testWithoutAutoAGenerateAttributeCoversItsParameter(): void
{
$seen = [];

$this->forAll(['x' => Gen::constant(7)])
->runs(20)
->seed(1)
->check(static function (int $x, #[Generate(new IntArbitrary(3, 3))] int $y) use (&$seen): void {
$seen[] = [$x, $y];
});

self::assertNotSame([], $seen);
self::assertSame([[7, 3]], array_values(array_unique($seen, SORT_REGULAR)));
}

public function testAutoReadsAGenerateAttributeBeforeTheType(): void
{
$seen = [];

$this->forAll()
->auto()
->runs(20)
->seed(1)
->check(static function (#[Generate(new IntArbitrary(5, 5))] int $n) use (&$seen): void {
$seen[] = $n;
});

self::assertSame([5], array_values(array_unique($seen)));
}

private function falsifiedOriginal(): mixed
{
try {
Expand Down
Loading