diff --git a/.gitattributes b/.gitattributes index 65a6a4e..76da65a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -21,19 +21,20 @@ *.gif binary *.ttf binary +# Ignore some meta files when creating an archive of this repository +/.github export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.phpunit-watcher.yml export-ignore +/.scrutinizer.yml export-ignore +/.styleci.yml export-ignore +/infection.json.dist export-ignore +/phpunit.xml.dist export-ignore +/psalm.xml export-ignore +/tests export-ignore +/docs export-ignore + # Avoid merge conflicts in CHANGELOG # https://about.gitlab.com/2015/02/10/gitlab-reduced-merge-conflicts-by-90-percent-with-changelog-placeholders/ /CHANGELOG.md merge=union - -# Exclude files from the archive -/.gitattributes export-ignore -/.github export-ignore -/.gitignore export-ignore -/.styleci.yml export-ignore -/codeception.yml export-ignore -/composer-require-checker.json export-ignore -/docs export-ignore -/phpunit.xml.dist export-ignore -/psalm.xml export-ignore -/rector.php export-ignore -/tests export-ignore diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d286d28..9b51512 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,8 +6,6 @@ on: - 'CHANGELOG.md' - '.gitignore' - '.gitattributes' - - 'infection.json.dist' - - 'psalm.xml' push: paths-ignore: @@ -16,18 +14,11 @@ on: - 'CHANGELOG.md' - '.gitignore' - '.gitattributes' - - 'infection.json.dist' - - 'psalm.xml' name: build jobs: phpunit: - uses: php-forge/actions/.github/workflows/phpunit.yml@main + uses: php-forge/actions/.github/workflows/phpunit.yml@v1 secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - with: - os: >- - ['ubuntu-latest', 'windows-latest'] - php: >- - ['8.1', '8.2', '8.3'] diff --git a/.github/workflows/dependency-check.yml b/.github/workflows/dependency-check.yml index dde6447..48115b8 100644 --- a/.github/workflows/dependency-check.yml +++ b/.github/workflows/dependency-check.yml @@ -6,9 +6,6 @@ on: - 'CHANGELOG.md' - '.gitignore' - '.gitattributes' - - 'infection.json.dist' - - 'phpunit.xml.dist' - - 'psalm.xml' push: paths-ignore: @@ -17,17 +14,9 @@ on: - 'CHANGELOG.md' - '.gitignore' - '.gitattributes' - - 'infection.json.dist' - - 'phpunit.xml.dist' - - 'psalm.xml' name: dependency-check jobs: composer-require-checker: - uses: php-forge/actions/.github/workflows/composer-require-checker.yml@main - with: - os: >- - ['ubuntu-latest'] - php: >- - ['8.1'] + uses: php-forge/actions/.github/workflows/composer-require-checker.yml@v1 diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index ef6929b..091ee70 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -6,7 +6,6 @@ on: - 'CHANGELOG.md' - '.gitignore' - '.gitattributes' - - 'psalm.xml' push: paths-ignore: @@ -15,17 +14,13 @@ on: - 'CHANGELOG.md' - '.gitignore' - '.gitattributes' - - 'psalm.xml' name: mutation test jobs: mutation: - uses: php-forge/actions/.github/workflows/roave-infection.yml@main + uses: php-forge/actions/.github/workflows/infection.yml@v1 + with: + phpstan: true secrets: STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} - with: - os: >- - ['ubuntu-latest'] - php: >- - ['8.1'] diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index a8e7acb..2e31d77 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -6,8 +6,6 @@ on: - 'CHANGELOG.md' - '.gitignore' - '.gitattributes' - - 'infection.json.dist' - - 'phpunit.xml.dist' push: paths-ignore: @@ -16,16 +14,9 @@ on: - 'CHANGELOG.md' - '.gitignore' - '.gitattributes' - - 'infection.json.dist' - - 'phpunit.xml.dist' name: static analysis jobs: psalm: - uses: php-forge/actions/.github/workflows/psalm.yml@main - with: - os: >- - ['ubuntu-latest'] - php: >- - ['8.1', '8.2', '8.3'] + uses: php-forge/actions/.github/workflows/phpstan.yml@v1 diff --git a/.gitignore b/.gitignore index b5300c1..bb7ff36 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,34 @@ -#code coverage -/code_coverage +# phpstorm project files +.idea + +# netbeans project files +nbproject + +# zend studio for eclipse project files +.buildpath +.project +.settings + +# windows thumbnail cache +Thumbs.db + +# Mac DS_Store Files +.DS_Store # composer vendor dir /vendor -/composer.lock -#node_modules -/node_modules +# composer lock file +/composer.lock -# phpstorm project files -.idea +# composer itself is not needed +composer.phar -# phpunit -.phpunit.cache -.phpunit.result.cache -phpunit.xml +# phpunit itself is not needed phpunit.phar -#yii3 config packages -/config/packages +# local phpunit config +/phpunit.xml -# windows thumbnail cache -Thumbs.db +# phpunit cache +.phpunit.result.cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d8d8ea..5222d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ Change Log ========== -## 0.1.1 Under development +## 0.1.1 August 18, 2025 + +- Bug #11: Refactor project structure and update dependencies (@terabytesoftw) ## 0.1.0 January 21, 2024 diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 136b000..0000000 --- a/LICENSE +++ /dev/null @@ -1,16 +0,0 @@ -MIT License - -Copyright (c) 2024 by Wilmer Arámbula (https://github.com/terabytesoftw) All rights reserved. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit -persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the -Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE -WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..a76279a --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,27 @@ +# BSD 3-Clause License + +Copyright © 2008 by Terabytesoftw () +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of PHP Forge (Terabytesoftw) nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index bb14c8f..12e2718 100644 --- a/README.md +++ b/README.md @@ -2,168 +2,189 @@ -

