diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index df796fb..48363a0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,10 +31,13 @@ jobs:
run: composer install --no-interaction --no-progress --prefer-dist
- name: Lint PHP files
- run: find src bin html templates -name '*.php' -print0 | xargs -0 -n1 -P4 php -l
+ run: find src bin html templates tests -name '*.php' -print0 | xargs -0 -n1 -P4 php -l
- name: Static analysis (PHPStan)
run: vendor/bin/phpstan analyse --no-progress
- name: Check UI translations
run: php bin/check_translations.php
+
+ - name: Tests (PHPUnit)
+ run: vendor/bin/phpunit
diff --git a/.gitignore b/.gitignore
index f8565f9..666c672 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@
/.cache/
*.log
/deploy/deploy.env
+/.phpunit.result.cache
diff --git a/composer.json b/composer.json
index cad972b..25835ba 100644
--- a/composer.json
+++ b/composer.json
@@ -7,13 +7,19 @@
"php": ">=8.3"
},
"require-dev": {
- "phpstan/phpstan": "^1.11"
+ "phpstan/phpstan": "^1.11",
+ "phpunit/phpunit": "^12.5"
},
"autoload": {
"psr-4": {
"Catenvis\\": "src/"
}
},
+ "autoload-dev": {
+ "psr-4": {
+ "Catenvis\\Tests\\": "tests/"
+ }
+ },
"config": {
"optimize-autoloader": true
}
diff --git a/composer.lock b/composer.lock
index 749afe7..ae3d9cc 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,9 +4,244 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "3434f49ce914a98f895c02c4a1490802",
+ "content-hash": "7b951f1da8a995fbcaf8361c65db7b3e",
"packages": [],
"packages-dev": [
+ {
+ "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/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "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"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+ },
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-01T08:46:24+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.8.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
+ "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
+ "shasum": ""
+ },
+ "require": {
+ "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": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
+ },
+ "time": "2026-07-04T14:30:18+00:00"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-phar": "*",
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:33:53+00:00"
+ },
+ {
+ "name": "phar-io/version",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "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"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "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": "1.12.33",
@@ -59,6 +294,1452 @@
}
],
"time": "2026-02-28T20:30:03+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "12.5.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "186dab580576598076de6818596d12b61801880e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e",
+ "reference": "186dab580576598076de6818596d12b61801880e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-xmlwriter": "*",
+ "nikic/php-parser": "^5.7.0",
+ "php": ">=8.3",
+ "phpunit/php-text-template": "^5.0",
+ "sebastian/complexity": "^5.0",
+ "sebastian/environment": "^8.1.2",
+ "sebastian/lines-of-code": "^4.0.1",
+ "sebastian/version": "^6.0",
+ "theseer/tokenizer": "^2.0.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.28"
+ },
+ "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",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "12.5.x-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 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.7"
+ },
+ "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": "2026-06-01T13:24:19+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "6.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
+ "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
+ "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.1"
+ },
+ "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-file-iterator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-02T14:04:18+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.31",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "0608d157a284f15cc73b99a3327eff06b66a176d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0608d157a284f15cc73b99a3327eff06b66a176d",
+ "reference": "0608d157a284f15cc73b99a3327eff06b66a176d",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-filter": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "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.7",
+ "phpunit/php-file-iterator": "^6.0.1",
+ "phpunit/php-invoker": "^6.0.0",
+ "phpunit/php-text-template": "^5.0.0",
+ "phpunit/php-timer": "^8.0.0",
+ "sebastian/cli-parser": "^4.2.1",
+ "sebastian/comparator": "^7.1.8",
+ "sebastian/diff": "^7.0.0",
+ "sebastian/environment": "^8.1.2",
+ "sebastian/exporter": "^7.0.3",
+ "sebastian/global-state": "^8.0.3",
+ "sebastian/object-enumerator": "^7.0.0",
+ "sebastian/recursion-context": "^7.0.1",
+ "sebastian/type": "^6.0.4",
+ "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.31"
+ },
+ "funding": [
+ {
+ "url": "https://phpunit.de/sponsoring.html",
+ "type": "other"
+ }
+ ],
+ "time": "2026-07-06T14:54:16+00:00"
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "4.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "7d05781b13f7dec9043a629a21d086ed74582a15"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15",
+ "reference": "7d05781b13f7dec9043a629a21d086ed74582a15",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "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.1"
+ },
+ "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": "2026-05-17T05:29:34+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "7.1.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "7c65c1e79836812819705b473a90c12399542485"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485",
+ "reference": "7c65c1e79836812819705b473a90c12399542485",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "php": ">=8.3",
+ "sebastian/diff": "^7.0",
+ "sebastian/exporter": "^7.0.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "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.8"
+ },
+ "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": "2026-05-21T04:45:25+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": "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.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439",
+ "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.26"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "https://github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "security": "https://github.com/sebastianbergmann/environment/security/policy",
+ "source": "https://github.com/sebastianbergmann/environment/tree/8.1.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/sebastian/environment",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-25T13:40:20+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "7.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
+ "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=8.3",
+ "sebastian/recursion-context": "^7.0.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "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": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "security": "https://github.com/sebastianbergmann/exporter/security/policy",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.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/exporter",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-20T04:37:17+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "8.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9",
+ "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3",
+ "sebastian/object-reflector": "^5.0",
+ "sebastian/recursion-context": "^7.0.1"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^12.5.28"
+ },
+ "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": "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.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/global-state",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-06-01T15:10:33+00:00"
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "4.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e",
+ "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^5.7.0",
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "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/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.1"
+ },
+ "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/lines-of-code",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-19T16:22:07+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894",
+ "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3",
+ "sebastian/object-reflector": "^5.0",
+ "sebastian/recursion-context": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "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"
+ }
+ ],
+ "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": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "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://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:58:17+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "7.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
+ "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "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": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "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://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": "2025-08-13T04:44:59+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "6.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "82ff822c2edc46724be9f7411d3163021f602773"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773",
+ "reference": "82ff822c2edc46724be9f7411d3163021f602773",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "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": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "security": "https://github.com/sebastianbergmann/type/security/policy",
+ "source": "https://github.com/sebastianbergmann/type/tree/6.0.4"
+ },
+ "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/type",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-20T06:45:45+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c",
+ "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "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": "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": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "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": [
+ "static analysis"
+ ],
+ "support": {
+ "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/staabm",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-20T05:08:20+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": [],
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..216f239
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,18 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php
new file mode 100644
index 0000000..710bbe0
--- /dev/null
+++ b/tests/ConfigTest.php
@@ -0,0 +1,99 @@
+ Temp fixture files to clean up. */
+ private array $tempFiles = [];
+
+ protected function tearDown(): void {
+ foreach ($this->tempFiles as $file) {
+ @unlink($file);
+ }
+ $this->tempFiles = [];
+ }
+
+ public function testLoadThrowsWhenFileMissing(): void {
+ $this->expectException(RuntimeException::class);
+
+ Config::load(sys_get_temp_dir() . '/catenvis-does-not-exist-' . __LINE__ . '.php');
+ }
+
+ public function testLoadThrowsWhenFileDoesNotReturnArray(): void {
+ $path = $this->writeConfigFile('expectException(RuntimeException::class);
+
+ Config::load($path);
+ }
+
+ public function testGetReturnsTopLevelValue(): void {
+ $config = $this->makeConfig(['base_url' => '/catenvis']);
+
+ self::assertSame('/catenvis', $config->get('base_url'));
+ }
+
+ public function testGetResolvesNestedValueViaDotNotation(): void {
+ $config = $this->makeConfig(['db' => ['host' => '127.0.0.1', 'port' => 3306]]);
+
+ self::assertSame('127.0.0.1', $config->get('db.host'));
+ self::assertSame(3306, $config->get('db.port'));
+ }
+
+ public function testGetReturnsWholeNestedArray(): void {
+ $config = $this->makeConfig(['db' => ['host' => '127.0.0.1']]);
+
+ self::assertSame(['host' => '127.0.0.1'], $config->get('db'));
+ }
+
+ public function testGetReturnsDefaultForMissingKey(): void {
+ $config = $this->makeConfig(['db' => ['host' => '127.0.0.1']]);
+
+ self::assertNull($config->get('missing'));
+ self::assertSame('fallback', $config->get('missing', 'fallback'));
+ self::assertSame('fallback', $config->get('db.missing', 'fallback'));
+ }
+
+ public function testGetReturnsDefaultWhenTraversingIntoNonArray(): void {
+ $config = $this->makeConfig(['db' => 'not-an-array']);
+
+ self::assertSame('fallback', $config->get('db.host', 'fallback'));
+ }
+
+ public function testIsProductionIsTrueForProductionEnvironment(): void {
+ $config = $this->makeConfig(['environment' => 'production']);
+
+ self::assertTrue($config->isProduction());
+ }
+
+ public function testIsProductionIsFalseOtherwise(): void {
+ self::assertFalse($this->makeConfig(['environment' => 'development'])->isProduction());
+ self::assertFalse($this->makeConfig([])->isProduction());
+ }
+
+ /**
+ * @param array $values
+ */
+ private function makeConfig(array $values): Config {
+ return Config::load($this->writeConfigFile('tempFiles[] = $path;
+
+ return $path;
+ }
+}
diff --git a/tests/RequestTest.php b/tests/RequestTest.php
new file mode 100644
index 0000000..5144575
--- /dev/null
+++ b/tests/RequestTest.php
@@ -0,0 +1,139 @@
+ 'post']);
+
+ self::assertSame('POST', $request->method());
+ self::assertTrue($request->isPost());
+ }
+
+ public function testMethodDefaultsToGetWhenAbsent(): void {
+ $request = new Request([], [], []);
+
+ self::assertSame('GET', $request->method());
+ self::assertFalse($request->isPost());
+ }
+
+ /**
+ * @param array $server
+ */
+ #[DataProvider('pathCases')]
+ public function testPathIsNormalized(array $server, string $basePath, string $expected): void {
+ $request = new Request([], [], $server, $basePath);
+
+ self::assertSame($expected, $request->path());
+ }
+
+ /**
+ * @return iterable, string, string}>
+ */
+ public static function pathCases(): iterable {
+ yield 'strips base path and query string' => [['REQUEST_URI' => '/catenvis/series/5?foo=bar'], '/catenvis', '/series/5'];
+ yield 'root after stripping base path' => [['REQUEST_URI' => '/catenvis/'], '/catenvis', '/'];
+ yield 'plain root' => [['REQUEST_URI' => '/'], '', '/'];
+ yield 'removes trailing slash' => [['REQUEST_URI' => '/series/5/'], '', '/series/5'];
+ yield 'decodes percent-encoding' => [['REQUEST_URI' => '/foo%20bar'], '', '/foo bar'];
+ yield 'leaves path unchanged when base path does not match' => [['REQUEST_URI' => '/series'], '/other', '/series'];
+ yield 'defaults to root when REQUEST_URI absent' => [[], '', '/'];
+ }
+
+ public function testGetStringPrefersBodyAndTrims(): void {
+ $request = new Request(['x' => 'from-query'], ['x' => ' from-body '], []);
+
+ self::assertSame('from-body', $request->getString('x'));
+ }
+
+ public function testGetStringFallsBackToQueryThenDefault(): void {
+ $request = new Request(['x' => 'from-query'], [], []);
+
+ self::assertSame('from-query', $request->getString('x'));
+ self::assertSame('fallback', $request->getString('missing', 'fallback'));
+ }
+
+ public function testGetStringReturnsDefaultForNonScalar(): void {
+ $request = new Request([], ['x' => ['a', 'b']], []);
+
+ self::assertSame('', $request->getString('x'));
+ }
+
+ #[DataProvider('intCases')]
+ public function testGetInt(mixed $raw, int $expected): void {
+ $request = new Request([], ['x' => $raw], []);
+
+ self::assertSame($expected, $request->getInt('x', -1));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function intCases(): iterable {
+ yield 'numeric string' => ['42', 42];
+ yield 'float string is truncated' => ['3.9', 3];
+ yield 'non-numeric returns default' => ['abc', -1];
+ yield 'array returns default' => [['1'], -1];
+ }
+
+ public function testGetIntFallsBackToDefaultWhenAbsent(): void {
+ $request = new Request([], [], []);
+
+ self::assertSame(7, $request->getInt('missing', 7));
+ }
+
+ #[DataProvider('boolCases')]
+ public function testGetBool(mixed $raw, bool $expected): void {
+ $request = new Request([], ['x' => $raw], []);
+
+ self::assertSame($expected, $request->getBool('x'));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function boolCases(): iterable {
+ yield 'string one' => ['1', true];
+ yield 'int one' => [1, true];
+ yield 'true boolean' => [true, true];
+ yield 'string true' => ['true', true];
+ yield 'string on' => ['on', true];
+ yield 'string zero' => ['0', false];
+ yield 'string false' => ['false', false];
+ yield 'arbitrary value' => ['yes', false];
+ }
+
+ public function testGetBoolIsFalseWhenAbsent(): void {
+ $request = new Request([], [], []);
+
+ self::assertFalse($request->getBool('missing'));
+ }
+
+ public function testGetIntListFiltersToIntegers(): void {
+ $request = new Request([], ['ids' => ['1', '2', 'x', 3, '4.5']], []);
+
+ self::assertSame([1, 2, 3, 4], $request->getIntList('ids'));
+ }
+
+ public function testGetIntListReturnsEmptyForNonArray(): void {
+ $request = new Request([], ['ids' => '1'], []);
+
+ self::assertSame([], $request->getIntList('ids'));
+ }
+
+ public function testGetIntListPrefersBodyOverQuery(): void {
+ $request = new Request(['ids' => ['9']], ['ids' => ['1', '2']], []);
+
+ self::assertSame([1, 2], $request->getIntList('ids'));
+ }
+}
diff --git a/tests/RouterTest.php b/tests/RouterTest.php
new file mode 100644
index 0000000..e41a7ea
--- /dev/null
+++ b/tests/RouterTest.php
@@ -0,0 +1,119 @@
+add('GET', '/series/{id}', static function (): void {});
+
+ self::assertFalse($router->dispatch($this->request('GET', '/unknown')));
+ }
+
+ public function testStaticRouteMatchesAndInvokesHandler(): void {
+ $router = new Router();
+ $called = false;
+ $router->add('GET', '/import', static function () use (&$called): void {
+ $called = true;
+ });
+
+ self::assertTrue($router->dispatch($this->request('GET', '/import')));
+ self::assertTrue($called);
+ }
+
+ public function testPlaceholderRouteExtractsParameter(): void {
+ $router = new Router();
+ $captured = null;
+ $router->add('GET', '/series/{id}', static function (Request $request, array $params) use (&$captured): void {
+ $captured = $params;
+ });
+
+ self::assertTrue($router->dispatch($this->request('GET', '/series/5')));
+ self::assertSame(['id' => '5'], $captured);
+ }
+
+ public function testMultiplePlaceholdersAreExtractedInOrder(): void {
+ $router = new Router();
+ $captured = null;
+ $router->add('GET', '/series/{id}/season/{number}', static function (Request $request, array $params) use (&$captured): void {
+ $captured = $params;
+ });
+
+ self::assertTrue($router->dispatch($this->request('GET', '/series/5/season/2')));
+ self::assertSame(['id' => '5', 'number' => '2'], $captured);
+ }
+
+ public function testMethodMismatchDoesNotMatch(): void {
+ $router = new Router();
+ $router->add('POST', '/follow', static function (): void {});
+
+ self::assertFalse($router->dispatch($this->request('GET', '/follow')));
+ }
+
+ public function testMethodRegistrationIsCaseInsensitive(): void {
+ $router = new Router();
+ $called = false;
+ $router->add('get', '/', static function () use (&$called): void {
+ $called = true;
+ });
+
+ self::assertTrue($router->dispatch($this->request('GET', '/')));
+ self::assertTrue($called);
+ }
+
+ public function testPlaceholderMatchesSingleSegmentOnly(): void {
+ $router = new Router();
+ $router->add('GET', '/series/{id}', static function (): void {});
+
+ self::assertFalse($router->dispatch($this->request('GET', '/series/5/extra')));
+ }
+
+ public function testStaticRouteIsAnchored(): void {
+ $router = new Router();
+ $router->add('GET', '/series', static function (): void {});
+
+ self::assertFalse($router->dispatch($this->request('GET', '/series/5')));
+ }
+
+ public function testHandlerReceivesTheDispatchedRequest(): void {
+ $router = new Router();
+ $request = $this->request('GET', '/import');
+ $received = null;
+ $router->add('GET', '/import', static function (Request $r, array $params) use (&$received): void {
+ $received = $r;
+ });
+
+ $router->dispatch($request);
+
+ self::assertSame($request, $received);
+ }
+
+ public function testFirstMatchingRouteWins(): void {
+ $router = new Router();
+ $order = [];
+ $router->add('GET', '/series/{id}', static function () use (&$order): void {
+ $order[] = 'first';
+ });
+ $router->add('GET', '/series/{slug}', static function () use (&$order): void {
+ $order[] = 'second';
+ });
+
+ $router->dispatch($this->request('GET', '/series/5'));
+
+ self::assertSame(['first'], $order);
+ }
+
+ private function request(string $method, string $path): Request {
+ return new Request([], [], ['REQUEST_METHOD' => $method, 'REQUEST_URI' => $path]);
+ }
+}
diff --git a/tests/SeriesTitleTest.php b/tests/SeriesTitleTest.php
new file mode 100644
index 0000000..1017261
--- /dev/null
+++ b/tests/SeriesTitleTest.php
@@ -0,0 +1,91 @@
+ English -> original, plus the "original" mode).
+ */
+final class SeriesTitleTest extends TestCase {
+ /**
+ * @param array $row
+ */
+ #[DataProvider('titleCases')]
+ public function testPickSelectsExpectedTitle(array $row, string $lang, string $userLang, string $expected): void {
+ self::assertSame($expected, SeriesTitle::pick($row, $lang, $userLang));
+ }
+
+ /**
+ * The third argument defaults to 'en', so in the "original" mode an
+ * English production keeps its translated name without an explicit userLang.
+ */
+ public function testPickUsesEnglishAsDefaultUserLanguage(): void {
+ $row = ['name' => 'Translated', 'original_name' => 'Original', 'original_language' => 'en'];
+
+ self::assertSame('Translated', SeriesTitle::pick($row, 'original'));
+ }
+
+ /**
+ * @return iterable, string, string, string}>
+ */
+ public static function titleCases(): iterable {
+ // --- own-language mode ($lang != 'original'): name -> title_en -> original_name ---
+ yield 'own language present wins' => [
+ ['name' => 'Der Herr der Ringe', 'title_en' => 'The Lord of the Rings', 'original_name' => 'The Lord of the Rings', 'original_language' => 'en'],
+ 'de', 'de', 'Der Herr der Ringe',
+ ];
+
+ yield 'falls back to English when own translation missing' => [
+ ['name' => '', 'title_en' => 'The Lord of the Rings', 'original_name' => 'Original', 'original_language' => 'en'],
+ 'de', 'de', 'The Lord of the Rings',
+ ];
+
+ yield 'falls back to original name when own and English missing' => [
+ ['name' => '', 'title_en' => '', 'original_name' => 'Original Name', 'original_language' => 'ja'],
+ 'de', 'de', 'Original Name',
+ ];
+
+ yield 'returns empty string when the row is empty' => [
+ [],
+ 'de', 'de', '',
+ ];
+
+ // --- "original" mode, own-language production: name -> original_name ---
+ yield 'original mode keeps translated title for own-language production' => [
+ ['name' => 'Deutscher Titel', 'original_name' => 'Origineel', 'original_language' => 'de'],
+ 'original', 'de', 'Deutscher Titel',
+ ];
+
+ yield 'original mode own-language falls back to original name when translation missing' => [
+ ['name' => '', 'original_name' => 'Origineel', 'original_language' => 'nl'],
+ 'original', 'nl', 'Origineel',
+ ];
+
+ // --- "original" mode, foreign production: title_en -> original_name -> name ---
+ yield 'original mode prefers English then original for a foreign production' => [
+ ['name' => 'Übersetzt', 'title_en' => 'English Title', 'original_name' => 'Original', 'original_language' => 'en'],
+ 'original', 'de', 'English Title',
+ ];
+
+ yield 'original mode foreign falls back to original name when English missing' => [
+ ['name' => 'Übersetzt', 'title_en' => '', 'original_name' => 'Original', 'original_language' => 'ja'],
+ 'original', 'de', 'Original',
+ ];
+
+ yield 'original mode foreign uses translated name as last resort' => [
+ ['name' => 'Übersetzt', 'title_en' => '', 'original_name' => '', 'original_language' => 'ja'],
+ 'original', 'de', 'Übersetzt',
+ ];
+
+ yield 'original mode returns empty string when nothing is set' => [
+ ['original_language' => 'de'],
+ 'original', 'de', '',
+ ];
+ }
+}
diff --git a/tests/TmdbClientTest.php b/tests/TmdbClientTest.php
new file mode 100644
index 0000000..7f2b4ce
--- /dev/null
+++ b/tests/TmdbClientTest.php
@@ -0,0 +1,33 @@
+
+ */
+ public static function languageCases(): iterable {
+ yield 'BCP 47 tag reduced to ISO-639-1' => ['en-US', 'en'];
+ yield 'German region tag' => ['de-DE', 'de'];
+ yield 'Portuguese Brazil' => ['pt-BR', 'pt'];
+ yield 'bare code passes through' => ['fr', 'fr'];
+ yield 'uppercase input is lowercased' => ['ES', 'es'];
+ yield 'empty string falls back to en' => ['', 'en'];
+ }
+}
diff --git a/tests/TranslatorTest.php b/tests/TranslatorTest.php
new file mode 100644
index 0000000..b05febd
--- /dev/null
+++ b/tests/TranslatorTest.php
@@ -0,0 +1,117 @@
+projectDir = sys_get_temp_dir() . '/catenvis-tr-' . uniqid();
+ mkdir($this->projectDir . '/lang', 0777, true);
+ }
+
+ protected function tearDown(): void {
+ foreach (glob($this->projectDir . '/lang/*') ?: [] as $file) {
+ @unlink($file);
+ }
+ @rmdir($this->projectDir . '/lang');
+ @rmdir($this->projectDir);
+ }
+
+ public function testEnglishSourceIsReturnedAsIs(): void {
+ $translator = new Translator($this->projectDir, 'en');
+
+ self::assertSame('Hello', $translator->translate('Hello'));
+ }
+
+ public function testReturnsTranslationFromCatalog(): void {
+ $this->writeCatalog('de', ['Hello' => 'Hallo']);
+ $translator = new Translator($this->projectDir, 'de');
+
+ self::assertSame('Hallo', $translator->translate('Hello'));
+ }
+
+ public function testFallsBackToSourceForMissingEntry(): void {
+ $this->writeCatalog('de', ['Hello' => 'Hallo']);
+ $translator = new Translator($this->projectDir, 'de');
+
+ self::assertSame('Goodbye', $translator->translate('Goodbye'));
+ }
+
+ public function testFallsBackToSourceWhenCatalogFileMissing(): void {
+ $translator = new Translator($this->projectDir, 'fr');
+
+ self::assertSame('Hello', $translator->translate('Hello'));
+ }
+
+ public function testFallsBackToSourceOnBrokenJson(): void {
+ file_put_contents($this->projectDir . '/lang/de.json', '{ this is not valid json');
+ $translator = new Translator($this->projectDir, 'de');
+
+ // The translator logs the broken catalog via error_log; redirect that
+ // to a temp file so it does not clutter the test/CI output.
+ $previous = ini_set('error_log', $this->projectDir . '/lang/error.log');
+ try {
+ self::assertSame('Hello', $translator->translate('Hello'));
+ } finally {
+ if ($previous !== false) {
+ ini_set('error_log', $previous);
+ }
+ }
+ }
+
+ public function testMetadataKeysAreNotTranslated(): void {
+ $this->writeCatalog('de', ['__language__' => 'Deutsch', 'Hello' => 'Hallo']);
+ $translator = new Translator($this->projectDir, 'de');
+
+ self::assertSame('__language__', $translator->translate('__language__'));
+ self::assertSame('Hallo', $translator->translate('Hello'));
+ }
+
+ public function testEmptyTranslationValueFallsBackToSource(): void {
+ $this->writeCatalog('de', ['Hello' => '']);
+ $translator = new Translator($this->projectDir, 'de');
+
+ self::assertSame('Hello', $translator->translate('Hello'));
+ }
+
+ public function testAppliesVsprintfArguments(): void {
+ $this->writeCatalog('de', ['%d new episodes' => '%d neue Folgen']);
+ $translator = new Translator($this->projectDir, 'de');
+
+ self::assertSame('3 neue Folgen', $translator->translate('%d new episodes', 3));
+ }
+
+ public function testSupportsPositionalPlaceholdersForWordOrder(): void {
+ $this->writeCatalog('de', ['%1$s %2$d' => '%2$d. %1$s']);
+ $translator = new Translator($this->projectDir, 'de');
+
+ self::assertSame('16. Jan', $translator->translate('%1$s %2$d', 'Jan', 16));
+ }
+
+ public function testAppliesArgumentsToSourceTextWhenUntranslated(): void {
+ $translator = new Translator($this->projectDir, 'en');
+
+ self::assertSame('5 items', $translator->translate('%d items', 5));
+ }
+
+ /**
+ * @param array $entries
+ */
+ private function writeCatalog(string $lang, array $entries): void {
+ file_put_contents(
+ $this->projectDir . '/lang/' . $lang . '.json',
+ (string) json_encode($entries, JSON_UNESCAPED_UNICODE)
+ );
+ }
+}