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

## 1.1.0 — 2026-10-02

- **Added:** generators written on the property's parameters with core's
`#[Generate]` (#68). Without `auto`, the attributes alone are enough
when they cover every parameter — no generators method needed — and they may
share the parameters with a provider; under `auto: true` they sit between
the provider and the `@param` type. Requires `rasuvaeff/property-testing-core`
`^1.2`.
- **Changed:** without `auto`, a parameter neither the provider nor
`#[Generate]` covers is refused naming the method and the parameter, instead
of the engine's `No generator for parameter "x"`.

## 1.0.0 — 2026-09-20

The stability release, in step with `rasuvaeff/property-testing-core` 1.0.0.
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,37 @@ Rules worth knowing:
environment dials the suite (runs, phases), while `auto` changes what one
property's arguments mean — attribute territory.

### 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;

#[Property]
public function delayStaysWithinCap(
#[Generate(new IntArbitrary(0, 10_000))]
int $base,
#[Generate([Generators::class, 'attempts'])]
int $attempt,
): void {}
```

- It works **without `auto`**: when no provider exists, the attributes alone
are enough — as long as they cover every parameter. A provider and
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: true` says so.
- Under `auto: true` it is read after the provider 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.

### Attribute parameters

| Parameter | Meaning |
Expand Down
31 changes: 31 additions & 0 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,37 @@ public static function provide(): array
(runs, phases), а `auto` меняет смысл аргументов конкретной property —
территория атрибута.

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

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

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

#[Property]
public function delayStaysWithinCap(
#[Generate(new IntArbitrary(0, 10_000))]
int $base,
#[Generate([Generators::class, 'attempts'])]
int $attempt,
): void {}
```

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

### Параметры атрибута

| Параметр | Значение |
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
},
"require": {
"php": "8.3 - 8.5",
"rasuvaeff/property-testing-core": "^1.0",
"rasuvaeff/property-testing-core": "^1.2",
"testo/assert": "^0.1.15",
"testo/testo": "^0.10.39"
},
Expand Down
6 changes: 6 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ installation fails at resolution time.
method and parameter). The provider becomes PARTIAL overrides. Strictly
opt-in, never the default (bare int/float derive their full native domain).
No PROPERTY_AUTO env variable, deliberately.
- `#[Generate(...)]` on a parameter (core 1.2) holds its generator: an
arbitrary built with `new`, or a static factory reference ('method',
'Class::method', [Class::class, 'method'], invokable). Works WITHOUT auto:
no provider needed when attributes (plus provider, if any) cover every
parameter; an uncovered one throws by name. Under auto: provider >
#[Generate] > @param > native type.
- A provider key that is not a parameter of the property is an error, with or
without `auto` (a provider shared by properties of different arity is
refused).
Expand Down
26 changes: 21 additions & 5 deletions src/Testo/PropertyInterceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Rasuvaeff\PropertyTesting\ArbitraryInterface;
use Rasuvaeff\PropertyTesting\Gen;
use Rasuvaeff\PropertyTesting\Generate;
use Rasuvaeff\PropertyTesting\Property;
use Rasuvaeff\PropertyTesting\PropertyListener;
use Rasuvaeff\PropertyTesting\Runner\Clock;
Expand Down Expand Up @@ -341,6 +342,12 @@ private function resolveGenerators(\ReflectionMethod $testMethod, TestInfo $info
return Gen::forParameters($testMethod);
}

if ($this->carriesGenerate($testMethod)) {
// No provider and no auto, but generators written on the
// parameters: they have to cover every one of them.
return Gen::forParameters($testMethod, derive: false);
}

throw new \InvalidArgumentException(sprintf(
'Property "%s" requires a generators method "%s" on %s returning array<string, ArbitraryInterface>',
$testMethod->getName(),
Expand Down Expand Up @@ -397,13 +404,22 @@ private function resolveGenerators(\ReflectionMethod $testMethod, TestInfo $info
}
}

if (!$property->auto) {
return $typed;
// The provider covers the parameters it names and #[Generate] the ones
// that carry it; under auto the signature covers the rest — the
// forClass(overrides) model applied to the property — and without it
// a parameter left uncovered is refused by name.
return Gen::forParameters($testMethod, $typed, derive: $property->auto);
}

private function carriesGenerate(\ReflectionMethod $testMethod): bool
{
foreach ($testMethod->getParameters() as $parameter) {
if ($parameter->getAttributes(Generate::class) !== []) {
return true;
}
}

// The provider covers the parameters it names; the signature covers
// the rest — the forClass(overrides) model applied to the property.
return Gen::forParameters($testMethod, $typed);
return false;
}

/**
Expand Down
41 changes: 41 additions & 0 deletions tests/Testo/PropertyInterceptorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2719,6 +2719,47 @@ public function withoutAutoAMissingProviderStillFailsWithTheEstablishedMessage()
->contains('requires a generators method "checkWithoutAutoGenerators"');
}

#[DataProvider('generateAttributeCases')]
public function aGenerateAttributeCoversItsParameter(string $method, \Closure $check): void
{
$interceptor = new PropertyInterceptor($this->createMessenger());
$next = static function (TestInfo $info) use ($check): TestResult {
Assert::true($check(...$info->arguments));

return new TestResult(info: $info, status: Status::Passed);
};

$result = $interceptor->runTest($this->info(GenerateStub::class, $method), $next);

Assert::same($result->status, Status::Passed);
}

/**
* @return iterable<string, array{string, \Closure(mixed...): bool}>
*/
public static function generateAttributeCases(): iterable
{
yield 'attributes alone, without auto or a provider' => [
'onlyAttributes',
static fn(mixed $x, mixed $y): bool => $x === 3 && $y === 5,
];
yield 'an attribute beside a provider, without auto' => [
'attributeBesideAProvider',
static fn(mixed $x, mixed $y): bool => $x === 7 && $y === 3,
];
yield 'an attribute under auto, the rest derived' => [
'attributeUnderAuto',
static fn(mixed $x, mixed $y): bool => $x === 3 && is_int($y) && $y >= 100 && $y <= 200,
];
}

public function withoutAutoAParameterNeitherAttributeNorProviderCoversIsRefused(): void
{
Assert::string($this->misconfiguration(GenerateStub::class, 'attributeLeavingAParameterUncovered')->getMessage())
->contains('GenerateStub::attributeLeavingAParameterUncovered()')
->contains('parameter $y has no generator; pass an override or #[Generate]');
}

private function info(string $class, string $method): TestInfo
{
$reflection = new \ReflectionMethod($class, $method);
Expand Down
50 changes: 50 additions & 0 deletions tests/Testo/PropertyStubs.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

namespace Rasuvaeff\PropertyTesting\Testo\Tests;

use Rasuvaeff\PropertyTesting\Arbitrary\IntArbitrary;
use Rasuvaeff\PropertyTesting\ArbitraryInterface;
use Rasuvaeff\PropertyTesting\Gen;
use Rasuvaeff\PropertyTesting\Generate;
use Rasuvaeff\PropertyTesting\Property;
use Rasuvaeff\PropertyTesting\Runner\EdgeCases;
use Rasuvaeff\PropertyTesting\Runner\Phase;
Expand Down Expand Up @@ -1055,3 +1057,51 @@ public static function provide(): array
return ['x' => Gen::intBetween(51, 100)];
}
}

/**
* Generators written on the parameters, with and without auto and a provider.
*/
final class GenerateStub
{
#[Property(runs: 10, seed: 1)]
public function onlyAttributes(
#[Generate(new IntArbitrary(3, 3))]
int $x,
#[Generate('fives')]
int $y,
): void {}

#[Property(runs: 10, seed: 1, generators: 'provide')]
public function attributeBesideAProvider(
int $x,
#[Generate(new IntArbitrary(3, 3))]
int $y,
): void {}

#[Property(runs: 10, seed: 1)]
public function attributeLeavingAParameterUncovered(
#[Generate(new IntArbitrary(3, 3))]
int $x,
int $y,
): void {}

/** @param int<100, 200> $y */
#[Property(runs: 10, seed: 1, auto: true)]
public function attributeUnderAuto(
#[Generate(new IntArbitrary(3, 3))]
int $x,
int $y,
): void {}

/** @return ArbitraryInterface<int> */
public static function fives(): ArbitraryInterface
{
return Gen::constant(value: 5);
}

/** @return array<string, ArbitraryInterface> */
public static function provide(): array
{
return ['x' => Gen::constant(value: 7)];
}
}
Loading