diff --git a/composer.json b/composer.json index 4be59682..7251a708 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ ], "require": { "php": "^8.0", - "nunomaduro/collision": "^5.10.0|^6.0", + "nunomaduro/collision": "^5.11.0|^6.0.0", "pestphp/pest-plugin": "^1.0.0", "phpunit/phpunit": "10.0.x-dev" }, @@ -54,7 +54,10 @@ "prefer-stable": true, "config": { "sort-packages": true, - "preferred-install": "dist" + "preferred-install": "dist", + "allow-plugins": { + "pestphp/pest-plugin": true + } }, "bin": [ "bin/pest" diff --git a/src/Expectation.php b/src/Expectation.php index f98638f7..191d762e 100644 --- a/src/Expectation.php +++ b/src/Expectation.php @@ -56,13 +56,13 @@ final class Expectation */ public function and(mixed $value): Expectation { - return new self($value); + return $value instanceof static ? $value : new self($value); } /** * Creates a new expectation with the decoded JSON value. * - * @return self + * @return self|bool> */ public function json(): Expectation { @@ -70,7 +70,10 @@ final class Expectation InvalidExpectationValue::expected('string'); } - return $this->toBeJson()->and(json_decode($this->value, true)); + /** @var array|bool $value */ + $value = json_decode($this->value, true); + + return $this->toBeJson()->and($value); } /** @@ -125,8 +128,8 @@ final class Expectation } if (is_callable($callback)) { - foreach ($this->value as $item) { - $callback(new self($item)); + foreach ($this->value as $key => $item) { + $callback(new self($item), $key); } } @@ -148,6 +151,7 @@ final class Expectation throw new BadMethodCallException('Expectation value is not iterable.'); } + //@phpstan-ignore-next-line $value = is_array($this->value) ? $this->value : iterator_to_array($this->value); $keys = array_keys($value); $values = array_values($value); diff --git a/src/Expectations/HigherOrderExpectation.php b/src/Expectations/HigherOrderExpectation.php index 83c43643..f3aa62e7 100644 --- a/src/Expectations/HigherOrderExpectation.php +++ b/src/Expectations/HigherOrderExpectation.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Pest\Expectations; +use Closure; use Pest\Concerns\Retrievable; use Pest\Expectation; @@ -79,6 +80,31 @@ final class HigherOrderExpectation return $this->expect($value); } + /** + * Scope an expectation callback to the current value in + * the HigherOrderExpectation chain. + * + * @param Closure(Expectation): void $expectation + * + * @return HigherOrderExpectation + */ + public function scoped(Closure $expectation): self + { + $expectation->__invoke($this->expectation); + + return new self($this->original, $this->original->value); + } + + /** + * Creates a new expectation with the decoded JSON value. + * + * @return self|bool> + */ + public function json(): self + { + return new self($this->original, $this->expectation->json()->value); + } + /** * Dynamically calls methods on the class with the given arguments. * diff --git a/src/Support/Backtrace.php b/src/Support/Backtrace.php index 09d9e438..56ac4fc9 100644 --- a/src/Support/Backtrace.php +++ b/src/Support/Backtrace.php @@ -26,6 +26,8 @@ final class Backtrace $current = null; foreach (debug_backtrace(self::BACKTRACE_OPTIONS) as $trace) { + assert(array_key_exists(self::FILE, $trace)); + if (Str::endsWith($trace[self::FILE], 'overrides/Runner/TestSuiteLoader.php')) { break; } @@ -45,7 +47,11 @@ final class Backtrace */ public static function file(): string { - return debug_backtrace(self::BACKTRACE_OPTIONS)[1][self::FILE]; + $trace = debug_backtrace(self::BACKTRACE_OPTIONS)[1]; + + assert(array_key_exists(self::FILE, $trace)); + + return $trace[self::FILE]; } /** @@ -53,7 +59,11 @@ final class Backtrace */ public static function dirname(): string { - return dirname(debug_backtrace(self::BACKTRACE_OPTIONS)[1][self::FILE]); + $trace = debug_backtrace(self::BACKTRACE_OPTIONS)[1]; + + assert(array_key_exists(self::FILE, $trace)); + + return dirname($trace[self::FILE]); } /** @@ -61,6 +71,10 @@ final class Backtrace */ public static function line(): int { - return debug_backtrace(self::BACKTRACE_OPTIONS)[1]['line']; + $trace = debug_backtrace(self::BACKTRACE_OPTIONS)[1]; + + assert(array_key_exists('line', $trace)); + + return $trace['line']; } } diff --git a/src/Support/HigherOrderCallables.php b/src/Support/HigherOrderCallables.php index c6533929..1ccf1f9f 100644 --- a/src/Support/HigherOrderCallables.php +++ b/src/Support/HigherOrderCallables.php @@ -44,7 +44,7 @@ final class HigherOrderCallables * * @param callable|TValue $value * - * @return Expectation + * @return Expectation<(callable(): mixed)|TValue> */ public function and(mixed $value) { @@ -52,9 +52,9 @@ final class HigherOrderCallables } /** - * Tap into the test case to perform an action and return the test case. + * Execute the given callable after the test has executed the setup method. */ - public function tap(callable $callable): object + public function defer(callable $callable): object { Reflection::bindCallableWithData($callable); diff --git a/tests/Features/Expect/HigherOrder/methods.php b/tests/Features/Expect/HigherOrder/methods.php index d6da636b..3c03ddb5 100644 --- a/tests/Features/Expect/HigherOrder/methods.php +++ b/tests/Features/Expect/HigherOrder/methods.php @@ -74,8 +74,37 @@ it('works with higher order tests') ->name()->toEqual('Has Methods') ->books()->each->toBeArray; +it('can use the scoped method to lock into the given level for expectations', function () { + expect(new HasMethods()) + ->attributes()->scoped(fn ($attributes) => $attributes + ->name->toBe('Has Methods') + ->quantity->toBe(20) + ) + ->name()->toBeString()->toBe('Has Methods') + ->newInstance()->newInstance()->scoped(fn ($instance) => $instance + ->name()->toBe('Has Methods') + ->quantity()->toBe(20) + ->attributes()->scoped(fn ($attributes) => $attributes + ->name->toBe('Has Methods') + ->quantity->toBe(20) + ) + ); +}); + +it('works consistently with the json expectation method', function () { + expect(new HasMethods()) + ->jsonString()->json()->id->toBe(1) + ->jsonString()->json()->name->toBe('Has Methods')->toBeString() + ->jsonString()->json()->quantity->toBe(20)->toBeInt(); +}); + class HasMethods { + public function jsonString(): string + { + return '{ "id": 1, "name": "Has Methods", "quantity": 20 }'; + } + public function name() { return 'Has Methods'; diff --git a/tests/Features/Expect/HigherOrder/methodsAndProperties.php b/tests/Features/Expect/HigherOrder/methodsAndProperties.php index b98c9f16..c9ef45aa 100644 --- a/tests/Features/Expect/HigherOrder/methodsAndProperties.php +++ b/tests/Features/Expect/HigherOrder/methodsAndProperties.php @@ -48,6 +48,15 @@ it('can start a new higher order expectation using the and syntax in higher orde ->toBeArray() ->foo->toEqual('bar'); +it('can start a new higher order expectation using the and syntax without nesting expectations', function () { + expect(new HasMethodsAndProperties()) + ->toBeInstanceOf(HasMethodsAndProperties::class) + ->meta + ->sequence( + function ($value, $key) { $value->toBeArray()->and($key)->toBe('foo'); }, + ); +}); + class HasMethodsAndProperties { public $name = 'Has Methods and Properties'; diff --git a/tests/Features/Expect/each.php b/tests/Features/Expect/each.php index 0f26a974..63b3d60c 100644 --- a/tests/Features/Expect/each.php +++ b/tests/Features/Expect/each.php @@ -87,3 +87,11 @@ it('accepts callables', function () { expect(static::getCount())->toBe(12); }); + +it('passes the key of the current item to callables', function () { + expect([1, 2, 3])->each(function ($number, $key) { + expect($key)->toBeInt(); + }); + + expect(static::getCount())->toBe(3); +}); diff --git a/tests/Features/HigherOrderTests.php b/tests/Features/HigherOrderTests.php index e2ff0686..7aafbaa5 100644 --- a/tests/Features/HigherOrderTests.php +++ b/tests/Features/HigherOrderTests.php @@ -21,9 +21,9 @@ it('resolves expect callables correctly') test('does not treat method names as callables') ->expect('it')->toBeString(); -it('can tap into the test') +it('can defer a method until after test setup') ->expect('foo')->toBeString() - ->tap(function () { expect($this)->toBeInstanceOf(TestCase::class); }) + ->defer(function () { expect($this)->toBeInstanceOf(TestCase::class); }) ->toBe('foo') ->and('hello world')->toBeString(); @@ -32,15 +32,15 @@ it('can pass datasets into the expect callables') ->expect(function (...$numbers) { return $numbers; })->toBe([1, 2, 3]) ->and(function (...$numbers) { return $numbers; })->toBe([1, 2, 3]); -it('can pass datasets into the tap callable') +it('can pass datasets into the defer callable') ->with([[1, 2, 3]]) - ->tap(function (...$numbers) { expect($numbers)->toBe([1, 2, 3]); }); + ->defer(function (...$numbers) { expect($numbers)->toBe([1, 2, 3]); }); it('can pass shared datasets into callables') ->with('numbers.closure.wrapped') ->expect(function ($value) { return $value; }) ->and(function ($value) { return $value; }) - ->tap(function ($value) { expect($value)->toBeInt(); }) + ->defer(function ($value) { expect($value)->toBeInt(); }) ->toBeInt(); afterEach()->assertTrue(true);