Support.

+

Support utilities for enhanced testing capabilities.


+ + PHP Version + PHPUnit - - Codecov - - Infection - - - Psalm - - - Psalm Coverage - - - Style ci - + Infection + + + Static Analysis +

-## Installation +## Features + +✅ **Advanced Reflection Utilities** +- Access and modify private and protected properties via reflection. +- Invoke inaccessible methods to expand testing coverage. + +✅ **Cross-Platform String Assertions** +- Eliminate false positives/negatives caused by Windows vs. Unix line-ending differences. +- Normalize line endings for consistent string comparisons across platforms. + +✅ **File System Test Management** +- Recursively clean files and directories for isolated test environments. +- Safe removal that preserves Git-tracking files (for example, '.gitignore', '.gitkeep'). + +## Quick start + +### System requirements + +- [`PHP`](https://www.php.net/downloads) 8.1 or higher. +- [`Composer`](https://getcomposer.org/download/) for dependency management. +- [`PHPUnit`](https://phpunit.de/) for testing framework integration. + +### Installation -The preferred way to install this extension is through [composer](https://getcomposer.org/download/). +#### Method 1: Using [Composer](https://getcomposer.org/download/) (recommended) -Either run +Install the extension. -```shell -composer require --prefer-dist php-forge/support +```bash +composer require --dev --prefer-dist php-forge/support:^0.1 ``` -or add +#### Method 2: Manual installation + +Add to your `composer.json`. ```json -"php-forge/support": "^0.1" +{ + "require-dev": { + "php-forge/support": "^0.1" + } +} ``` -to the require-dev section of your `composer.json` file. +Then run. + +```bash +composer update +``` -## Usage +## Basic Usage -### Equals without line ending +### Accessing private properties ```php assertSame('bar', Assert::inaccessibleProperty($object, 'foo')); +// normalize line endings for consistent comparisons +Assert::equalsWithoutLE( + "Foo\r\nBar", + "Foo\nBar", + "Should match regardless of line ending style" +); ``` -### Invoke method +### Invoking protected methods ```php assertSame('foo', Assert::invokeMethod($object, 'foo')); +// test protected method behavior +$result = Assert::invokeMethod($object, 'calculate', [5, 3]); + +self::assertSame(8, $result); ``` -### Set inaccessible property +### Remove files from directory ```php assertSame('baz', Assert::inaccessibleProperty($object, 'foo')); +// clean up test artifacts (preserves '.gitignore' and '.gitkeep') +Assert::removeFilesFromDirectory($testDir); ``` -### Remove files from directory +### Set inaccessible property ```php assertFileDoesNotExist($dir . '/test.txt'); +$newValue = Assert::inaccessibleProperty($object, 'config'); -rmdir(__DIR__ . '/runtime'); +self::assertSame('test-mode', $newValue); ``` -## Support versions +## Documentation + +For comprehensive testing guidance, see: -[![PHP81](https://img.shields.io/badge/PHP-%3E%3D8.1-787CB5)](https://www.php.net/releases/8.1/en.php) -[![Yii30](https://img.shields.io/badge/Yii%20version-3.0-blue)](https://yiiframework.com) +- 🧪 [Testing Guide](docs/testing.md) -## Testing +## Quality code -[Check the documentation testing](/docs/testing.md) to learn about testing. +[![Latest Stable Version](https://poser.pugx.org/php-forge/support/v)](https://github.com/php-forge/support/releases) +[![Total Downloads](https://poser.pugx.org/php-forge/support/downloads)](https://packagist.org/packages/php-forge/support) +[![codecov](https://codecov.io/gh/php-forge/support/graph/badge.svg?token=Upc4yA23YN)](https://codecov.io/gh/php-forge/support) +[![phpstan-level](https://img.shields.io/badge/PHPStan%20level-max-blue)](https://github.com/php-forge/support/actions/workflows/static.yml) +[![StyleCI](https://github.styleci.io/repos/661073468/shield?branch=main)](https://github.styleci.io/repos/661073468?branch=main) ## Our social networks -[![Twitter](https://img.shields.io/badge/twitter-follow-1DA1F2?logo=twitter&logoColor=1DA1F2&labelColor=555555?style=flat)](https://twitter.com/Terabytesoftw) +[![X](https://img.shields.io/badge/follow-@terabytesoftw-1DA1F2?logo=x&logoColor=1DA1F2&labelColor=555555&style=flat)](https://x.com/Terabytesoftw) ## License -The MIT License. Please see [License File](LICENSE.md) for more information. +[![License](https://img.shields.io/github/license/php-forge/support?cacheSeconds=0)](LICENSE.md) diff --git a/code_coverage/.gitignore b/code_coverage/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/code_coverage/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/composer.json b/composer.json index 5eda0d1..bb1cb90 100644 --- a/composer.json +++ b/composer.json @@ -9,16 +9,18 @@ "testing", "tests" ], - "license": "MIT", + "license": "BSD-3-Clause", "require": { "php": "^8.1", "phpunit/phpunit": "^10.5" }, "require-dev": { + "infection/infection": "^0.27|^0.31", "maglnet/composer-require-checker": "^4.7", - "roave/infection-static-analysis-plugin": "^1.34", - "symplify/easy-coding-standard": "^12.1", - "vimeo/psalm": "^5.20" + "phpstan/phpstan-strict-rules": "^2.0.3", + "symplify/easy-coding-standard": "^12.5", + "phpstan/phpstan": "^2.1", + "rector/rector": "^2.1" }, "autoload": { "psr-4": { @@ -38,17 +40,17 @@ "config": { "sort-packages": true, "allow-plugins": { - "infection/extension-installer": true + "infection/extension-installer": true, + "phpstan/extension-installer": true } }, "scripts": { - "check-dependencies": "composer-require-checker", - "easy-coding-standard": "ecs --fix", - "mutation": [ - "Composer\\Config::disableProcessTimeout", - "roave-infection-static-analysis-plugin --threads=2 --only-covered" - ], - "psalm": "psalm", - "test": "phpunit" + "check-dependencies": "./vendor/bin/composer-require-checker check", + "ecs": "./vendor/bin/ecs --fix", + "mutation": "./vendor/bin/infection --threads=4 --ignore-msi-with-no-mutations --only-covered --min-msi=100 --min-covered-msi=100", + "mutation-static": "./vendor/bin/infection --threads=4 --ignore-msi-with-no-mutations --only-covered --min-msi=100 --min-covered-msi=100 --static-analysis-tool=phpstan", + "rector": "./vendor/bin/rector process src", + "static": "./vendor/bin/phpstan --memory-limit=512M", + "tests": "./vendor/bin/phpunit" } } diff --git a/docs/testing.md b/docs/testing.md index b2aa5df..2838bf5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,31 +4,46 @@ This package uses [composer-require-checker](https://github.com/maglnet/ComposerRequireChecker) to check if all dependencies are correctly defined in `composer.json`. -To run the checker, execute the following command: +To run the checker, execute the following command. ```shell composer run check-dependencies ``` +## Easy coding standard + +The code is checked with [Easy Coding Standard](https://github.com/easy-coding-standard/easy-coding-standard) and +[PHP CS Fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer). To run it. + +```shell +composer run ecs +``` + ## Mutation testing -Mutation testing is checked with [Infection](https://infection.github.io/). To run it: +Mutation testing is checked with [Infection](https://infection.github.io/). To run it. ```shell composer run mutation ``` +With PHPStan analysis, it will also check for static analysis issues during mutation testing. + +```shell +composer run mutation-static +``` + ## Static analysis -The code is statically analyzed with [Psalm](https://psalm.dev/). To run static analysis: +The code is statically analyzed with [PHPStan](https://phpstan.org/). To run static analysis. ```shell -composer run psalm +composer run static ``` -## Unit tests +## Unit Tests -The code is tested with [PHPUnit](https://phpunit.de/). To run tests: +The code is tested with [PHPUnit](https://phpunit.de/). To run tests. ```shell composer run test diff --git a/ecs.php b/ecs.php index 78342b8..00781d8 100644 --- a/ecs.php +++ b/ecs.php @@ -5,43 +5,74 @@ use PhpCsFixer\Fixer\ClassNotation\ClassDefinitionFixer; use PhpCsFixer\Fixer\ClassNotation\OrderedClassElementsFixer; use PhpCsFixer\Fixer\ClassNotation\OrderedTraitsFixer; +use PhpCsFixer\Fixer\ClassNotation\VisibilityRequiredFixer; use PhpCsFixer\Fixer\Import\NoUnusedImportsFixer; +use PhpCsFixer\Fixer\Import\OrderedImportsFixer; +use PhpCsFixer\Fixer\StringNotation\SingleQuoteFixer; use Symplify\EasyCodingStandard\Config\ECSConfig; -use Symplify\EasyCodingStandard\ValueObject\Set\SetList; -return function (ECSConfig $ecsConfig): void { - $ecsConfig->paths( +return ECSConfig::configure() + ->withConfiguredRule( + ClassDefinitionFixer::class, + [ + 'space_before_parenthesis' => true, + ], + ) + ->withConfiguredRule( + OrderedClassElementsFixer::class, + [ + 'order' => [ + 'use_trait', + 'constant_public', + 'constant_protected', + 'constant_private', + 'case', + 'property_public', + 'property_protected', + 'property_private', + 'construct', + 'destruct', + 'magic', + 'phpunit', + 'method_public', + 'method_protected', + 'method_private', + ], + 'sort_algorithm' => 'alpha', + ], + ) + ->withConfiguredRule( + OrderedImportsFixer::class, + [ + 'imports_order' => ['class', 'function', 'const'], + 'sort_algorithm' => 'alpha', + ], + ) + ->withConfiguredRule( + VisibilityRequiredFixer::class, + [ + 'elements' => [], + ], + ) + ->withFileExtensions(['php']) + ->withPaths( [ __DIR__ . '/src', __DIR__ . '/tests', - ] - ); - - // this way you add a single rule - $ecsConfig->rules( + ], + ) + ->withPhpCsFixerSets(perCS20: true) + ->withPreparedSets( + cleanCode: true, + comments: true, + docblocks: true, + namespaces: true, + strict: true, + ) + ->withRules( [ - OrderedClassElementsFixer::class, - OrderedTraitsFixer::class, NoUnusedImportsFixer::class, + OrderedTraitsFixer::class, + SingleQuoteFixer::class, ] ); - - // this way you can add sets - group of rules - $ecsConfig->sets( - [ - // run and fix, one by one - SetList::DOCBLOCK, - SetList::NAMESPACES, - SetList::COMMENTS, - SetList::PSR_12, - ] - ); - - // this way configures a rule - $ecsConfig->ruleWithConfiguration( - ClassDefinitionFixer::class, - [ - 'space_before_parenthesis' => true, - ], - ); -}; diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..63c0f31 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,21 @@ +includes: + - phar://phpstan.phar/conf/bleedingEdge.neon + +parameters: + level: max + + paths: + - src + - tests + + tmpDir: %currentWorkingDirectory%/runtime + + # Enable strict advanced checks + checkImplicitMixed: true + checkBenevolentUnionTypes: true + checkUninitializedProperties: true + checkMissingCallableSignature: true + checkTooWideReturnTypesInProtectedAndPublicMethods: true + reportAnyTypeWideningInVarTag: true + reportPossiblyNonexistentConstantArrayOffset: true + reportPossiblyNonexistentGeneralArrayOffset: true diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e12bbda..e5d7313 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,19 +1,24 @@ - - - - - - - - ./tests - - - - - ./src - - - ./vendor - - + + + + + tests + + + + + + ./src + + diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index 23bfcce..0000000 --- a/psalm.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..4a7a938 --- /dev/null +++ b/rector.php @@ -0,0 +1,28 @@ +parallel(); + + $rectorConfig->importNames(); + + $rectorConfig->paths( + [ + __DIR__ . '/src', + __DIR__ . '/tests', + ], + ); + + $rectorConfig->sets( + [ + Rector\Set\ValueObject\SetList::PHP_81, + Rector\Set\ValueObject\LevelSetList::UP_TO_PHP_81, + Rector\Set\ValueObject\SetList::TYPE_DECLARATION, + ], + ); + + $rectorConfig->rule( + Rector\CodeQuality\Rector\BooleanAnd\SimplifyEmptyArrayCheckRector::class + ); +}; diff --git a/runtime/.gitignore b/runtime/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/runtime/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/Assert.php b/src/Assert.php index 87e26f4..b5ada78 100644 --- a/src/Assert.php +++ b/src/Assert.php @@ -4,14 +4,15 @@ namespace PHPForge\Support; -use PHPUnit\Framework\TestCase; use ReflectionClass; +use ReflectionException; use ReflectionObject; use RuntimeException; use function basename; use function closedir; use function is_dir; +use function is_string; use function opendir; use function readdir; use function rmdir; @@ -19,16 +20,37 @@ use function unlink; /** - * @psalm-suppress PropertyNotSetInConstructor + * Assertion utility class for advanced test introspection and manipulation. + * + * Provides static helper methods for accessing and modifying inaccessible properties and methods invoking parent class + * logic, and performing file system cleanup in test environments. + * + * Extends {@see \PHPUnit\Framework\Assert} to offer additional capabilities for testing private/protected members and + * for managing test artifacts, supporting robust and isolated unit tests. + * + * Key features. + * - Access and modify inaccessible (private/protected) properties and methods via reflection. + * - Invoke parent class methods and properties for testing inheritance scenarios. + * - Normalize line endings for cross-platform string assertions. + * - Remove files and directories recursively for test environment cleanup. + * + * @copyright Copyright (C) 2025 PHPForge. + * @license https://opensource.org/license/bsd-3-clause BSD 3-Clause License. */ -final class Assert extends TestCase +final class Assert extends \PHPUnit\Framework\Assert { /** - * Asserting two strings equality ignoring line endings. + * Asserts that two strings are equal after normalizing line endings to unix style ('\n'). * - * @param string $expected The expected string. - * @param string $actual The actual string. - * @param string $message The message to display if the assertion fails. + * Replaces all windows style ('\r\n') line endings with unix style ('\n') in both the expected and actual strings + * before performing the equality assertion. + * + * This ensures cross-platform consistency in string comparisons where line ending differences may otherwise cause + * `false` negatives. + * + * @param string $expected Expected string value, with any line endings. + * @param string $actual Actual string value, with any line endings. + * @param string $message Optional failure message to display if the assertion fails. Default is an empty string. */ public static function equalsWithoutLE(string $expected, string $actual, string $message = ''): void { @@ -39,74 +61,142 @@ public static function equalsWithoutLE(string $expected, string $actual, string } /** - * Gets an inaccessible object property. + * Retrieves the value of an inaccessible property from a parent class instance. + * + * Uses reflection to access the specified property of the given parent class, allowing tests to inspect or assert + * the value of private or protected properties inherited from parent classes. + * + * This method is useful for verifying the internal state of objects in inheritance scenarios where direct access + * to parent properties is not possible. + * + * @param object $object Object instance from which to retrieve the property value. + * @param object|string $className Name or instance of the parent class containing the property. + * @param string $propertyName Name of the property to access. + * + * @throws ReflectionException + * + * @return mixed Value of the specified parent property. + * + * @phpstan-param class-string|object $className + */ + public static function inaccessibleParentProperty( + object $object, + string|object $className, + string $propertyName, + ): mixed { + $class = new ReflectionClass($className); + + return $class->getProperty($propertyName)->getValue($object); + } + + /** + * Retrieves the value of an inaccessible property from an object or class instance. + * + * Uses reflection to access the specified property of the given object or class, allowing tests to inspect or + * assert the value of private or protected properties that are otherwise inaccessible. + * + * This method is useful for verifying the internal state of objects during testing, especially when direct access + * to the property is not possible due to visibility constraints. * - * @param object $object The object to get the property from. - * @param string $propertyName The name of the property to get. + * @param object|string $object Name of the class or object instance from which to retrieve the property value. + * @param string $propertyName Name of the property to access. + * + * @throws ReflectionException if the property does not exist or is inaccessible. + * + * @return mixed Value of the specified property, or `null` if the property name is empty. + * + * @phpstan-param class-string|object $object */ - public static function inaccessibleProperty(object $object, string $propertyName): mixed + public static function inaccessibleProperty(string|object $object, string $propertyName): mixed { $class = new ReflectionClass($object); - $result = null; - if ($propertyName !== '') { $property = $class->getProperty($propertyName); - - /** @psalm-var mixed $result */ - $result = $property->getValue($object); + $result = is_string($object) ? $property->getValue() : $property->getValue($object); } - return $result; + return $result ?? null; } /** - * Invokes an inaccessible method. + * Invokes an inaccessible method on the given object instance with the specified arguments. * - * @param object $object The object to invoke the method on. - * @param string $method The name of the method to invoke. - * @param array $args The arguments to pass to the method. + * Uses reflection to access and invoke a private or protected method of the provided object, allowing tests to + * execute logic that is not publicly accessible. + * + * This is useful for verifying internal behavior or side effects during unit testing. + * + * @param object $object Object instance containing the method to invoke. + * @param string $method Name of the method to invoke. + * @param array $args Arguments to pass to the method invocation. + * + * @throws ReflectionException if the method does not exist or is inaccessible. + * + * @return mixed Value of the invoked method, or `null` if the method name is empty. + * + * @phpstan-param array $args */ public static function invokeMethod(object $object, string $method, array $args = []): mixed { $reflection = new ReflectionObject($object); - $result = null; - if ($method !== '') { $method = $reflection->getMethod($method); - - /** @psalm-var mixed $result */ $result = $method->invokeArgs($object, $args); } - return $result; + return $result ?? null; } /** - * Sets an inaccessible object property to a designated value. + * Invokes an inaccessible method from a parent class on the given object instance with the specified arguments. + * + * Uses reflection to access and invoke a private or protected method defined in the specified parent class, + * allowing tests to execute logic that is not publicly accessible from the child class. + * + * This is useful for verifying inherited behavior or side effects during unit testing of subclasses. + * + * @param object $object Object instance containing the method to invoke. + * @param string $parentClass Name of the parent class containing the method. + * @param string $method Name of the method to invoke. + * @param array $args Arguments to pass to the method invocation. + * + * @throws ReflectionException if the method does not exist or is inaccessible in the parent class. + * + * @return mixed Value of the invoked method, or `null` if the method name is empty. + * + * @phpstan-param class-string $parentClass + * @phpstan-param array $args */ - public static function setInaccessibleProperty( + public static function invokeParentMethod( object $object, - string $propertyName, - mixed $value - ): void { - $class = new ReflectionClass($object); + string $parentClass, + string $method, + array $args = [], + ): mixed { + $reflection = new ReflectionClass($parentClass); - if ($propertyName !== '') { - $property = $class->getProperty($propertyName); - $property->setValue($object, $value); + if ($method !== '') { + $method = $reflection->getMethod($method); + $result = $method->invokeArgs($object, $args); } - unset($class, $property); + return $result ?? null; } /** - * Remove files from the directory. + * Removes all files and directories recursively from the specified base path, excluding '.gitignore' and + * '.gitkeep'. + * + * Opens the given directory, iterates through its contents, and removes all files and subdirectories except for + * special entries ('.', '..', '.gitignore', '.gitkeep'). * - * @param string $basePath The directory to remove files from. + * Subdirectories are processed recursively before removal. * - * @throws RuntimeException + * @param string $basePath Absolute path to the directory whose contents will be removed. + * + * @throws RuntimeException if the directory cannot be opened for reading. */ public static function removeFilesFromDirectory(string $basePath): void { @@ -134,4 +224,67 @@ public static function removeFilesFromDirectory(string $basePath): void closedir($handle); } + + /** + * Sets the value of an inaccessible property on a parent class instance. + * + * Uses reflection to assign the specified value to a private or protected property defined in the given parent + * class, enabling tests to modify internal state that is otherwise inaccessible due to visibility constraints in + * inheritance scenarios. + * + * This method is useful for testing scenarios that require direct manipulation of parent class internals. + * + * @param object $object Object instance whose parent property will be set. + * @param string $parentClass Name of the parent class containing the property. + * @param string $propertyName Name of the property to set. + * @param mixed $value Value to assign to the property. + * + * @throws ReflectionException if the property does not exist or is inaccessible in the parent class. + * + * @phpstan-param class-string $parentClass + */ + public static function setInaccessibleParentProperty( + object $object, + string $parentClass, + string $propertyName, + mixed $value, + ): void { + $class = new ReflectionClass($parentClass); + + if ($propertyName !== '') { + $property = $class->getProperty($propertyName); + $property->setValue($object, $value); + } + + unset($class, $property); + } + + /** + * Sets the value of an inaccessible property on the given object instance. + * + * Uses reflection to assign the specified value to a private or protected property of the provided object enabling + * tests to modify internal state that is otherwise inaccessible due to visibility constraints. + * + * This method is useful for testing scenarios that require direct manipulation of object internals. + * + * @param object $object Object instance whose property will be set. + * @param string $propertyName Name of the property to set. + * @param mixed $value Value to assign to the property. + * + * @throws ReflectionException if the property does not exist or is inaccessible. + */ + public static function setInaccessibleProperty( + object $object, + string $propertyName, + mixed $value, + ): void { + $class = new ReflectionClass($object); + + if ($propertyName !== '') { + $property = $class->getProperty($propertyName); + $property->setValue($object, $value); + } + + unset($class, $property); + } } diff --git a/tests/AssertTest.php b/tests/AssertTest.php index d102a4e..7e90880 100644 --- a/tests/AssertTest.php +++ b/tests/AssertTest.php @@ -5,68 +5,153 @@ namespace PHPForge\Support\Tests; use PHPForge\Support\Assert; +use PHPForge\Support\Tests\Stub\{TestBaseClass, TestClass}; use PHPUnit\Framework\TestCase; +use ReflectionException; use RuntimeException; /** - * @psalm-suppress PropertyNotSetInConstructor + * Test suite for {@see Assert} utility methods and reflection helpers. + * + * Verifies the behavior of assertion and reflection-based utility methods for testing inaccessible properties, parent + * properties, and methods, as well as file system operations for test directories. + * + * These tests ensure correct access and mutation of private/protected members, invocation of inaccessible methods, and + * robust file removal logic, including error handling for non-existent directories. + * + * Test coverage. + * - Accessing and asserting values of inaccessible properties and parent properties. + * - Ensuring correct exception handling for invalid operations. + * - Invoking inaccessible methods and parent methods. + * - Removing files from directories and handling missing directories. + * - Setting values for inaccessible properties and parent properties. + * + * @copyright Copyright (C) 2025 Terabytesoftw. + * @license https://opensource.org/license/bsd-3-clause BSD 3-Clause License. */ final class AssertTest extends TestCase { - public function testEqualsWithoutLE(): void + public function testEqualsWithoutLEReturnsTrueWhenStringsAreIdenticalWithLineEndings(): void { - Assert::equalsWithoutLE('foo' . "\r\n" . 'bar', 'foo' . "\r\n" . 'bar'); + Assert::equalsWithoutLE( + "foo\r\nbar", + "foo\r\nbar", + "Should return 'true' when both strings are identical including line endings.", + ); } - public function testInaccessibleProperty(): void + /** + * @throws ReflectionException + */ + public function testInaccessibleParentPropertyReturnsExpectedValue(): void { - $object = new class () { - private string $foo = 'bar'; - }; - - $this->assertSame('bar', Assert::inaccessibleProperty($object, 'foo')); + self::assertSame( + 'valueParent', + Assert::inaccessibleParentProperty( + new TestClass(), + TestBaseClass::class, + 'propertyParent', + ), + "Should return the value of the parent property 'propertyParent' when accessed via reflection.", + ); } - public function testInvokeMethod(): void + public function testRemoveFilesFromDirectoryRemovesAllFiles(): void { - $object = new class () { - protected function foo(): string - { - return 'foo'; - } - }; - - $this->assertSame('foo', Assert::invokeMethod($object, 'foo')); + $dir = dirname(__DIR__) . '/runtime'; + + mkdir("{$dir}/subdir"); + touch("{$dir}/test.txt"); + touch("{$dir}/subdir/test.txt"); + + Assert::removeFilesFromDirectory($dir); + + $this->assertFileDoesNotExist( + "{$dir}/test.txt", + "File 'test.txt' should not exist after 'removeFilesFromDirectory' method is called.", + ); + $this->assertFileDoesNotExist( + "{$dir}/subdir/test.txt", + "File 'subdir/test.txt' should not exist after 'removeFilesFromDirectory' method is called.", + ); } - public function testSetInaccessibleProperty(): void + /** + * @throws ReflectionException + */ + public function testReturnInaccessiblePropertyValueWhenPropertyIsPrivate(): void { - $object = new class () { - private string $foo = 'bar'; - }; + self::assertSame( + 'value', + Assert::inaccessibleProperty(new TestClass(), 'property'), + "Should return the value of the private property 'property' when accessed via reflection.", + ); + } - Assert::setInaccessibleProperty($object, 'foo', 'baz'); + /** + * @throws ReflectionException + */ + public function testReturnValueWhenInvokingInaccessibleMethod(): void + { + $this->assertSame( + 'value', + Assert::invokeMethod(new TestClass(), 'inaccessibleMethod'), + "Should return 'value' when invoking the inaccessible method 'inaccessibleParentMethod' on 'TestClass' " . + 'via reflection.', + ); + } - $this->assertSame('baz', Assert::inaccessibleProperty($object, 'foo')); + /** + * @throws ReflectionException + */ + public function testReturnValueWhenInvokingInaccessibleParentMethod(): void + { + $this->assertSame( + 'valueParent', + Assert::invokeParentMethod( + new TestClass(), + TestBaseClass::class, + 'inaccessibleParentMethod', + ), + "Should return 'valueParent' when invoking the inaccessible parent method 'inaccessibleParentMethod' on " . + "'TestClass' via reflection.", + ); } - public function testRemoveFilesFromDirectory(): void + /** + * @throws ReflectionException + */ + public function testSetInaccessibleParentProperty(): void { - $dir = __DIR__ . '/runtime'; + $object = new TestClass(); - mkdir($dir); - mkdir($dir . '/subdir'); - touch($dir . '/test.txt'); - touch($dir . '/subdir/test.txt'); + Assert::setInaccessibleParentProperty($object, TestBaseClass::class, 'propertyParent', 'foo'); - Assert::removeFilesFromDirectory($dir); + $this->assertSame( + 'foo', + Assert::inaccessibleParentProperty($object, TestBaseClass::class, 'propertyParent'), + "Should return 'foo' after setting the parent property 'propertyParent' via " . + "'setInaccessibleParentProperty' method.", + ); + } + + /** + * @throws ReflectionException + */ + public function testSetInaccessiblePropertySetsValueCorrectly(): void + { + $object = new TestClass(); - $this->assertFileDoesNotExist($dir . '/test.txt'); + Assert::setInaccessibleProperty($object, 'property', 'foo'); - rmdir(__DIR__ . '/runtime'); + $this->assertSame( + 'foo', + Assert::inaccessibleProperty($object, 'property'), + "Should return 'foo' after setting the private property 'property' via 'setInaccessibleProperty' method.", + ); } - public function testRemoveFilesFromDirectoryWithException(): void + public function testThrowRuntimeExceptionWhenRemoveFilesFromDirectoryNonExistingDirectory(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Unable to open directory: non-existing-directory'); diff --git a/tests/Stub/TestBaseClass.php b/tests/Stub/TestBaseClass.php new file mode 100644 index 0000000..8606b59 --- /dev/null +++ b/tests/Stub/TestBaseClass.php @@ -0,0 +1,23 @@ +propertyParent; + } +} diff --git a/tests/Stub/TestClass.php b/tests/Stub/TestClass.php new file mode 100644 index 0000000..95936f9 --- /dev/null +++ b/tests/Stub/TestClass.php @@ -0,0 +1,23 @@ +property; + } +}