From 9da2c7d4214324b6577294e207c7953b050389f5 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:20:03 +0100 Subject: [PATCH 1/4] Prepare tests environment --- .github/workflows/release.yaml | 4 +- .gitignore | 6 + .vscode/settings.json | 3 +- composer.json | 12 +- composer.lock | 2405 ++++++++++++++++++++++++++------ formwork/.php-cs-fixer.php | 5 +- formwork/.rector.php | 1 + formwork/config/system.yaml | 1 + formwork/phpstan.neon | 1 + phpunit.xml.dist | 18 + tests/Environment.php | 30 + tests/TestCase.php | 37 + tests/bootstrap.php | 17 + 13 files changed, 2133 insertions(+), 407 deletions(-) create mode 100644 phpunit.xml.dist create mode 100644 tests/Environment.php create mode 100644 tests/TestCase.php create mode 100644 tests/bootstrap.php diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 57a6c4a28..bf21ab0ab 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -45,7 +45,9 @@ jobs: zip -r formwork-${{ github.event.release.tag_name }}.zip . -x \ \*.git/\* \ \*.github/\* \ - \*node_modules/\* + \*node_modules/\* \ + \tests/\* \ + \phpunit.xml.dist - name: Upload release assets uses: svenstaro/upload-release-action@v2 diff --git a/.gitignore b/.gitignore index 54cea3deb..9731761ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .DS_Store .php-cs-fixer.cache +.phpunit.cache + +phpunit.xml /backup/* /cache/* @@ -18,4 +21,7 @@ /site/users/accounts/* /site/users/images/* +/tests/coverage/ +/tests/tmp/ + !.gitkeep diff --git a/.vscode/settings.json b/.vscode/settings.json index dc8131d80..09a3f86d2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,9 @@ { "eslint.workingDirectories": ["./panel"], - "eslint.experimental.useFlatConfig": true, + "eslint.useFlatConfig": true, "prettier.tabWidth": 4, "prettier.useEditorConfig": true, "prettier.embeddedLanguageFormatting": "off", "stylelint.configBasedir": "./panel", + "intelephense.files.exclude": ["**/tests/**/functions.php"] } diff --git a/composer.json b/composer.json index 2e2738e4c..ef1237caf 100644 --- a/composer.json +++ b/composer.json @@ -35,11 +35,13 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", "phpstan/phpstan": "^2.0.3", - "rector/rector": "^2.0.3" + "rector/rector": "^2.0.3", + "phpunit/phpunit": "^12.0" }, "scripts": { - "fix": "php-cs-fixer fix --config=formwork/.php-cs-fixer.php --cache-file=formwork/.php-cs-fixer.cache --verbose", - "fix:check": "php-cs-fixer check --config=formwork/.php-cs-fixer.php --cache-file=formwork/.php-cs-fixer.cache", + "fix": "php-cs-fixer fix --config=formwork/.php-cs-fixer.php --verbose", + "fix:check": "php-cs-fixer check --config=formwork/.php-cs-fixer.php", + "fix:tests": "php-cs-fixer fix tests/ --config=formwork/.php-cs-fixer.php --verbose", "phpstan": "phpstan analyse --configuration=formwork/phpstan.neon", "phpstan:baseline": "phpstan analyse --configuration=formwork/phpstan.neon --generate-baseline=formwork/phpstan-baseline.neon", "rector": "rector --config=formwork/.rector.php", @@ -47,6 +49,8 @@ "serve": [ "Composer\\Config::disableProcessTimeout", "php bin/serve" - ] + ], + "test": "phpunit", + "test:coverage": "phpunit -d pcov.enabled=1 --coverage-html tests/coverage" } } diff --git a/composer.lock b/composer.lock index de93ed769..7c04f9062 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3401b16f31d82d76621fc0029be2c7c8", + "content-hash": "c242fdd6cc08959cc37503e1c18f1946", "packages": [ { "name": "dflydev/dot-access-data", @@ -1804,722 +1804,2236 @@ "time": "2025-10-24T12:05:10+00:00" }, { - "name": "phpstan/phpstan", - "version": "2.1.32", + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e126cad1e30a99b137b8ed75a85a676450ebb227", - "reference": "e126cad1e30a99b137b8ed75a85a676450ebb227", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { - "php": "^7.4|^8.0" + "php": "^7.1 || ^8.0" }, "conflict": { - "phpstan/phpstan-shim": "*" + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, - "bin": [ - "phpstan", - "phpstan.phar" - ], "type": "library", "autoload": { "files": [ - "bootstrap.php" - ] + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "PHPStan - PHP Static Analysis Tool", + "description": "Create deep copies (clones) of your objects", "keywords": [ - "dev", - "static analysis" + "clone", + "copy", + "duplicate", + "object", + "object graph" ], "support": { - "docs": "https://phpstan.org/user-guide/getting-started", - "forum": "https://github.com/phpstan/phpstan/discussions", - "issues": "https://github.com/phpstan/phpstan/issues", - "security": "https://github.com/phpstan/phpstan/security/policy", - "source": "https://github.com/phpstan/phpstan-src" + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { - "url": "https://github.com/ondrejmirtes", - "type": "github" - }, - { - "url": "https://github.com/phpstan", - "type": "github" + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" } ], - "time": "2025-11-11T15:18:17+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { - "name": "psr/container", - "version": "2.0.2", + "name": "nikic/php-parser", + "version": "v5.7.0", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { - "php": ">=7.4.0" + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" }, + "bin": [ + "bin/php-parse" + ], "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "5.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Container\\": "src/" + "PhpParser\\": "lib/PhpParser" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Nikita Popov" } ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "A PHP parser written in PHP", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "parser", + "php" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2021-11-05T16:47:00+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { - "name": "react/cache", - "version": "v1.2.0", + "name": "phar-io/manifest", + "version": "2.0.4", "source": { "type": "git", - "url": "https://github.com/reactphp/cache.git", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { - "php": ">=5.3.0", - "react/promise": "^3.0 || ^2.0 || ^1.1" - }, - "require-dev": { - "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Cache\\": "src/" + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" }, { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "Async, Promise-based cache interface for ReactPHP", - "keywords": [ - "cache", - "caching", - "promise", - "reactphp" - ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { - "issues": "https://github.com/reactphp/cache/issues", - "source": "https://github.com/reactphp/cache/tree/v1.2.0" + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/theseer", + "type": "github" } ], - "time": "2022-11-30T15:59:55+00:00" + "time": "2024-03-03T12:33:53+00:00" }, { - "name": "react/child-process", - "version": "v0.6.6", + "name": "phar-io/version", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/reactphp/child-process.git", - "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159" + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/child-process/zipball/1721e2b93d89b745664353b9cfc8f155ba8a6159", - "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.0", - "react/event-loop": "^1.2", - "react/stream": "^1.4" - }, - "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/socket": "^1.16", - "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + "php": "^7.2 || ^8.0" }, "type": "library", "autoload": { - "psr-4": { - "React\\ChildProcess\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" }, { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "Event-driven library for executing child processes with ReactPHP.", + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.1.32", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e126cad1e30a99b137b8ed75a85a676450ebb227", + "reference": "e126cad1e30a99b137b8ed75a85a676450ebb227", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ - "event-driven", - "process", - "reactphp" + "dev", + "static analysis" ], "support": { - "issues": "https://github.com/reactphp/child-process/issues", - "source": "https://github.com/reactphp/child-process/tree/v0.6.6" + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" } ], - "time": "2025-01-01T16:37:48+00:00" + "time": "2025-11-11T15:18:17+00:00" }, { - "name": "react/dns", - "version": "v1.13.0", + "name": "phpunit/php-code-coverage", + "version": "12.5.2", "source": { "type": "git", - "url": "https://github.com/reactphp/dns.git", - "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5" + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/dns/zipball/eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", - "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", + "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", "shasum": "" }, "require": { - "php": ">=5.3.0", - "react/cache": "^1.0 || ^0.6 || ^0.5", - "react/event-loop": "^1.2", - "react/promise": "^3.2 || ^2.7 || ^1.2.1" + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-file-iterator": "^6.0", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.0.3", + "sebastian/lines-of-code": "^4.0", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4.3 || ^3 || ^2", - "react/promise-timer": "^1.11" + "phpunit/phpunit": "^12.5.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Dns\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:03:04+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "961bc913d42fe24a257bfff826a5068079ac7782" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/961bc913d42fe24a257bfff826a5068079ac7782", + "reference": "961bc913d42fe24a257bfff826a5068079ac7782", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:37+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4ba0e923f9d3fc655de22f9547c01d15a41fc93a", + "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.1", + "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.0", + "sebastian/comparator": "^7.1.3", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.0.3", + "sebastian/exporter": "^7.0.2", + "sebastian/global-state": "^8.0.2", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/type": "^6.0.3", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.4" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2025-12-15T06:05:34+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.6", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/1721e2b93d89b745664353b9cfc8f155ba8a6159", + "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.6" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-01-01T16:37:48+00:00" + }, + { + "name": "react/dns", + "version": "v1.13.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.13.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-13T14:18:03+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2023-11-13T13:48:05+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.16.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.16.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-07-26T10:38:09+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", "email": "reactphp@ceesjankiewiet.nl", "homepage": "https://wyrihaximus.net/" }, { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "rector/rector", + "version": "2.2.10", + "source": { + "type": "git", + "url": "https://github.com/rectorphp/rector.git", + "reference": "2abbf73dc953fdc5a556c0c3d5c47aa4da47d34c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/2abbf73dc953fdc5a556c0c3d5c47aa4da47d34c", + "reference": "2abbf73dc953fdc5a556c0c3d5c47aa4da47d34c", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "phpstan/phpstan": "^2.1.32" + }, + "conflict": { + "rector/rector-doctrine": "*", + "rector/rector-downgrade-php": "*", + "rector/rector-phpunit": "*", + "rector/rector-symfony": "*" + }, + "suggest": { + "ext-dom": "To manipulate phpunit.xml via the custom-rule command" + }, + "bin": [ + "bin/rector" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Instant Upgrade and Automated Refactoring of any PHP code", + "homepage": "https://getrector.com/", + "keywords": [ + "automation", + "dev", + "migration", + "refactoring" + ], + "support": { + "issues": "https://github.com/rectorphp/rector/issues", + "source": "https://github.com/rectorphp/rector/tree/2.2.10" + }, + "funding": [ + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2025-12-01T10:59:13+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2025-09-14T09:36:45+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/dc904b4bb3ab070865fa4068cd84f3da8b945148", + "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.2" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2025-08-20T11:27:00+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:46+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "Async DNS resolver for ReactPHP", + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", "keywords": [ - "async", - "dns", - "dns-resolver", - "reactphp" + "Xdebug", + "environment", + "hhvm" ], "support": { - "issues": "https://github.com/reactphp/dns/issues", - "source": "https://github.com/reactphp/dns/tree/v1.13.0" + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2024-06-13T14:18:03+00:00" + "time": "2025-08-12T14:11:56+00:00" }, { - "name": "react/event-loop", - "version": "v1.5.0", + "name": "sebastian/exporter", + "version": "7.0.2", "source": { "type": "git", - "url": "https://github.com/reactphp/event-loop.git", - "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354" + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "016951ae10980765e4e7aee491eb288c64e505b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", - "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", + "reference": "016951ae10980765e4e7aee491eb288c64e505b7", "shasum": "" }, "require": { - "php": ">=5.3.0" + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" - }, - "suggest": { - "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + "phpunit/phpunit": "^12.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\EventLoop\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" }, { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "name": "Volker Dusch", + "email": "github@wallbash.com" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" } ], - "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", "keywords": [ - "asynchronous", - "event-loop" + "export", + "exporter" ], "support": { - "issues": "https://github.com/reactphp/event-loop/issues", - "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2023-11-13T13:48:05+00:00" + "time": "2025-09-24T06:16:11+00:00" }, { - "name": "react/promise", - "version": "v3.3.0", + "name": "sebastian/global-state", + "version": "8.0.2", "source": { "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d", "shasum": "" }, "require": { - "php": ">=7.1.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "phpstan/phpstan": "1.12.28 || 1.4.10", - "phpunit/phpunit": "^9.6 || ^7.5" + "ext-dom": "*", + "phpunit/phpunit": "^12.0" }, "type": "library", - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "React\\Promise\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" }, { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" }, { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" } ], - "description": "A lightweight implementation of CommonJS Promises/A for PHP", - "keywords": [ - "promise", - "promises" + "time": "2025-08-29T11:29:25+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.3.0" + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" } ], - "time": "2025-08-19T18:57:03+00:00" + "time": "2025-02-07T04:57:28+00:00" }, { - "name": "react/socket", - "version": "v1.16.0", + "name": "sebastian/object-enumerator", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/socket.git", - "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1" + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/socket/zipball/23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", - "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.0", - "react/dns": "^1.13", - "react/event-loop": "^1.2", - "react/promise": "^3.2 || ^2.6 || ^1.2.1", - "react/stream": "^1.4" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4.3 || ^3.3 || ^2", - "react/promise-stream": "^1.4", - "react/promise-timer": "^1.11" + "phpunit/phpunit": "^12.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, "autoload": { - "psr-4": { - "React\\Socket\\": "src/" + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", - "keywords": [ - "Connection", - "Socket", - "async", - "reactphp", - "stream" - ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { - "issues": "https://github.com/reactphp/socket/issues", - "source": "https://github.com/reactphp/socket/tree/v1.16.0" + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" } ], - "time": "2024-07-26T10:38:09+00:00" + "time": "2025-02-07T04:58:17+00:00" }, { - "name": "react/stream", - "version": "v1.4.0", + "name": "sebastian/recursion-context", + "version": "7.0.1", "source": { "type": "git", - "url": "https://github.com/reactphp/stream.git", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.8", - "react/event-loop": "^1.2" + "php": ">=8.3" }, "require-dev": { - "clue/stream-filter": "~1.2", - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + "phpunit/phpunit": "^12.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Stream\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", - "keywords": [ - "event-driven", - "io", - "non-blocking", - "pipe", - "reactphp", - "readable", - "stream", - "writable" - ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "issues": "https://github.com/reactphp/stream/issues", - "source": "https://github.com/reactphp/stream/tree/v1.4.0" + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2024-06-11T12:45:25+00:00" + "time": "2025-08-13T04:44:59+00:00" }, { - "name": "rector/rector", - "version": "2.2.10", + "name": "sebastian/type", + "version": "6.0.3", "source": { "type": "git", - "url": "https://github.com/rectorphp/rector.git", - "reference": "2abbf73dc953fdc5a556c0c3d5c47aa4da47d34c" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/2abbf73dc953fdc5a556c0c3d5c47aa4da47d34c", - "reference": "2abbf73dc953fdc5a556c0c3d5c47aa4da47d34c", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", "shasum": "" }, "require": { - "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.32" - }, - "conflict": { - "rector/rector-doctrine": "*", - "rector/rector-downgrade-php": "*", - "rector/rector-phpunit": "*", - "rector/rector-symfony": "*" + "php": ">=8.3" }, - "suggest": { - "ext-dom": "To manipulate phpunit.xml via the custom-rule command" + "require-dev": { + "phpunit/phpunit": "^12.0" }, - "bin": [ - "bin/rector" - ], "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, "autoload": { - "files": [ - "bootstrap.php" + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], - "description": "Instant Upgrade and Automated Refactoring of any PHP code", - "homepage": "https://getrector.com/", - "keywords": [ - "automation", - "dev", - "migration", - "refactoring" + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.2.10" + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" }, "funding": [ { - "url": "https://github.com/tomasvotruba", + "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2025-12-01T10:59:13+00:00" + "time": "2025-08-09T06:57:12+00:00" }, { - "name": "sebastian/diff", - "version": "7.0.0", + "name": "sebastian/version", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", "shasum": "" }, "require": { "php": ">=8.3" }, - "require-dev": { - "phpunit/phpunit": "^12.0", - "symfony/process": "^7.2" - }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -2534,33 +4048,76 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" + "url": "https://github.com/sebastianbergmann", + "type": "github" } ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" + "static analysis" ], "support": { - "issues": "https://github.com/sebastianbergmann/diff/issues", - "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/staabm", "type": "github" } ], - "time": "2025-02-07T04:55:46+00:00" + "time": "2024-10-20T05:08:20+00:00" }, { "name": "symfony/console", @@ -3675,6 +5232,56 @@ } ], "time": "2025-09-11T14:36:48+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" } ], "aliases": [], @@ -3692,5 +5299,5 @@ "ext-zip": "*" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/formwork/.php-cs-fixer.php b/formwork/.php-cs-fixer.php index 8fc98ff69..d91f92eed 100644 --- a/formwork/.php-cs-fixer.php +++ b/formwork/.php-cs-fixer.php @@ -4,7 +4,7 @@ $finder = Finder::create() ->in(dirname(__DIR__)) - ->exclude(['cache', 'formwork/views', 'panel/node_modules', 'panel/views', 'site/plugins', 'site/templates']); + ->exclude(['cache', 'formwork/views', 'panel/node_modules', 'panel/views', 'site/plugins', 'site/templates', 'tests']); $config = new Config(); @@ -51,4 +51,5 @@ 'single_quote' => true, 'string_implicit_backslashes' => true, ]) - ->setFinder($finder); + ->setFinder($finder) + ->setCacheFile(__DIR__ . '/.php-cs-fixer.cache'); diff --git a/formwork/.rector.php b/formwork/.rector.php index 0f178c4b6..6e9a032e6 100644 --- a/formwork/.rector.php +++ b/formwork/.rector.php @@ -37,6 +37,7 @@ dirname(__DIR__) . '/panel/views', dirname(__DIR__) . '/site/templates', dirname(__DIR__) . '/site/plugins', + dirname(__DIR__) . '/tests', dirname(__DIR__) . '/vendor', AddOverrideAttributeToOverriddenMethodsRector::class, ChangeSwitchToMatchRector::class, diff --git a/formwork/config/system.yaml b/formwork/config/system.yaml index 4988c6aac..7e39bc1a5 100644 --- a/formwork/config/system.yaml +++ b/formwork/config/system.yaml @@ -12,6 +12,7 @@ backup: - 'backup/*' - 'cache/*' - 'site/auth/*' + - 'tests/*' - 'vendor/*' - '*node_modules/*' diff --git a/formwork/phpstan.neon b/formwork/phpstan.neon index e1c10b625..0688ed5c9 100644 --- a/formwork/phpstan.neon +++ b/formwork/phpstan.neon @@ -12,6 +12,7 @@ parameters: - ../panel/views - ../site/templates - ../site/plugins + - ../tests - ../vendor scanFiles: - ../index.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 000000000..80decdf40 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,18 @@ + + + + + ./tests + + + + + + ./formwork/src + + + ./formwork/src/Images/Exif/tables + ./formwork/src/Utils/scripts + + + diff --git a/tests/Environment.php b/tests/Environment.php new file mode 100644 index 000000000..57544665a --- /dev/null +++ b/tests/Environment.php @@ -0,0 +1,30 @@ + true, + 'fileinfo' => true, + 'gd' => true, + 'mbstring' => true, + 'openssl' => true, + 'zip' => true, + ]; + + public static function enableExtension(string $extension): void + { + self::$extensions[$extension] = true; + } + + public static function disableExtension(string $extension): void + { + self::$extensions[$extension] = false; + } + + public static function isExtensionEnabled(string $extension): bool + { + return self::$extensions[$extension] ?? false; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 000000000..aeedd69f2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,37 @@ +getFileName()); + + if (FileSystem::exists($dir . '/fixtures/functions.php')) { + require_once $dir . '/fixtures/functions.php'; + } + } + + protected function setUpTempDirectory(): void + { + if (!FileSystem::isDirectory(TESTS_TMP_PATH, assertExists: false)) { + FileSystem::createDirectory(TESTS_TMP_PATH); + } + } + + protected function tearDownTempDirectory(): void + { + if (FileSystem::isDirectory(TESTS_TMP_PATH, assertExists: false)) { + FileSystem::deleteDirectory(TESTS_TMP_PATH, recursive: true); + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 000000000..041bcbbfc --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,17 @@ +addPsr4('Formwork\Tests\\', TESTS_PATH); +$autoloader->register(); From 33d5eb6ca9557ed9c34dc9df6fe695a6219c298d Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:23:20 +0100 Subject: [PATCH 2/4] Add tests for the `Utils` namespace --- tests/Utils/ArrTest.php | 1361 +++++++++++++++++ tests/Utils/ConstraintTest.php | 178 +++ tests/Utils/DateTest.php | 98 ++ tests/Utils/FileSystemTest.php | 970 ++++++++++++ tests/Utils/Fixtures/ArrayableFixture.php | 15 + tests/Utils/Fixtures/FileSystemFixture.php | 195 +++ tests/Utils/Fixtures/StringableFixture.php | 15 + tests/Utils/Fixtures/TraversableFixture.php | 16 + .../Utils/Fixtures/files/mimetype/invalid.svg | 1 + .../Utils/Fixtures/files/mimetype/sample.html | 9 + .../Utils/Fixtures/files/mimetype/sample.yaml | 1 + tests/Utils/Fixtures/files/mimetype/valid.svg | 1 + tests/Utils/Fixtures/files/tmp/dir/.hidden | 1 + tests/Utils/Fixtures/files/tmp/dir/b.txt | 1 + tests/Utils/Fixtures/files/tmp/dir/sample.txt | 1 + .../Utils/Fixtures/files/tmp/dir/subdir/a.txt | 1 + tests/Utils/Fixtures/files/tmp/sample.txt | 1 + tests/Utils/Fixtures/files/tmp/symlink | 1 + tests/Utils/Fixtures/functions.php | 101 ++ tests/Utils/HtmlTest.php | 56 + tests/Utils/MimeTypeTest.php | 63 + tests/Utils/PathTest.php | 209 +++ tests/Utils/StrTest.php | 164 ++ tests/Utils/TextTest.php | 76 + tests/Utils/UriTest.php | 165 ++ 25 files changed, 3700 insertions(+) create mode 100644 tests/Utils/ArrTest.php create mode 100644 tests/Utils/ConstraintTest.php create mode 100644 tests/Utils/DateTest.php create mode 100644 tests/Utils/FileSystemTest.php create mode 100644 tests/Utils/Fixtures/ArrayableFixture.php create mode 100644 tests/Utils/Fixtures/FileSystemFixture.php create mode 100644 tests/Utils/Fixtures/StringableFixture.php create mode 100644 tests/Utils/Fixtures/TraversableFixture.php create mode 100644 tests/Utils/Fixtures/files/mimetype/invalid.svg create mode 100644 tests/Utils/Fixtures/files/mimetype/sample.html create mode 100644 tests/Utils/Fixtures/files/mimetype/sample.yaml create mode 100644 tests/Utils/Fixtures/files/mimetype/valid.svg create mode 100644 tests/Utils/Fixtures/files/tmp/dir/.hidden create mode 100644 tests/Utils/Fixtures/files/tmp/dir/b.txt create mode 100644 tests/Utils/Fixtures/files/tmp/dir/sample.txt create mode 100644 tests/Utils/Fixtures/files/tmp/dir/subdir/a.txt create mode 100644 tests/Utils/Fixtures/files/tmp/sample.txt create mode 120000 tests/Utils/Fixtures/files/tmp/symlink create mode 100644 tests/Utils/Fixtures/functions.php create mode 100644 tests/Utils/HtmlTest.php create mode 100644 tests/Utils/MimeTypeTest.php create mode 100644 tests/Utils/PathTest.php create mode 100644 tests/Utils/StrTest.php create mode 100644 tests/Utils/TextTest.php create mode 100644 tests/Utils/UriTest.php diff --git a/tests/Utils/ArrTest.php b/tests/Utils/ArrTest.php new file mode 100644 index 000000000..606f43858 --- /dev/null +++ b/tests/Utils/ArrTest.php @@ -0,0 +1,1361 @@ + [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ], + 'date' => '2025-03-29', + ]; + + $this->assertSame('2025-03-29', Arr::get($data, 'date')); + $this->assertSame('Sempronius', Arr::get($data, 'user.name')); + $this->assertNull(Arr::get($data, 'user.age')); + } + + public function testHas(): void + { + $data = [ + 'user' => [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ], + 'date' => '2025-03-29', + ]; + + $this->assertTrue(Arr::has($data, 'date')); + $this->assertTrue(Arr::has($data, 'user.email')); + $this->assertFalse(Arr::has($data, 'user.age')); + } + + public function testSet(): void + { + $data = [ + 'user' => [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ], + 'date' => '2025-03-29', + ]; + + Arr::set($data, 'user.age', 30); + $this->assertSame(30, Arr::get($data, 'user.age')); + + Arr::set($data, 'roles.available', 'admin'); + $this->assertSame('admin', Arr::get($data, 'roles.available')); + + Arr::set($data, 'date', '2025-04-01'); + $this->assertSame('2025-04-01', Arr::get($data, 'date')); + } + + public function testRemove(): void + { + $data = [ + 'user' => [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ], + 'date' => '2025-03-29', + ]; + + Arr::remove($data, 'user.email'); + $this->assertFalse(Arr::has($data, 'user.email')); + + Arr::remove($data, 'roles.available'); + $this->assertFalse(Arr::has($data, 'roles.available')); + + Arr::remove($data, 'date'); + $this->assertFalse(Arr::has($data, 'date')); + } + + public function testFlatten(): void + { + $data = [ + 'app' => [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + ], + + ]; + + $expected = [ + 'app.theme' => 'dark', + 'app.notifications' => true, + 'app.roles' => ['admin', 'editor'], + 'app.permissions' => ['pages', 'files'], + 'app.cache.enabled' => true, + 'app.cache.duration' => 3600, + ]; + + $this->assertSame($expected, Arr::dot($data)); + } + + public function testExpand(): void + { + $data = [ + 'app' => [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + ], + ]; + + $dot = [ + 'app.theme' => 'dark', + 'app.notifications' => true, + 'app.roles' => ['admin', 'editor'], + 'app.permissions' => ['pages', 'files'], + 'app.cache.enabled' => true, + 'app.cache.duration' => 3600, + ]; + + $this->assertSame($data, Arr::undot($dot)); + } + + public function testPull(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + Arr::pull($data, 'banana'); + $this->assertNotContains('banana', $data); + } + + public function testSplice(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $removed = Arr::splice($data, 1, 2, ['orange', 'grapes']); + + $this->assertSame(['banana', 'cherry'], $removed); + $this->assertSame(['apple', 'orange', 'grapes', 'banana'], $data); + } + + public function testSpliceWithAssociativeArray(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $removed = Arr::splice($data, 1, 1, ['age' => 30]); + + $this->assertSame(['email' => 'sempronius@example.com'], $removed); + $this->assertSame(['name' => 'Sempronius', 'age' => 30, 'country' => 'Italy'], $data); + } + + public function testSpliceWithNegativeOffset(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $removed = Arr::splice($data, -2, 1, ['age' => 30]); + + $this->assertSame(['email' => 'sempronius@example.com'], $removed); + $this->assertSame(['name' => 'Sempronius', 'age' => 30, 'country' => 'Italy'], $data); + } + + public function testSpliceWithNegativeLength(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $removed = Arr::splice($data, 0, -1, ['age' => 30]); + + $this->assertSame(['name' => 'Sempronius', 'email' => 'sempronius@example.com'], $removed); + $this->assertSame(['age' => 30, 'country' => 'Italy'], $data); + } + + public function testSpliceThrowsOnDuplicateKeys(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Cannot replace 1 items from offset 1: some keys in the replacement array are the same of the resulting array'); + Arr::splice($data, 1, 1, ['country' => 'Canada']); + } + + public function testMove(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + Arr::moveItem($data, 0, 2); + $this->assertSame(['banana', 'cherry', 'apple', 'banana'], $data); + } + + public function testEntries(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $expected = [ + ['name', 'Sempronius'], + ['email', 'sempronius@example.com'], + ['country', 'Italy'], + ]; + + $this->assertSame($expected, Arr::entries($data)); + } + + public function testNth(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertSame('cherry', Arr::nth($data, 2)); + $this->assertNull(Arr::nth($data, 5)); + } + + public function testAt(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertSame('cherry', Arr::at($data, 2)); + $this->assertNull(Arr::at($data, 5)); + + $this->assertSame('banana', Arr::at($data, -1)); + $this->assertNull(Arr::at($data, -5)); + } + + public function testIndex(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertSame(1, Arr::indexOf($data, 'banana')); + $this->assertNull(Arr::indexOf($data, 'orange')); + } + + public function testKey(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $this->assertSame('email', Arr::keyOf($data, 'sempronius@example.com')); + $this->assertNull(Arr::keyOf($data, 'Brazil')); + } + + public function testDuplicates(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertSame([3 => 'banana'], Arr::duplicates($data)); + } + + public function testAppendMissing(): void + { + $data = [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + ]; + + $expected = [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor', 'user'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + 'language' => 'en', + ]; + + $result = Arr::appendMissing($data, ['roles' => ['admin', 'editor', 'user'], 'language' => 'en', 'cache' => false]); + $this->assertSame($expected, $result); + } + + public function testExtend(): void + { + // Test basic array extension with associative arrays + $base = [ + 'app' => [ + 'name' => 'Formwork', + 'debug' => false, + ], + 'cache' => [ + 'enabled' => true, + ], + ]; + + $extension = [ + 'app' => [ + 'version' => '2.0', + 'debug' => true, + ], + 'database' => [ + 'host' => 'localhost', + ], + ]; + + $expected = [ + 'app' => [ + 'name' => 'Formwork', + 'debug' => true, + 'version' => '2.0', + ], + 'cache' => [ + 'enabled' => true, + ], + 'database' => [ + 'host' => 'localhost', + ], + ]; + + $result = Arr::extend($base, $extension); + $this->assertSame($expected, $result); + } + + public function testExtendWithListConcatenation(): void + { + // Test that lists are concatenated instead of merged element-by-element + $base = [ + 'roles' => ['admin', 'editor'], + 'permissions' => ['read', 'write'], + ]; + + $extension = [ + 'roles' => ['user', 'guest'], + ]; + + $expected = [ + 'roles' => ['admin', 'editor', 'user', 'guest'], + 'permissions' => ['read', 'write'], + ]; + + $result = Arr::extend($base, $extension); + $this->assertSame($expected, $result); + } + + public function testExtendWithDeeplyNested(): void + { + // Test deeply nested structures + $base = [ + 'config' => [ + 'system' => [ + 'cache' => [ + 'enabled' => true, + 'lifetime' => 3600, + ], + 'features' => ['search', 'backup'], + ], + ], + ]; + + $extension = [ + 'config' => [ + 'system' => [ + 'cache' => [ + 'lifetime' => 7200, + 'driver' => 'file', + ], + 'features' => ['images'], + 'logging' => true, + ], + ], + ]; + + $expected = [ + 'config' => [ + 'system' => [ + 'cache' => [ + 'enabled' => true, + 'lifetime' => 7200, + 'driver' => 'file', + ], + 'features' => ['search', 'backup', 'images'], + 'logging' => true, + ], + ], + ]; + + $result = Arr::extend($base, $extension); + $this->assertSame($expected, $result); + } + + public function testExtendWithMultipleArrays(): void + { + // Test extending with multiple arrays + $base = [ + 'a' => 1, + 'b' => ['x' => 10], + ]; + + $ext1 = [ + 'b' => ['y' => 20], + 'c' => 3, + ]; + + $ext2 = [ + 'a' => 2, + 'd' => 4, + ]; + + $expected = [ + 'a' => 2, + 'b' => ['x' => 10, 'y' => 20], + 'c' => 3, + 'd' => 4, + ]; + + $result = Arr::extend($base, $ext1, $ext2); + $this->assertSame($expected, $result); + } + + public function testExtendWithEmptyArrays(): void + { + // Test extending empty arrays + $result = Arr::extend([], ['a' => 1]); + $this->assertSame(['a' => 1], $result); + + $result = Arr::extend(['a' => 1], []); + $this->assertSame(['a' => 1], $result); + + $result = Arr::extend([], []); + $this->assertSame([], $result); + } + + public function testExtendWithMixedTypes(): void + { + // Test that scalar values override arrays and vice versa + $base = [ + 'scalar_to_array' => 'value', + 'array_to_scalar' => ['a', 'b'], + 'keep_scalar' => 42, + ]; + + $extension = [ + 'scalar_to_array' => ['new' => 'array'], + 'array_to_scalar' => 'string', + 'keep_scalar' => 100, + ]; + + $expected = [ + 'scalar_to_array' => ['new' => 'array'], + 'array_to_scalar' => 'string', + 'keep_scalar' => 100, + ]; + + $result = Arr::extend($base, $extension); + $this->assertSame($expected, $result); + } + + public function testOverride(): void + { + // Test basic override functionality + $base = [ + 'app' => [ + 'name' => 'Formwork', + 'debug' => false, + ], + 'cache' => [ + 'enabled' => true, + ], + ]; + + $override = [ + 'app' => [ + 'debug' => true, + ], + 'database' => [ + 'host' => 'localhost', + ], + ]; + + $expected = [ + 'app' => [ + 'name' => 'Formwork', + 'debug' => true, + ], + 'cache' => [ + 'enabled' => true, + ], + 'database' => [ + 'host' => 'localhost', + ], + ]; + + $result = Arr::override($base, $override); + $this->assertSame($expected, $result); + } + + public function testOverrideReplacingLists(): void + { + // Test that lists are completely replaced, not merged + $base = [ + 'roles' => ['admin', 'editor'], + 'permissions' => ['read', 'write', 'delete'], + ]; + + $override = [ + 'roles' => ['user', 'guest'], + ]; + + $expected = [ + 'roles' => ['user', 'guest'], + 'permissions' => ['read', 'write', 'delete'], + ]; + + $result = Arr::override($base, $override); + $this->assertSame($expected, $result); + } + + public function testOverrideWithDeeplyNested(): void + { + // Test deeply nested structures with lists + $base = [ + 'config' => [ + 'system' => [ + 'features' => ['search', 'backup'], + 'cache' => [ + 'enabled' => true, + 'lifetime' => 3600, + 'stores' => ['file', 'redis'], + ], + ], + ], + ]; + + $override = [ + 'config' => [ + 'system' => [ + 'features' => ['images'], + 'cache' => [ + 'lifetime' => 7200, + 'stores' => ['memcached'], + ], + ], + ], + ]; + + $expected = [ + 'config' => [ + 'system' => [ + 'features' => ['images'], + 'cache' => [ + 'enabled' => true, + 'lifetime' => 7200, + 'stores' => ['memcached'], + ], + ], + ], + ]; + + $result = Arr::override($base, $override); + $this->assertSame($expected, $result); + } + + public function testOverrideWithMultipleArrays(): void + { + // Test overriding with multiple arrays + $base = [ + 'a' => 1, + 'b' => ['x' => 10, 'y' => [1, 2, 3]], + ]; + + $override1 = [ + 'b' => ['y' => [4, 5]], + 'c' => 3, + ]; + + $override2 = [ + 'a' => 2, + 'd' => 4, + ]; + + $expected = [ + 'a' => 2, + 'b' => ['x' => 10, 'y' => [4, 5]], + 'c' => 3, + 'd' => 4, + ]; + + $result = Arr::override($base, $override1, $override2); + $this->assertSame($expected, $result); + } + + public function testOverrideWithEmptyArrays(): void + { + // Test overriding with empty arrays + $result = Arr::override([], ['a' => 1]); + $this->assertSame(['a' => 1], $result); + + $result = Arr::override(['a' => 1], []); + $this->assertSame(['a' => 1], $result); + + $result = Arr::override([], []); + $this->assertSame([], $result); + } + + public function testOverrideReplacingListWithEmptyList(): void + { + // Test replacing a list with an empty list + $base = [ + 'items' => [1, 2, 3], + 'other' => 'value', + ]; + + $override = [ + 'items' => [], + ]; + + $expected = [ + 'items' => [], + 'other' => 'value', + ]; + + $result = Arr::override($base, $override); + $this->assertSame($expected, $result); + } + + public function testOverrideWithMixedTypes(): void + { + // Test that mixed types are properly replaced + $base = [ + 'scalar_to_array' => 'value', + 'array_to_scalar' => ['a', 'b'], + 'list_to_assoc' => [1, 2, 3], + 'assoc_to_list' => ['x' => 1, 'y' => 2], + ]; + + $override = [ + 'scalar_to_array' => ['new' => 'array'], + 'array_to_scalar' => 'string', + 'list_to_assoc' => ['a' => 1], + 'assoc_to_list' => [1, 2], + ]; + + $expected = [ + 'scalar_to_array' => ['new' => 'array'], + 'array_to_scalar' => 'string', + 'list_to_assoc' => ['a' => 1], + 'assoc_to_list' => [1, 2], + ]; + + $result = Arr::override($base, $override); + $this->assertSame($expected, $result); + } + + public function testExclude(): void + { + // Test basic exclusion functionality + $array = [ + 'name' => 'Formwork', + 'version' => '2.0', + 'debug' => true, + 'cache' => [ + 'enabled' => true, + 'lifetime' => 3600, + ], + ]; + + $exclusion = [ + 'debug' => true, + 'cache' => [ + 'lifetime' => 3600, + ], + ]; + + $expected = [ + 'name' => 'Formwork', + 'version' => '2.0', + 'cache' => [ + 'enabled' => true, + ], + ]; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testExcludeRemovingEmptyArrays(): void + { + // Test that empty arrays resulting from recursion are removed + $array = [ + 'settings' => [ + 'cache' => [ + 'enabled' => true, + ], + ], + 'other' => 'value', + ]; + + $exclusion = [ + 'settings' => [ + 'cache' => [ + 'enabled' => true, + ], + ], + ]; + + $expected = [ + 'other' => 'value', + ]; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testExcludeWithDeeplyNested(): void + { + // Test deeply nested exclusions + $array = [ + 'config' => [ + 'system' => [ + 'cache' => [ + 'enabled' => true, + 'lifetime' => 3600, + 'driver' => 'file', + ], + 'features' => [ + 'search' => true, + 'backup' => false, + ], + ], + 'other' => 'preserved', + ], + ]; + + $exclusion = [ + 'config' => [ + 'system' => [ + 'cache' => [ + 'lifetime' => 3600, + ], + 'features' => [ + 'backup' => false, + ], + ], + ], + ]; + + $expected = [ + 'config' => [ + 'system' => [ + 'cache' => [ + 'enabled' => true, + 'driver' => 'file', + ], + 'features' => [ + 'search' => true, + ], + ], + 'other' => 'preserved', + ], + ]; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testExcludeWithMultipleArrays(): void + { + // Test excluding with multiple exclusion arrays + $array = [ + 'a' => 1, + 'b' => 2, + 'c' => 3, + 'd' => 4, + ]; + + $exc1 = ['a' => 1]; + $exc2 = ['c' => 3]; + + $expected = [ + 'b' => 2, + 'd' => 4, + ]; + + $result = Arr::exclude($array, $exc1, $exc2); + $this->assertSame($expected, $result); + } + + public function testExcludePreservingNonMatchingValues(): void + { + // Test that non-matching values are preserved + $array = [ + 'name' => 'Formwork', + 'debug' => true, + 'cache' => [ + 'enabled' => true, + 'lifetime' => 3600, + ], + ]; + + $exclusion = [ + 'debug' => false, + 'cache' => [ + 'lifetime' => 7200, + ], + ]; + + $expected = [ + 'name' => 'Formwork', + 'debug' => true, + 'cache' => [ + 'enabled' => true, + 'lifetime' => 3600, + ], + ]; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testExcludeWithEmptyArray(): void + { + // Test with empty exclusion array + $array = [ + 'a' => 1, + 'b' => 2, + ]; + + $result = Arr::exclude($array, []); + $this->assertSame($array, $result); + } + + public function testExcludeAllValues(): void + { + // Test when all items are excluded + $array = [ + 'a' => 1, + 'b' => 2, + ]; + + $exclusion = [ + 'a' => 1, + 'b' => 2, + ]; + + $expected = []; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testExcludeWithNonExistentKeys(): void + { + // Test excluding keys that don't exist in the array + $array = [ + 'a' => 1, + 'b' => 2, + ]; + + $exclusion = [ + 'c' => 3, + 'd' => 4, + ]; + + $expected = [ + 'a' => 1, + 'b' => 2, + ]; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testExcludeWithMixedTypes(): void + { + // Test excluding with mixed scalar and array values + $array = [ + 'scalar' => 'value', + 'number' => 42, + 'nested' => [ + 'a' => 1, + 'b' => 2, + ], + 'preserve_me' => 'keep', + ]; + + $exclusion = [ + 'scalar' => 'value', + 'nested' => [ + 'a' => 1, + ], + ]; + + $expected = [ + 'number' => 42, + 'nested' => [ + 'b' => 2, + ], + 'preserve_me' => 'keep', + ]; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testExcludeDoesNotMatchScalarWithArray(): void + { + // Test that array exclusions don't match scalar values + $array = [ + 'value' => 'scalar', + ]; + + $exclusion = [ + 'value' => ['array'], + ]; + + $expected = [ + 'value' => 'scalar', + ]; + + $result = Arr::exclude($array, $exclusion); + $this->assertSame($expected, $result); + } + + public function testRandom(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertContains(Arr::random($data), $data); + $this->assertNull(Arr::random([])); + } + + public function testShuffle(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $shuffled = Arr::shuffle($data); + + $this->assertSameSize($data, $shuffled); + + foreach ($shuffled as $fruit) { + $this->assertContains($fruit, $data); + } + + $this->assertSame(['test'], Arr::shuffle(['test'])); + } + + public function testShufflePreservingKeys(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $shuffled = Arr::shuffle($data, preserveKeys: true); + $this->assertSameSize($data, $shuffled); + + foreach ($shuffled as $key => $value) { + $this->assertArrayHasKey($key, $data); + $this->assertSame($data[$key], $value); + } + } + + public function testIsAssociative(): void + { + $user = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $fruits = [ + 'apple', + 'banana', + 'cherry', + 'banana', + ]; + + $this->assertTrue(Arr::isAssociative($user)); + $this->assertFalse(Arr::isAssociative($fruits)); + $this->assertFalse(Arr::isAssociative([])); + } + + public function testMap(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $expected = [ + 'name' => 'SEMPRONIUS', + 'email' => 'SEMPRONIUS@EXAMPLE.COM', + 'country' => 'ITALY', + ]; + + $this->assertSame($expected, Arr::map($data, fn($data) => strtoupper($data))); + } + + public function testMapKeys(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $expected = [ + 'USER_NAME' => 'Sempronius', + 'USER_EMAIL' => 'sempronius@example.com', + 'USER_COUNTRY' => 'Italy', + ]; + + $this->assertSame($expected, Arr::mapKeys($data, fn($key) => 'USER_' . strtoupper($key))); + } + + public function testFilter(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $expected = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + ]; + + $this->assertSame($expected, Arr::filter($data, fn($value, $key) => $value !== 'Italy')); + } + + public function testReject(): void + { + $data = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $expected = [ + 'email' => 'sempronius@example.com', + ]; + + $this->assertSame($expected, Arr::reject($data, fn($value, $key) => !str_contains($value, '@'))); + } + + public function testEvery(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertTrue(Arr::every($data, fn($value) => is_string($value))); + $this->assertFalse(Arr::every($data, fn($value) => $value === 'banana')); + } + + public function testSome(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertTrue(Arr::some($data, fn($value) => $value === 'banana')); + $this->assertFalse(Arr::some($data, fn($value) => $value === 'orange')); + } + + public function testFind(): void + { + $data = ['apple', 'banana', 'cherry', 'banana']; + + $this->assertSame('banana', Arr::find($data, fn($value) => $value === 'banana')); + $this->assertNull(Arr::find($data, fn($value) => $value === 'orange')); + } + + public function testPluck(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1', 'group' => 'A', 'count' => 5], + ['id' => 2, 'name' => 'Item 2', 'group' => 'A', 'count' => 3], + ['id' => 3, 'name' => 'Item 3', 'group' => 'B', 'count' => 8], + ]; + + $expected = [ + 'Item 1', + 'Item 2', + 'Item 3', + ]; + + $this->assertSame($expected, Arr::extract($data, 'name')); + } + + public function testGroupBy(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1', 'group' => 'A', 'count' => 5], + ['id' => 2, 'name' => 'Item 2', 'group' => 'A', 'count' => 3], + ['id' => 3, 'name' => 'Item 3', 'group' => 'B', 'count' => 8], + + ]; + + $expected = [ + 'A' => [ + ['id' => 1, 'name' => 'Item 1', 'group' => 'A', 'count' => 5], + ['id' => 2, 'name' => 'Item 2', 'group' => 'A', 'count' => 3], + ], + 'B' => [ + ['id' => 3, 'name' => 'Item 3', 'group' => 'B', 'count' => 8], + ], + ]; + + $this->assertSame($expected, Arr::group($data, fn($item) => $item['group'])); + + // Test with Stringable values + $this->assertSame($expected, Arr::group($data, fn($item) => new StringableFixture($item['group']))); + } + + public function testCollapse(): void + { + $nested = [ + 'a', + ['b', 'c'], + [['d', 'e'], 'f'], + ]; + + $this->assertSame($nested, Arr::flatten($nested, depth: 0)); + $this->assertSame(['a', 'b', 'c', ['d', 'e'], 'f'], Arr::flatten($nested, depth: 1)); + + $this->assertSame(['a', 'b', 'c', 'd', 'e', 'f'], Arr::flatten($nested)); + } + + public function testCollapseWithArrayableObjects(): void + { + $nested = [ + 'a', + new ArrayableFixture(['b', 'c']), + new TraversableFixture([new ArrayableFixture(['d', 'e']), 'f']), + ]; + + $this->assertSame(['a', 'b', 'c', 'd', 'e', 'f'], Arr::flatten($nested)); + } + + public function testFlattenThrowsOnNegativeDepth(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('expects a non-negative depth'); + Arr::flatten([], depth: -1); + } + + public function testSort(): void + { + $fruits = ['banana', 'apple', 'cherry']; + + $this->assertSame([1 => 'apple', 0 => 'banana', 2 => 'cherry'], Arr::sort($fruits)); + + $this->assertSame([2 => 'cherry', 0 => 'banana', 1 => 'apple'], Arr::sort($fruits, direction: SORT_DESC)); + + $this->assertSame(['apple', 'banana', 'cherry'], Arr::sort($fruits, preserveKeys: false)); + + $this->assertSame(['cherry', 'banana', 'apple'], Arr::sort($fruits, direction: SORT_DESC, preserveKeys: false)); + } + + public function testSortWithCallable(): void + { + $expected = [ + ['id' => 2, 'name' => 'Item 2', 'group' => 'A', 'count' => 3], + ['id' => 1, 'name' => 'Item 1', 'group' => 'A', 'count' => 5], + ['id' => 3, 'name' => 'Item 3', 'group' => 'B', 'count' => 8], + ]; + + $data = [ + ['id' => 1, 'name' => 'Item 1', 'group' => 'A', 'count' => 5], + ['id' => 2, 'name' => 'Item 2', 'group' => 'A', 'count' => 3], + ['id' => 3, 'name' => 'Item 3', 'group' => 'B', 'count' => 8], + ]; + + $this->assertSame($expected, Arr::sort($data, sortBy: fn($a, $b) => $a['count'] <=> $b['count'], preserveKeys: false)); + } + + public function testSortWithOrderArray(): void + { + $expected = [ + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + 'notifications' => true, + 'theme' => 'dark', + ]; + + $data = [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + ]; + + $this->assertSame($expected, Arr::sort($data, sortBy: ['roles' => 0, 'permissions' => 1, 'cache' => 2, 'notifications' => 3, 'theme' => 4])); + } + + public function testSortWithOrderWithoutPreservingKeys(): void + { + $data = [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + ]; + + $expected = [ + ['admin', 'editor'], + ['pages', 'files'], + [ + 'enabled' => true, + 'duration' => 3600, + ], + true, + 'dark', + ]; + + $this->assertSame($expected, Arr::sort($data, sortBy: ['roles' => 0, 'permissions' => 1, 'cache' => 2, 'notifications' => 3, 'theme' => 4], preserveKeys: false)); + } + + public function testSortThrowsOnInvalidDirection(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('only accepts SORT_ASC and SORT_DESC as "direction" option'); + Arr::sort([], direction: 123); + } + + public function testSortThrowsOnInvalidType(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('only accepts SORT_REGULAR, SORT_NUMERIC, SORT_STRING and SORT_NATURAL as "type" option'); + Arr::sort([], type: 123); + } + + public function testSortThrowsOnInvalidSortByCount(): void + { + $data = [ + 'app' => [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + ], + ]; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Cannot sort array: the $sortBy array must have the same number of items as the array to sort'); + Arr::sort($data, sortBy: []); + } + + public function testSortThrowsOnMissingSortByKey(): void + { + $data = [ + 'theme' => 'dark', + 'notifications' => true, + 'roles' => ['admin', 'editor'], + 'permissions' => ['pages', 'files'], + 'cache' => [ + 'enabled' => true, + 'duration' => 3600, + ], + ]; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Cannot sort array: key "test" from the $sortBy array is not present in the array to sort'); + Arr::sort($data, sortBy: ['roles' => 0, 'permissions' => 1, 'cache' => 2, 'notifications' => 3, 'test' => 4]); + } + + public function testToArray(): void + { + $user = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $fruits = [ + 'apple', + 'banana', + 'cherry', + 'banana', + ]; + + $this->assertSame($user, Arr::from($user)); + $this->assertSame($fruits, Arr::from(new TraversableFixture($fruits))); + $this->assertSame($user, Arr::from(new ArrayableFixture($user))); + } + + public function testFromThrowsOnInvalidType(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Cannot convert to array an object of type string'); + Arr::from('invalid_value'); + } + + public function testFromEntries(): void + { + $entries = [ + ['name', 'Sempronius'], + ['email', 'sempronius@example.com'], + ['country', 'Italy'], + ]; + + $expected = [ + 'name' => 'Sempronius', + 'email' => 'sempronius@example.com', + 'country' => 'Italy', + ]; + + $this->assertSame($expected, Arr::fromEntries($entries)); + } +} diff --git a/tests/Utils/ConstraintTest.php b/tests/Utils/ConstraintTest.php new file mode 100644 index 000000000..66e8769ee --- /dev/null +++ b/tests/Utils/ConstraintTest.php @@ -0,0 +1,178 @@ +assertTrue(Constraint::isTruthy($value)); + } + + $nonTruthyValues = [false, 0, 'false', '0', 'off', 'no', null, '', [], 2, 'random']; + foreach ($nonTruthyValues as $nonTruthyValue) { + $this->assertFalse(Constraint::isTruthy($nonTruthyValue)); + } + } + + public function testIsFalsy(): void + { + $falsyValues = [false, 0, 'false', '0', 'off', 'no']; + foreach ($falsyValues as $value) { + $this->assertTrue(Constraint::isFalsy($value)); + } + + $nonFalsyValues = [true, 1, 'true', '1', 'on', 'yes', null, '', [], 2, 'random']; + foreach ($nonFalsyValues as $nonFalsyValue) { + $this->assertFalse(Constraint::isFalsy($nonFalsyValue)); + } + } + + public function testIsEmpty(): void + { + $emptyValues = [null, '', []]; + foreach ($emptyValues as $value) { + $this->assertTrue(Constraint::isEmpty($value)); + } + + $nonEmptyValues = [false, 0, 'false', '0', 'off', 'no', true, 1, 'true', '1', 'on', 'yes', 2, 'random']; + foreach ($nonEmptyValues as $nonEmptyValue) { + $this->assertFalse(Constraint::isEmpty($nonEmptyValue)); + } + } + + public function testIsEqual(): void + { + // Strict comparison + $this->assertTrue(Constraint::isEqualTo(1, 1, true)); + $this->assertFalse(Constraint::isEqualTo(1, '1', true)); + + // Non-strict comparison + $this->assertTrue(Constraint::isEqualTo(1, '1', false)); + $this->assertFalse(Constraint::isEqualTo(1, 2, false)); + } + + public function testIsNotEqual(): void + { + // Strict comparison + $this->assertTrue(Constraint::isNotEqualTo(1, '1', true)); + $this->assertFalse(Constraint::isNotEqualTo(1, 1, true)); + + // Non-strict comparison + $this->assertTrue(Constraint::isNotEqualTo(1, 2, false)); + $this->assertFalse(Constraint::isNotEqualTo(1, '1', false)); + } + + public function testIsGreaterThan(): void + { + $this->assertTrue(Constraint::isGreaterThan(2, 1)); + $this->assertFalse(Constraint::isGreaterThan(1, 1)); + $this->assertFalse(Constraint::isGreaterThan(1, 2)); + } + + public function testIsGreaterThanOrEqual(): void + { + $this->assertTrue(Constraint::isGreaterThanOrEqualTo(2, 1)); + $this->assertTrue(Constraint::isGreaterThanOrEqualTo(1, 1)); + $this->assertFalse(Constraint::isGreaterThanOrEqualTo(1, 2)); + } + + public function testIsLessThan(): void + { + $this->assertTrue(Constraint::isLessThan(1, 2)); + $this->assertFalse(Constraint::isLessThan(1, 1)); + $this->assertFalse(Constraint::isLessThan(2, 1)); + } + + public function testIsLessThanOrEqual(): void + { + $this->assertTrue(Constraint::isLessThanOrEqualTo(1, 2)); + $this->assertTrue(Constraint::isLessThanOrEqualTo(1, 1)); + $this->assertFalse(Constraint::isLessThanOrEqualTo(2, 1)); + } + + public function testMatches(): void + { + $this->assertTrue(Constraint::matchesRegex('hello', '/^h.*o$/')); + $this->assertFalse(Constraint::matchesRegex('hello', '/^H.*O$/i')); + } + + public function testMatchesWithoutEntireMatch(): void + { + $this->assertTrue(Constraint::matchesRegex('hello', '/e/', entireMatch: false)); + $this->assertFalse(Constraint::matchesRegex('hello', '/e/', entireMatch: true)); + } + + public function testIsInRange(): void + { + $this->assertTrue(Constraint::isInRange(5.25, 1, 10)); + $this->assertTrue(Constraint::isInRange(5, 1, 10)); + $this->assertFalse(Constraint::isInRange(11, 1, 10)); + + $this->assertTrue(Constraint::isInRange(M_PI, 10, 1)); + $this->assertFalse(Constraint::isInRange(-1, 10, 1)); + + $this->assertFalse(Constraint::isInRange(1, 1, 10, includeMin: false)); + $this->assertFalse(Constraint::isInRange(10, 1, 10, includeMax: false)); + } + + public function testIsInRangeWithIntegerRange(): void + { + $this->assertTrue(Constraint::isInIntegerRange(5, 1, 10)); + $this->assertFalse(Constraint::isInIntegerRange(11, 1, 10)); + $this->assertFalse(Constraint::isInIntegerRange(-1, 10, 1)); + + $this->assertFalse(Constraint::isInIntegerRange(1, 1, 10, includeMin: false)); + $this->assertFalse(Constraint::isInIntegerRange(10, 1, 10, includeMax: false)); + } + + public function testIsInRangeWithStep(): void + { + $this->assertTrue(Constraint::isInIntegerRange(4, 0, 10, step: 2)); + $this->assertFalse(Constraint::isInIntegerRange(5, 0, 10, step: 2)); + } + + public function testIsType(): void + { + $this->assertTrue(Constraint::isOfType(123, 'int')); + $this->assertTrue(Constraint::isOfType('hello', 'string')); + $this->assertTrue(Constraint::isOfType([], 'array')); + $this->assertTrue(Constraint::isOfType(12.34, 'float')); + $this->assertTrue(Constraint::isOfType(true, 'bool')); + $this->assertTrue(Constraint::isOfType(new stdClass(), 'stdClass')); + + $this->assertFalse(Constraint::isOfType(123, 'string')); + $this->assertFalse(Constraint::isOfType('hello', 'array')); + $this->assertFalse(Constraint::isOfType([], 'int')); + $this->assertFalse(Constraint::isOfType(12.34, 'bool')); + $this->assertFalse(Constraint::isOfType(true, 'float')); + $this->assertFalse(Constraint::isOfType(new stdClass(), 'array')); + } + + public function testIsTypeWithUnionTypes(): void + { + $this->assertTrue(Constraint::isOfType(123, 'int|string', unionTypes: true)); + $this->assertTrue(Constraint::isOfType('hello', 'int|string', unionTypes: true)); + $this->assertTrue(Constraint::isOfType(new stdClass(), 'array|stdClass', unionTypes: true)); + + $this->assertFalse(Constraint::isOfType(12.34, 'int|string', unionTypes: true)); + $this->assertFalse(Constraint::isOfType(new stdClass(), 'int|string', unionTypes: true)); + } + + public function testHasKeys(): void + { + $array = ['a' => 1, 'b' => 2, 'c' => 3]; + + $this->assertTrue(Constraint::hasKeys($array, ['a', 'b'])); + $this->assertTrue(Constraint::hasKeys($array, [])); + $this->assertFalse(Constraint::hasKeys($array, ['a', 'd'])); + } +} diff --git a/tests/Utils/DateTest.php b/tests/Utils/DateTest.php new file mode 100644 index 000000000..334efdc6c --- /dev/null +++ b/tests/Utils/DateTest.php @@ -0,0 +1,98 @@ + '%s fa', + 'date.distance.in' => 'tra %s', + 'date.duration.days' => ['giorno', 'giorni'], + 'date.duration.hours' => ['ora', 'ore'], + 'date.duration.minutes' => ['minuto', 'minuti'], + 'date.duration.months' => ['mese', 'mesi'], + 'date.duration.seconds' => ['secondo', 'secondi'], + 'date.duration.weeks' => ['settimana', 'settimane'], + 'date.duration.years' => ['anno', 'anni'], + 'date.months.long' => ['Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'], + 'date.months.short' => ['Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu', 'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic'], + 'date.now' => 'adesso', + 'date.weekdays.long' => ['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato'], + 'date.weekdays.short' => ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'], + ]; + + public function testToTimestamp(): void + { + $this->assertSame(1869436800, Date::toTimestamp('2029-03-29', 'Y-m-d')); + $this->assertSame(1869436800, Date::toTimestamp('29/03/2029', ['Y-m-d', 'd/m/Y'])); + $this->assertSame(1869436800, Date::toTimestamp('2029-03-29', '')); + } + + public function testToTimestampThrowsOnInvalidDate(): void + { + $this->expectException(InvalidArgumentException::class); + Date::toTimestamp('invalid-date', 'Y-m-d'); + } + + public function testFormatToPattern(): void + { + $this->assertSame('YYYY-MM-DD', Date::formatToPattern('Y-m-d')); + $this->assertSame('DD/MM/YYYY hh:mm:ss', Date::formatToPattern('d/m/Y H:i:s')); + $this->assertSame('DD/MM/YYYY [at] hh:mm:ss', Date::formatToPattern('d/m/Y \a\t H:i:s')); + } + + public function testPatternToFormat(): void + { + $this->assertSame('Y-m-d', Date::patternToFormat('YYYY-MM-DD')); + $this->assertSame('d/m/Y H:i:s', Date::patternToFormat('DD/MM/YYYY hh:mm:ss')); + $this->assertSame('l d F Y \a\t h:i:s A \o\' \c\l\o\c\k', Date::patternToFormat('DDDD DD MMMM YYYY [at] HH:mm:ss A [o\' clock]')); + } + + public function testFormat(): void + { + $dateTime = new DateTime('2029-03-29 15:30:00'); + + $translation = new Translation('it', $this->translation); + + $this->assertSame('Gio, 29 Mar 2029 15:30:00 +0000', Date::formatDateTime(new DateTime('2029-03-29 15:30:00'), 'r', $translation)); + $this->assertSame('Giovedì 29 Marzo 2029', Date::formatDateTime($dateTime, 'l d F Y', $translation)); + } + + public function testFormatWithTimestamp(): void + { + $translation = new Translation('it', $this->translation); + + $this->assertSame('Gio, 29 Mar 2029 10:10:00 +0000', Date::formatTimestamp(1869473400, 'r', $translation)); + $this->assertSame('Giovedì 29 Marzo 2029', Date::formatTimestamp(1869473400, 'l d F Y', $translation)); + } + + public function testFormatDistance(): void + { + $translation = new Translation('it', $this->translation); + + $now = time(); + + $this->assertSame('adesso', Date::formatDateTimeAsDistance(new DateTime('@' . $now), $translation, $now)); + $this->assertSame('5 giorni fa', Date::formatDateTimeAsDistance(new DateTime('@' . ($now - 5 * 86400)), $translation, $now)); + $this->assertSame('tra 3 ore', Date::formatDateTimeAsDistance(new DateTime('@' . ($now + 3 * 3600)), $translation, $now)); + } + + public function testFormatDistanceWithTimestamp(): void + { + $translation = new Translation('it', $this->translation); + + $now = time(); + + $this->assertSame('adesso', Date::formatTimestampAsDistance($now, $translation, $now)); + $this->assertSame('2 mesi fa', Date::formatTimestampAsDistance($now - 60 * 86400, $translation, $now)); + $this->assertSame('tra 10 minuti', Date::formatTimestampAsDistance($now + 10 * 60, $translation, $now)); + } +} diff --git a/tests/Utils/FileSystemTest.php b/tests/Utils/FileSystemTest.php new file mode 100644 index 000000000..22c41181a --- /dev/null +++ b/tests/Utils/FileSystemTest.php @@ -0,0 +1,970 @@ +tearDownTempDirectory(); + } + + public function testNormalize(): void + { + $this->assertSame('path/to/directory', FileSystem::normalizePath('path\to/directory')); + $this->assertSame('path/to/directory', FileSystem::normalizePath('path//to\directory')); + } + + public function testJoinPaths(): void + { + $this->assertSame('path/to/directory/file.txt', FileSystem::joinPaths('path/to', 'directory', 'file.txt')); + $this->assertSame('path/to/directory/file.txt', FileSystem::joinPaths('path\to\\', '/directory/', '\file.txt')); + } + + public function testResolve(): void + { + $this->assertSame('/var/www/html', FileSystem::resolvePath('/var/www/html/../html')); + $this->assertSame('C:/Projects/Formwork', FileSystem::resolvePath('C:\Projects\Formwork\..\Formwork')); + } + + public function testBasename(): void + { + $this->assertSame('file', FileSystem::name('/path/to/file.txt')); + $this->assertSame('directory', FileSystem::name('/path/to/directory/')); + } + + public function testExtension(): void + { + $this->assertSame('txt', FileSystem::extension('/path/to/file.txt')); + $this->assertSame('', FileSystem::extension('/path/to/directory/')); + } + + public function testCwd(): void + { + $this->assertSame(getcwd(), FileSystem::cwd()); + } + + #[RunInSeparateProcess] + public function testCwdThrowsOnUnresolved(): void + { + FileSystemFixture::disable('cwd'); + $this->expectException(FileSystemException::class); + $this->expectExceptionMessage('Cannot get current working directory'); + FileSystem::cwd(); + } + + public function testIsVisible(): void + { + $this->assertTrue(FileSystem::isVisible('/path/to/visibleFile.txt')); + $this->assertFalse(FileSystem::isVisible('/path/to/.hiddenFile.txt')); + } + + public function testMimeType(): void + { + $this->assertSame('text/plain', FileSystem::mimeType(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testExists(): void + { + $this->assertTrue(FileSystem::exists(TESTS_TMP_PATH . '/sample.txt')); + $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/nonexistent.txt')); + } + + public function testAssertExists(): void + { + FileSystem::assertExists(TESTS_TMP_PATH . '/sample.txt'); + $this->assertTrue(true); + + $this->expectException(FileNotFoundException::class); + FileSystem::assertExists(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + public function testAssertNotExists(): void + { + FileSystem::assertExists(TESTS_TMP_PATH . '/nonexistent.txt', false); + $this->assertTrue(true); + + $this->expectException(FileSystemException::class); + FileSystem::assertExists(TESTS_TMP_PATH . '/sample.txt', false); + } + + public function testIsReadable(): void + { + $this->assertTrue(FileSystem::isReadable(TESTS_TMP_PATH . '/sample.txt')); + + $mode = fileperms(TESTS_TMP_PATH . '/sample.txt'); + chmod(TESTS_TMP_PATH . '/sample.txt', $mode & ~0o444); + + $this->assertFalse(FileSystem::isReadable(TESTS_TMP_PATH . '/sample.txt')); + + chmod(TESTS_TMP_PATH . '/sample.txt', $mode); + } + + public function testIsWritable(): void + { + $this->assertTrue(FileSystem::isWritable(TESTS_TMP_PATH . '/sample.txt')); + + $mode = fileperms(TESTS_TMP_PATH . '/sample.txt'); + chmod(TESTS_TMP_PATH . '/sample.txt', $mode & ~0o222); + + $this->assertFalse(FileSystem::isWritable(TESTS_TMP_PATH . '/sample.txt')); + + chmod(TESTS_TMP_PATH . '/sample.txt', $mode); + } + + public function testIsFile(): void + { + $this->assertTrue(FileSystem::isFile(TESTS_TMP_PATH . '/sample.txt')); + $this->assertFalse(FileSystem::isFile(TESTS_TMP_PATH . '/dir')); + } + + public function testIsDirectory(): void + { + $this->assertTrue(FileSystem::isDirectory(TESTS_TMP_PATH . '/dir')); + $this->assertTrue(FileSystem::isDirectory(TESTS_TMP_PATH . '/emptydir')); + $this->assertFalse(FileSystem::isDirectory(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testIsEmptyDirectory(): void + { + $this->assertTrue(FileSystem::isEmptyDirectory(TESTS_TMP_PATH . '/emptydir')); + $this->assertFalse(FileSystem::isEmptyDirectory(TESTS_TMP_PATH . '/dir')); + $this->assertFalse(FileSystem::isEmptyDirectory(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testIsLink(): void + { + $this->assertTrue(FileSystem::isLink(TESTS_TMP_PATH . '/symlink')); + $this->assertFalse(FileSystem::isLink(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testAccessTime(): void + { + $this->assertIsInt(FileSystem::accessTime(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testAccessTimeThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::accessTime(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + #[RunInSeparateProcess] + public function testAccessTimeThrowsOnSystemError(): void + { + FileSystemFixture::disable('fileatime'); + $this->expectException(FileSystemException::class); + FileSystem::accessTime(TESTS_TMP_PATH . '/dir/sample.txt'); + } + + public function testCreationTime(): void + { + $this->assertIsInt(FileSystem::creationTime(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testCreationTimeThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::creationTime(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + #[RunInSeparateProcess] + public function testCreationTimeThrowsOnSystemError(): void + { + FileSystemFixture::disable('filectime'); + $this->expectException(FileSystemException::class); + FileSystem::creationTime(TESTS_TMP_PATH . '/dir/sample.txt'); + } + + public function testLastModifiedTime(): void + { + $this->assertIsInt(FileSystem::lastModifiedTime(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testLastModifiedTimeThrowsOnNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::lastModifiedTime(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + #[RunInSeparateProcess] + public function testLastModifiedTimeThrowsOnSystemError(): void + { + FileSystemFixture::disable('filemtime'); + $this->expectException(FileSystemException::class); + FileSystem::lastModifiedTime(TESTS_TMP_PATH . '/dir/sample.txt'); + } + + public function testDirectoryModifiedSince(): void + { + $this->assertTrue(FileSystem::directoryModifiedSince(TESTS_TMP_PATH . '/dir', 0)); + $this->assertFalse(FileSystem::directoryModifiedSince(TESTS_TMP_PATH . '/dir', time() + 3600)); + } + + public function testDirectoryModifiedSinceRecursively(): void + { + touch(TESTS_TMP_PATH . '/dir', 0); + touch(TESTS_TMP_PATH . '/dir/subdir', 0); + $this->assertTrue(FileSystem::directoryModifiedSince(TESTS_TMP_PATH . '/dir', 10)); + } + + public function testDirectoryModifiedThrowsOnNotDir(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::directoryModifiedSince(TESTS_TMP_PATH . '/sample.txt', 0); + } + + public function testTouch(): void + { + $this->assertTrue(FileSystem::touch(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testTouchThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::touch(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + #[RunInSeparateProcess] + public function testTouchThrowsOnSystemError(): void + { + FileSystemFixture::disable('touch'); + $this->expectException(FileSystemException::class); + FileSystem::touch(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testMode(): void + { + $this->assertIsInt(FileSystem::mode(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testModeThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::mode(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + #[RunInSeparateProcess] + public function testModeThrowsOnSystemError(): void + { + FileSystemFixture::disable('fileperms'); + $this->expectException(FileSystemException::class); + FileSystem::mode(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testSize(): void + { + $this->assertIsInt(FileSystem::size(TESTS_TMP_PATH . '/sample.txt')); + $this->assertIsInt(FileSystem::size(TESTS_TMP_PATH . '/dir')); + } + + public function testSizeThrowsOnUnsupportedType(): void + { + $this->expectException(FileSystemException::class); + $this->expectExceptionMessage('unsupported file type'); + FileSystem::size('/dev/null'); + } + + public function testFileSizeReturnsSize(): void + { + $this->assertIsInt(FileSystem::fileSize(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testFileSizeThrowsOnNotFile(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::fileSize(TESTS_TMP_PATH . '/dir'); + } + + public function testFileSizeThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::fileSize(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + #[RunInSeparateProcess] + public function testFileSizeThrowsOnSystemError(): void + { + FileSystemFixture::disable('filesize'); + $this->expectException(FileSystemException::class); + FileSystem::fileSize(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testDirectorySizeReturnsSize(): void + { + $this->assertIsInt(FileSystem::directorySize(TESTS_TMP_PATH . '/dir')); + } + + public function testDirectorySizeThrowsOnNotDir(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::directorySize(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testDirectorySizeThrowsOnNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::directorySize(TESTS_TMP_PATH . '/nonexistentdir'); + } + + #[RunInSeparateProcess] + public function testDirectorySizeThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('filesize'); + $this->expectException(FileSystemException::class); + FileSystem::directorySize(TESTS_TMP_PATH . '/dir'); + } + + public function testDelete(): void + { + $this->assertTrue(FileSystem::delete(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testDeleteThrowsOnUnsupportedType(): void + { + // Create a FIFO special file for testing + posix_mkfifo(TESTS_TMP_PATH . '/myfifo', 0o600); + + $this->expectException(FileSystemException::class); + $this->expectExceptionMessage('unsupported file type'); + + try { + FileSystem::delete(TESTS_TMP_PATH . '/myfifo'); + } finally { + // Clean up the FIFO file + unlink(TESTS_TMP_PATH . '/myfifo'); + } + } + + public function testDeleteFile(): void + { + $this->assertTrue(FileSystem::deleteFile(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testDeleteFileThrowsOnNotFile(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::deleteFile(TESTS_TMP_PATH . '/dir'); + } + + public function testDeleteFileThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::deleteFile(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + #[RunInSeparateProcess] + public function testDeleteFileThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('unlink'); + $this->expectException(FileSystemException::class); + FileSystem::deleteFile(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testDeleteDirectory(): void + { + $this->assertTrue(FileSystem::deleteDirectory(TESTS_TMP_PATH . '/emptydir')); + } + + public function testDeleteDirectoryThrowsOnNotEmpty(): void + { + $this->expectException(FileSystemException::class); + $this->expectExceptionMessage('must be empty to be deleted'); + FileSystem::deleteDirectory(TESTS_TMP_PATH . '/dir'); + } + + public function testDeleteDirectoryRecursively(): void + { + $this->assertTrue(FileSystem::deleteDirectory(TESTS_TMP_PATH . '/dir', recursive: true)); + } + + public function testDeleteDirectoryThrowsOnNotDir(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::deleteDirectory(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testDeleteDirectoryThrowsOnNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::deleteDirectory(TESTS_TMP_PATH . '/nonexistentdir'); + } + + #[RunInSeparateProcess] + public function testDeleteDirectoryThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('rmdir'); + $this->expectException(FileSystemException::class); + FileSystem::deleteDirectory(TESTS_TMP_PATH . '/emptydir'); + } + + public function testDeleteLink(): void + { + $this->assertTrue(FileSystem::deleteLink(TESTS_TMP_PATH . '/symlink')); + } + + public function testDeleteLinkThrowsOnNotLink(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::deleteLink(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testDeleteLinkThrowsOnLinkNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::deleteLink(TESTS_TMP_PATH . '/nonexistentlink'); + } + + #[RunInSeparateProcess] + public function testDeleteLinkThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('unlink'); + $this->expectException(FileSystemException::class); + FileSystem::deleteLink(TESTS_TMP_PATH . '/symlink'); + } + + public function testCopy(): void + { + $this->assertTrue(FileSystem::copy(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_copy.txt')); + + $this->assertTrue(FileSystem::copy(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_copy')); + + $this->assertTrue(FileSystem::copy(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_copy')); + } + + public function testCopyThrowsOnUnsupportedType(): void + { + $this->expectException(FileSystemException::class); + $this->expectExceptionMessage('unsupported file type'); + FileSystem::copy('/dev/null', TESTS_TMP_PATH . '/null_copy'); + } + + public function testCopyFile(): void + { + $this->assertTrue(FileSystem::copyFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_copy.txt')); + } + + public function testCopyFileThrowsOnNotFile(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::copyFile(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_copy'); + } + + public function testCopyFileThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::copyFile(TESTS_TMP_PATH . '/nonexistent.txt', TESTS_TMP_PATH . '/nonexistent_copy.txt'); + } + + public function testCopyFileThrowsOnDestExists(): void + { + $this->expectException(FileSystemException::class); + FileSystem::copyFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample.txt'); + } + + #[RunInSeparateProcess] + public function testCopyFileThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('copy'); + $this->expectException(FileSystemException::class); + FileSystem::copyFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_copy.txt'); + } + + public function testCopyDirectory(): void + { + $this->assertTrue(FileSystem::copyDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_copy')); + } + + public function testCopyDirectoryThrowsOnNotDir(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::copyDirectory(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_copy.txt'); + } + + public function testCopyDirectoryThrowsOnNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::copyDirectory(TESTS_TMP_PATH . '/nonexistentdir', TESTS_TMP_PATH . '/nonexistentdir_copy'); + } + + public function testCopyDirectoryThrowsOnDestExists(): void + { + $this->expectException(FileSystemException::class); + FileSystem::copyDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir'); + } + + #[RunInSeparateProcess] + public function testCopyDirectoryThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('copy'); + $this->expectException(FileSystemException::class); + FileSystem::copyDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_copy'); + } + + public function testCopyLink(): void + { + $this->assertTrue(FileSystem::copyLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_copy')); + } + + public function testCopyLinkWithOverwrite(): void + { + $this->assertTrue(FileSystem::copyLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/dir/b.txt', overwrite: true)); + } + + public function testCopyLinkThrowsOnNotLink(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::copyLink(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_link'); + } + + public function testCopyLinkThrowsOnLinkNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::copyLink(TESTS_TMP_PATH . '/nonexistentlink', TESTS_TMP_PATH . '/nonexistentlink_copy'); + } + + public function testCopyLinkThrowsOnDestExists(): void + { + $this->expectException(FileSystemException::class); + FileSystem::copyLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink'); + } + + #[RunInSeparateProcess] + public function testCopyLinkThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('symlink'); + $this->expectException(FileSystemException::class); + FileSystem::copyLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_copy'); + } + + public function testMove(): void + { + $this->assertTrue(FileSystem::move(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_moved.txt')); + $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/sample.txt')); + + $this->assertTrue(FileSystem::move(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_moved')); + $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/dir')); + + $this->assertTrue(FileSystem::move(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_moved')); + $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/symlink')); + } + + public function testMoveThrowsOnUnsupportedType(): void + { + $this->expectException(FileSystemException::class); + $this->expectExceptionMessage('unsupported file type'); + FileSystem::move('/dev/null', TESTS_TMP_PATH . '/null_moved'); + } + + public function testMoveFile(): void + { + $this->assertTrue(FileSystem::moveFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_moved.txt')); + $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testMoveFileThrowsOnNotFile(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::moveFile(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_moved'); + } + + public function testMoveFileThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::moveFile(TESTS_TMP_PATH . '/nonexistent.txt', TESTS_TMP_PATH . '/nonexistent_moved.txt'); + } + + public function testMoveFileThrowsOnDestExists(): void + { + $this->expectException(FileSystemException::class); + FileSystem::moveFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample.txt'); + } + + #[RunInSeparateProcess] + public function testMoveFileThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('rename'); + $this->expectException(FileSystemException::class); + FileSystem::moveFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_moved.txt'); + } + + public function testMoveDirectory(): void + { + $this->assertTrue(FileSystem::moveDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_moved')); + $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/dir')); + } + + public function testMoveDirectoryThrowsOnNotDir(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::moveDirectory(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_moved.txt'); + } + + public function testMoveDirectoryThrowsOnNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::moveDirectory(TESTS_TMP_PATH . '/nonexistentdir', TESTS_TMP_PATH . '/nonexistentdir_moved'); + } + + public function testMoveDirectoryThrowsOnDestExists(): void + { + $this->expectException(FileSystemException::class); + FileSystem::moveDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir'); + } + + #[RunInSeparateProcess] + public function testMoveDirectoryThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('copy'); + $this->expectException(FileSystemException::class); + FileSystem::moveDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_moved'); + } + + public function testMoveLink(): void + { + $this->assertTrue(FileSystem::moveLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_moved')); + $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/symlink')); + } + + public function testMoveLinkThrowsOnNotLink(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::moveLink(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_moved'); + } + + public function testMoveLinkThrowsOnLinkNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::moveLink(TESTS_TMP_PATH . '/nonexistentlink', TESTS_TMP_PATH . '/nonexistentlink_moved'); + } + + public function testMoveLinkThrowsOnDestExists(): void + { + $this->expectException(FileSystemException::class); + FileSystem::moveLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink'); + } + + #[RunInSeparateProcess] + public function testMoveLinkThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('symlink'); + $this->expectException(FileSystemException::class); + FileSystem::moveLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_moved'); + } + + public function testRead(): void + { + $this->assertSame("This is a sample text file for testing purposes.\n", FileSystem::read(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testReadThrowsOnNotFile(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::read(TESTS_TMP_PATH . '/dir'); + } + + public function testReadThrowsOnFileNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::read(TESTS_TMP_PATH . '/nonexistent.txt'); + } + + public function testReadThrowsOnFileUnreadable(): void + { + $mode = fileperms(TESTS_TMP_PATH . '/sample.txt'); + chmod(TESTS_TMP_PATH . '/sample.txt', $mode & ~0o444); + + $this->expectException(FileSystemException::class); + FileSystem::read(TESTS_TMP_PATH . '/sample.txt'); + + chmod(TESTS_TMP_PATH . '/sample.txt', $mode); + } + + #[RunInSeparateProcess] + public function testReadThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('file_get_contents'); + $this->expectException(FileSystemException::class); + FileSystem::read(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testListContents(): void + { + $contents = iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/dir')); + $this->assertCount(3, $contents); + } + + public function testListContentsIncludingHidden(): void + { + $contents = iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/dir', FileSystem::LIST_ALL)); + $this->assertContains('.hidden', $contents); + } + + public function testListContentsFilesOnly(): void + { + $contents = iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/dir', FileSystem::LIST_FILES)); + $this->assertNotContains('subdir', $contents); + } + + public function testListContentsDirectoriesOnly(): void + { + $contents = iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/dir', FileSystem::LIST_DIRECTORIES)); + $this->assertNotContains('.hidden', $contents); + $this->assertNotContains('b.txt', $contents); + $this->assertNotContains('sample.txt', $contents); + } + + public function testListContentsExcludingEmptyDirectories(): void + { + $contents = iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH, FileSystem::LIST_VISIBLE | FileSystem::LIST_EXCLUDE_EMPTY_DIRECTORIES)); + $this->assertNotContains('emptydir', $contents); + } + + public function testListContentsThrowsOnNotDir(): void + { + $this->expectException(InvalidArgumentException::class); + iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testListContentsThrowsOnNotFound(): void + { + $this->expectException(FileNotFoundException::class); + iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/nonexistentdir')); + } + + #[RunInSeparateProcess] + public function testListContentsThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('opendir'); + $this->expectException(FileSystemException::class); + iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/dir')); + } + + public function testListContentsRecursively(): void + { + $contents = iterator_to_array(FileSystem::listRecursive(TESTS_TMP_PATH)); + $this->assertContains('dir/subdir/a.txt', $contents); + } + + public function testListRecursiveThrowsOnNotDir(): void + { + $this->expectException(InvalidArgumentException::class); + iterator_to_array(FileSystem::listRecursive(TESTS_TMP_PATH . '/sample.txt')); + } + + public function testListFiles(): void + { + $files = iterator_to_array(FileSystem::listFiles(TESTS_TMP_PATH . '/dir')); + $this->assertContains('b.txt', $files); + $this->assertContains('sample.txt', $files); + $this->assertNotContains('subdir', $files); + $this->assertNotContains('.hidden', $files); + } + + public function testListFilesIncludingHidden(): void + { + $files = iterator_to_array(FileSystem::listFiles(TESTS_TMP_PATH . '/dir', includeHidden: true)); + $this->assertContains('.hidden', $files); + } + + public function testListDirectories(): void + { + $dirs = iterator_to_array(FileSystem::listDirectories(TESTS_TMP_PATH . '/dir')); + $this->assertContains('subdir', $dirs); + $this->assertNotContains('b.txt', $dirs); + $this->assertNotContains('sample.txt', $dirs); + $this->assertNotContains('.hidden', $dirs); + } + + public function testListDirectoriesIncludingHidden(): void + { + $dirs = iterator_to_array(FileSystem::listDirectories(TESTS_TMP_PATH . '/dir', includeHidden: true)); + $this->assertContains('.hiddendir', $dirs); + } + + public function testListDirectoriesExcludingEmpty(): void + { + $dirs = iterator_to_array(FileSystem::listDirectories(TESTS_TMP_PATH, includeEmpty: false)); + $this->assertNotContains('emptydir', $dirs); + } + + public function testReadLink(): void + { + $this->assertSame('sample.txt', FileSystem::readLink(TESTS_TMP_PATH . '/symlink')); + } + + public function testReadLinkThrowsOnNotLink(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::readLink(TESTS_TMP_PATH . '/sample.txt'); + } + + public function testReadLinkThrowsOnLinkNotFound(): void + { + $this->expectException(FileNotFoundException::class); + FileSystem::readLink(TESTS_TMP_PATH . '/nonexistentlink'); + } + + #[RunInSeparateProcess] + public function testReadLinkThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('readlink'); + $this->expectException(FileSystemException::class); + FileSystem::readLink(TESTS_TMP_PATH . '/symlink'); + } + + public function testCreateFile(): void + { + $this->assertTrue(FileSystem::createFile(TESTS_TMP_PATH . '/newfile.txt')); + $this->assertTrue(FileSystem::exists(TESTS_TMP_PATH . '/newfile.txt')); + } + + #[RunInSeparateProcess] + public function testCreateFileThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('fopen'); + $this->expectException(FileSystemException::class); + FileSystem::createFile(TESTS_TMP_PATH . '/newfile.txt'); + } + + public function testCreateTemporaryFile(): void + { + $tempFile = FileSystem::createTemporaryFile(TESTS_TMP_PATH, '_tmp'); + $this->assertTrue(FileSystem::exists($tempFile)); + $this->assertStringStartsWith('_tmp', basename($tempFile)); + } + + #[RunInSeparateProcess] + public function testCreateTempFileThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('fopen'); + $this->expectException(FileSystemException::class); + FileSystem::createTemporaryFile(TESTS_TMP_PATH, '_tmp'); + } + + public function testWrite(): void + { + FileSystem::write(TESTS_TMP_PATH . '/newfile.txt', 'Hello, World!'); + $this->assertSame('Hello, World!', FileSystem::read(TESTS_TMP_PATH . '/newfile.txt')); + } + + public function testWriteToExistingFile(): void + { + chmod(TESTS_TMP_PATH . '/sample.txt', 0o644); + FileSystem::write(TESTS_TMP_PATH . '/sample.txt', 'Hello, World!'); + $this->assertSame('Hello, World!', FileSystem::read(TESTS_TMP_PATH . '/sample.txt')); + $this->assertSame(0o644, FileSystem::mode(TESTS_TMP_PATH . '/sample.txt') & 0o777); + } + + public function testWriteThrowsOnNotFile(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::write(TESTS_TMP_PATH . '/dir', 'Hello, World!'); + } + + public function testWriteThrowsOnFileUnwritable(): void + { + $mode = fileperms(TESTS_TMP_PATH . '/sample.txt'); + chmod(TESTS_TMP_PATH . '/sample.txt', $mode & ~0o222); + + $this->expectException(FileSystemException::class); + FileSystem::write(TESTS_TMP_PATH . '/sample.txt', 'Hello, World!'); + + chmod(TESTS_TMP_PATH . '/sample.txt', $mode); + } + + #[RunInSeparateProcess] + public function testWriteThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('file_put_contents'); + $this->expectException(FileSystemException::class); + FileSystem::write(TESTS_TMP_PATH . '/newfile.txt', 'Hello, World!'); + } + + public function testCreateDirectory(): void + { + $this->assertTrue(FileSystem::createDirectory(TESTS_TMP_PATH . '/newdir')); + $this->assertTrue(FileSystem::exists(TESTS_TMP_PATH . '/newdir')); + } + + #[RunInSeparateProcess] + public function testCreateDirectoryThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('mkdir'); + $this->expectException(FileSystemException::class); + FileSystem::createDirectory(TESTS_TMP_PATH . '/newdir'); + } + + public function testCreateLink(): void + { + $this->assertTrue(FileSystem::createLink(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/newlink')); + $this->assertTrue(FileSystem::isLink(TESTS_TMP_PATH . '/newlink')); + } + + #[RunInSeparateProcess] + public function testCreateLinkThrowsExceptionOnSystemError(): void + { + FileSystemFixture::disable('symlink'); + $this->expectException(FileSystemException::class); + FileSystem::createLink(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/newlink'); + } + + public function testFormatSize(): void + { + $this->assertSame('0 B', FileSystem::formatSize(-1)); + $this->assertSame('0 B', FileSystem::formatSize(0)); + $this->assertSame('1 B', FileSystem::formatSize(1)); + $this->assertSame('1 KB', FileSystem::formatSize(1024)); + $this->assertSame('1 MB', FileSystem::formatSize(1024 ** 2)); + $this->assertSame('1 GB', FileSystem::formatSize(1024 ** 3)); + $this->assertSame('1 TB', FileSystem::formatSize(1024 ** 4)); + $this->assertSame('1024 TB', FileSystem::formatSize(1024 ** 5)); + } + + public function testShorthandToBytes(): void + { + $this->assertSame(1048576, FileSystem::shorthandToBytes('1M')); + $this->assertSame(1073741824, FileSystem::shorthandToBytes('1G')); + $this->assertSame(512, FileSystem::shorthandToBytes('512')); + $this->assertSame(2048, FileSystem::shorthandToBytes('2K')); + } + + public function testShorthandToBytesThrowsOnInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + FileSystem::shorthandToBytes('1T'); + } + + public function testRandomName(): void + { + $name1 = FileSystem::randomName(); + $name2 = FileSystem::randomName(); + $this->assertNotSame($name1, $name2); + $this->assertSame(16, strlen($name1)); + $this->assertSame(16, strlen($name2)); + } + + public function testRandomNameWithPrefix(): void + { + $name = FileSystem::randomName('test_'); + $this->assertStringStartsWith('test_', $name); + } +} diff --git a/tests/Utils/Fixtures/ArrayableFixture.php b/tests/Utils/Fixtures/ArrayableFixture.php new file mode 100644 index 000000000..d41f6643f --- /dev/null +++ b/tests/Utils/Fixtures/ArrayableFixture.php @@ -0,0 +1,15 @@ +data; + } +} diff --git a/tests/Utils/Fixtures/FileSystemFixture.php b/tests/Utils/Fixtures/FileSystemFixture.php new file mode 100644 index 000000000..d398105eb --- /dev/null +++ b/tests/Utils/Fixtures/FileSystemFixture.php @@ -0,0 +1,195 @@ + true, + 'fileatime' => true, + 'filectime' => true, + 'filemtime' => true, + 'touch' => true, + 'fileperms' => true, + 'filesize' => true, + 'unlink' => true, + 'rmdir' => true, + 'copy' => true, + 'symlink' => true, + 'readlink' => true, + 'rename' => true, + 'file_get_contents' => true, + 'file_put_contents' => true, + 'opendir' => true, + 'fopen' => true, + 'mkdir' => true, + ]; + + public static function enable(string $function): void + { + self::$enabledFunctions[$function] = true; + } + + public static function disable(string $function): void + { + self::$enabledFunctions[$function] = false; + } + + public static function enableAll(): void + { + foreach (array_keys(self::$enabledFunctions) as $function) { + self::enable($function); + } + } + + public static function disableAll(): void + { + foreach (array_keys(self::$enabledFunctions) as $function) { + self::disable($function); + } + } + + public static function cwd(): string|false + { + if (self::$enabledFunctions['cwd']) { + return getcwd(); + } + return false; + } + + public static function fileatime(string $filename): int|false + { + if (self::$enabledFunctions['fileatime']) { + return fileatime($filename); + } + return false; + } + + public static function filectime(string $filename): int|false + { + if (self::$enabledFunctions['filectime']) { + return filectime($filename); + } + return false; + } + + public static function filemtime(string $filename): int|false + { + if (self::$enabledFunctions['filemtime']) { + return filemtime($filename); + } + return false; + } + + public static function touch(string $filename, ?int $time = null, ?int $atime = null): bool + { + if (!self::$enabledFunctions['touch']) { + return false; + } + return touch($filename, $time, $atime); + } + + public static function fileperms(string $filename): int|false + { + if (self::$enabledFunctions['fileperms']) { + return fileperms($filename); + } + return false; + } + + public static function filesize(string $filename): int|false + { + if (self::$enabledFunctions['filesize']) { + return filesize($filename); + } + return false; + } + + public static function unlink(string $filename): bool + { + if (!self::$enabledFunctions['unlink']) { + return false; + } + return unlink($filename); + } + + public static function rmdir(string $dirname): bool + { + if (!self::$enabledFunctions['rmdir']) { + return false; + } + return rmdir($dirname); + } + + public static function copy(string $from, string $to): bool + { + if (!self::$enabledFunctions['copy']) { + return false; + } + return copy($from, $to); + } + + public static function symlink(string $target, string $link): bool + { + if (!self::$enabledFunctions['symlink']) { + return false; + } + return symlink($target, $link); + } + + public static function readlink(string $path): string|false + { + if (!self::$enabledFunctions['readlink']) { + return false; + } + return readlink($path); + } + + public static function rename(string $from, string $to): bool + { + if (!self::$enabledFunctions['rename']) { + return false; + } + return rename($from, $to); + } + + public static function file_get_contents(string $filename): string|false + { + if (!self::$enabledFunctions['file_get_contents']) { + return false; + } + return file_get_contents($filename); + } + + public static function file_put_contents(string $filename, mixed $data, int $flags = 0): int|false + { + if (!self::$enabledFunctions['file_put_contents']) { + return false; + } + return file_put_contents($filename, $data, $flags); + } + + public static function opendir(string $directory): mixed + { + if (!self::$enabledFunctions['opendir']) { + return false; + } + return opendir($directory); + } + + public static function fopen(string $filename, string $mode): mixed + { + if (!self::$enabledFunctions['fopen']) { + return false; + } + return fopen($filename, $mode); + } + + public static function mkdir(string $directory, int $mode = 0o777, bool $recursive = false): bool + { + if (!self::$enabledFunctions['mkdir']) { + return false; + } + return mkdir($directory, $mode, $recursive); + } +} diff --git a/tests/Utils/Fixtures/StringableFixture.php b/tests/Utils/Fixtures/StringableFixture.php new file mode 100644 index 000000000..c721734da --- /dev/null +++ b/tests/Utils/Fixtures/StringableFixture.php @@ -0,0 +1,15 @@ +value; + } +} diff --git a/tests/Utils/Fixtures/TraversableFixture.php b/tests/Utils/Fixtures/TraversableFixture.php new file mode 100644 index 000000000..c23a54390 --- /dev/null +++ b/tests/Utils/Fixtures/TraversableFixture.php @@ -0,0 +1,16 @@ +data = $data; + } +} diff --git a/tests/Utils/Fixtures/files/mimetype/invalid.svg b/tests/Utils/Fixtures/files/mimetype/invalid.svg new file mode 100644 index 000000000..76c62df1e --- /dev/null +++ b/tests/Utils/Fixtures/files/mimetype/invalid.svg @@ -0,0 +1 @@ + + + + Sample HTML + + +

This is a sample HTML file.

+ + diff --git a/tests/Utils/Fixtures/files/mimetype/sample.yaml b/tests/Utils/Fixtures/files/mimetype/sample.yaml new file mode 100644 index 000000000..470446edd --- /dev/null +++ b/tests/Utils/Fixtures/files/mimetype/sample.yaml @@ -0,0 +1 @@ +title: Sample YAML file diff --git a/tests/Utils/Fixtures/files/mimetype/valid.svg b/tests/Utils/Fixtures/files/mimetype/valid.svg new file mode 100644 index 000000000..474ea3392 --- /dev/null +++ b/tests/Utils/Fixtures/files/mimetype/valid.svg @@ -0,0 +1 @@ + diff --git a/tests/Utils/Fixtures/files/tmp/dir/.hidden b/tests/Utils/Fixtures/files/tmp/dir/.hidden new file mode 100644 index 000000000..185c42002 --- /dev/null +++ b/tests/Utils/Fixtures/files/tmp/dir/.hidden @@ -0,0 +1 @@ +This is a sample text file for testing purposes. diff --git a/tests/Utils/Fixtures/files/tmp/dir/b.txt b/tests/Utils/Fixtures/files/tmp/dir/b.txt new file mode 100644 index 000000000..185c42002 --- /dev/null +++ b/tests/Utils/Fixtures/files/tmp/dir/b.txt @@ -0,0 +1 @@ +This is a sample text file for testing purposes. diff --git a/tests/Utils/Fixtures/files/tmp/dir/sample.txt b/tests/Utils/Fixtures/files/tmp/dir/sample.txt new file mode 100644 index 000000000..185c42002 --- /dev/null +++ b/tests/Utils/Fixtures/files/tmp/dir/sample.txt @@ -0,0 +1 @@ +This is a sample text file for testing purposes. diff --git a/tests/Utils/Fixtures/files/tmp/dir/subdir/a.txt b/tests/Utils/Fixtures/files/tmp/dir/subdir/a.txt new file mode 100644 index 000000000..185c42002 --- /dev/null +++ b/tests/Utils/Fixtures/files/tmp/dir/subdir/a.txt @@ -0,0 +1 @@ +This is a sample text file for testing purposes. diff --git a/tests/Utils/Fixtures/files/tmp/sample.txt b/tests/Utils/Fixtures/files/tmp/sample.txt new file mode 100644 index 000000000..185c42002 --- /dev/null +++ b/tests/Utils/Fixtures/files/tmp/sample.txt @@ -0,0 +1 @@ +This is a sample text file for testing purposes. diff --git a/tests/Utils/Fixtures/files/tmp/symlink b/tests/Utils/Fixtures/files/tmp/symlink new file mode 120000 index 000000000..bdbc09545 --- /dev/null +++ b/tests/Utils/Fixtures/files/tmp/symlink @@ -0,0 +1 @@ +sample.txt \ No newline at end of file diff --git a/tests/Utils/Fixtures/functions.php b/tests/Utils/Fixtures/functions.php new file mode 100644 index 000000000..3af75a686 --- /dev/null +++ b/tests/Utils/Fixtures/functions.php @@ -0,0 +1,101 @@ +assertSame('class1 class2', Html::classes(['class1', 'class2'])); + $this->assertSame('class1 class3', Html::classes(['class1' => true, 'class2' => false, 'class3' => true])); + $this->assertSame('', Html::classes([])); + } + + public function testAttribute(): void + { + $this->assertSame('disabled', Html::attribute('disabled', true)); + $this->assertSame('data-value="123"', Html::attribute('data-value', 123)); + $this->assertSame('data-list="item1 item2 item3"', Html::attribute('data-list', ['item1', 'item2', 'item3'])); + $this->assertSame('', Html::attribute('hidden', false)); + } + + public function testAttributes(): void + { + $attributes = [ + 'disabled' => true, + 'data-value' => 123, + 'data-list' => ['item1', 'item2', 'item3'], + 'hidden' => false, + ]; + $this->assertSame('disabled data-value="123" data-list="item1 item2 item3"', Html::attributes($attributes)); + } + + public function testTag(): void + { + $this->assertSame('
Content
', Html::tag('div', ['class' => 'container'], 'Content')); + $this->assertSame('', Html::tag('input', ['type' => 'text', 'disabled' => true])); + } + + public function testTagThrowsOnVoidWithContent(): void + { + $this->expectException(InvalidArgumentException::class); + Html::tag('img', [], 'Content'); + } + + public function testIsVoid(): void + { + $this->assertTrue(Html::isVoid('img')); + $this->assertFalse(Html::isVoid('div')); + } +} diff --git a/tests/Utils/MimeTypeTest.php b/tests/Utils/MimeTypeTest.php new file mode 100644 index 000000000..527f81bf9 --- /dev/null +++ b/tests/Utils/MimeTypeTest.php @@ -0,0 +1,63 @@ +assertSame('image/jpeg', MimeType::fromExtension('jpg')); + $this->assertSame('text/plain', MimeType::fromExtension('txt')); + $this->assertSame('application/pdf', MimeType::fromExtension('pdf')); + $this->assertSame('application/octet-stream', MimeType::fromExtension('unknown_extension')); + } + + public function testFromFile(): void + { + $this->assertSame('text/html', MimeType::fromFile(__DIR__ . '/fixtures/files/mimetype/sample.html')); + $this->assertSame('text/yaml', MimeType::fromFile(__DIR__ . '/fixtures/files/mimetype/sample.yaml')); + } + + public function testFromFileWithSvg(): void + { + $this->assertSame('image/svg+xml', MimeType::fromFile(__DIR__ . '/fixtures/files/mimetype/valid.svg')); + $this->assertSame('application/octet-stream', MimeType::fromFile(__DIR__ . '/fixtures/files/mimetype/invalid.svg')); + } + + public function testFromFileThrowsOnDisabledFileinfo(): void + { + Environment::disableExtension('fileinfo'); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('requires the extension "fileinfo" to be enabled'); + MimeType::fromFile(__DIR__ . '/fixtures/MimeType/sample.html'); + } + + public function testExtensions(): void + { + $this->assertSame(['jpg', 'jpeg', 'jpe'], MimeType::getAssociatedExtensions('image/jpeg')); + $this->assertSame([], MimeType::getAssociatedExtensions('unknown/mime-type')); + } + + public function testToExtension(): void + { + $this->assertSame('jpg', MimeType::toExtension('image/jpeg')); + } + + public function testType(): void + { + $extensionTypes = MimeType::extensionTypes(); + $this->assertIsArray($extensionTypes); + } +} diff --git a/tests/Utils/PathTest.php b/tests/Utils/PathTest.php new file mode 100644 index 000000000..a7731a1ca --- /dev/null +++ b/tests/Utils/PathTest.php @@ -0,0 +1,209 @@ +assertTrue(Path::isAbsolute('/foo/bar')); + $this->assertFalse(Path::isAbsolute('./foo/bar')); + $this->assertFalse(Path::isAbsolute('../foo/bar')); + + // Windows-style + $this->assertTrue(Path::isAbsolute('C:\foo\bar', '\\')); + $this->assertFalse(Path::isAbsolute('.\foo\bar', '\\')); + $this->assertFalse(Path::isAbsolute('..\foo\bar', '\\')); + } + + public function testHasSeparators(): void + { + $this->assertTrue(Path::isSeparator('/')); + $this->assertTrue(Path::isSeparator('\\')); + $this->assertFalse(Path::isSeparator('invalid_separator')); + } + + public function testIsRelative(): void + { + // POSIX-style + $this->assertTrue(Path::isRelativeTo('/foo/bar/baz', '/foo/bar')); + $this->assertFalse(Path::isRelativeTo('/foo/bar/baz', '/bar/foo')); + + // Windows-style + $this->assertTrue(Path::isRelativeTo('C:\foo\bar\baz', 'C:\foo\bar', '\\')); + $this->assertFalse(Path::isRelativeTo('D:\foo\bar\baz', 'D:\bar\foo', '\\')); + } + + public function testIsRelativeToThrowsOnNonAbsolutePath(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$path must be an absolute path'); + Path::isRelativeTo('../foo/bar/baz', '/foo/bar'); + } + + public function testIsRelativeToThrowsOnNonAbsoluteBase(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$base must be an absolute path'); + Path::isRelativeTo('C:\foo\bar\baz', 'foo\bar', '\\'); + } + + public function testNormalize(): void + { + // POSIX-style + $this->assertSame('fixtures/b/c.js', Path::normalize('./fixtures///b/../b/c.js')); + $this->assertSame('/bar', Path::normalize('/foo/../../../bar')); + $this->assertSame('a/b', Path::normalize('a//b//../b')); + $this->assertSame('a/b/c', Path::normalize('a//b//./c')); + $this->assertSame('a/b', Path::normalize('a//b//.')); + $this->assertSame('/x/y/z', Path::normalize('/a/b/c/../../../x/y/z')); + $this->assertSame('/foo/bar', Path::normalize('///..//./foo/.//bar')); + $this->assertSame('bar/', Path::normalize('bar/foo../../')); + $this->assertSame('bar', Path::normalize('bar/foo../..')); + $this->assertSame('bar/baz', Path::normalize('bar/foo../../baz')); + $this->assertSame('bar/foo../', Path::normalize('bar/foo../')); + $this->assertSame('bar/foo..', Path::normalize('bar/foo..')); + $this->assertSame('../../bar', Path::normalize('../foo../../../bar')); + $this->assertSame('../../bar', Path::normalize('../.../.././.../../../bar')); + $this->assertSame('../../../../../bar', Path::normalize('../../../foo/../../../bar')); + $this->assertSame('../../../../../../', Path::normalize('../../../foo/../../../bar/../../')); + $this->assertSame('../../', Path::normalize('../foobar/barfoo/foo/../../../bar/../../')); + $this->assertSame('../../../../baz', Path::normalize('../.../../foobar/../../../bar/../../baz')); + $this->assertSame('foo/bar/baz', Path::normalize('foo/bar\baz')); + + // Windows-style + $this->assertSame('fixtures\b\c.js', Path::normalize('./fixtures///b/../b/c.js', '\\')); + $this->assertSame('\bar', Path::normalize('/foo/../../../bar', '\\')); + $this->assertSame('a\b', Path::normalize('a//b//../b', '\\')); + $this->assertSame('a\b\c', Path::normalize('a//b//./c', '\\')); + $this->assertSame('a\b', Path::normalize('a//b//.', '\\')); + $this->assertSame('\x\y\z', Path::normalize('/a/b/c/../../../x/y/z', '\\')); + $this->assertSame('C:', Path::normalize('C:', '\\')); + $this->assertSame('C:..\abc', Path::normalize('C:..\abc', '\\')); + $this->assertSame('C:..\..\def', Path::normalize('C:..\..\abc\..\def', '\\')); + $this->assertSame('C:', Path::normalize('C:\.', '\\')); + $this->assertSame('file:stream', Path::normalize('file:stream', '\\')); + $this->assertSame('bar\\', Path::normalize('bar\foo..\..\\', '\\')); + $this->assertSame('bar', Path::normalize('bar\foo..\..', '\\')); + $this->assertSame('bar\baz', Path::normalize('bar\foo..\..\baz', '\\')); + $this->assertSame('bar\foo..\\', Path::normalize('bar\foo..\\', '\\')); + $this->assertSame('bar\foo..', Path::normalize('bar\foo..', '\\')); + $this->assertSame('..\..\bar', Path::normalize('..\foo..\..\..\bar', '\\')); + $this->assertSame('..\..\bar', Path::normalize('..\...\..\.\...\..\..\bar', '\\')); + $this->assertSame('..\..\..\..\..\bar', Path::normalize('../../../foo/../../../bar', '\\')); + $this->assertSame('..\..\..\..\..\..\\', Path::normalize('../../../foo/../../../bar/../../', '\\')); + $this->assertSame('..\..\\', Path::normalize('../foobar/barfoo/foo/../../../bar/../../', '\\')); + $this->assertSame('..\..\..\..\baz', Path::normalize('../.../../foobar/../../../bar/../../baz', '\\')); + $this->assertSame('foo\bar\baz', Path::normalize('foo/bar\baz', '\\')); + } + + public function testNormalizeThrowsOnInvalidSeparator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$separator must be a valid directory separator'); + Path::normalize('some/path', 'invalid_separator'); + } + + public function testSegments(): void + { + // POSIX-style + $this->assertSame(['', 'var', 'www', 'html', 'index.php'], Path::split('/var/www/html/index.php')); + $this->assertSame(['home', 'user', 'documents', 'file.txt'], Path::split('home/user/documents/file.txt')); + + // Windows-style + $this->assertSame(['C:', 'Program Files', 'App', 'app.exe'], Path::split('C:\Program Files\App\app.exe', '\\')); + $this->assertSame(['D:', 'Data', 'Projects', 'project.docx'], Path::split('D:\Data\Projects\project.docx', '\\')); + } + + public function testJoin(): void + { + // POSIX-style + $this->assertSame('/var/www/html/index.php', Path::join(['/var', 'www', 'html', 'index.php'])); + $this->assertSame('home/user/documents/file.txt', Path::join(['home', 'user', 'documents', 'file.txt'])); + + // Windows-style + $this->assertSame('C:\Program Files\App\app.exe', Path::join(['C:', 'Program Files', 'App', 'app.exe'], '\\')); + $this->assertSame('D:\Data\Projects\project.docx', Path::join(['D:', 'Data', 'Projects', 'project.docx'], '\\')); + } + + public function testJoinThrowsOnInvalidSeparator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$separator must be a valid directory separator'); + Path::join(['some', 'path'], 'invalid_separator'); + } + + public function testResolve(): void + { + // POSIX-style + $this->assertSame('/var/file/', Path::resolve('../file/', '/var/lib')); + $this->assertSame('/file/', Path::resolve('/../file/', '/var/lib')); + $this->assertSame('', Path::resolve('../../..', 'a/b/c')); + $this->assertSame('/absolute/', Path::resolve('/absolute/', './some/dir')); + $this->assertSame('/foo/tmp.3/cycles/root.js', Path::resolve('../tmp.3/cycles/root.js', '/foo/tmp.3/')); + + // Windows-style + $this->assertSame('c:\blah\a', Path::resolve('c:../a', 'c:/blah\blah', '\\')); + $this->assertSame('d:\e.exe', Path::resolve('\e.exe', 'd:\a/b\c/d', '\\')); + $this->assertSame('c:\some\file', Path::resolve('c:/some/file', 'c:/ignore', '\\')); + $this->assertSame('d:\ignore\some\dir\\', Path::resolve('d:some/dir//', 'd:/ignore', '\\')); + $this->assertSame('c:\\', Path::resolve('//', 'c:/', '\\')); + $this->assertSame('c:\dir', Path::resolve('//dir', 'c:/', '\\')); + $this->assertSame('c:\some\dir', Path::resolve('///some//dir', 'c:/', '\\')); + $this->assertSame('C:\foo\tmp.3\cycles\root.js', Path::resolve('..\tmp.3\cycles\root.js', 'C:\foo\tmp.3\\', '\\')); + } + + public function testResolveThrowsOnInvalidSeparator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$separator must be a valid directory separator'); + Path::resolve('some/path', 'some/base', 'invalid_separator'); + } + + public function testRelativeTo(): void + { + // POSIX-style + $this->assertSame('html/index.php', Path::makeRelative('/var/www/html/index.php', '/var/www')); + $this->assertSame('../documents/file.txt', Path::makeRelative('/home/user/documents/file.txt', '/home/user/downloads')); + $this->assertSame('../../../etc/config.yaml', Path::makeRelative('/etc/config.yaml', '/var/www/html/')); + + // Windows-style + $this->assertSame('App\app.exe', Path::makeRelative('C:\Program Files\App\app.exe', 'C:\Program Files', '\\')); + $this->assertSame('..\Projects\project.docx', Path::makeRelative('D:\Data\Projects\project.docx', 'D:\Data\Downloads', '\\')); + } + + public function testMakeRelativeThrowsOnNonAbsolutePath(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$path must be an absolute path'); + Path::makeRelative('relative/path', '/absolute/base'); + } + + public function testMakeRelativeThrowsOnNonAbsoluteBase(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$base must be an absolute path'); + Path::makeRelative('/absolute/path', 'relative/base'); + } + + public function testMakeRelativeThrowsOnInvalidSeparator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$separator must be a valid directory separator'); + Path::makeRelative('/some/path', '/some/base', 'invalid_separator'); + } + + public function testMakeRelativeThrowsOnIncompatibleDrives(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$path and $base must have a compatible drive letter'); + Path::makeRelative('C:\folder\file.txt', 'D:\folder', '\\'); + } +} diff --git a/tests/Utils/StrTest.php b/tests/Utils/StrTest.php new file mode 100644 index 000000000..c9a261ff8 --- /dev/null +++ b/tests/Utils/StrTest.php @@ -0,0 +1,164 @@ +assertTrue(Str::startsWith('Hello, world!', 'Hello')); + $this->assertFalse(Str::startsWith('Hello, world!', 'world')); + + $this->assertTrue(Str::startsWith('Hello, world!', '')); + } + + public function testEndsWith(): void + { + $this->assertTrue(Str::endsWith('Hello, world!', 'world!')); + $this->assertFalse(Str::endsWith('Hello, world!', 'Hello')); + + $this->assertTrue(Str::endsWith('Hello, world!', '')); + } + + public function testContains(): void + { + $this->assertTrue(Str::contains('Hello, world!', 'lo, wo')); + $this->assertFalse(Str::contains('Hello, world!', 'planet')); + + $this->assertTrue(Str::contains('Hello, world!', '')); + } + + public function testBefore(): void + { + $this->assertSame('Hello', Str::before('Hello, world!', ',')); + $this->assertSame('Hello, world!', Str::before('Hello, world!', 'planet')); + $this->assertSame('Hello, world!', Str::before('Hello, world!', '')); + } + + public function testBeforeLast(): void + { + $this->assertSame('Hello, world', Str::beforeLast('Hello, world, again!', ',')); + $this->assertSame('Hello, world, again!', Str::beforeLast('Hello, world, again!', 'planet')); + $this->assertSame('Hello, world, again!', Str::beforeLast('Hello, world, again!', '')); + } + + public function testAfter(): void + { + $this->assertSame(' world!', Str::after('Hello, world!', ',')); + $this->assertSame('Hello, world!', Str::after('Hello, world!', 'planet')); + $this->assertSame('Hello, world!', Str::after('Hello, world!', '')); + } + + public function testAfterLast(): void + { + $this->assertSame(' again!', Str::afterLast('Hello, world, again!', ',')); + $this->assertSame('Hello, world, again!', Str::afterLast('Hello, world, again!', 'planet')); + $this->assertSame('Hello, world, again!', Str::afterLast('Hello, world, again!', '')); + } + + public function testEscape(): void + { + $this->assertSame('Hello & welcome to <Formwork>!', Str::escape('Hello & welcome to !')); + } + + public function testEscapeAttr(): void + { + $this->assertSame('Hello & welcome to "Framework"!', Str::escapeAttr('Hello & welcome to "Framework"!')); + } + + public function testRemoveTags(): void + { + $this->assertSame('Hello, welcome to Formwork!', Str::removeHtml('Hello, welcome to Formwork!')); + } + + public function testSlug(): void + { + $this->assertSame('hello-world', Str::slug('Hello World!')); + $this->assertSame('hello-world', Str::slug(' Hello ~ World !')); + $this->assertSame('de-etna-erklaerung', Str::slug('De Ætna Erklärung')); + } + + public function testAppend(): void + { + $this->assertSame('Hello, world!!!', Str::append('Hello, world', '!!!')); + } + + public function testPrepend(): void + { + $this->assertSame('!!!Hello, world', Str::prepend('Hello, world', '!!!')); + } + + public function testWrap(): void + { + $this->assertSame('**Hello, world**', Str::wrap('Hello, world', '**')); + } + + public function testRemoveStart(): void + { + $this->assertSame('world!', Str::removeStart('Hello, world!', 'Hello, ')); + $this->assertSame('Hello, world!', Str::removeStart('Hello, world!', 'world')); + } + + public function testRemoveEnd(): void + { + $this->assertSame('Hello, world', Str::removeEnd('Hello, world!', '!')); + $this->assertSame('Hello, world!', Str::removeEnd('Hello, world!', 'Hello')); + } + + public function testDotToBrackets(): void + { + $this->assertSame('array[key1][key2]', Str::dotNotationToBrackets('array.key1.key2')); + $this->assertSame('array', Str::dotNotationToBrackets('array')); + } + + public function testInterpolate(): void + { + $this->assertSame('Hello, John Doe! Welcome to Formwork.', Str::interpolate('Hello, {{name}}! Welcome to {{platform}}.', ['name' => 'John Doe', 'platform' => 'Formwork'])); + $this->assertSame('Hello, {{name}}! Welcome to {{platform}}.', Str::interpolate('Hello, \{{name}}! Welcome to \{{platform}}.', [])); + } + + public function testInterpolateWithClosure(): void + { + $this->assertSame('Hello, John Doe! Welcome to platform.', Str::interpolate('Hello, {{name}}! Welcome to {{platform}}.', function ($key) { + return match ($key) { + 'name' => 'John Doe', + default => $key, + }; + })); + } + + public function testChunk(): void + { + $this->assertSame('Hel lo, wo rld !', Str::chunk('Hello, world!', 3, ' ')); + $this->assertSame('Hello, world!', Str::chunk('Hello, world!', 20, ' ')); + } + + public function testChunkThrowsOnNonPositiveLength(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('$length must be greater than 0'); + Str::chunk('Hello, world!', -1, ' '); + } + + public function testDashCase(): void + { + $this->assertSame('hello-world-string', Str::toDashCase('HelloWorldString')); + } + + public function testSnakeCase(): void + { + $this->assertSame('hello_world_string', Str::toSnakeCase('HelloWorldString')); + } + + public function testCamelCase(): void + { + $this->assertSame('helloWorldString', Str::toCamelCase('hello_world_string')); + $this->assertSame('helloWorldString', Str::toCamelCase('hello-world-string')); + } +} diff --git a/tests/Utils/TextTest.php b/tests/Utils/TextTest.php new file mode 100644 index 000000000..79a817632 --- /dev/null +++ b/tests/Utils/TextTest.php @@ -0,0 +1,76 @@ +assertSame('Hello World!', Text::normalizeWhitespace(" Hello World! \n")); + $this->assertSame('Hello World!', Text::normalizeWhitespace("Hello\tWorld!")); + $this->assertSame('', Text::normalizeWhitespace(" \n\t ")); + } + + public function testWords(): void + { + $this->assertSame(['Hello', 'World!'], Text::splitWords('Hello World!')); + $this->assertSame(['Hello', 'World!'], Text::splitWords(" Hello World! \n")); + $this->assertSame(['Hello', 'World!'], Text::splitWords("Hello\tWorld!")); + $this->assertSame([], Text::splitWords(" \n\t ")); + } + + public function testWordsWithLimit(): void + { + $this->assertSame(['Hello', 'World! This is a test.'], Text::splitWords('Hello World! This is a test.', 2)); + $this->assertSame(['Hello', 'World!', 'This is a test.'], Text::splitWords('Hello World! This is a test.', 3)); + } + + public function testCountWords(): void + { + $this->assertSame(6, Text::countWords('Hello World! This is a test.')); + $this->assertSame(6, Text::countWords(" Hello World! \nThis is a test. ")); + $this->assertSame(6, Text::countWords("Hello\tWorld! This is a test.")); + $this->assertSame(0, Text::countWords(" \n\t ")); + } + + public function testTruncate(): void + { + $this->assertSame('Hello…', Text::truncate('Hello World!', 5)); + $this->assertSame('Hello…', Text::truncate('Hello World!', 8)); + $this->assertSame('Hello World!', Text::truncate('Hello World!', 20)); + } + + public function testTruncateThrowsOnDisabledMbstring(): void + { + Environment::disableExtension('mbstring'); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('requires the extension "mbstring" to be enabled'); + Text::truncate('Hello World!', 5); + } + + public function testTruncateByWords(): void + { + $this->assertSame('Hello…', Text::truncateWords('Hello World! This is a test.', 1)); + $this->assertSame('Hello World! This…', Text::truncateWords('Hello World! This is a test.', 3)); + $this->assertSame('Hello World! This is a test.', Text::truncateWords('Hello World! This is a test.', 10)); + } + + public function testEstimateReadingTime(): void + { + $this->assertSame(1, Text::readingTime('')); + $this->assertSame(1, Text::readingTime('This is a short text.')); + $this->assertSame(3, Text::readingTime(str_repeat('This is a short text. ', 100))); + } +} diff --git a/tests/Utils/UriTest.php b/tests/Utils/UriTest.php new file mode 100644 index 000000000..5280c5873 --- /dev/null +++ b/tests/Utils/UriTest.php @@ -0,0 +1,165 @@ +assertSame('http', Uri::scheme('http://example.com')); + $this->assertSame('https', Uri::scheme('https://example.com')); + $this->assertSame('ftp', Uri::scheme('ftp://example.com')); + $this->assertNull(Uri::scheme('example.com')); + } + + public function testHost(): void + { + $this->assertSame('example.com', Uri::host('http://example.com')); + $this->assertSame('example.com', Uri::host('https://example.com/path')); + $this->assertSame('example.com', Uri::host('ftp://example.com/resource')); + $this->assertNull(Uri::host('no-scheme-host')); + } + + public function testPort(): void + { + $this->assertNull(Uri::port('http://example.com')); + $this->assertNull(Uri::port('https://example.com')); + $this->assertNull(Uri::port('ftp://example.com')); + $this->assertSame(8080, Uri::port('http://example.com:8080')); + $this->assertSame(21, Uri::port('ftp://example.com:21')); + } + + public function testDefaultPort(): void + { + $this->assertSame(80, Uri::getDefaultPort('http')); + $this->assertSame(443, Uri::getDefaultPort('https')); + } + + public function testDefaultPortThrowsOnUnknownScheme(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown scheme "sch"'); + Uri::getDefaultPort('sch'); + } + + public function testIsDefaultPort(): void + { + $this->assertTrue(Uri::isDefaultPort(80, 'http')); + $this->assertTrue(Uri::isDefaultPort(443, 'https')); + $this->assertFalse(Uri::isDefaultPort(8080, 'http')); + $this->assertFalse(Uri::isDefaultPort(80, 'https')); + } + + public function testIsDefaultPortThrowsOnUnknownScheme(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown scheme "sch"'); + Uri::isDefaultPort(80, 'sch'); + } + + public function testPath(): void + { + $this->assertSame('/path/to/resource', Uri::path('http://example.com/path/to/resource')); + $this->assertSame('/another/path', Uri::path('https://example.com/another/path?query=string')); + $this->assertSame('/', Uri::path('ftp://example.com/')); + $this->assertSame('/path/../test/', Uri::path('/path/../test/?query=string')); + } + + public function testAbsolutePath(): void + { + $this->assertSame('http://example.com/path/to/resource', Uri::absolutePath('http://example.com/path/to/resource')); + $this->assertSame('https://example.com/another/path', Uri::absolutePath('https://example.com/another/path?query=string')); + $this->assertSame('ftp://example.com/', Uri::absolutePath('ftp://example.com/')); + } + + public function testQuery(): void + { + $this->assertSame('key=value&foo=bar', Uri::query('http://example.com/path?key=value&foo=bar')); + $this->assertNull(Uri::query('https://example.com/path')); + } + + public function testFragment(): void + { + $this->assertSame('section1', Uri::fragment('http://example.com/path#section1')); + $this->assertNull(Uri::fragment('https://example.com/path')); + } + + public function testQueryToArray(): void + { + $this->assertSame(['key' => 'value', 'foo' => 'bar'], Uri::queryToArray('http://example.com/index?key=value&foo=bar')); + $this->assertSame([], Uri::queryToArray('')); + } + + public function testParse(): void + { + $expected = [ + 'scheme' => 'https', + 'host' => 'example.com', + 'port' => 443, + 'path' => '/path/to/resource', + 'query' => 'key=value&foo=bar', + 'fragment' => 'section1', + ]; + + $this->assertSame($expected, Uri::parse('https://example.com:443/path/to/resource?key=value&foo=bar#section1')); + } + + public function testParseThrowsOnMalformedUri(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid URI "/;c:354/"'); + Uri::parse('/;c:354/'); + } + + public function testMake(): void + { + $this->assertSame('http://localhost:8080/?q=test#top', Uri::make(['port' => 8080, 'query' => 'q=test', 'fragment' => 'top'], 'http://localhost')); + $this->assertSame('http://localhost:8080/path/?foo=bar&baz=qux#frag', Uri::make(['port' => 8080, 'path' => '/path', 'query' => ['foo' => 'bar', 'baz' => 'qux'], 'fragment' => 'frag'], 'http://localhost')); + $this->assertSame('http://localhost:80/', Uri::make(['port' => 80], 'http://localhost', true)); + $this->assertSame('https://example.com:443/?x=1', Uri::make(['scheme' => 'https', 'host' => 'example.com', 'port' => 443, 'path' => '/', 'query' => ['x' => '1']], 'http://localhost', true)); + $this->assertSame('https://example.com/search/?q=test+value&lang=en', Uri::make(['scheme' => 'https', 'host' => 'example.com', 'path' => '/search', 'query' => ['q' => 'test value', 'lang' => 'en']], 'http://localhost')); + } + + public function testNormalize(): void + { + $this->assertSame('http://example.com/path/to/resource.jpg', Uri::normalize('http://example.com/path/to/resource.jpg')); + $this->assertSame('https://example.com/another/path/', Uri::normalize('https://example.com/another/path')); + $this->assertSame('http://example.com/test/?query=string', Uri::normalize('http://example.com/path/../test/?query=string')); + } + + public function testRemoveQuery(): void + { + $this->assertSame('http://example.com/path/to/resource.jpg', Uri::removeQuery('http://example.com/path/to/resource.jpg?key=value&foo=bar')); + $this->assertSame('https://example.com/another/path/', Uri::removeQuery('https://example.com/another/path?query=string')); + } + + public function testRemoveFragment(): void + { + $this->assertSame('http://example.com/path/to/resource.jpg', Uri::removeFragment('http://example.com/path/to/resource.jpg#section1')); + $this->assertSame('https://example.com/another/path/', Uri::removeFragment('https://example.com/another/path#top')); + } + + public function testResolve(): void + { + $this->assertSame('http://example.com/path/to/resource.jpg', Uri::resolveRelative('resource.jpg', 'http://example.com/path/to/')); + $this->assertSame('https://example.com/path/index.html', Uri::resolveRelative('path/index.html', 'https://example.com/another')); + $this->assertSame('http://example.com/assets/css/style.css', Uri::resolveRelative('assets/css/style.css', 'http://example.com/')); + } + + public function testResolveWithFragmentOnly(): void + { + $this->assertSame('http://example.com/path/to/resource.jpg#section1', Uri::resolveRelative('#section1', 'http://example.com/path/to/resource.jpg')); + $this->assertSame('https://example.com/another/path/index.html#top', Uri::resolveRelative('#top', 'https://example.com/another/path/index.html')); + } + + public function testEncode(): void + { + $this->assertSame('/path/to/resource?key=%7B%7D&foo=%C3%89%C6%92#%C3%A5n%C2%A9', Uri::encode('/path/to/resource?key={}&foo=Ƀ#ån©')); + } +} From 2e2dbac4f2b3d120328c6bd2d054914edf0a621a Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:24:18 +0100 Subject: [PATCH 3/4] Add tests for the `Data` namespace --- tests/Data/CollectionDataProxyTest.php | 73 ++ tests/Data/CollectionTest.php | 1011 +++++++++++++++++ .../Exceptions/InvalidValueExceptionTest.php | 24 + tests/Data/Fixtures/DataArrayableFixture.php | 16 + tests/Data/Fixtures/DataCountableFixture.php | 16 + tests/Data/Fixtures/DataGetterFixture.php | 15 + tests/Data/Fixtures/DataIteratorFixture.php | 16 + .../Fixtures/DataMultipleGetterFixture.php | 15 + .../Fixtures/DataMultipleSetterFixture.php | 20 + tests/Data/Fixtures/DataSetterFixture.php | 20 + tests/Data/PaginationTest.php | 54 + tests/Data/Traits/DataArrayableTest.php | 19 + tests/Data/Traits/DataCountableTest.php | 19 + tests/Data/Traits/DataGetterTest.php | 35 + tests/Data/Traits/DataIteratorTest.php | 23 + tests/Data/Traits/DataMultipleGetterTest.php | 42 + tests/Data/Traits/DataMultipleSetterTest.php | 38 + tests/Data/Traits/DataSetterTest.php | 40 + 18 files changed, 1496 insertions(+) create mode 100644 tests/Data/CollectionDataProxyTest.php create mode 100644 tests/Data/CollectionTest.php create mode 100644 tests/Data/Exceptions/InvalidValueExceptionTest.php create mode 100644 tests/Data/Fixtures/DataArrayableFixture.php create mode 100644 tests/Data/Fixtures/DataCountableFixture.php create mode 100644 tests/Data/Fixtures/DataGetterFixture.php create mode 100644 tests/Data/Fixtures/DataIteratorFixture.php create mode 100644 tests/Data/Fixtures/DataMultipleGetterFixture.php create mode 100644 tests/Data/Fixtures/DataMultipleSetterFixture.php create mode 100644 tests/Data/Fixtures/DataSetterFixture.php create mode 100644 tests/Data/PaginationTest.php create mode 100644 tests/Data/Traits/DataArrayableTest.php create mode 100644 tests/Data/Traits/DataCountableTest.php create mode 100644 tests/Data/Traits/DataGetterTest.php create mode 100644 tests/Data/Traits/DataIteratorTest.php create mode 100644 tests/Data/Traits/DataMultipleGetterTest.php create mode 100644 tests/Data/Traits/DataMultipleSetterTest.php create mode 100644 tests/Data/Traits/DataSetterTest.php diff --git a/tests/Data/CollectionDataProxyTest.php b/tests/Data/CollectionDataProxyTest.php new file mode 100644 index 000000000..a9002adc1 --- /dev/null +++ b/tests/Data/CollectionDataProxyTest.php @@ -0,0 +1,73 @@ + (object) ['key' => 'value1'], + 'item2' => (object) ['key' => 'value2'], + ]); + + $this->assertInstanceOf(CollectionDataProxy::class, $collection->everyItem()); + } + + public function testPropertyGet(): void + { + $collection = Collection::from([ + 'item1' => (object) ['key' => 'value1'], + 'item2' => (object) ['key' => 'value2'], + ]); + + $this->assertSame([ + 'item1' => 'value1', + 'item2' => 'value2', + ], $collection->everyItem()->key->toArray()); + } + + public function testPropertySet(): void + { + $collection = Collection::from([ + 'item1' => (object) ['key' => 'value1'], + 'item2' => (object) ['key' => 'value2'], + ]); + + $collection->everyItem()->key = 'newValue'; + + $this->assertSame([ + 'item1' => 'newValue', + 'item2' => 'newValue', + ], $collection->everyItem()->key->toArray()); + } + + public function testMethodCall(): void + { + $collection = Collection::from([ + 'item1' => new class { + public function greet(): string + { + return 'Hello from item1'; + } + }, + 'item2' => new class { + public function greet(): string + { + return 'Hello from item2'; + } + }, + ], typed: false); + + $this->assertSame([ + 'item1' => 'Hello from item1', + 'item2' => 'Hello from item2', + ], $collection->everyItem()->greet()->toArray()); + } +} diff --git a/tests/Data/CollectionTest.php b/tests/Data/CollectionTest.php new file mode 100644 index 000000000..630403a63 --- /dev/null +++ b/tests/Data/CollectionTest.php @@ -0,0 +1,1011 @@ +assertInstanceOf(Collection::class, $collection); + $this->assertInstanceOf(AbstractCollection::class, $collection); + $this->assertCount(3, $collection); + } + + public function testToMutable(): void + { + $immutableCollection = Collection::from(['itemA', 'itemB'], mutable: false); + $mutableCollection = $immutableCollection->toMutable(); + + $this->assertInstanceOf(Collection::class, $mutableCollection); + $this->assertNotSame($immutableCollection, $mutableCollection); + $this->assertTrue($mutableCollection->isMutable()); + $this->assertSame(['itemA', 'itemB'], $mutableCollection->values()); + } + + public function testToMutableThrowsOnAlreadyMutable(): void + { + $mutableCollection = Collection::from(['itemA', 'itemB'], mutable: true); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot convert an already mutable collection to mutable'); + $mutableCollection->toMutable(); + } + + public function testToImmutable(): void + { + $mutableCollection = Collection::from(['itemA', 'itemB'], mutable: true); + $immutableCollection = $mutableCollection->toImmutable(); + + $this->assertInstanceOf(Collection::class, $immutableCollection); + $this->assertNotSame($mutableCollection, $immutableCollection); + $this->assertFalse($immutableCollection->isMutable()); + $this->assertSame(['itemA', 'itemB'], $immutableCollection->values()); + } + + public function testToImmutableThrowsOnAlreadyImmutable(): void + { + $immutableCollection = Collection::from(['itemA', 'itemB'], mutable: false); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot convert an already immutable collection to immutable'); + $immutableCollection->toImmutable(); + } + + public function testOfThrowsOnAssociativityMismatch(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Associative collections cannot be created from non-associative data'); + Collection::of('string', ['item1', 'item2'], associative: true); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Non-associative collections cannot be created from associative data'); + Collection::of('string', ['key1' => 'item1', 'key2' => 'item2'], associative: false); + } + + public function testOfThrowsOnTypeMismatch(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Typed collections cannot be created from data of different types'); + Collection::of('string', ['item1', 2, 'item3']); + } + + public function testFromWithMatchingTypes(): void + { + $collection = Collection::from(['item1', 'item2', 'item3']); + + $this->assertInstanceOf(Collection::class, $collection); + $this->assertTrue($collection->isTyped()); + $this->assertSame('string', $collection->dataType()); + } + + public function testFromWithMismatchedTypes(): void + { + $collection = Collection::from(['item1', 2, 'item3']); + + $this->assertInstanceOf(Collection::class, $collection); + $this->assertFalse($collection->isTyped()); + } + + public function testFromThrowsOnStrictTypeMismatch(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot create a typed collection with data of different types'); + Collection::from(['item1', 2, 'item3'], typed: true); + } + + public function testIsAssociative(): void + { + $associativeCollection = Collection::from(['key1' => 'value1', 'key2' => 'value2']); + $indexedCollection = Collection::from(['value1', 'value2', 'value3']); + + $this->assertTrue($associativeCollection->isAssociative()); + $this->assertFalse($indexedCollection->isAssociative()); + } + + public function testIsMutable(): void + { + $mutableCollection = Collection::from([], mutable: true); + $immutableCollection = Collection::from([], mutable: false); + + $this->assertTrue($mutableCollection->isMutable()); + $this->assertFalse($immutableCollection->isMutable()); + } + + public function testIsTyped(): void + { + $typedCollection = Collection::of('string'); + $untypedCollection = Collection::from([]); + + $this->assertTrue($typedCollection->isTyped()); + $this->assertFalse($untypedCollection->isTyped()); + } + + public function testDataType(): void + { + $arrayCollection = Collection::of('array'); + $intCollection = Collection::of('int'); + + $this->assertSame('array', $arrayCollection->dataType()); + $this->assertSame('int', $intCollection->dataType()); + } + + public function testIsEmpty(): void + { + $emptyCollection = Collection::from([]); + $nonEmptyCollection = Collection::from(['item1']); + + $this->assertTrue($emptyCollection->isEmpty()); + $this->assertFalse($nonEmptyCollection->isEmpty()); + } + + public function testNth(): void + { + $collection = Collection::from(['first', 'second', 'third', 'fourth']); + + $this->assertSame('first', $collection->nth(0)); + $this->assertSame('second', $collection->nth(1)); + $this->assertSame('third', $collection->nth(2)); + $this->assertSame('fourth', $collection->nth(3)); + } + + public function testAt(): void + { + $collection = Collection::from(['apple', 'banana', 'cherry']); + + // Positive indices + $this->assertSame('apple', $collection->at(0)); + $this->assertSame('banana', $collection->at(1)); + $this->assertSame('cherry', $collection->at(2)); + + // Negative indices + $this->assertSame('cherry', $collection->at(-1)); + $this->assertSame('banana', $collection->at(-2)); + $this->assertSame('apple', $collection->at(-3)); + } + + public function testFirst(): void + { + $collection = Collection::from(['alpha', 'beta', 'gamma']); + + $this->assertSame('alpha', $collection->first()); + } + + public function testLast(): void + { + $collection = Collection::from(['alpha', 'beta', 'gamma']); + + $this->assertSame('gamma', $collection->last()); + } + + public function testRandom(): void + { + $data = ['red', 'green', 'blue']; + $collection = Collection::from($data); + + $randomItem = $collection->random(); + $this->assertContains($randomItem, $data); + } + + public function testIndexOf(): void + { + $collection = Collection::from(['cat', 'dog', 'fish']); + + $this->assertSame(0, $collection->indexOf('cat')); + $this->assertSame(1, $collection->indexOf('dog')); + $this->assertSame(2, $collection->indexOf('fish')); + $this->assertNull($collection->indexOf('bird')); + } + + public function testKeyOf(): void + { + $collection = Collection::from(['a' => 'apple', 'b' => 'banana', 'c' => 'cherry']); + + $this->assertSame('a', $collection->keyOf('apple')); + $this->assertSame('b', $collection->keyOf('banana')); + $this->assertSame('c', $collection->keyOf('cherry')); + $this->assertNull($collection->keyOf('date')); + } + + public function testKeyOfThrowsOnNonAssociative(): void + { + $collection = Collection::from(['apple', 'banana', 'cherry']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Only associative collections support keys'); + $collection->keyOf('banana'); + } + + public function testKeys(): void + { + $collection = Collection::from(['x' => 10, 'y' => 20, 'z' => 30]); + + $this->assertSame(['x', 'y', 'z'], $collection->keys()); + } + + public function testKeysThrowsOnNonAssociative(): void + { + $collection = Collection::from([10, 20, 30]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Only associative collections support keys'); + $collection->keys(); + } + + public function testValues(): void + { + $associativeCollection = Collection::from(['a' => 'apple', 'b' => 'banana', 'c' => 'cherry']); + $indexedCollection = Collection::from(['apple', 'banana', 'cherry']); + + $this->assertSame(['apple', 'banana', 'cherry'], $associativeCollection->values()); + $this->assertSame(['apple', 'banana', 'cherry'], $indexedCollection->values()); + } + + public function testContains(): void + { + $associativeCollection = Collection::from(['key1' => 'value1', 'key2' => 'value2']); + $indexedCollection = Collection::from(['value1', 'value2', 'value3']); + + $this->assertTrue($associativeCollection->contains('value1')); + $this->assertFalse($associativeCollection->contains('value3')); + + $this->assertTrue($indexedCollection->contains('value2')); + $this->assertFalse($indexedCollection->contains('value4')); + } + + public function testEvery(): void + { + $data = [2, 4, 6, 8]; + $collection = Collection::from($data); + + $this->assertTrue($collection->every(fn($item) => $item % 2 === 0)); + $this->assertFalse($collection->every(fn($item) => $item > 4)); + } + + public function testSome(): void + { + $data = [1, 3, 5, 7]; + $collection = Collection::from($data); + + $this->assertTrue($collection->some(fn($item) => $item === 3)); + $this->assertFalse($collection->some(fn($item) => $item % 2 === 0)); + } + + public function testFind(): void + { + $associativeCollection = Collection::from(['a' => 10, 'b' => 15, 'c' => 20]); + $indexedCollection = Collection::from([10, 15, 20]); + + $this->assertSame(15, $associativeCollection->find(fn($item) => $item > 12)); + $this->assertNull($associativeCollection->find(fn($item) => $item > 25)); + + $this->assertSame(15, $indexedCollection->find(fn($item) => $item > 12)); + $this->assertNull($indexedCollection->find(fn($item) => $item > 25)); + } + + public function testClone(): void + { + $collection = Collection::from(['itemA', 'itemB', 'itemC']); + $clonedCollection = $collection->clone(); + + $this->assertInstanceOf(Collection::class, $clonedCollection); + $this->assertNotSame($collection, $clonedCollection); + $this->assertEquals($collection, $clonedCollection); + } + + public function testDeepClone(): void + { + $data = [ + new Collection(['a', 'b']), + new Collection(['c', 'd']), + ]; + + $collection = Collection::from($data); + $deepClonedCollection = $collection->deepClone(); + + $this->assertInstanceOf(Collection::class, $deepClonedCollection); + $this->assertNotSame($collection, $deepClonedCollection); + $this->assertEquals($collection, $deepClonedCollection); + + foreach ($deepClonedCollection->values() as $index => $item) { + $this->assertNotSame($collection->at($index), $item); + $this->assertEquals($collection->at($index), $item); + } + } + + public function testReverse(): void + { + $collection = Collection::from(['first', 'second', 'third']); + $reversedCollection = $collection->reverse(); + + $this->assertInstanceOf(Collection::class, $reversedCollection); + $this->assertNotSame($collection, $reversedCollection); + $this->assertSame(['third', 'second', 'first'], $reversedCollection->values()); + } + + public function testShuffle(): void + { + $collection = Collection::from(['one', 'two', 'three', 'four', 'five']); + $shuffledCollection = $collection->shuffle(); + + $this->assertInstanceOf(Collection::class, $shuffledCollection); + $this->assertNotSame($collection, $shuffledCollection); + $this->assertCount(5, $shuffledCollection); + $this->assertNotSame($collection->values(), $shuffledCollection->values()); + + foreach (['one', 'two', 'three', 'four', 'five'] as $item) { + $this->assertTrue($shuffledCollection->contains($item)); + } + } + + public function testUnique(): void + { + $collection = Collection::from(['apple', 'banana', 'apple', 'orange', 'banana']); + $uniqueCollection = $collection->unique(); + + $this->assertInstanceOf(Collection::class, $uniqueCollection); + $this->assertNotSame($collection, $uniqueCollection); + $this->assertSame(['apple', 'banana', 'orange'], $uniqueCollection->values()); + } + + public function testDuplicates(): void + { + $collection = Collection::from(['apple', 'banana', 'apple', 'orange', 'banana', 'banana']); + $duplicatesCollection = $collection->duplicates(); + + $this->assertInstanceOf(Collection::class, $duplicatesCollection); + $this->assertNotSame($collection, $duplicatesCollection); + $this->assertSame(['apple', 'banana', 'banana'], $duplicatesCollection->values()); + } + + public function testSlice(): void + { + $collection = Collection::from(['a', 'b', 'c', 'd', 'e']); + + $slicedCollection = $collection->slice(1, 3); + $this->assertInstanceOf(Collection::class, $slicedCollection); + $this->assertNotSame($collection, $slicedCollection); + $this->assertSame(['b', 'c', 'd'], $slicedCollection->values()); + } + + public function testLimit(): void + { + $collection = Collection::from(['x', 'y', 'z', 'w']); + + $limitedCollection = $collection->limit(2); + $this->assertInstanceOf(Collection::class, $limitedCollection); + $this->assertNotSame($collection, $limitedCollection); + $this->assertSame(['x', 'y'], $limitedCollection->values()); + } + + public function testEach(): void + { + $collection = Collection::from(['red', 'green', 'blue']); + + $collectedItems = []; + $collection->each(function ($item) use (&$collectedItems) { + $collectedItems[] = $item; + }); + + $this->assertSame(['red', 'green', 'blue'], $collectedItems); + } + + public function testEachWithEarlyReturn(): void + { + $collection = Collection::from([1, 2, 3, 4, 5]); + + $collectedItems = []; + $collection->each(function ($item) use (&$collectedItems) { + $collectedItems[] = $item; + if ($item >= 3) { + return false; + } + return true; + }); + + $this->assertSame([1, 2, 3], $collectedItems); + } + + public function testMap(): void + { + $collection = Collection::from([1, 2, 3]); + + $mappedCollection = $collection->map(fn($item) => $item * 2); + + $this->assertInstanceOf(Collection::class, $mappedCollection); + $this->assertNotSame($collection, $mappedCollection); + $this->assertSame([2, 4, 6], $mappedCollection->values()); + } + + public function testFilter(): void + { + $data = [1, 2, 3, 4, 5]; + $collection = Collection::from($data); + + $filteredCollection = $collection->filter(fn($item) => $item % 2 === 0); + + $this->assertInstanceOf(Collection::class, $filteredCollection); + $this->assertNotSame($collection, $filteredCollection); + $this->assertSame([2, 4], $filteredCollection->values()); + } + + public function testReject(): void + { + $data = [1, 2, 3, 4, 5]; + $collection = Collection::from($data); + + $rejectedCollection = $collection->reject(fn($item) => $item % 2 === 0); + + $this->assertInstanceOf(Collection::class, $rejectedCollection); + $this->assertNotSame($collection, $rejectedCollection); + $this->assertSame([1, 3, 5], $rejectedCollection->values()); + } + + public function testSort(): void + { + $collection = Collection::from([3, 1, 4, 2]); + + $sortedCollection = $collection->sort(); + + $this->assertInstanceOf(Collection::class, $sortedCollection); + $this->assertNotSame($collection, $sortedCollection); + $this->assertSame([1, 2, 3, 4], $sortedCollection->values()); + } + + public function testGroup(): void + { + $collection = Collection::from(['apple', 'apricot', 'banana', 'blueberry', 'cherry']); + + $grouped = $collection->group(fn($item) => $item[0]); + + $this->assertIsArray($grouped); + $this->assertCount(3, $grouped); + $this->assertSame(['apple', 'apricot'], $grouped['a']); + $this->assertSame(['banana', 'blueberry'], $grouped['b']); + $this->assertSame(['cherry'], $grouped['c']); + } + + public function testExtract(): void + { + $collection = Collection::from([ + 'alice' => ['name' => 'Alice', 'age' => 30], + 'bob' => ['name' => 'Bob', 'age' => 25], + 'charlie' => ['name' => 'Charlie', 'age' => 35], + ]); + + $names = $collection->extract('name'); + $ages = $collection->extract('age'); + + $this->assertSame(['alice' => 'Alice', 'bob' => 'Bob', 'charlie' => 'Charlie'], $names); + $this->assertSame(['alice' => 30, 'bob' => 25, 'charlie' => 35], $ages); + } + + public function testPluck(): void + { + $collection = Collection::from([ + 'alice' => ['name' => 'Alice', 'age' => 30], + 'bob' => ['name' => 'Bob', 'age' => 25], + 'charlie' => ['name' => 'Charlie', 'age' => 35], + ]); + + $names = $collection->pluck('name'); + $ages = $collection->pluck('age'); + + $this->assertSame(['Alice', 'Bob', 'Charlie'], $names); + $this->assertSame([30, 25, 35], $ages); + } + + public function testFlatten(): void + { + $collection = Collection::from([ + ['a', 'b'], + ['c', 'd'], + ['e', 'f'], + ]); + + $flattenedCollection = $collection->flatten(); + + $this->assertInstanceOf(Collection::class, $flattenedCollection); + $this->assertNotSame($collection, $flattenedCollection); + $this->assertSame(['a', 'b', 'c', 'd', 'e', 'f'], $flattenedCollection->values()); + } + + public function testFilterByWithCallback(): void + { + $collection = Collection::from([ + ['name' => 'Alice', 'age' => 30], + ['name' => 'Bob', 'age' => 25], + ['name' => 'Charlie', 'age' => 35], + ]); + + $filteredCollection = $collection->filterBy('age', fn($age) => $age > 30); + + $this->assertInstanceOf(Collection::class, $filteredCollection); + $this->assertNotSame($collection, $filteredCollection); + $this->assertSame([['name' => 'Charlie', 'age' => 35]], $filteredCollection->values()); + } + + public function testFilterByWithDefaultFilters(): void + { + $filters = [ + '==', + 'equalTo', + '!=', + 'notEqualTo', + '===', + 'strictlyEqualTo', + '!==', + 'strictlyNotEqualTo', + '>', + 'greaterThan', + '>=', + 'greaterThanOrEqualTo', + '<', + 'lessThan', + '<=', + 'lessThanOrEqualTo', + ]; + + $data = [ + ['value' => 10], + ['value' => 20], + ['value' => 30], + ]; + + $collection = Collection::from($data); + + foreach ($filters as $filter) { + $expected = match ($filter) { + '==', 'equalTo' => [['value' => 20]], + '!=', 'notEqualTo' => [['value' => 10], ['value' => 30]], + '===', 'strictlyEqualTo' => [['value' => 20]], + '!==', 'strictlyNotEqualTo' => [['value' => 10], ['value' => 30]], + '>', 'greaterThan' => [['value' => 30]], + '>=', 'greaterThanOrEqualTo' => [['value' => 20], ['value' => 30]], + '<', 'lessThan' => [['value' => 10]], + '<=', 'lessThanOrEqualTo' => [['value' => 10], ['value' => 20]], + }; + + $filteredCollection = $collection->filterBy('value', $filter, 20); + + $this->assertInstanceOf(Collection::class, $filteredCollection); + $this->assertNotSame($collection, $filteredCollection); + $this->assertSame($expected, $filteredCollection->values()); + } + } + + public function testFilterByThrowsOnInvalidFilter(): void + { + $collection = Collection::from([ + ['value' => 10], + ['value' => 20], + ['value' => 30], + ]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Unknown filter "invalidComparison"'); + $collection->filterBy('value', 'invalidComparison', 20); + } + + public function testFilterByThrowsOnCallbackWithThirdArgument(): void + { + $collection = Collection::from([ + ['value' => 10], + ['value' => 20], + ['value' => 30], + ]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Unexpected third argument passed'); + $collection->filterBy('value', fn($value) => $value > 15, 20); + } + + public function testSortBy(): void + { + $collection = Collection::from([ + ['name' => 'Charlie', 'age' => 35], + ['name' => 'Alice', 'age' => 30], + ['name' => 'Bob', 'age' => 25], + ]); + + $sortedByName = $collection->sortBy('name'); + $sortedByAge = $collection->sortBy('age'); + + $this->assertInstanceOf(Collection::class, $sortedByName); + $this->assertNotSame($collection, $sortedByName); + $this->assertSame([ + ['name' => 'Alice', 'age' => 30], + ['name' => 'Bob', 'age' => 25], + ['name' => 'Charlie', 'age' => 35], + ], $sortedByName->values()); + + $this->assertInstanceOf(Collection::class, $sortedByAge); + $this->assertNotSame($collection, $sortedByAge); + $this->assertSame([ + ['name' => 'Bob', 'age' => 25], + ['name' => 'Alice', 'age' => 30], + ['name' => 'Charlie', 'age' => 35], + ], $sortedByAge->values()); + } + + public function testGroupBy(): void + { + $collection = Collection::from([ + ['name' => 'Alice', 'department' => 'HR'], + ['name' => 'Bob', 'department' => 'IT'], + ['name' => 'Charlie', 'department' => 'HR'], + ['name' => 'David', 'department' => 'IT'], + ['name' => 'Eve', 'department' => 'Finance'], + ]); + + $grouped = $collection->groupBy('department'); + + $this->assertIsArray($grouped); + $this->assertCount(3, $grouped); + $this->assertSame([ + ['name' => 'Alice', 'department' => 'HR'], + ['name' => 'Charlie', 'department' => 'HR'], + ], $grouped['HR']); + $this->assertSame([ + ['name' => 'Bob', 'department' => 'IT'], + ['name' => 'David', 'department' => 'IT'], + ], $grouped['IT']); + $this->assertSame([ + ['name' => 'Eve', 'department' => 'Finance'], + ], $grouped['Finance']); + } + + public function testKeyBy(): void + { + $collection = Collection::from([ + ['id' => 'a1', 'name' => 'Alice'], + ['id' => 'b2', 'name' => 'Bob'], + ['id' => 'c3', 'name' => 'Charlie'], + ]); + + $keyedCollection = $collection->keyBy('id'); + + $this->assertInstanceOf(Collection::class, $keyedCollection); + $this->assertNotSame($collection, $keyedCollection); + $this->assertSame([ + 'a1' => ['id' => 'a1', 'name' => 'Alice'], + 'b2' => ['id' => 'b2', 'name' => 'Bob'], + 'c3' => ['id' => 'c3', 'name' => 'Charlie'], + ], $keyedCollection->toArray()); + } + + public function testWith(): void + { + $data = ['item1', 'item2']; + $collection = Collection::from($data); + + $newCollection = $collection->with('item3'); + + $this->assertInstanceOf(Collection::class, $newCollection); + $this->assertNotSame($collection, $newCollection); + $this->assertSame(['item1', 'item2', 'item3'], $newCollection->values()); + } + + public function testWithSkipsExistingItems(): void + { + $collection = Collection::from(['item1', 'item2']); + + $newCollection = $collection->with('item2'); + + $this->assertInstanceOf(Collection::class, $newCollection); + $this->assertNotSame($collection, $newCollection); + $this->assertSame(['item1', 'item2'], $newCollection->values()); + } + + public function testWithout(): void + { + $collection = Collection::from(['item1', 'item2', 'item3']); + + $newCollection = $collection->without('item2'); + + $this->assertInstanceOf(Collection::class, $newCollection); + $this->assertNotSame($collection, $newCollection); + $this->assertSame(['item1', 'item3'], $newCollection->values()); + } + + public function testEveryItem(): void + { + $collection = Collection::from(['itemA', 'itemB', 'itemC']); + + $dataProxy = $collection->everyItem(); + + $this->assertInstanceOf(CollectionDataProxy::class, $dataProxy); + } + + public function testUnion(): void + { + $collection1 = Collection::from(['a' => 'item1', 'b' => 'item2']); + $collection2 = Collection::from(['b' => 'item2', 'c' => 'item3']); + + $unionCollection = $collection1->union($collection2); + + $this->assertInstanceOf(Collection::class, $unionCollection); + $this->assertNotSame($collection1, $unionCollection); + $this->assertSame(['a' => 'item1', 'b' => 'item2', 'c' => 'item3'], $unionCollection->toArray()); + } + + public function testIntersection(): void + { + $collection1 = Collection::from(['a' => 'item1', 'b' => 'item2', 'c' => 'item3']); + $collection2 = Collection::from(['b' => 'item2', 'c' => 'item4', 'd' => 'item3']); + + $intersectionCollection = $collection1->intersection($collection2); + + $this->assertInstanceOf(Collection::class, $intersectionCollection); + $this->assertNotSame($collection1, $intersectionCollection); + $this->assertSame(['b' => 'item2', 'c' => 'item3'], $intersectionCollection->toArray()); + } + + public function testDifference(): void + { + $collection1 = Collection::from(['a' => 'item1', 'b' => 'item2', 'c' => 'item3']); + $collection2 = Collection::from(['b' => 'item2', 'c' => 'item4']); + + $differenceCollection = $collection1->difference($collection2); + + $this->assertInstanceOf(Collection::class, $differenceCollection); + $this->assertNotSame($collection1, $differenceCollection); + $this->assertSame(['a' => 'item1', 'c' => 'item3'], $differenceCollection->toArray()); + } + + public function testAdd(): void + { + $collection = Collection::from(['item1', 'item2'], mutable: true); + + $collection->add('item3'); + + $this->assertCount(3, $collection); + $this->assertSame(['item1', 'item2', 'item3'], $collection->values()); + } + + public function testAddThrowsOnImmutable(): void + { + $collection = Collection::from(['item1', 'item2'], mutable: false); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be added only to mutable and non-associative collections'); + $collection->add('item3'); + } + + public function testAddThrowsOnAssociative(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2'], mutable: true); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be added only to mutable and non-associative collections'); + $collection->add('value3'); + } + + public function testAddThrowsOnTypeMismatch(): void + { + $collection = Collection::of('int', mutable: true); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Value must be of type int to be added, string given'); + $collection->add('notAnInteger'); + } + + public function testAddMultiple(): void + { + $collection = Collection::from(['item1'], mutable: true); + + $collection->addMultiple(['item2', 'item3']); + + $this->assertCount(3, $collection); + $this->assertSame(['item1', 'item2', 'item3'], $collection->values()); + } + + public function testPull(): void + { + $collection = Collection::from(['item1', 'item2', 'item3'], mutable: true); + + $collection->pull('item2'); + + $this->assertSame(['item1', 'item3'], $collection->values()); + } + + public function testPullThrowsOnImmutable(): void + { + $collection = Collection::from(['item1', 'item2', 'item3'], mutable: false); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be pulled only from mutable and non-associative collections'); + $collection->pull('item2'); + } + + public function testPullThrowsOnAssociative(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2'], mutable: true); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be pulled only from mutable and non-associative collections'); + $collection->pull('value1'); + } + + public function testPullMultiple(): void + { + $collection = Collection::from(['item1', 'item2', 'item3', 'item4'], mutable: true); + + $collection->pullMultiple(['item2', 'item4']); + + $this->assertSame(['item1', 'item3'], $collection->values()); + } + + public function testMoveItem(): void + { + $collection = Collection::from(['item1', 'item2', 'item3'], mutable: true); + + $collection->moveItem(0, 2); + + $this->assertSame(['item2', 'item3', 'item1'], $collection->values()); + } + + public function testMoveItemInAssociative(): void + { + $collection = Collection::from(['a' => 'item1', 'b' => 'item2', 'c' => 'item3'], mutable: true); + + $collection->moveItem(0, 2); + + $this->assertSame(['b' => 'item2', 'c' => 'item3', 'a' => 'item1'], $collection->toArray()); + } + + public function testHas(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2']); + + $this->assertTrue($collection->has('key1')); + $this->assertFalse($collection->has('key3')); + } + + public function testHasThrowsOnNonAssociative(): void + { + $collection = Collection::from(['value1', 'value2', 'value3']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Value presence can be checked only in associative collections'); + $collection->has('key1'); + } + + public function testGet(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2']); + + $this->assertSame('value1', $collection->get('key1')); + $this->assertSame('value2', $collection->get('key2')); + $this->assertNull($collection->get('key3')); + $this->assertSame('default', $collection->get('key3', 'default')); + } + + public function testGetThrowsOnNonAssociative(): void + { + $collection = Collection::from(['value1', 'value2', 'value3']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be get only from associative collections'); + $collection->get('key1'); + } + + public function testSet(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2'], mutable: true); + + $collection->set('key2', 'newValue2'); + $collection->set('key3', 'value3'); + + $this->assertSame(['key1' => 'value1', 'key2' => 'newValue2', 'key3' => 'value3'], $collection->toArray()); + } + + public function testSetThrowsOnNonAssociative(): void + { + $collection = Collection::from(['value1', 'value2', 'value3'], mutable: true); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be set only to associative and mutable collections'); + $collection->set('key1', 'newValue1'); + } + + public function testSetThrowsOnImmutable(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2'], mutable: false); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be set only to associative and mutable collections'); + $collection->set('key2', 'newValue2'); + } + + public function testSetThrowsOnTypeMismatch(): void + { + $collection = Collection::of('int', associative: true, mutable: true); + $collection->set('key1', 10); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Value must be of type int, string given'); + $collection->set('key2', 'notAnInteger'); + } + + public function testRemove(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3'], mutable: true); + + $collection->remove('key2'); + + $this->assertSame(['key1' => 'value1', 'key3' => 'value3'], $collection->toArray()); + } + + public function testRemoveThrowsOnNonAssociative(): void + { + $collection = Collection::from(['value1', 'value2', 'value3'], mutable: true); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be removed only from associative and mutable collections'); + $collection->remove('key1'); + } + + public function testRemoveThrowsOnImmutable(): void + { + $collection = Collection::from(['key1' => 'value1', 'key2' => 'value2'], mutable: false); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be removed only from associative and mutable collections'); + $collection->remove('key2'); + } + + public function testMerge(): void + { + $collection1 = Collection::from(['a' => 'item1', 'b' => 'item2'], mutable: true); + $collection2 = Collection::from(['b' => 'item3', 'c' => 'item4']); + + $collection1->merge($collection2); + + $this->assertSame(['a' => 'item1', 'b' => 'item3', 'c' => 'item4'], $collection1->toArray()); + } + + public function testMergeThrowsOnImmutable(): void + { + $collection1 = Collection::from(['a' => 'item1', 'b' => 'item2'], mutable: false); + $collection2 = Collection::from(['b' => 'item3', 'c' => 'item4']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Values can be merged only into mutable collections'); + $collection1->merge($collection2); + } + + public function testMergeThrowsOnAssociativityMismatch(): void + { + $collection1 = Collection::from(['item1', 'item2'], mutable: true); + $collection2 = Collection::from(['b' => 'item3', 'c' => 'item4']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Collections cannot be merged if their associativeness is different'); + $collection1->merge($collection2); + } + + public function testMergeThrowsOnTypeMismatch(): void + { + $collection1 = Collection::of('int', mutable: true); + $collection1->add(1); + $collection1->add(2); + + $collection2 = Collection::of('string', mutable: true); + $collection2->add('three'); + $collection2->add('four'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Collections with data of different types cannot be merged'); + $collection1->merge($collection2); + } +} diff --git a/tests/Data/Exceptions/InvalidValueExceptionTest.php b/tests/Data/Exceptions/InvalidValueExceptionTest.php new file mode 100644 index 000000000..ac7cefcc3 --- /dev/null +++ b/tests/Data/Exceptions/InvalidValueExceptionTest.php @@ -0,0 +1,24 @@ +assertSame('Invalid value provided', $exception->getMessage()); + } + + public function testConstructorWithIdentifierAndContext(): void + { + $exception = new InvalidValueException('Invalid age', 'age.invalid', ['age' => -5]); + $this->assertSame('age.invalid', $exception->getIdentifier()); + $this->assertSame(['age' => -5], $exception->getContext()); + } +} diff --git a/tests/Data/Fixtures/DataArrayableFixture.php b/tests/Data/Fixtures/DataArrayableFixture.php new file mode 100644 index 000000000..5832e7a0a --- /dev/null +++ b/tests/Data/Fixtures/DataArrayableFixture.php @@ -0,0 +1,16 @@ +data = $data; + } +} diff --git a/tests/Data/Fixtures/DataCountableFixture.php b/tests/Data/Fixtures/DataCountableFixture.php new file mode 100644 index 000000000..5a902d3cf --- /dev/null +++ b/tests/Data/Fixtures/DataCountableFixture.php @@ -0,0 +1,16 @@ +data = $data; + } +} diff --git a/tests/Data/Fixtures/DataGetterFixture.php b/tests/Data/Fixtures/DataGetterFixture.php new file mode 100644 index 000000000..9b41dc14e --- /dev/null +++ b/tests/Data/Fixtures/DataGetterFixture.php @@ -0,0 +1,15 @@ +data = $data; + } +} diff --git a/tests/Data/Fixtures/DataIteratorFixture.php b/tests/Data/Fixtures/DataIteratorFixture.php new file mode 100644 index 000000000..08bab3d2b --- /dev/null +++ b/tests/Data/Fixtures/DataIteratorFixture.php @@ -0,0 +1,16 @@ +data = $data; + } +} diff --git a/tests/Data/Fixtures/DataMultipleGetterFixture.php b/tests/Data/Fixtures/DataMultipleGetterFixture.php new file mode 100644 index 000000000..a7f6e3cdb --- /dev/null +++ b/tests/Data/Fixtures/DataMultipleGetterFixture.php @@ -0,0 +1,15 @@ +data = $data; + } +} diff --git a/tests/Data/Fixtures/DataMultipleSetterFixture.php b/tests/Data/Fixtures/DataMultipleSetterFixture.php new file mode 100644 index 000000000..334467cac --- /dev/null +++ b/tests/Data/Fixtures/DataMultipleSetterFixture.php @@ -0,0 +1,20 @@ +data = $data; + } + + public function data(): array + { + return $this->data; + } +} diff --git a/tests/Data/Fixtures/DataSetterFixture.php b/tests/Data/Fixtures/DataSetterFixture.php new file mode 100644 index 000000000..9c44a20ed --- /dev/null +++ b/tests/Data/Fixtures/DataSetterFixture.php @@ -0,0 +1,20 @@ +data = $data; + } + + public function data(): array + { + return $this->data; + } +} diff --git a/tests/Data/PaginationTest.php b/tests/Data/PaginationTest.php new file mode 100644 index 000000000..e1a37f773 --- /dev/null +++ b/tests/Data/PaginationTest.php @@ -0,0 +1,54 @@ +assertSame(10, $pagination->length()); + $this->assertSame(5, $pagination->pages()); + $this->assertSame($pagination->pages(), $pagination->lastPage()); + $this->assertSame(1, $pagination->currentPage()); + $this->assertSame(0, $pagination->offset()); + $this->assertTrue($pagination->hasPages()); + + $this->assertTrue($pagination->has(1)); + $this->assertTrue($pagination->has(5)); + $this->assertFalse($pagination->has(6)); + $this->assertFalse($pagination->has(0)); + + $this->assertTrue($pagination->isFirstPage()); + $this->assertTrue($pagination->hasNextPage()); + $this->assertFalse($pagination->isLastPage()); + $this->assertFalse($pagination->hasPreviousPage()); + $this->assertSame(1, $pagination->previousPage()); + $this->assertSame(2, $pagination->nextPage()); + + $pagination->setCurrentPage(3); + $this->assertSame(3, $pagination->currentPage()); + $this->assertSame(20, $pagination->offset()); + $this->assertTrue($pagination->hasNextPage()); + $this->assertTrue($pagination->hasPreviousPage()); + $this->assertSame(2, $pagination->previousPage()); + $this->assertSame(4, $pagination->nextPage()); + + $pagination->setCurrentPage(5); + $this->assertSame(5, $pagination->currentPage()); + $this->assertSame(40, $pagination->offset()); + $this->assertFalse($pagination->hasNextPage()); + $this->assertTrue($pagination->isLastPage()); + $this->assertTrue($pagination->hasPreviousPage()); + $this->assertSame(4, $pagination->previousPage()); + $this->assertSame(5, $pagination->nextPage()); + } +} diff --git a/tests/Data/Traits/DataArrayableTest.php b/tests/Data/Traits/DataArrayableTest.php new file mode 100644 index 000000000..701357aed --- /dev/null +++ b/tests/Data/Traits/DataArrayableTest.php @@ -0,0 +1,19 @@ + 'value1', 'key2' => 'value2']; + $fixture = new DataArrayableFixture($data); + $this->assertSame($data, $fixture->toArray()); + } +} diff --git a/tests/Data/Traits/DataCountableTest.php b/tests/Data/Traits/DataCountableTest.php new file mode 100644 index 000000000..57370405c --- /dev/null +++ b/tests/Data/Traits/DataCountableTest.php @@ -0,0 +1,19 @@ + 'value1', 'key2' => 'value2', 'key3' => 'value3']; + $fixture = new DataCountableFixture($data); + $this->assertCount(3, $fixture); + } +} diff --git a/tests/Data/Traits/DataGetterTest.php b/tests/Data/Traits/DataGetterTest.php new file mode 100644 index 000000000..ad654b15e --- /dev/null +++ b/tests/Data/Traits/DataGetterTest.php @@ -0,0 +1,35 @@ + 'value1', 'key2' => 'value2']; + $fixture = new DataGetterFixture($data); + $this->assertSame('value1', $fixture->get('key1')); + $this->assertSame('value2', $fixture->get('key2')); + } + + public function testGetReturnsDefaultValueForNonExistingKey(): void + { + $data = ['key1' => 'value1']; + $fixture = new DataGetterFixture($data); + $this->assertSame('default', $fixture->get('key2', 'default')); + } + + public function testHasReturnsKeyExistence(): void + { + $data = ['key1' => 'value1']; + $fixture = new DataGetterFixture($data); + $this->assertTrue($fixture->has('key1')); + $this->assertFalse($fixture->has('key2')); + } +} diff --git a/tests/Data/Traits/DataIteratorTest.php b/tests/Data/Traits/DataIteratorTest.php new file mode 100644 index 000000000..532fa26d2 --- /dev/null +++ b/tests/Data/Traits/DataIteratorTest.php @@ -0,0 +1,23 @@ + 'value1', 'key2' => 'value2', 'key3' => 'value3']; + $fixture = new DataIteratorFixture($data); + $iteratedData = []; + foreach ($fixture as $key => $value) { + $iteratedData[$key] = $value; + } + $this->assertSame($data, $iteratedData); + } +} diff --git a/tests/Data/Traits/DataMultipleGetterTest.php b/tests/Data/Traits/DataMultipleGetterTest.php new file mode 100644 index 000000000..8984ca438 --- /dev/null +++ b/tests/Data/Traits/DataMultipleGetterTest.php @@ -0,0 +1,42 @@ + 'value1', 'key2' => 'value2', 'key3' => 'value3']; + $fixture = new DataMultipleGetterFixture($data); + $values = $fixture->getMultiple(['key1', 'key3']); + $this->assertSame(['key1' => 'value1', 'key3' => 'value3'], $values); + } + + public function testGetMultipleWithNonExistentKeysReturnsDefault(): void + { + $data = ['key1' => 'value1', 'key2' => 'value2']; + $fixture = new DataMultipleGetterFixture($data); + $values = $fixture->getMultiple(['key1', 'key3'], 'default'); + $this->assertSame(['key1' => 'value1', 'key3' => 'default'], $values); + } + + public function testHasMultipleReturnsTrueWhenAllKeysExist(): void + { + $data = ['key1' => 'value1', 'key2' => 'value2']; + $fixture = new DataMultipleGetterFixture($data); + $this->assertTrue($fixture->hasMultiple(['key1', 'key2'])); + } + + public function testHasMultipleReturnsFalseWhenAnyKeyDoesNotExist(): void + { + $data = ['key1' => 'value1', 'key2' => 'value2']; + $fixture = new DataMultipleGetterFixture($data); + $this->assertFalse($fixture->hasMultiple(['key1', 'key3'])); + } +} diff --git a/tests/Data/Traits/DataMultipleSetterTest.php b/tests/Data/Traits/DataMultipleSetterTest.php new file mode 100644 index 000000000..7df6296a8 --- /dev/null +++ b/tests/Data/Traits/DataMultipleSetterTest.php @@ -0,0 +1,38 @@ + 'value1', 'key2' => 'value2']); + + $fixture->setMultiple(['key1' => 'newValue1', 'key3' => 'value3']); + + $data = $fixture->data(); + + $this->assertSame('newValue1', $data['key1']); + $this->assertSame('value2', $data['key2']); + $this->assertSame('value3', $data['key3']); + } + + public function testRemoveMultiple(): void + { + $fixture = new DataMultipleSetterFixture(['key1' => 'value1', 'key2' => 'value2', 'key3' => 'value3']); + + $fixture->removeMultiple(['key1', 'key3']); + + $data = $fixture->data(); + + $this->assertArrayNotHasKey('key1', $data); + $this->assertSame('value2', $data['key2']); + $this->assertArrayNotHasKey('key3', $data); + } +} diff --git a/tests/Data/Traits/DataSetterTest.php b/tests/Data/Traits/DataSetterTest.php new file mode 100644 index 000000000..ad33ef905 --- /dev/null +++ b/tests/Data/Traits/DataSetterTest.php @@ -0,0 +1,40 @@ +set('key1', 'value1'); + $this->assertSame(['key1' => 'value1'], $fixture->data()); + + $fixture->set('key2', 'value2'); + $this->assertSame(['key1' => 'value1', 'key2' => 'value2'], $fixture->data()); + + $fixture->set('key1', 'newValue1'); + $this->assertSame(['key1' => 'newValue1', 'key2' => 'value2'], $fixture->data()); + } + + public function testRemove(): void + { + $fixture = new DataSetterFixture(['key1' => 'value1', 'key2' => 'value2']); + + $fixture->remove('key1'); + $this->assertSame(['key2' => 'value2'], $fixture->data()); + + $fixture->remove('key3'); // Removing non-existent key + $this->assertSame(['key2' => 'value2'], $fixture->data()); + + $fixture->remove('key2'); + $this->assertSame([], $fixture->data()); + } +} From bf606d15836e65c77ce165b6019f02e6af3135fd Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:24:57 +0100 Subject: [PATCH 4/4] Add tests for the `Parsers` namespace --- .../Fixtures/CommonMarkExtensionFixture.php | 11 ++ tests/Parsers/Fixtures/EnumFixture.php | 10 ++ .../Parsers/Fixtures/SerializableFixture.php | 23 +++ .../SetStateImplementingClassFixture.php | 13 ++ tests/Parsers/Fixtures/files/json/test.json | 5 + tests/Parsers/Fixtures/files/php/test.php | 7 + tests/Parsers/Fixtures/files/yaml/test.yaml | 6 + tests/Parsers/JsonTest.php | 97 ++++++++++++ tests/Parsers/MarkdownTest.php | 88 +++++++++++ tests/Parsers/PhpTest.php | 147 ++++++++++++++++++ tests/Parsers/YamlTest.php | 97 ++++++++++++ 11 files changed, 504 insertions(+) create mode 100644 tests/Parsers/Fixtures/CommonMarkExtensionFixture.php create mode 100644 tests/Parsers/Fixtures/EnumFixture.php create mode 100644 tests/Parsers/Fixtures/SerializableFixture.php create mode 100644 tests/Parsers/Fixtures/SetStateImplementingClassFixture.php create mode 100644 tests/Parsers/Fixtures/files/json/test.json create mode 100644 tests/Parsers/Fixtures/files/php/test.php create mode 100644 tests/Parsers/Fixtures/files/yaml/test.yaml create mode 100644 tests/Parsers/JsonTest.php create mode 100644 tests/Parsers/MarkdownTest.php create mode 100644 tests/Parsers/PhpTest.php create mode 100644 tests/Parsers/YamlTest.php diff --git a/tests/Parsers/Fixtures/CommonMarkExtensionFixture.php b/tests/Parsers/Fixtures/CommonMarkExtensionFixture.php new file mode 100644 index 000000000..545a832ce --- /dev/null +++ b/tests/Parsers/Fixtures/CommonMarkExtensionFixture.php @@ -0,0 +1,11 @@ +data = $data; + } + + public function __serialize(): array + { + return ['data' => $this->data]; + } + + public function __unserialize(array $data): void + { + $this->data = $data['data']; + } +} diff --git a/tests/Parsers/Fixtures/SetStateImplementingClassFixture.php b/tests/Parsers/Fixtures/SetStateImplementingClassFixture.php new file mode 100644 index 000000000..718510a65 --- /dev/null +++ b/tests/Parsers/Fixtures/SetStateImplementingClassFixture.php @@ -0,0 +1,13 @@ + 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'testing', 'formwork'], +]; diff --git a/tests/Parsers/Fixtures/files/yaml/test.yaml b/tests/Parsers/Fixtures/files/yaml/test.yaml new file mode 100644 index 000000000..39b0a3bff --- /dev/null +++ b/tests/Parsers/Fixtures/files/yaml/test.yaml @@ -0,0 +1,6 @@ +title: Test +description: This is a test. +tags: + - php + - yaml + - parser diff --git a/tests/Parsers/JsonTest.php b/tests/Parsers/JsonTest.php new file mode 100644 index 000000000..64df695cc --- /dev/null +++ b/tests/Parsers/JsonTest.php @@ -0,0 +1,97 @@ +tearDownTempDirectory(); + } + + public function testParse(): void + { + $json = << 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'json', 'parser'], + ]; + + $this->assertSame($expected, Json::parse($json)); + } + + public function testParseFile(): void + { + $jsonFilePath = TESTS_TMP_PATH . '/test.json'; + + $expected = [ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'json', 'parser'], + ]; + + $this->assertSame($expected, Json::parseFile($jsonFilePath)); + } + + public function testEncode(): void + { + $data = [ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'json', 'parser'], + ]; + + $expected = '{"title":"Test","description":"This is a test.","tags":["php","json","parser"]}'; + + $this->assertJsonStringEqualsJsonString($expected, Json::encode($data)); + } + + public function testEncodeWithPrettyPrint(): void + { + $data = [ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'json', 'parser'], + ]; + + $expected = <<assertJsonStringEqualsJsonString($expected, Json::encode($data, ['prettyPrint' => true])); + } + + public function testEncodeEmptyArrayWithForceObjectOption(): void + { + $data = []; + + $this->assertJsonStringEqualsJsonString('{}', Json::encode($data, ['forceObject' => true])); + } +} diff --git a/tests/Parsers/MarkdownTest.php b/tests/Parsers/MarkdownTest.php new file mode 100644 index 000000000..bf7ddfa60 --- /dev/null +++ b/tests/Parsers/MarkdownTest.php @@ -0,0 +1,88 @@ +Hello, World!\n

This is a bold statement.

\n"; + + $this->assertSame($expectedHtml, Markdown::parse($markdown)); + } + + public function testParseWithSiteUri(): void + { + $site = $this->createStub(Site::class); + + $site->method('uri') + ->willReturnArgument(0); + + $markdown = "![Alt text](image.jpg)\n\n[Link text](https://example.com)"; + $expectedHtml = "

\"Alt

\n

Link text

\n"; + + $this->assertSame($expectedHtml, Markdown::parse($markdown, ['site' => $site])); + } + + public function testParseReturnsHeadingsIdsWhenOptionEnabled(): void + { + $markdown = "## Section One\n\n## Section Two"; + $expectedHtml = "

Section One

\n

Section Two

\n"; + + $this->assertSame($expectedHtml, Markdown::parse($markdown, ['addHeadingIds' => true])); + } + + public function testParseWithCommonMarkExtensions(): void + { + $options = [ + 'commonmarkExtensions' => [ + CommonMarkExtensionFixture::class => [ + 'enabled' => true, + ], + ], + ]; + + $this->expectNotToPerformAssertions(); + Markdown::parse('', $options); + } + + public function testParseWithCommonMarkExtensionsDoesNotAddEnvironmentExtensions(): void + { + $options = [ + 'commonmarkExtensions' => [ + CommonMarkCoreExtension::class => [ + 'enabled' => true, + ], + ], + ]; + + $this->expectNotToPerformAssertions(); + Markdown::parse('', $options); + } + + public function testParseThrowsUnexpectedValueExceptionOnInvalidCommonMarkExtension(): void + { + $options = [ + 'commonmarkExtensions' => [ + stdClass::class => [ + 'enabled' => true, + ], + ], + ]; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid CommonMark extension "stdClass"'); + Markdown::parse('', $options); + } +} diff --git a/tests/Parsers/PhpTest.php b/tests/Parsers/PhpTest.php new file mode 100644 index 000000000..8f431b2e7 --- /dev/null +++ b/tests/Parsers/PhpTest.php @@ -0,0 +1,147 @@ +tearDownTempDirectory(); + } + + public function testParseAlwaysThrowsException(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Parsing a string of Php code is not allowed'); + Php::parse('assertSame([ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'testing', 'formwork'], + ], Php::parseFile($filePath)); + } + + public function testEncodeExportsDataAsPhpString(): void + { + $enumFixtureClass = EnumFixture::class; + + $arraySerializable = $this->createStub(ArraySerializable::class); + $arraySerializable->method('toArray')->willReturn([ + 'key' => 'value', + ]); + $arraySerializableClass = $arraySerializable::class; + + $setStateImplementingClass = SetStateImplementingClassFixture::class; + + $serializable = new SerializableFixture('value'); + $serialized = addcslashes(serialize($serializable), '\\'); + + $data = [ + 'string' => 'Test', + 'int' => 3, + 'float' => 3.14, + 'boolean' => false, + 'null' => null, + 'array' => ['test', 29, 2.71, true, null, []], + 'object' => (object) ['key' => 'value'], + 'enum' => EnumFixture::Alpha, + 'arraySerializable' => $arraySerializable, + 'setStateImplementing' => new SetStateImplementingClassFixture('value'), + 'serializable' => $serializable, + ]; + + $expected = << 'Test', + 'int' => 3, + 'float' => 3.14, + 'boolean' => false, + 'null' => null, + 'array' => [ + 'test', + 29, + 2.71, + true, + null, + [] + ], + 'object' => (object) [ + 'key' => 'value' + ], + 'enum' => \\$enumFixtureClass::Alpha, + 'arraySerializable' => \\$arraySerializableClass::fromArray([ + 'key' => 'value' + ]), + 'setStateImplementing' => \\$setStateImplementingClass::__set_state([ + 'key' => 'value' + ]), + 'serializable' => unserialize('$serialized') + ] + PHP; + + $this->assertSame($expected, Php::encode($data)); + } + + public function testEncodeThrowsExceptionWithUnencodableClasses(): void + { + $data = [ + 'closure' => fn() => 'Unencodable', + ]; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Objects of class "Closure" cannot be encoded'); + Php::encode($data); + } + + public function testEncodeThrowsExceptionWithResources(): void + { + $stream = fopen('php://temp', 'r'); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Data of type "resource" cannot be encoded'); + + try { + Php::encode(['resource' => $stream]); + } finally { + fclose($stream); + } + } + + public function testEncodeToFile(): void + { + $data = [ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'testing', 'formwork'], + ]; + + $filePath = TESTS_TMP_PATH . '/output.php'; + + Php::encodeToFile($data, $filePath); + + $this->assertSame($data, include $filePath); + } +} diff --git a/tests/Parsers/YamlTest.php b/tests/Parsers/YamlTest.php new file mode 100644 index 000000000..fbaf3d7d2 --- /dev/null +++ b/tests/Parsers/YamlTest.php @@ -0,0 +1,97 @@ +tearDownTempDirectory(); + } + + public function testParse(): void + { + $yamlString = << 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'yaml', 'parser'], + ]; + + $this->assertSame($expected, Yaml::parse($yamlString)); + } + + public function testParseFile(): void + { + $yamlFilePath = TESTS_TMP_PATH . '/test.yaml'; + + $expected = [ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'yaml', 'parser'], + ]; + + $this->assertSame($expected, Yaml::parseFile($yamlFilePath)); + } + + public function testEncode(): void + { + $data = [ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'yaml', 'parser'], + ]; + + $expectedYamlString = <<assertSame($expectedYamlString, Yaml::encode($data)); + } + + public function testEncodeToFile(): void + { + $data = [ + 'title' => 'Test', + 'description' => 'This is a test.', + 'tags' => ['php', 'yaml', 'parser'], + ]; + + $yamlFilePath = TESTS_TMP_PATH . '/output.yaml'; + + Yaml::encodeToFile($data, $yamlFilePath); + + $this->assertFileExists($yamlFilePath); + $this->assertSame(Yaml::encode($data), FileSystem::read($yamlFilePath)); + } + + public function testEncodeReturnsEmptyStringForEmptyData(): void + { + $this->assertSame('', Yaml::encode([])); + } +}