diff --git a/CHANGELOG.md b/CHANGELOG.md index e05343c..5b517e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- Support for `Closure` as a dynamic return value on filter hook expectations. A closure passed to `onFilter(...)->with(...)->reply()` or as the second argument of `expectFilter()` is now invoked with the runtime filter arguments and its return value is used as the filter reply. + +### Removed +- Typo method `iExcpectWhenIRun()` in the Behat `FunctionsContext` (it duplicated a step definition and prevented the Behat suite from running at all due to a parse error). + ## [1.1.1](https://github.com/10up/wp_mock/compare/1.1.0...1.1.1) - 2025-12-03 ### Fixed - Address PHP deprecation warnings about implicitly nullable parameters diff --git a/docs/usage/mocking-wp-action-and-filter-hooks.md b/docs/usage/mocking-wp-action-and-filter-hooks.md index c79e52e..343e257 100644 --- a/docs/usage/mocking-wp-action-and-filter-hooks.md +++ b/docs/usage/mocking-wp-action-and-filter-hooks.md @@ -201,7 +201,7 @@ use MyPlugin\NewClass; final class MyClassTest extends TestCase { - public function testAnonymousObject() : void + public function testAnonymousObject() : void { WP_Mock::expectFilter('custom_content_filter', WP_Mock\Functions::type(NewClass::class)); @@ -209,4 +209,32 @@ final class MyClassTest extends TestCase $this->assertConditionsMet(); } } +``` + +## Dynamic return values with a Closure + +When a single static return value is not enough, pass a `Closure` to `reply()`. The closure is invoked with the runtime arguments passed to `apply_filters()` and its return value is used as the filter reply. This is useful for loop or iteration scenarios where the expected value changes per call. + +```php +WP_Mock::onFilter('custom_content_filter') + ->withAnyArgs() + ->reply(function ( $value ) { + return strtoupper( $value ); + }); + +apply_filters( 'custom_content_filter', 'hello' ); // 'HELLO' +apply_filters( 'custom_content_filter', 'world' ); // 'WORLD' +``` + +The same works through `WP_Mock::expectFilter()`. Pass the `Closure` as the second argument and the runtime filter value(s) are forwarded into it on each call: + +```php +WP_Mock::expectFilter('custom_content_filter', function ( $value ) { + return strtoupper( $value ); +}); + +apply_filters( 'custom_content_filter', 'hello' ); // 'HELLO' +``` + +The closure can take as many arguments as the filter is invoked with. Returning a value from the closure is required; actions are not affected. ``` \ No newline at end of file diff --git a/features/bootstrap/FunctionsContext.php b/features/bootstrap/FunctionsContext.php index e4325af..2fbc18d 100644 --- a/features/bootstrap/FunctionsContext.php +++ b/features/bootstrap/FunctionsContext.php @@ -87,15 +87,6 @@ public function iExpectWhenIRunWithArgs( $return, $function, TableNode $args ) { \PHPUnit\Framework\Assert::assertEquals( $return, call_user_func_array( $function, $args->getRow( 0 ) ) ); } - /** - * @Then I expect :return when I run :function - * - * @deprected use static::iExpectWhenIRun instead - */ - public function iExcpectWhenIRun( $return, $function ) { - static::iExpectWhenIRun( $return, $function ) - } - /** * @Then I expect :return when I run :function */ diff --git a/features/bootstrap/HooksContext.php b/features/bootstrap/HooksContext.php index ac0d4be..1d9d0ca 100644 --- a/features/bootstrap/HooksContext.php +++ b/features/bootstrap/HooksContext.php @@ -176,6 +176,29 @@ public function iExpectFilterToRespondWith( $filter, $response ) { $this->iExpectFilterToRespondToWith( $filter, null, $response ); } + /** + * @Given I expect filter :filter to reply dynamically with a :operation closure + */ + public function iExpectFilterToReplyDynamicallyWithClosure( $filter, $operation ) { + $callback = $this->makeDynamicReplyCallback( $operation ); + WP_Mock::onFilter( $filter )->with( \Closure::class )->reply( $callback ); + } + + private function makeDynamicReplyCallback( $operation ) { + switch ( $operation ) { + case 'uppercase': + return function ( $value ) { + return strtoupper( (string) $value ); + }; + case 'concat': + return function ( $a, $b ) { + return $a . $b; + }; + } + + throw new \InvalidArgumentException( sprintf( 'Unknown dynamic-reply operation "%s"', $operation ) ); + } + /** * @When I apply the filter :filter with :with */ diff --git a/features/hooks.feature b/features/hooks.feature index 45f169a..42ac0c8 100644 --- a/features/hooks.feature +++ b/features/hooks.feature @@ -226,3 +226,14 @@ Feature: Hook mocking | foobar | bazbat | When I do nothing Then tearDown should not fail + + Scenario: filter reply can be a closure invoked with runtime args + Given I expect filter "the_content" to reply dynamically with a uppercase closure + When I apply the filter "the_content" with "hello" + Then The filter "the_content" should return "HELLO" + + Scenario: filter reply closure receives all runtime args + Given I expect filter "the_content" to reply dynamically with a concat closure + When I apply the filter "the_content" with: + | foo | bar | + Then The filter "the_content" should return "foobar" diff --git a/php/WP_Mock.php b/php/WP_Mock.php index 49ef1f3..ee3c769 100644 --- a/php/WP_Mock.php +++ b/php/WP_Mock.php @@ -244,19 +244,37 @@ public static function expectAction(string $action) : void /** * Adds an expectation that a filter will be applied during the test. * + * A `\Closure` may be passed as a variadic argument; it is invoked with the runtime filter arguments and its return value is used as the filter reply. Non-Closure callables are not supported in this slot and will fall through to the standard pass-through behavior. + * * @param string $filter expected filter * @return void */ public static function expectFilter(string $filter) : void { + $args = func_num_args() > 1 ? array_slice(func_get_args(), 1) : array( null ); + + $callback = null; + foreach ($args as $arg) { + if ($arg instanceof \Closure) { + $callback = $arg; + break; + } + } + + $mocked_filter = self::onFilter($filter); + + if ($callback !== null) { + /** @var \WP_Mock\Filter_Responder $responder */ + $responder = $mocked_filter->with(\Closure::class); + $responder->reply($callback); + return; + } + $intercept = Mockery::mock('intercept'); $intercept->shouldReceive('intercepted')->atLeast()->once()->andReturnUsing(function ($value) { return $value; }); - $args = func_num_args() > 1 ? array_slice(func_get_args(), 1) : array( null ); - - $mocked_filter = self::onFilter($filter); - $responder = call_user_func_array(array( $mocked_filter, 'with' ), $args); + $responder = call_user_func_array(array( $mocked_filter, 'with' ), $args); $responder->reply(new WP_Mock\InvokedFilterValue(array( $intercept, 'intercepted' ))); } diff --git a/php/WP_Mock/Filter.php b/php/WP_Mock/Filter.php index bde7979..51334b1 100644 --- a/php/WP_Mock/Filter.php +++ b/php/WP_Mock/Filter.php @@ -27,8 +27,13 @@ public function apply($args) if ($args[0] === null && count($args) === 1) { if (isset($this->processors['argsnull'])) { - return $this->processors['argsnull']->send(); + return call_user_func_array(array($this->processors['argsnull'], 'send'), $args); } + + if (isset($this->processors['__CLOSURE__'])) { + return call_user_func_array(array($this->processors['__CLOSURE__'], 'send'), $args); + } + $this->strict_check(); return null; @@ -38,6 +43,10 @@ public function apply($args) foreach ($args as $arg) { $key = $this->safe_offset($arg); if (! is_array($processors) || ! isset($processors[ $key ])) { + if (isset($this->processors['__CLOSURE__'])) { + return call_user_func_array(array($this->processors['__CLOSURE__'], 'send'), $args); + } + $this->strict_check(); return $arg; @@ -91,6 +100,10 @@ public function reply($value) public function send() { + if ($this->value instanceof \Closure) { + return ($this->value)(...func_get_args()); + } + if ($this->value instanceof InvokedFilterValue) { return call_user_func_array($this->value, func_get_args()); } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9ca9448..d1f7ab0 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -287,7 +287,7 @@ parameters: - message: "#^Parameter \\#1 \\$function of function call_user_func_array expects callable\\(\\)\\: mixed, array\\{mixed, 'send'\\} given\\.$#" - count: 1 + count: 4 path: php/WP_Mock/Filter.php - diff --git a/tests/Unit/WP_MockTest.php b/tests/Unit/WP_MockTest.php index 82f34c0..d2d76ef 100644 --- a/tests/Unit/WP_MockTest.php +++ b/tests/Unit/WP_MockTest.php @@ -401,4 +401,81 @@ public function testMultipleOnFilterPassesWithAnyArgs(): void Mockery::close(); } + + /** + * @covers \WP_Mock::expectFilter() + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + * @throws Exception|InvalidArgumentException + */ + public function testExpectFilterWithClosureRepliesDynamically(): void + { + WP_Mock::bootstrap(); + + WP_Mock::expectFilter('testFilter', function ($value) { + return strtoupper($value); + }); + + $this->assertSame('HELLO', apply_filters('testFilter', 'hello')); + $this->assertSame('WORLD', apply_filters('testFilter', 'world')); + + WP_Mock::assertFiltersCalled(); + + Mockery::close(); + } + + /** + * @covers \WP_Mock::onFilter() + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + * @throws Exception|InvalidArgumentException + */ + public function testOnFilterWithClosureReplyIsInvokedWithRuntimeArgs(): void + { + WP_Mock::bootstrap(); + + /** @phpstan-ignore-next-line */ + WP_Mock::onFilter('testFilter') + ->with(\Closure::class) + ->reply(function ($a, $b) { + return $a . $b; + }); + + /** @phpstan-ignore-next-line */ + $this->assertSame('ab', apply_filters('testFilter', 'a', 'b')); + + Mockery::close(); + } + + /** + * @covers \WP_Mock::onFilter() + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + * @throws Exception|InvalidArgumentException + */ + public function testOnFilterWithClosureMatcherAndClosureReply(): void + { + WP_Mock::bootstrap(); + + /** @phpstan-ignore-next-line */ + WP_Mock::onFilter('testFilter') + ->with(\Closure::class) + ->reply(function ($a, $b) { + return $a . '-' . $b; + }); + + /** @phpstan-ignore-next-line */ + $this->assertSame('x-y', apply_filters('testFilter', 'x', 'y')); + + Mockery::close(); + } }