diff --git a/CHANGELOG.md b/CHANGELOG.md index d557fc4..194e09a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index ede0f5e..9557961 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/README.ru.md b/README.ru.md index fa6ca19..d2849ae 100644 --- a/README.ru.md +++ b/README.ru.md @@ -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 ядра. + ### Параметры атрибута | Параметр | Значение | diff --git a/composer.json b/composer.json index e72d118..27940bd 100644 --- a/composer.json +++ b/composer.json @@ -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" }, diff --git a/llms.txt b/llms.txt index c50742f..c84b729 100644 --- a/llms.txt +++ b/llms.txt @@ -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). diff --git a/src/Testo/PropertyInterceptor.php b/src/Testo/PropertyInterceptor.php index b0510c1..6e5c597 100644 --- a/src/Testo/PropertyInterceptor.php +++ b/src/Testo/PropertyInterceptor.php @@ -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; @@ -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', $testMethod->getName(), @@ -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; } /** diff --git a/tests/Testo/PropertyInterceptorTest.php b/tests/Testo/PropertyInterceptorTest.php index 50f1cb1..1f4d566 100644 --- a/tests/Testo/PropertyInterceptorTest.php +++ b/tests/Testo/PropertyInterceptorTest.php @@ -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 + */ + 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); diff --git a/tests/Testo/PropertyStubs.php b/tests/Testo/PropertyStubs.php index 8971e91..ec67f19 100644 --- a/tests/Testo/PropertyStubs.php +++ b/tests/Testo/PropertyStubs.php @@ -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; @@ -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 */ + public static function fives(): ArbitraryInterface + { + return Gen::constant(value: 5); + } + + /** @return array */ + public static function provide(): array + { + return ['x' => Gen::constant(value: 7)]; + } +}