diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index d00ba6ed..09e0d064 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -15,7 +15,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.0 + php-version: 8.1 tools: composer:v2 coverage: none @@ -23,7 +23,7 @@ jobs: run: composer update --no-interaction --no-progress - name: Run PHP-CS-Fixer - run: vendor/bin/php-cs-fixer fix -v --allow-risky=yes --dry-run + run: PHP_CS_FIXER_IGNORE_ENV=true vendor/bin/php-cs-fixer fix -v --allow-risky=yes --dry-run phpstan: runs-on: ubuntu-latest @@ -40,7 +40,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.0 + php-version: 8.1 tools: composer:v2 coverage: none @@ -48,4 +48,4 @@ jobs: run: composer update --prefer-stable --no-interaction --no-progress - name: Run PHPStan - run: vendor/bin/phpstan analyse --no-progress + run: vendor/bin/phpstan analyse --no-progress --debug diff --git a/composer.json b/composer.json index 8b4bdf03..4d50f7b0 100644 --- a/composer.json +++ b/composer.json @@ -60,8 +60,8 @@ "bin/pest" ], "scripts": { - "lint": "php-cs-fixer fix -v", - "test:lint": "php-cs-fixer fix -v --dry-run", + "lint": "PHP_CS_FIXER_IGNORE_ENV=true php-cs-fixer fix -v", + "test:lint": "PHP_CS_FIXER_IGNORE_ENV=true php-cs-fixer fix -v --dry-run", "test:types": "phpstan analyse --ansi --memory-limit=-1 --debug", "test:unit": "php bin/pest --colors=always --exclude-group=integration", "test:parallel": "exit 1", diff --git a/phpstan.neon b/phpstan.neon index d5dc4ef6..0a1cba5c 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -9,7 +9,6 @@ parameters: - src checkMissingIterableValueType: true - checkGenericClassInNonGenericObjectType: false reportUnmatchedIgnoredErrors: true ignoreErrors: diff --git a/src/Concerns/RetrievesValues.php b/src/Concerns/RetrievesValues.php index 56f3d2c8..c7c6cdd9 100644 --- a/src/Concerns/RetrievesValues.php +++ b/src/Concerns/RetrievesValues.php @@ -14,6 +14,8 @@ trait RetrievesValues * * Safely retrieve the value at the given key from an object or array. * + * @template TRetrievableValue + * * @param array|object $value * @param TRetrievableValue|null $default * diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 76e8bc88..e45f1956 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -7,6 +7,7 @@ namespace Pest\Concerns; use Closure; use Pest\Support\ChainableClosure; use Pest\Support\ExceptionTrace; +use Pest\Support\Reflection; use Pest\TestSuite; use Throwable; @@ -210,7 +211,28 @@ trait Testable */ private function __resolveTestArguments(array $arguments): array { - return array_map(fn ($data) => $data instanceof Closure ? $this->__callClosure($data, []) : $data, $arguments); + if (count($arguments) !== 1) { + return $arguments; + } + + if (!$arguments[0] instanceof Closure) { + return $arguments; + } + + $underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure'); + $testParameterTypes = array_values(Reflection::getFunctionArguments($underlyingTest)); + + if (in_array($testParameterTypes[0], ['Closure', 'callable'])) { + return $arguments; + } + + $boundDatasetResult = $this->__callClosure($arguments[0], []); + + if (count($testParameterTypes) === 1 || !is_array($boundDatasetResult)) { + return [$boundDatasetResult]; + } + + return array_values($boundDatasetResult); } /** diff --git a/src/Each.php b/src/Each.php index e16933be..19105945 100644 --- a/src/Each.php +++ b/src/Each.php @@ -7,7 +7,9 @@ namespace Pest; /** * @internal * - * @mixin Expectation + * @template TValue + * + * @mixin Expectation */ final class Each { @@ -15,14 +17,21 @@ final class Each /** * Creates an expectation on each item of the iterable "value". + * + * @param Expectation $original */ public function __construct(private Expectation $original) { - // .. } /** * Creates a new expectation. + * + * @template TAndValue + * + * @param TAndValue $value + * + * @return Expectation */ public function and(mixed $value): Expectation { @@ -31,6 +40,8 @@ final class Each /** * Creates the opposite expectation for the value. + * + * @return self */ public function not(): Each { @@ -43,6 +54,8 @@ final class Each * Dynamically calls methods on the class with the given arguments on each item. * * @param array $arguments + * + * @return self */ public function __call(string $name, array $arguments): Each { @@ -58,6 +71,8 @@ final class Each /** * Dynamically calls methods on the class without any arguments on each item. + * + * @return self */ public function __get(string $name): Each { diff --git a/src/Expectation.php b/src/Expectation.php index 3310451c..42acbd9d 100644 --- a/src/Expectation.php +++ b/src/Expectation.php @@ -45,9 +45,11 @@ final class Expectation /** * Creates a new expectation. * - * @param TValue $value + * @template TAndValue * - * @return Expectation + * @param TAndValue $value + * + * @return self */ public function and(mixed $value): Expectation { @@ -56,6 +58,8 @@ final class Expectation /** * Creates a new expectation with the decoded JSON value. + * + * @return self */ public function json(): Expectation { @@ -84,6 +88,8 @@ final class Expectation /** * Send the expectation value to Ray along with all given arguments. + * + * @return self */ public function ray(mixed ...$arguments): self { @@ -96,6 +102,8 @@ final class Expectation /** * Creates the opposite expectation for the value. + * + * @return OppositeExpectation */ public function not(): OppositeExpectation { @@ -104,6 +112,8 @@ final class Expectation /** * Creates an expectation on each item of the iterable "value". + * + * @return Each */ public function each(callable $callback = null): Each { @@ -125,7 +135,9 @@ final class Expectation * * @template TSequenceValue * - * @param (callable(self, self): void)|TSequenceValue ...$callbacks + * @param (callable(self, self): void)|TSequenceValue ...$callbacks + * + * @return self */ public function sequence(mixed ...$callbacks): Expectation { @@ -167,15 +179,13 @@ final class Expectation * @template TMatchSubject of array-key * * @param (callable(): TMatchSubject)|TMatchSubject $subject - * @param array): mixed)|TValue> $expressions + * @param array): mixed)|TValue> $expressions + * + * @return self */ public function match(mixed $subject, array $expressions): Expectation { - $subject = is_callable($subject) - ? $subject - : fn () => $subject; - - $subject = $subject(); + $subject = $subject instanceof Closure ? $subject() : $subject; $matched = false; @@ -208,6 +218,8 @@ final class Expectation * * @param (callable(): bool)|bool $condition * @param callable(Expectation): mixed $callback + * + * @return self */ public function unless(callable|bool $condition, callable $callback): Expectation { @@ -224,7 +236,9 @@ final class Expectation * Apply the callback if the given "condition" is truthy. * * @param (callable(): bool)|bool $condition - * @param callable(Expectation): mixed $callback + * @param callable(self): mixed $callback + * + * @return self */ public function when(callable|bool $condition, callable $callback): Expectation { diff --git a/src/Factories/TestCaseFactory.php b/src/Factories/TestCaseFactory.php index f6a61ba2..b214631b 100644 --- a/src/Factories/TestCaseFactory.php +++ b/src/Factories/TestCaseFactory.php @@ -7,7 +7,6 @@ namespace Pest\Factories; use ParseError; use Pest\Concerns; use Pest\Contracts\HasPrintableTestCaseName; -use Pest\Datasets; use Pest\Exceptions\DatasetMissing; use Pest\Exceptions\ShouldNotHappen; use Pest\Exceptions\TestAlreadyExist; @@ -73,9 +72,10 @@ final class TestCaseFactory { $methodsUsingOnly = $this->methodsUsingOnly(); - $methods = array_values(array_filter($this->methods, function ($method) use ($methodsUsingOnly) { - return count($methodsUsingOnly) === 0 || in_array($method, $methodsUsingOnly, true); - })); + $methods = array_values(array_filter( + $this->methods, + fn ($method) => count($methodsUsingOnly) === 0 || in_array($method, $methodsUsingOnly, true) + )); if (count($methods) > 0) { $this->evaluate($this->filename, $methods); @@ -141,56 +141,10 @@ final class TestCaseFactory $classFQN .= $className; } - $methodsCode = implode('', array_map(static function (TestCaseMethodFactory $method): string { - if ($method->description === null) { - throw ShouldNotHappen::fromMessage('The test description may not be empty.'); - } - - $methodName = Str::evaluable($method->description); - - $datasetsCode = ''; - $annotations = ['@test']; - - foreach (self::$annotations as $annotation) { - /** @phpstan-ignore-next-line */ - $annotations = (new $annotation())->__invoke($method, $annotations); - } - - if (count($method->datasets) > 0) { - $dataProviderName = $methodName . '_dataset'; - $annotations[] = "@dataProvider $dataProviderName"; - - Datasets::with($method->filename, $methodName, $method->datasets); - - $datasetsCode = << sprintf("\n * %s", $annotation), $annotations, - )); - - return <<__runTest( - \$this->__test, - ...func_get_args(), - ); - } - - $datasetsCode -EOF; - }, $methods)); + $methodsCode = implode('', array_map( + fn (TestCaseMethodFactory $methodFactory) => $methodFactory->buildForEvaluation(self::$annotations), + $methods + )); try { eval(" diff --git a/src/Factories/TestCaseMethodFactory.php b/src/Factories/TestCaseMethodFactory.php index 540f2677..1aaa2dae 100644 --- a/src/Factories/TestCaseMethodFactory.php +++ b/src/Factories/TestCaseMethodFactory.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace Pest\Factories; use Closure; +use Pest\Datasets; use Pest\Exceptions\ShouldNotHappen; use Pest\Factories\Concerns\HigherOrderable; +use Pest\Support\Str; use Pest\TestSuite; use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; @@ -17,6 +19,7 @@ use PHPUnit\Framework\TestCase; final class TestCaseMethodFactory { use HigherOrderable; + /** * Determines if the Test Case will be the "only" being run. */ @@ -51,11 +54,9 @@ final class TestCaseMethodFactory public ?string $description, public ?Closure $closure, ) { - if ($this->closure === null) { - $this->closure = function () { - Assert::getCount() > 0 ?: self::markTestIncomplete(); // @phpstan-ignore-line - }; - } + $this->closure ??= function () { + Assert::getCount() > 0 ?: self::markTestIncomplete(); // @phpstan-ignore-line + }; $this->bootHigherOrderable(); } @@ -100,4 +101,68 @@ final class TestCaseMethodFactory { return count($this->datasets) > 0 || count($this->depends) > 0; } + + /** + * Creates a PHPUnit method as a string ready for evaluation. + * + * @param array $annotationsToUse + */ + public function buildForEvaluation(array $annotationsToUse): string + { + if ($this->description === null) { + throw ShouldNotHappen::fromMessage('The test description may not be empty.'); + } + + $methodName = Str::evaluable($this->description); + + $datasetsCode = ''; + $annotations = ['@test']; + + foreach ($annotationsToUse as $annotation) { + /** @phpstan-ignore-next-line */ + $annotations = (new $annotation())->__invoke($this, $annotations); + } + + if (count($this->datasets) > 0) { + $dataProviderName = $methodName . '_dataset'; + $annotations[] = "@dataProvider $dataProviderName"; + $datasetsCode = $this->buildDatasetForEvaluation($methodName, $dataProviderName); + } + + $annotations = implode('', array_map( + static fn ($annotation) => sprintf("\n * %s", $annotation), $annotations, + )); + + return <<__runTest( + \$this->__test, + ...func_get_args(), + ); + } + + $datasetsCode + EOF; + } + + /** + * Creates a PHPUnit Data Provider as a string ready for evaluation. + */ + private function buildDatasetForEvaluation(string $methodName, string $dataProviderName): string + { + Datasets::with($this->filename, $methodName, $this->datasets); + + return <<|Extendable */ function expect($value = null): Expectation|Extendable { diff --git a/src/HigherOrderExpectation.php b/src/HigherOrderExpectation.php index 7abd9d1d..8c4dcc4e 100644 --- a/src/HigherOrderExpectation.php +++ b/src/HigherOrderExpectation.php @@ -4,19 +4,23 @@ declare(strict_types=1); namespace Pest; -use Pest\Concerns\Expectable; use Pest\Concerns\RetrievesValues; /** * @internal * - * @mixin Expectation + * @template TOriginalValue + * @template TValue + * + * @mixin Expectation */ final class HigherOrderExpectation { - use Expectable; use RetrievesValues; + /** + * @var Expectation|Each + */ private Expectation|Each $expectation; private bool $opposite = false; @@ -25,6 +29,9 @@ final class HigherOrderExpectation /** * Creates a new higher order expectation. + * + * @param Expectation $original + * @param TValue $value */ public function __construct(private Expectation $original, mixed $value) { @@ -33,6 +40,8 @@ final class HigherOrderExpectation /** * Creates the opposite expectation for the value. + * + * @return self */ public function not(): HigherOrderExpectation { @@ -41,14 +50,28 @@ final class HigherOrderExpectation return $this; } + /** + * Creates a new Expectation. + * + * @template TExpectValue + * + * @param TExpectValue $value + * + * @return Expectation + */ + public function expect(mixed $value): Expectation + { + return new Expectation($value); + } + /** * Creates a new expectation. * - * @template TValue + * @template TExpectValue * - * @param TValue $value + * @param TExpectValue $value * - * @return Expectation + * @return Expectation */ public function and(mixed $value): Expectation { @@ -59,6 +82,8 @@ final class HigherOrderExpectation * Dynamically calls methods on the class with the given arguments. * * @param array $arguments + * + * @return self|self */ public function __call(string $name, array $arguments): self { @@ -72,6 +97,8 @@ final class HigherOrderExpectation /** * Accesses properties in the value or in the expectation. + * + * @return self|self */ public function __get(string $name): self { @@ -99,9 +126,12 @@ final class HigherOrderExpectation /** * Retrieve the applicable value based on the current reset condition. + * + * @return TOriginalValue|TValue */ private function getValue(): mixed { + // @phpstan-ignore-next-line return $this->shouldReset ? $this->original->value : $this->expectation->value; } @@ -109,6 +139,8 @@ final class HigherOrderExpectation * Performs the given assertion with the current expectation. * * @param array $arguments + * + * @return self */ private function performAssertion(string $name, array $arguments): self { diff --git a/src/OppositeExpectation.php b/src/OppositeExpectation.php index da9ec6ab..e2062e75 100644 --- a/src/OppositeExpectation.php +++ b/src/OppositeExpectation.php @@ -10,29 +10,34 @@ use SebastianBergmann\Exporter\Exporter; /** * @internal * - * @mixin Expectation + * @template TValue + * + * @mixin Expectation */ final class OppositeExpectation { /** * Creates a new opposite expectation. + * + * @param Expectation $original */ public function __construct(private Expectation $original) { - // .. } /** * Asserts that the value array not has the provided $keys. * * @param array $keys + * + * @return Expectation */ public function toHaveKeys(array $keys): Expectation { foreach ($keys as $key) { try { $this->original->toHaveKey($key); - } catch (ExpectationFailedException $exception) { + } catch (ExpectationFailedException) { continue; } @@ -47,14 +52,14 @@ final class OppositeExpectation * * @param array $arguments * - * @return Expectation|never + * @return Expectation|Expectation|never */ public function __call(string $name, array $arguments): Expectation { try { /* @phpstan-ignore-next-line */ $this->original->{$name}(...$arguments); - } catch (ExpectationFailedException $exception) { + } catch (ExpectationFailedException) { return $this->original; } @@ -64,13 +69,13 @@ final class OppositeExpectation /** * Handle dynamic properties gets into the original expectation. * - * @return Expectation|never + * @return Expectation|Expectation|never */ public function __get(string $name): Expectation { try { $this->original->{$name}; // @phpstan-ignore-line - } catch (ExpectationFailedException $exception) { // @phpstan-ignore-line + } catch (ExpectationFailedException) { // @phpstan-ignore-line return $this->original; } diff --git a/src/Support/HigherOrderMessage.php b/src/Support/HigherOrderMessage.php index 575eaf98..766a9743 100644 --- a/src/Support/HigherOrderMessage.php +++ b/src/Support/HigherOrderMessage.php @@ -38,6 +38,10 @@ final class HigherOrderMessage /** * Re-throws the given `$throwable` with the good line and filename. + * + * @template TValue of object + * + * @param TValue $target */ public function call(object $target): mixed { @@ -59,7 +63,7 @@ final class HigherOrderMessage Reflection::setPropertyValue($throwable, 'line', $this->line); if ($throwable->getMessage() === self::getUndefinedMethodMessage($target, $this->name)) { - /** @var ReflectionClass $reflection */ + /** @var ReflectionClass $reflection */ $reflection = new ReflectionClass($target); /* @phpstan-ignore-next-line */ $reflection = $reflection->getParentClass() ?: $reflection; diff --git a/src/Support/Reflection.php b/src/Support/Reflection.php index 479a86f6..3a0a554a 100644 --- a/src/Support/Reflection.php +++ b/src/Support/Reflection.php @@ -118,11 +118,14 @@ final class Reflection /** * Sets the property value of the given object. * - * @param mixed $value + * @template TValue of object + * + * @param TValue $object + * @param mixed $value */ public static function setPropertyValue(object $object, string $property, $value): void { - /** @var ReflectionClass $reflectionClass */ + /** @var ReflectionClass $reflectionClass */ $reflectionClass = new ReflectionClass($object); $reflectionProperty = null; @@ -202,4 +205,12 @@ final class Reflection return $arguments; } + + /** + * @return mixed + */ + public static function getFunctionVariable(Closure $function, string $key) + { + return (new ReflectionFunction($function))->getStaticVariables()[$key] ?? null; + } } diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index e8990cbb..fe437645 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -96,11 +96,20 @@ ✓ more than two datasets with (2) / (4) / (5) ✓ more than two datasets with (2) / (4) / (6) ✓ more than two datasets did the job right - ✓ it can resolve a dataset after the test case is available with (Closure Object (...)) + ✓ it can resolve a dataset after the test case is available with (Closure Object (...)) #1 + ✓ it can resolve a dataset after the test case is available with (Closure Object (...)) #2 ✓ it can resolve a dataset after the test case is available with shared yield sets with (Closure Object (...)) #1 ✓ it can resolve a dataset after the test case is available with shared yield sets with (Closure Object (...)) #2 ✓ it can resolve a dataset after the test case is available with shared array sets with (Closure Object (...)) #1 ✓ it can resolve a dataset after the test case is available with shared array sets with (Closure Object (...)) #2 + ✓ it resolves a potential bound dataset logically with ('foo', Closure Object (...)) + ✓ it resolves a potential bound dataset logically even when the closure comes first with (Closure Object (...), 'bar') + ✓ it will not resolve a closure if it is type hinted as a closure with (Closure Object (...)) #1 + ✓ it will not resolve a closure if it is type hinted as a closure with (Closure Object (...)) #2 + ✓ it will not resolve a closure if it is type hinted as a callable with (Closure Object (...)) #1 + ✓ it will not resolve a closure if it is type hinted as a callable with (Closure Object (...)) #2 + ✓ it can correctly resolve a bound dataset that returns an array with (Closure Object (...)) + ✓ it can correctly resolve a bound dataset that returns an array but wants to be spread with (Closure Object (...)) PASS Tests\Features\Exceptions ✓ it gives access the the underlying expectException diff --git a/tests/Features/Datasets.php b/tests/Features/Datasets.php index 753c4eaf..c7e695e1 100644 --- a/tests/Features/Datasets.php +++ b/tests/Features/Datasets.php @@ -234,6 +234,7 @@ it('can resolve a dataset after the test case is available', function ($result) expect($result)->toBe('bar'); })->with([ function () { return $this->foo; }, + [function () { return $this->foo; }], ]); it('can resolve a dataset after the test case is available with shared yield sets', function ($result) { @@ -243,3 +244,43 @@ it('can resolve a dataset after the test case is available with shared yield set it('can resolve a dataset after the test case is available with shared array sets', function ($result) { expect($result)->toBeInt()->toBeLessThan(3); })->with('bound.array'); + +it('resolves a potential bound dataset logically', function ($foo, $bar) { + expect($foo)->toBe('foo'); + expect($bar())->toBe('bar'); +})->with([ + ['foo', function () { return 'bar'; }], // This should be passed as a closure because we've passed multiple arguments +]); + +it('resolves a potential bound dataset logically even when the closure comes first', function ($foo, $bar) { + expect($foo())->toBe('foo'); + expect($bar)->toBe('bar'); +})->with([ + [function () { return 'foo'; }, 'bar'], // This should be passed as a closure because we've passed multiple arguments +]); + +it('will not resolve a closure if it is type hinted as a closure', function (Closure $data) { + expect($data())->toBeString(); +})->with([ + function () { return 'foo'; }, + function () { return 'bar'; }, +]); + +it('will not resolve a closure if it is type hinted as a callable', function (callable $data) { + expect($data())->toBeString(); +})->with([ + function () { return 'foo'; }, + function () { return 'bar'; }, +]); + +it('can correctly resolve a bound dataset that returns an array', function (array $data) { + expect($data)->toBe(['foo', 'bar', 'baz']); +})->with([ + function () { return ['foo', 'bar', 'baz']; }, +]); + +it('can correctly resolve a bound dataset that returns an array but wants to be spread', function (string $foo, string $bar, string $baz) { + expect([$foo, $bar, $baz])->toBe(['foo', 'bar', 'baz']); +})->with([ + function () { return ['foo', 'bar', 'baz']; }, +]);