From 58c91f125e48df252e0e06dbcdd857ebe6b36dd9 Mon Sep 17 00:00:00 2001 From: DevBot CI Date: Sat, 29 Aug 2026 13:38:26 +0000 Subject: [PATCH] Publish 2026-08-29T13:38:26Z (v1.1.0) --- .github/workflows/build.yml | 38 +- .gitignore | 1 + CHANGELOG.md | 8 + README.md | 21 +- composer.json | 7 +- docs/cache.md | 35 + renovate.json | 7 + src/Cache/CacheItem.php | 133 ++ src/Cache/FileCachePool.php | 354 ++++++ src/Exceptions/InvalidArgument.php | 12 + tests/Abstracts/AbstractReflectionTest.php | 93 +- tests/Abstracts/AbstractWithTest.php | 72 +- tests/Attributes/AttributeTest.php | 50 +- tests/Attributes/ValidationAttributeTest.php | 72 +- tests/Cache/FileCachePoolTest.php | 107 ++ tests/Exceptions/ValidationExceptionTest.php | 19 +- tests/Mapper/ReflectionMapperTest.php | 123 +- tests/Runtime/WithHandlerTest.php | 1138 +++++++++--------- tests/Validation/PropertyValidatorTest.php | 52 +- 19 files changed, 1500 insertions(+), 842 deletions(-) create mode 100644 docs/cache.md create mode 100644 renovate.json create mode 100644 src/Cache/CacheItem.php create mode 100644 src/Cache/FileCachePool.php create mode 100644 src/Exceptions/InvalidArgument.php create mode 100644 tests/Cache/FileCachePoolTest.php diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7e1cae2..e4b9c8b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,24 +1,24 @@ name: Build on: - pull_request: - types: [closed] - branches: [main] + pull_request: + types: [closed] + branches: [main] jobs: - build: - if: github.event.pull_request.merged == true - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Build install archive - run: | - chmod +x create_install_archive.sh - ARCHIVE=$(./create_install_archive.sh) - test -f "$ARCHIVE" - echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV" - - uses: actions/upload-artifact@v4 - with: - name: install - path: ${{ env.ARCHIVE }} - if-no-files-found: error + build: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build install archive + run: | + chmod +x create_install_archive.sh + ARCHIVE=$(./create_install_archive.sh) + test -f "$ARCHIVE" + echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV" + - uses: actions/upload-artifact@v4 + with: + name: install + path: ${{ env.ARCHIVE }} + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 8f5da31..d67fb16 100644 --- a/.gitignore +++ b/.gitignore @@ -531,6 +531,7 @@ upload/devcraft/.composer/* install_*.zip .phpunit.result.cache/ +.phpunit.result.cache .phpunit.cache/ .phpunit/ *.code-workspace diff --git a/CHANGELOG.md b/CHANGELOG.md index 633c211..5baa8df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 1.1.0 + +### Added + +- PSR-6 file cache: `Devcraft\Cache\FileCachePool`, `CacheItem`, `InvalidArgument` +- `FileCachePool::clearNamespace()` extension for prefix clears (`Translation/…`) +- Dependency on `psr/cache` `^3.0` + ## 1.0.1 ### Added diff --git a/README.md b/README.md index f13b4ca..86bc3ee 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Reusable PHP 8.3 attributes and runtime helpers. -See [the documentation](https://readme.devcraft.club/dev/dev-tools/1.0.1/getting_started) for the full guide. +See [the documentation](https://readme.devcraft.club/dev/dev-tools/1.1.0/getting_started) for the full guide. ## Local installation @@ -18,7 +18,7 @@ Add a Composer path repository in the consuming project and require the package: } ], "require": { - "devcraftclub/dev-tools": "@dev" + "devcraftclub/dev-tools": "^1.1" } } ``` @@ -27,7 +27,22 @@ Path repositories must be configured by the consuming root project; Composer does not inherit them transitively. Publish this package later, or replace the path repository with a normal repository and version. -The package depends on [`marcin-orlowski/lombok-php`](https://github.com/MarcinOrlowski/lombok-php) `^1.2`. +The package depends on [`marcin-orlowski/lombok-php`](https://github.com/MarcinOrlowski/lombok-php) `^1.2` and [`psr/cache`](https://www.php-fig.org/psr/psr-6/) `^3`. + +## PSR-6 file cache + +```php +use Devcraft\Cache\FileCachePool; + +$pool = new FileCachePool('/path/to/cache', defaultTtlSeconds: 3600); +$item = $pool->getItem('Translation/dict'); +$item->set(['hello' => 'world']); +$pool->save($item); + +$pool->clearNamespace('Translation'); // DevTools extension (not in PSR-6) +``` + +Keys may contain `/` as a namespace separator (stored as subdirectories). Other PSR-6 reserved characters remain forbidden. See [docs/cache.md](docs/cache.md). ## Fluent properties diff --git a/composer.json b/composer.json index a994d07..c3ceaae 100644 --- a/composer.json +++ b/composer.json @@ -1,16 +1,18 @@ { "name": "devcraftclub/dev-tools", "description": "Reusable PHP attributes and validation utilities", - "version": "1.0.1", + "type": "library", + "version": "1.1.0", "homepage": "https://github.com/DevCraftClub/DevTools", "support": { "email": "dev@devcraft.club", "issues": "https://github.com/DevCraftClub/DevTools/issues", "forum": "https://devcraft.club", - "docs": "https://readme.devcraft.club/dev/dev-tools/1.0.1/en/getting_started" + "docs": "https://readme.devcraft.club/dev/dev-tools/1.1.0/en/getting_started" }, "require": { "php": ">=8.3", + "psr/cache": "^3.0", "psr/log": "^3.0", "analog/analog": "^1.0", "marcin-orlowski/lombok-php": "^1.2" @@ -23,6 +25,7 @@ "psr-4": { "Devcraft\\Abstracts\\": "src/Abstracts/", "Devcraft\\Attributes\\": "src/Attributes/", + "Devcraft\\Cache\\": "src/Cache/", "Devcraft\\Exceptions\\": "src/Exceptions/", "Devcraft\\Interfaces\\": "src/Interfaces/", "Devcraft\\Mapper\\": "src/Mapper/", diff --git a/docs/cache.md b/docs/cache.md new file mode 100644 index 0000000..88feafd --- /dev/null +++ b/docs/cache.md @@ -0,0 +1,35 @@ +# PSR-6 file cache + +`Devcraft\Cache\FileCachePool` implements `Psr\Cache\CacheItemPoolInterface`. + +## Basics + +| Class | Role | +|-------|------| +| `FileCachePool` | Filesystem pool | +| `CacheItem` | Cache item | +| `InvalidArgument` | Invalid key / argument | + +Constructor: `new FileCachePool(string $baseDir, ?int $defaultTtlSeconds = null)`. + +- `null` or `0` default TTL → items without explicit expiry do not expire. +- Positive TTL → applied when the item still uses the default expiry (`expiresAfter` / `expiresAt` not called). + +## Keys and storage + +- Allowed: letters, digits, and `/` as a **namespace** separator (extension beyond strict PSR-6 reserved list). +- Forbidden: `{}()\@:` and `..`. +- File path: `{baseDir}/{key}.cache` (`/` → subdirectory). +- Envelope JSON: `{ "e": expiryUnix|null, "f": "j"|"s", "v": value }`. + +## Extension: `clearNamespace` + +```php +$pool->clearNamespace('Translation'); // deletes Translation/* keys +``` + +Not part of PSR-6; provided for DevCraft-style typed caches. + +## DevCraft Admin + +`DevCraft\Core\Cache\CacheControl` is a thin facade: `setCache($type, $name, $data)` maps to key `{type}/{name}` and delegates to `FileCachePool`. Prefer `CacheControl::pool()` for new code. diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..f262b54 --- /dev/null +++ b/renovate.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "enabled": true, + "automerge": true, + "enabledManagers": ["composer"] +} diff --git a/src/Cache/CacheItem.php b/src/Cache/CacheItem.php new file mode 100644 index 0000000..0f66de6 --- /dev/null +++ b/src/Cache/CacheItem.php @@ -0,0 +1,133 @@ +key; + } + + public function get(): mixed { + return $this->isHit()? $this->value : NULL; + } + + public function isHit(): bool { + return $this->hit; + } + + public function set(mixed $value): static { + $this->value = $value; + + return $this; + } + + public function expiresAt(?DateTimeInterface $expiration): static { + $this->isDefaultExpiry = false; + + if($expiration === NULL) { + $this->expiry = NULL; + + return $this; + } + + $this->expiry = $expiration->getTimestamp(); + + return $this; + } + + public function expiresAfter(int|DateInterval|null $time): static { + $this->isDefaultExpiry = false; + + if($time === NULL) { + $this->expiry = NULL; + + return $this; + } + + if($time instanceof DateInterval) { + $this->expiry = (new DateTimeImmutable())->add($time)->getTimestamp(); + + return $this; + } + + if($time <= 0) { + $this->expiry = NULL; + + return $this; + } + + $this->expiry = time() + $time; + + return $this; + } + + /** + * @internal + */ + public function markHit(mixed $value, ?int $expiry): void { + $this->hit = true; + $this->value = $value; + $this->expiry = $expiry; + $this->isDefaultExpiry = false; + } + + /** + * @internal + */ + public function markMiss(): void { + $this->hit = false; + $this->value = NULL; + } + + /** + * @internal + */ + public function rawValue(): mixed { + return $this->value; + } + + /** + * @internal + */ + public function expiryUnix(): ?int { + return $this->expiry; + } + + /** + * @internal + */ + public function usesDefaultExpiry(): bool { + return $this->isDefaultExpiry; + } + + /** + * @internal + */ + public function isExpired(): bool { + return $this->expiry !== NULL && $this->expiry <= time(); + } + +} diff --git a/src/Cache/FileCachePool.php b/src/Cache/FileCachePool.php new file mode 100644 index 0000000..8d4cdb0 --- /dev/null +++ b/src/Cache/FileCachePool.php @@ -0,0 +1,354 @@ + */ + private array $deferred = []; + + /** + * @param string $baseDir Cache root directory + * @param int|null $defaultTtlSeconds Default TTL; null or 0 means no expiry + */ + public function __construct( + private string $baseDir, + private readonly ?int $defaultTtlSeconds = NULL, + ) { + $this->baseDir = rtrim($baseDir, "/\\"); + } + + public function getItem(string $key): CacheItemInterface { + $this->validateKey($key); + $item = new CacheItem($key); + $path = $this->pathForKey($key); + + if(!is_file($path)) { + $item->markMiss(); + + return $item; + } + + $envelope = $this->readEnvelope($path); + + if($envelope === NULL) { + @unlink($path); + $item->markMiss(); + + return $item; + } + + $expiry = $envelope['e']; + + if($expiry !== NULL && $expiry <= time()) { + @unlink($path); + $item->markMiss(); + + return $item; + } + + $item->markHit($envelope['v'], $expiry); + + return $item; + } + + /** + * @param array $keys + * + * @return iterable + */ + public function getItems(array $keys = []): iterable { + $result = []; + + foreach($keys as $key) { + $result[$key] = $this->getItem($key); + } + + return $result; + } + + public function hasItem(string $key): bool { + return $this->getItem($key)->isHit(); + } + + public function clear(): bool { + $this->deferred = []; + + if(!is_dir($this->baseDir)) { + return true; + } + + $this->wipeDirectoryContents($this->baseDir); + + return true; + } + + public function deleteItem(string $key): bool { + $this->validateKey($key); + unset($this->deferred[$key]); + $path = $this->pathForKey($key); + + if(is_file($path)) { + @unlink($path); + } + + $this->removeEmptyParents(dirname($path)); + + return true; + } + + /** + * @param array $keys + */ + public function deleteItems(array $keys): bool { + $ok = true; + + foreach($keys as $key) { + $ok = $this->deleteItem($key) && $ok; + } + + return $ok; + } + + public function save(CacheItemInterface $item): bool { + if(!$item instanceof CacheItem) { + return false; + } + + $key = $item->getKey(); + $this->validateKey($key); + + $expiry = $item->expiryUnix(); + + if($item->usesDefaultExpiry()) { + $ttl = $this->defaultTtlSeconds; + + if($ttl !== NULL && $ttl > 0) { + $expiry = time() + $ttl; + } else { + $expiry = NULL; + } + } + + if($expiry !== NULL && $expiry <= time()) { + return $this->deleteItem($key); + } + + $path = $this->pathForKey($key); + $dir = dirname($path); + + if(!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { + return false; + } + + $payload = $this->encodeEnvelope($item->rawValue(), $expiry); + $tmp = $path . '.tmp.' . bin2hex(random_bytes(4)); + + if(@file_put_contents($tmp, $payload, LOCK_EX) === false) { + @unlink($tmp); + + return false; + } + + if(!@rename($tmp, $path)) { + @unlink($tmp); + + return false; + } + + return true; + } + + public function saveDeferred(CacheItemInterface $item): bool { + if(!$item instanceof CacheItem) { + return false; + } + + $this->validateKey($item->getKey()); + $this->deferred[$item->getKey()] = $item; + + return true; + } + + public function commit(): bool { + $ok = true; + + foreach($this->deferred as $item) { + $ok = $this->save($item) && $ok; + } + + $this->deferred = []; + + return $ok; + } + + /** + * Deletes every key under prefix `{prefix}/` (DevTools extension, not PSR-6). + */ + public function clearNamespace(string $prefix): bool { + $prefix = trim($prefix, "/\\"); + + if($prefix === '') { + return $this->clear(); + } + + $this->validateKey($prefix . '/x'); + + foreach(array_keys($this->deferred) as $key) { + if($key === $prefix || str_starts_with($key, $prefix . '/')) { + unset($this->deferred[$key]); + } + } + + $dir = $this->baseDir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $prefix); + + if(is_dir($dir)) { + $this->wipeDirectoryContents($dir); + @rmdir($dir); + } + + return true; + } + + public function getBaseDir(): string { + return $this->baseDir; + } + + /** + * PSR-6 reserved characters: `{}()/\@:` — `/` is allowed as a namespace separator. + */ + private function validateKey(string $key): void { + if($key === '' || strlen($key) > 64 * 8) { + throw new InvalidArgument('Cache key is empty or too long'); + } + + if(preg_match('/[{}()\\\\@:]/', $key) === 1) { + throw new InvalidArgument('Cache key contains reserved characters'); + } + + if(str_contains($key, '..')) { + throw new InvalidArgument('Cache key must not contain ..'); + } + } + + private function pathForKey(string $key): string { + $relative = str_replace('/', DIRECTORY_SEPARATOR, $key); + + return $this->baseDir . DIRECTORY_SEPARATOR . $relative . '.cache'; + } + + /** + * @return array{e: ?int, v: mixed}|null + */ + private function readEnvelope(string $path): ?array { + $raw = @file_get_contents($path); + + if($raw === false || $raw === '') { + return NULL; + } + + try { + $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + } catch(Throwable) { + return NULL; + } + + if(!is_array($data) || !array_key_exists('v', $data) || !array_key_exists('e', $data)) { + return NULL; + } + + $expiry = $data['e']; + $expiry = $expiry === NULL? NULL : (int) $expiry; + $format = (string) ($data['f'] ?? 'j'); + $value = $data['v']; + + if($format === 's' && is_string($value)) { + $decoded = base64_decode($value, true); + + if($decoded === false) { + return NULL; + } + + $value = unserialize($decoded, ['allowed_classes' => true]); + } + + return ['e' => $expiry, 'v' => $value]; + } + + private function encodeEnvelope(mixed $value, ?int $expiry): string { + try { + json_encode($value, JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + $format = 'j'; + $payload = $value; + } catch(Throwable) { + $format = 's'; + $payload = base64_encode(serialize($value)); + } + + return (string) json_encode( + ['e' => $expiry, 'f' => $format, 'v' => $payload], + JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES, + ); + } + + private function wipeDirectoryContents(string $dir): void { + if(!is_dir($dir)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach($iterator as $fileInfo) { + $path = $fileInfo->getPathname(); + + if($fileInfo->isFile() || $fileInfo->isLink()) { + @unlink($path); + } elseif($fileInfo->isDir()) { + @rmdir($path); + } + } + } + + private function removeEmptyParents(string $dir): void { + $dir = rtrim($dir, "/\\"); + $base = $this->baseDir; + + while($dir !== '' && $dir !== $base && str_starts_with($dir, $base . DIRECTORY_SEPARATOR)) { + if(!is_dir($dir)) { + break; + } + + $files = @scandir($dir); + + if($files === false || count($files) > 2) { + break; + } + + @rmdir($dir); + $dir = dirname($dir); + } + } + +} diff --git a/src/Exceptions/InvalidArgument.php b/src/Exceptions/InvalidArgument.php new file mode 100644 index 0000000..83f7184 --- /dev/null +++ b/src/Exceptions/InvalidArgument.php @@ -0,0 +1,12 @@ + */ public array $addresses = []; + } -final class AbstractReflectionLoggedFixture extends AbstractReflection -{ +final class AbstractReflectionLoggedFixture extends AbstractReflection { + #[Range(min: 1)] public int $count; + } -final class CollectingLogger extends AbstractLogger -{ +final class CollectingLogger extends AbstractLogger { + /** @var list}> */ public array $records = []; - public function log($level, string|\Stringable $message, array $context = []): void - { + public function log($level, string|\Stringable $message, array $context = []): void { $this->records[] = [ - 'level' => (string) $level, + 'level' => (string) $level, 'message' => (string) $message, 'context' => $context, ]; } + } -final class AbstractReflectionTest extends TestCase -{ - protected function tearDown(): void - { +final class AbstractReflectionTest extends TestCase { + + protected function tearDown(): void { AbstractReflection::resetLogger(); } - public function testToArrayNormalizesNestedDtosAndLists(): void - { - $fixture = new AbstractReflectionListFixture(); + public function testToArrayNormalizesNestedDtosAndLists(): void { + $fixture = new AbstractReflectionListFixture(); $fixture->name = 'Ada'; - $first = new AbstractReflectionAddressFixture(); + $first = new AbstractReflectionAddressFixture(); $first->city = 'Berlin'; - $second = new AbstractReflectionAddressFixture(); + $second = new AbstractReflectionAddressFixture(); $second->city = 'Paris'; $fixture->addresses = [$first, $second]; self::assertSame([ - 'name' => 'Ada', + 'name' => 'Ada', 'addresses' => [ ['city' => 'Berlin'], ['city' => 'Paris'], @@ -79,10 +81,9 @@ public function testToArrayNormalizesNestedDtosAndLists(): void ], $fixture->toArray()); } - public function testFromArrayHydratesNestedDto(): void - { + public function testFromArrayHydratesNestedDto(): void { $fixture = AbstractReflectionProfileFixture::fromArray([ - 'name' => 'Ada', + 'name' => 'Ada', 'address' => ['city' => 'Berlin'], ]); @@ -91,32 +92,31 @@ public function testFromArrayHydratesNestedDto(): void self::assertSame('Berlin', $fixture->address->city); } - public function testToJsonUsesExpectedEncodingOptions(): void - { + public function testToJsonUsesExpectedEncodingOptions(): void { $fixture = AbstractReflectionProfileFixture::fromArray([ - 'name' => 'Ada', + 'name' => 'Ada', 'address' => ['city' => 'Berlin'], ]); self::assertSame(<<<'JSON' -{ - "name": "Ada", - "address": { - "city": "Berlin" - } -} -JSON, $fixture->toJson()); + { + "name": "Ada", + "address": { + "city": "Berlin" + } + } + JSON, + $fixture->toJson()); } - public function testCustomLoggerReceivesValidationFailures(): void - { + public function testCustomLoggerReceivesValidationFailures(): void { $logger = new CollectingLogger(); AbstractReflection::setLogger($logger); try { AbstractReflectionLoggedFixture::fromArray(['count' => 0]); self::fail('Invalid data must raise a validation exception.'); - } catch (ValidationException $exception) { + } catch(ValidationException $exception) { self::assertSame(['count' => ['must be greater than or equal to 1']], $exception->getErrors()); } @@ -124,10 +124,11 @@ public function testCustomLoggerReceivesValidationFailures(): void self::assertSame('error', $logger->records[0]['level']); self::assertSame('Response validation failed.', $logger->records[0]['message']); self::assertSame([ - 'class' => AbstractReflectionLoggedFixture::class, - 'property' => 'count', - 'expected' => 'must be greater than or equal to 1', + 'class' => AbstractReflectionLoggedFixture::class, + 'property' => 'count', + 'expected' => 'must be greater than or equal to 1', 'actual_type' => 'int', ], $logger->records[0]['context']); } + } diff --git a/tests/Abstracts/AbstractWithTest.php b/tests/Abstracts/AbstractWithTest.php index f8298c0..12d1ae9 100644 --- a/tests/Abstracts/AbstractWithTest.php +++ b/tests/Abstracts/AbstractWithTest.php @@ -7,55 +7,53 @@ use Lombok\Getter; use Lombok\Setter; use BadMethodCallException; -use PHPUnit\Framework\TestCase; use Devcraft\Attributes\With; +use PHPUnit\Framework\TestCase; use Devcraft\Abstracts\AbstractWith; -#[Getter, Setter] -final class AccessorFixture extends AbstractWith -{ - #[With] - private ?int $page = null; +final class AbstractWithTest extends TestCase { - private bool $visible = false; -} + public function testWithRoutesToWithHandlerAndGetterReadsValue(): void { + $fixture = new AccessorFixture(); + + $returned = $fixture->withPage(4); -final class AbstractWithTest extends TestCase -{ - public function testWithRoutesToWithHandlerAndGetterReadsValue(): void - { - $fixture = new AccessorFixture(); + self::assertSame($fixture, $returned); + self::assertSame(4, $fixture->getPage()); + } - $returned = $fixture->withPage(4); + public function testSetterMutatesAndReturnsSameInstance(): void { + $fixture = new AccessorFixture(); - self::assertSame($fixture, $returned); - self::assertSame(4, $fixture->getPage()); - } + $returned = $fixture->setPage(9); - public function testSetterMutatesAndReturnsSameInstance(): void - { - $fixture = new AccessorFixture(); + self::assertSame($fixture, $returned); + self::assertSame(9, $fixture->getPage()); + } - $returned = $fixture->setPage(9); + public function testBooleanPropertyUsesIsPrefix(): void { + $fixture = new AccessorFixture(); - self::assertSame($fixture, $returned); - self::assertSame(9, $fixture->getPage()); - } + self::assertFalse($fixture->isVisible()); + self::assertSame($fixture, $fixture->setVisible(true)); + self::assertTrue($fixture->isVisible()); + } - public function testBooleanPropertyUsesIsPrefix(): void - { - $fixture = new AccessorFixture(); + public function testUnknownMethodThrowsBadMethodCallException(): void { + $fixture = new AccessorFixture(); + + $this->expectException(BadMethodCallException::class); + $fixture->missing(); + } + +} + +#[Getter, Setter] +final class AccessorFixture extends AbstractWith { - self::assertFalse($fixture->isVisible()); - self::assertSame($fixture, $fixture->setVisible(true)); - self::assertTrue($fixture->isVisible()); - } + #[With] + private ?int $page = NULL; - public function testUnknownMethodThrowsBadMethodCallException(): void - { - $fixture = new AccessorFixture(); + private bool $visible = false; - $this->expectException(BadMethodCallException::class); - $fixture->missing(); - } } diff --git a/tests/Attributes/AttributeTest.php b/tests/Attributes/AttributeTest.php index ad59f9b..5cffcd2 100644 --- a/tests/Attributes/AttributeTest.php +++ b/tests/Attributes/AttributeTest.php @@ -10,30 +10,28 @@ use PHPUnit\Framework\TestCase; use Devcraft\Attributes\WithItem; -final class AttributeTest extends TestCase -{ - public function testWithTargetsOnlyPropertiesAndIsNotRepeatable(): void - { - $attribute = (new ReflectionClass(With::class)) - ->getAttributes(Attribute::class)[0] - ->newInstance(); - - self::assertSame(Attribute::TARGET_PROPERTY, $attribute->flags); - } - - public function testWithItemPreservesOrderedTypePositions(): void - { - $attribute = new WithItem('string', ['int', 'null']); - - self::assertSame(['string', ['int', 'null']], $attribute->types()); - } - - public function testWithItemTargetsOnlyPropertiesAndIsNotRepeatable(): void - { - $attribute = (new ReflectionClass(WithItem::class)) - ->getAttributes(Attribute::class)[0] - ->newInstance(); - - self::assertSame(Attribute::TARGET_PROPERTY, $attribute->flags); - } +final class AttributeTest extends TestCase { + + public function testWithTargetsOnlyPropertiesAndIsNotRepeatable(): void { + $attribute = (new ReflectionClass(With::class)) + ->getAttributes(Attribute::class)[0] + ->newInstance(); + + self::assertSame(Attribute::TARGET_PROPERTY, $attribute->flags); + } + + public function testWithItemPreservesOrderedTypePositions(): void { + $attribute = new WithItem('string', ['int', 'null']); + + self::assertSame(['string', ['int', 'null']], $attribute->types()); + } + + public function testWithItemTargetsOnlyPropertiesAndIsNotRepeatable(): void { + $attribute = (new ReflectionClass(WithItem::class)) + ->getAttributes(Attribute::class)[0] + ->newInstance(); + + self::assertSame(Attribute::TARGET_PROPERTY, $attribute->flags); + } + } diff --git a/tests/Attributes/ValidationAttributeTest.php b/tests/Attributes/ValidationAttributeTest.php index dacf68e..8e74639 100644 --- a/tests/Attributes/ValidationAttributeTest.php +++ b/tests/Attributes/ValidationAttributeTest.php @@ -4,48 +4,17 @@ namespace Devcraft\DevTools\Tests\Attributes; -use Devcraft\Attributes\ArrayOf; -use Devcraft\Attributes\Filter; use Devcraft\Attributes\Range; use Devcraft\Attributes\Regex; +use Devcraft\Attributes\Filter; +use PHPUnit\Framework\TestCase; +use Devcraft\Attributes\ArrayOf; use Devcraft\Interfaces\ValidationRule; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\TestCase; -final class ValidationAttributeTest extends TestCase -{ - #[DataProvider('validationRuleProvider')] - public function testValidationRulesAcceptValidValues( - ValidationRule $rule, - mixed $validValue, - mixed $invalidValue, - string $expectedMessage, - ): void { - self::assertNull($rule->validate($validValue)); - } - - #[DataProvider('validationRuleProvider')] - public function testValidationRulesRejectInvalidValues( - ValidationRule $rule, - mixed $validValue, - mixed $invalidValue, - string $expectedMessage, - ): void { - self::assertSame($expectedMessage, $rule->validate($invalidValue)); - } - - #[DataProvider('validationRuleProvider')] - public function testValidationRulesAllowNull( - ValidationRule $rule, - mixed $validValue, - mixed $invalidValue, - string $expectedMessage, - ): void { - self::assertNull($rule->validate(null)); - } +final class ValidationAttributeTest extends TestCase { - public static function validationRuleProvider(): iterable - { + public static function validationRuleProvider(): iterable { yield 'filter' => [ new Filter(FILTER_VALIDATE_EMAIL), 'user@example.com', @@ -74,4 +43,35 @@ public static function validationRuleProvider(): iterable 'must be a list of string', ]; } + + #[DataProvider('validationRuleProvider')] + public function testValidationRulesAcceptValidValues( + ValidationRule $rule, + mixed $validValue, + mixed $invalidValue, + string $expectedMessage, + ): void { + self::assertNull($rule->validate($validValue)); + } + + #[DataProvider('validationRuleProvider')] + public function testValidationRulesRejectInvalidValues( + ValidationRule $rule, + mixed $validValue, + mixed $invalidValue, + string $expectedMessage, + ): void { + self::assertSame($expectedMessage, $rule->validate($invalidValue)); + } + + #[DataProvider('validationRuleProvider')] + public function testValidationRulesAllowNull( + ValidationRule $rule, + mixed $validValue, + mixed $invalidValue, + string $expectedMessage, + ): void { + self::assertNull($rule->validate(NULL)); + } + } diff --git a/tests/Cache/FileCachePoolTest.php b/tests/Cache/FileCachePoolTest.php new file mode 100644 index 0000000..aacb002 --- /dev/null +++ b/tests/Cache/FileCachePoolTest.php @@ -0,0 +1,107 @@ +pool->getItem('ns/key1'); + self::assertFalse($item->isHit()); + + $item->set(['a' => 1]); + self::assertTrue($this->pool->save($item)); + + $loaded = $this->pool->getItem('ns/key1'); + self::assertTrue($loaded->isHit()); + self::assertSame(['a' => 1], $loaded->get()); + } + + public function testMissAndDelete(): void { + self::assertFalse($this->pool->hasItem('missing')); + $item = $this->pool->getItem('to-delete'); + $item->set('x'); + $this->pool->save($item); + self::assertTrue($this->pool->deleteItem('to-delete')); + self::assertFalse($this->pool->hasItem('to-delete')); + } + + public function testExpiry(): void { + $item = $this->pool->getItem('exp'); + $item->set('old'); + $item->expiresAfter(1); + $this->pool->save($item); + self::assertTrue($this->pool->getItem('exp')->isHit()); + sleep(2); + self::assertFalse($this->pool->getItem('exp')->isHit()); + } + + public function testClearNamespace(): void { + foreach(['Translation/a', 'Translation/b', 'Other/c'] as $key) { + $item = $this->pool->getItem($key); + $item->set($key); + $this->pool->save($item); + } + + $this->pool->clearNamespace('Translation'); + self::assertFalse($this->pool->hasItem('Translation/a')); + self::assertFalse($this->pool->hasItem('Translation/b')); + self::assertTrue($this->pool->hasItem('Other/c')); + } + + public function testClearAll(): void { + $item = $this->pool->getItem('x/y'); + $item->set(1); + $this->pool->save($item); + $this->pool->clear(); + self::assertFalse($this->pool->hasItem('x/y')); + } + + public function testDeferredCommit(): void { + $item = $this->pool->getItem('def/key'); + $item->set('deferred'); + $this->pool->saveDeferred($item); + self::assertFalse($this->pool->hasItem('def/key')); + self::assertTrue($this->pool->commit()); + self::assertTrue($this->pool->hasItem('def/key')); + self::assertSame('deferred', $this->pool->getItem('def/key')->get()); + } + + public function testInvalidKey(): void { + $this->expectException(InvalidArgument::class); + $this->pool->getItem('bad{key}'); + } + + public function testDefaultTtlNeverExpiresWhenZero(): void { + $forever = $this->dir . '/forever'; + mkdir($forever, 0775, true); + $pool = new FileCachePool($forever, 0); + $item = $pool->getItem('k'); + $item->set('v'); + $pool->save($item); + self::assertTrue($pool->getItem('k')->isHit()); + $pool->clear(); + @rmdir($forever); + } + + protected function setUp(): void { + $this->dir = sys_get_temp_dir() . '/devtools-cache-' . bin2hex(random_bytes(4)); + mkdir($this->dir, 0775, true); + $this->pool = new FileCachePool($this->dir, 3600); + } + + protected function tearDown(): void { + $this->pool->clear(); + @rmdir($this->dir); + } + +} diff --git a/tests/Exceptions/ValidationExceptionTest.php b/tests/Exceptions/ValidationExceptionTest.php index 346abc1..3b35b7f 100644 --- a/tests/Exceptions/ValidationExceptionTest.php +++ b/tests/Exceptions/ValidationExceptionTest.php @@ -4,29 +4,28 @@ namespace Devcraft\DevTools\Tests\Exceptions; -use Devcraft\Exceptions\ValidationException; use PHPUnit\Framework\TestCase; +use Devcraft\Exceptions\ValidationException; -final class ValidationExceptionTest extends TestCase -{ - public function testMessageListsInvalidProperties(): void - { +final class ValidationExceptionTest extends TestCase { + + public function testMessageListsInvalidProperties(): void { $exception = new ValidationException([ - 'count' => ['must be int'], + 'count' => ['must be int'], 'status' => ['must be string'], ]); self::assertSame('Validation failed for: count, status', $exception->getMessage()); } - public function testGetErrorsReturnsOriginalErrorMap(): void - { - $errors = [ - 'count' => ['must be int'], + public function testGetErrorsReturnsOriginalErrorMap(): void { + $errors = [ + 'count' => ['must be int'], 'status' => ['must be string'], ]; $exception = new ValidationException($errors); self::assertSame($errors, $exception->getErrors()); } + } diff --git a/tests/Mapper/ReflectionMapperTest.php b/tests/Mapper/ReflectionMapperTest.php index ed8a601..c0af7e4 100644 --- a/tests/Mapper/ReflectionMapperTest.php +++ b/tests/Mapper/ReflectionMapperTest.php @@ -4,28 +4,23 @@ namespace Devcraft\DevTools\Tests\Mapper; -use Devcraft\Abstracts\AbstractReflection; -use Devcraft\Attributes\ArrayOf; +use Stringable; +use Psr\Log\AbstractLogger; use Devcraft\Attributes\Range; -use Devcraft\Exceptions\ValidationException; -use Devcraft\Mapper\ReflectionMapper; use PHPUnit\Framework\TestCase; -use Psr\Log\AbstractLogger; +use Devcraft\Attributes\ArrayOf; +use Devcraft\Mapper\ReflectionMapper; +use Devcraft\Abstracts\AbstractReflection; +use Devcraft\Exceptions\ValidationException; -final class ReflectionMapperScalarFixture extends AbstractReflection -{ - public int $count; - public float $ratio; - public bool $active; -} +final class ReflectionMapperChildFixture extends AbstractReflection { -final class ReflectionMapperChildFixture extends AbstractReflection -{ public string $label; + } -final class ReflectionMapperCompositeFixture extends AbstractReflection -{ +final class ReflectionMapperCompositeFixture extends AbstractReflection { + public ReflectionMapperChildFixture $child; #[ArrayOf('int')] @@ -33,18 +28,11 @@ final class ReflectionMapperCompositeFixture extends AbstractReflection #[ArrayOf(ReflectionMapperChildFixture::class)] public array $children = []; -} -final class ReflectionMapperNullableFixture extends AbstractReflection -{ - public ?int $optional; - - #[Range(min: 10)] - public ?int $score = 20; } -final class ReflectionMapperInvalidFixture extends AbstractReflection -{ +final class ReflectionMapperInvalidFixture extends AbstractReflection { + public int $required; #[Range(min: 5)] @@ -55,33 +43,50 @@ final class ReflectionMapperInvalidFixture extends AbstractReflection #[ArrayOf('int')] public array $ids = []; + } -final class ReflectionMapperLogger extends AbstractLogger -{ +final class ReflectionMapperLogger extends AbstractLogger { + /** @var list}> */ public array $records = []; - public function log($level, string|\Stringable $message, array $context = []): void - { + public function log($level, string|Stringable $message, array $context = []): void { $this->records[] = [ - 'level' => (string) $level, + 'level' => (string) $level, 'message' => (string) $message, 'context' => $context, ]; } + } -final class ReflectionMapperTest extends TestCase -{ - public function testHydrateConvertsSupportedScalarValues(): void - { +final class ReflectionMapperNullableFixture extends AbstractReflection { + + public ?int $optional; + + #[Range(min: 10)] + public ?int $score = 20; + +} + +final class ReflectionMapperScalarFixture extends AbstractReflection { + + public int $count; + public float $ratio; + public bool $active; + +} + +final class ReflectionMapperTest extends TestCase { + + public function testHydrateConvertsSupportedScalarValues(): void { $fixture = new ReflectionMapperScalarFixture(); - $mapper = new ReflectionMapper(new ReflectionMapperLogger()); + $mapper = new ReflectionMapper(new ReflectionMapperLogger()); $mapper->hydrate($fixture, [ - 'count' => '12', - 'ratio' => '2.5', + 'count' => '12', + 'ratio' => '2.5', 'active' => 'false', ]); @@ -90,14 +95,13 @@ public function testHydrateConvertsSupportedScalarValues(): void self::assertFalse($fixture->active); } - public function testHydrateBuildsNestedDtosAndArrayOfValues(): void - { + public function testHydrateBuildsNestedDtosAndArrayOfValues(): void { $fixture = new ReflectionMapperCompositeFixture(); - $mapper = new ReflectionMapper(new ReflectionMapperLogger()); + $mapper = new ReflectionMapper(new ReflectionMapperLogger()); $mapper->hydrate($fixture, [ - 'child' => ['label' => 'primary'], - 'ids' => ['1', 2, '3'], + 'child' => ['label' => 'primary'], + 'ids' => ['1', 2, '3'], 'children' => [ ['label' => 'first'], ['label' => 'second'], @@ -108,16 +112,16 @@ public function testHydrateBuildsNestedDtosAndArrayOfValues(): void self::assertSame('primary', $fixture->child->label); self::assertSame([1, 2, 3], $fixture->ids); self::assertContainsOnlyInstancesOf(ReflectionMapperChildFixture::class, $fixture->children); - self::assertSame(['first', 'second'], array_map( - static fn(ReflectionMapperChildFixture $child): string => $child->label, - $fixture->children, - )); + self::assertSame(['first', 'second'], + array_map( + static fn(ReflectionMapperChildFixture $child): string => $child->label, + $fixture->children, + )); } - public function testHydrateUsesNullForNullablePropertiesWhenMissingOrInvalid(): void - { + public function testHydrateUsesNullForNullablePropertiesWhenMissingOrInvalid(): void { $fixture = new ReflectionMapperNullableFixture(); - $mapper = new ReflectionMapper(new ReflectionMapperLogger()); + $mapper = new ReflectionMapper(new ReflectionMapperLogger()); $mapper->hydrate($fixture, ['score' => 9]); @@ -125,8 +129,7 @@ public function testHydrateUsesNullForNullablePropertiesWhenMissingOrInvalid(): self::assertNull($fixture->score); } - public function testHydrateAggregatesValidationErrors(): void - { + public function testHydrateAggregatesValidationErrors(): void { $logger = new ReflectionMapperLogger(); $mapper = new ReflectionMapper($logger); @@ -134,15 +137,15 @@ public function testHydrateAggregatesValidationErrors(): void $mapper->hydrate(new ReflectionMapperInvalidFixture(), [ 'score' => 100, 'child' => ['label' => 123], - 'ids' => ['1', 'bad'], + 'ids' => ['1', 'bad'], ]); self::fail('Invalid payload must raise a validation exception.'); - } catch (ValidationException $exception) { + } catch(ValidationException $exception) { self::assertSame([ - 'required' => ['is required'], - 'score' => ['must be less than or equal to 10'], + 'required' => ['is required'], + 'score' => ['must be less than or equal to 10'], 'child.label' => ['must be string'], - 'ids.1' => ['must be int'], + 'ids.1' => ['must be int'], ], $exception->getErrors()); } @@ -151,9 +154,11 @@ public function testHydrateAggregatesValidationErrors(): void 'score', 'child.label', 'ids.1', - ], array_map( - static fn(array $record): string => $record['context']['property'], - $logger->records, - )); + ], + array_map( + static fn(array $record): string => $record['context']['property'], + $logger->records, + )); } + } diff --git a/tests/Runtime/WithHandlerTest.php b/tests/Runtime/WithHandlerTest.php index 76b2a39..1475548 100644 --- a/tests/Runtime/WithHandlerTest.php +++ b/tests/Runtime/WithHandlerTest.php @@ -4,680 +4,662 @@ namespace Devcraft\DevTools\Tests\Runtime; +use Closure; +use stdClass; use TypeError; +use ArrayIterator; use LogicException; use ArgumentCountError; use ReflectionProperty; +use ReflectionFunction; use BadMethodCallException; -use Devcraft\Abstracts\AbstractWith; use Devcraft\Attributes\With; use PHPUnit\Framework\TestCase; use Devcraft\Attributes\WithItem; use Devcraft\Runtime\WithHandler; +use Devcraft\Abstracts\AbstractWith; use PHPUnit\Framework\Attributes\DataProvider; -final class ScalarFixture extends AbstractWith -{ - #[With] - private ?int $page = null; +interface ItemContract {} - #[With] - private string $flag = ''; +final class ArrayFixture extends AbstractWith { - #[With] - private ?string $starting_after = null; + #[With, WithItem('string')] + private array $items = []; - public function page(): ?int - { - return $this->page; - } + #[WithItem(['int', 'string'], ['string', 'null'])] + private array $labels = []; - public function flag(): string - { - return $this->flag; - } + #[WithItem([ItemContract::class, ItemState::class])] + private array $objects = []; - public function startingAfter(): ?string - { - return $this->starting_after; - } -} + public function items(): array { + return $this->items; + } -interface ItemContract -{ -} + public function labels(): array { + return $this->labels; + } -final class ItemObject implements ItemContract -{ -} + public function objects(): array { + return $this->objects; + } -enum ItemState: string -{ - case READY = 'ready'; } -final class ArrayFixture extends AbstractWith -{ - #[With, WithItem('string')] - private array $items = []; +final class BuiltinItemFixture extends AbstractWith { + + #[WithItem('float')] + private array $floats = []; + + #[WithItem('bool')] + private array $booleans = []; + + #[WithItem('true')] + private array $trueValues = []; + + #[WithItem('false')] + private array $falseValues = []; + + #[WithItem('array')] + private array $arrays = []; + + #[WithItem('object')] + private array $objects = []; - #[WithItem(['int', 'string'], ['string', 'null'])] - private array $labels = []; + #[WithItem('iterable')] + private array $iterables = []; - #[WithItem([ItemContract::class, ItemState::class])] - private array $objects = []; + #[WithItem('callable')] + private array $callables = []; - public function items(): array - { - return $this->items; - } + #[WithItem('mixed')] + private array $mixedValues = []; - public function labels(): array - { - return $this->labels; - } + public function values(): array { + return [ + 'floats' => $this->floats, + 'booleans' => $this->booleans, + 'true' => $this->trueValues, + 'false' => $this->falseValues, + 'arrays' => $this->arrays, + 'objects' => $this->objects, + 'iterables' => $this->iterables, + 'callables' => $this->callables, + 'mixed' => $this->mixedValues, + ]; + } - public function objects(): array - { - return $this->objects; - } } -final class ConcreteDescriptorFixture extends AbstractWith -{ - #[WithItem(ItemObject::class)] - private array $items = []; +final class CaseCollisionFixture extends AbstractWith { + + #[With] + private string $name = ''; + + #[With] + private string $NAME = ''; - public function items(): array - { - return $this->items; - } } -final class BuiltinItemFixture extends AbstractWith -{ - #[WithItem('float')] - private array $floats = []; - - #[WithItem('bool')] - private array $booleans = []; - - #[WithItem('true')] - private array $trueValues = []; - - #[WithItem('false')] - private array $falseValues = []; - - #[WithItem('array')] - private array $arrays = []; - - #[WithItem('object')] - private array $objects = []; - - #[WithItem('iterable')] - private array $iterables = []; - - #[WithItem('callable')] - private array $callables = []; - - #[WithItem('mixed')] - private array $mixedValues = []; - - public function values(): array - { - return [ - 'floats' => $this->floats, - 'booleans' => $this->booleans, - 'true' => $this->trueValues, - 'false' => $this->falseValues, - 'arrays' => $this->arrays, - 'objects' => $this->objects, - 'iterables' => $this->iterables, - 'callables' => $this->callables, - 'mixed' => $this->mixedValues, - ]; - } +final class ChildCollisionFixture extends ParentCollisionFixture { + + #[With] + private string $duplicate = ''; + } -class ParentFixture extends AbstractWith -{ - #[With] - private string $parentValue = ''; +final class ChildFixture extends ParentFixture {} - #[WithItem('string')] - protected array $parentItems = []; +final class CollisionFixture extends AbstractWith { - public function parentValue(): string - { - return $this->parentValue; - } + #[With] + private array $fooItem = []; - public function parentItems(): array - { - return $this->parentItems; - } -} + #[WithItem('string')] + private array $foo = []; -final class ChildFixture extends ParentFixture -{ } -class InverseParentFixture extends AbstractWith -{ - #[With] - protected string $inverseValue = ''; +final class ConcreteDescriptorFixture extends AbstractWith { - #[WithItem('string')] - private array $inverseItems = []; + #[WithItem(ItemObject::class)] + private array $items = []; - public function inverseValue(): string - { - return $this->inverseValue; - } + public function items(): array { + return $this->items; + } - public function inverseItems(): array - { - return $this->inverseItems; - } } -final class InverseChildFixture extends InverseParentFixture -{ +final class EmptyDescriptorFixture extends AbstractWith { + + #[WithItem] + private array $items = []; + } -final class UninitializedArrayFixture extends AbstractWith -{ - #[WithItem('string')] - private array $items; +final class EmptyUnionFixture extends AbstractWith { + + #[WithItem([])] + private array $items = []; + } -final class CollisionFixture extends AbstractWith -{ - #[With] - private array $fooItem = []; +final class InvalidKeyFixture extends AbstractWith { + + #[WithItem(['string', 'bool'], 'string')] + private array $items = []; - #[WithItem('string')] - private array $foo = []; } -final class PublicTargetFixture extends AbstractWith -{ - #[With] - public int $value = 0; +final class InverseChildFixture extends InverseParentFixture {} + +class InverseParentFixture extends AbstractWith { + + #[With] + protected string $inverseValue = ''; + + #[WithItem('string')] + private array $inverseItems = []; + + public function inverseValue(): string { + return $this->inverseValue; + } + + public function inverseItems(): array { + return $this->inverseItems; + } + } -final class StaticTargetFixture extends AbstractWith -{ - #[With] - private static int $value = 0; +final class ItemObject implements ItemContract {} + +enum ItemState: string { + + case READY = 'ready'; + } -final class ReadonlyTargetFixture extends AbstractWith -{ - #[With] - private readonly int $value; +final class MixedUnionFixture extends AbstractWith { + + #[WithItem(['mixed', 'null'])] + private array $items = []; - public function __construct() - { - $this->value = 0; - } } -final class PublicItemTargetFixture extends AbstractWith -{ - #[WithItem('string')] - public array $items = []; +final class NonArrayTargetFixture extends AbstractWith { + + #[WithItem('string')] + private string $items = ''; + } -final class StaticItemTargetFixture extends AbstractWith -{ - #[WithItem('string')] - private static array $items = []; +final class NullableArrayTargetFixture extends AbstractWith { + + #[WithItem('string')] + private ?array $items = NULL; + } -final class ReadonlyItemTargetFixture extends AbstractWith -{ - #[WithItem('string')] - private readonly array $items; +class ParentCollisionFixture extends AbstractWith { + + #[With] + private string $duplicate = ''; - public function __construct() - { - $this->items = []; - } } -final class NullableArrayTargetFixture extends AbstractWith -{ - #[WithItem('string')] - private ?array $items = null; +class ParentFixture extends AbstractWith { + + #[WithItem('string')] + protected array $parentItems = []; + #[With] + private string $parentValue = ''; + + public function parentValue(): string { + return $this->parentValue; + } + + public function parentItems(): array { + return $this->parentItems; + } + } -final class EmptyDescriptorFixture extends AbstractWith -{ - #[WithItem] - private array $items = []; +final class PublicItemTargetFixture extends AbstractWith { + + #[WithItem('string')] + public array $items = []; + } -final class EmptyUnionFixture extends AbstractWith -{ - #[WithItem([])] - private array $items = []; +final class PublicTargetFixture extends AbstractWith { + + #[With] + public int $value = 0; + } -final class MixedUnionFixture extends AbstractWith -{ - #[WithItem(['mixed', 'null'])] - private array $items = []; +final class ReadonlyItemTargetFixture extends AbstractWith { + + #[WithItem('string')] + private readonly array $items; + + public function __construct() { + $this->items = []; + } + } -final class InvalidKeyFixture extends AbstractWith -{ - #[WithItem(['string', 'bool'], 'string')] - private array $items = []; +final class ReadonlyTargetFixture extends AbstractWith { + + #[With] + private readonly int $value; + + public function __construct() { + $this->value = 0; + } + } -final class TooManyDescriptorsFixture extends AbstractWith -{ - #[WithItem('string', 'string', 'string')] - private array $items = []; +final class RepeatedWithFixture extends AbstractWith { + + #[With, With] + private string $value = ''; + } -final class UnknownTypeFixture extends AbstractWith -{ - #[WithItem('Missing\\UnknownType')] - private array $items = []; +final class RepeatedWithItemFixture extends AbstractWith { + + #[WithItem('string'), WithItem('string')] + private array $items = []; + } -final class NonArrayTargetFixture extends AbstractWith -{ - #[WithItem('string')] - private string $items = ''; +final class ScalarFixture extends AbstractWith { + + #[With] + private ?int $page = NULL; + + #[With] + private string $flag = ''; + + #[With] + private ?string $starting_after = NULL; + + public function page(): ?int { + return $this->page; + } + + public function flag(): string { + return $this->flag; + } + + public function startingAfter(): ?string { + return $this->starting_after; + } + } -class ParentCollisionFixture extends AbstractWith -{ - #[With] - private string $duplicate = ''; +final class StaticItemTargetFixture extends AbstractWith { + + #[WithItem('string')] + private static array $items = []; + } -final class ChildCollisionFixture extends ParentCollisionFixture -{ - #[With] - private string $duplicate = ''; +final class StaticTargetFixture extends AbstractWith { + + #[With] + private static int $value = 0; + } -final class CaseCollisionFixture extends AbstractWith -{ - #[With] - private string $name = ''; +final class TooManyDescriptorsFixture extends AbstractWith { + + #[WithItem('string', 'string', 'string')] + private array $items = []; - #[With] - private string $NAME = ''; } -final class RepeatedWithFixture extends AbstractWith -{ - #[With, With] - private string $value = ''; +final class UninitializedArrayFixture extends AbstractWith { + + #[WithItem('string')] + private array $items; + } -final class RepeatedWithItemFixture extends AbstractWith -{ - #[WithItem('string'), WithItem('string')] - private array $items = []; +final class UnknownTypeFixture extends AbstractWith { + + #[WithItem('Missing\\UnknownType')] + private array $items = []; + } -final class WithHandlerTest extends TestCase -{ - public function testWithMutatesAndReturnsSameInstance(): void - { - $fixture = new ScalarFixture(); +final class WithHandlerTest extends TestCase { + + public static function invalidConfigurationProvider(): iterable { + yield 'public' => [new PublicTargetFixture(), 'withValue']; + yield 'static' => [new StaticTargetFixture(), 'withValue']; + yield 'readonly' => [new ReadonlyTargetFixture(), 'withValue']; + yield 'public item' => [new PublicItemTargetFixture(), 'withItemsItem']; + yield 'static item' => [new StaticItemTargetFixture(), 'withItemsItem']; + yield 'readonly item' => [new ReadonlyItemTargetFixture(), 'withItemsItem']; + yield 'nullable array' => [new NullableArrayTargetFixture(), 'withItemsItem']; + yield 'zero descriptors' => [new EmptyDescriptorFixture(), 'withItemsItem']; + yield 'empty union' => [new EmptyUnionFixture(), 'withItemsItem']; + yield 'mixed union' => [new MixedUnionFixture(), 'withItemsItem']; + yield 'invalid key' => [new InvalidKeyFixture(), 'withItemsItem']; + yield 'too many descriptors' => [new TooManyDescriptorsFixture(), 'withItemsItem']; + yield 'unknown type' => [new UnknownTypeFixture(), 'withItemsItem']; + yield 'non-array target' => [new NonArrayTargetFixture(), 'withItemsItem']; + yield 'collision' => [new CollisionFixture(), 'withFooItem']; + yield 'inherited collision' => [new ChildCollisionFixture(), 'withDuplicate']; + yield 'case-insensitive collision' => [new CaseCollisionFixture(), 'withName']; + } + + public function testWithMutatesAndReturnsSameInstance(): void { + $fixture = new ScalarFixture(); + + $returned = $fixture->withPage(3)->withFlag('false'); + + self::assertSame($fixture, $returned); + self::assertSame(3, $fixture->page()); + self::assertSame('false', $fixture->flag()); + } + + public function testWithRejectsScalarCoercion(): void { + $fixture = new ScalarFixture(); + + $this->expectException(TypeError::class); + $fixture->withPage('3'); + } + + public function testWithAcceptsNullForNullableProperty(): void { + $fixture = new ScalarFixture(); + + self::assertSame($fixture, $fixture->withPage(NULL)); + self::assertNull($fixture->page()); + } + + public function testSnakeCasePropertyUsesStudlyMethodName(): void { + $fixture = new ScalarFixture(); + + $fixture->withStartingAfter('cursor'); + + self::assertSame('cursor', $fixture->startingAfter()); + } + + public function testWithRequiresExactlyOneArgument(): void { + $fixture = new ScalarFixture(); + + try { + $fixture->withPage(); + self::fail('Zero arguments must fail.'); + } catch(ArgumentCountError) { + self::assertNull($fixture->page()); + } + + $this->expectException(ArgumentCountError::class); + $fixture->withPage(1, 2); + } - $returned = $fixture->withPage(3)->withFlag('false'); + public function testLookupIsCaseInsensitive(): void { + $fixture = new ScalarFixture(); - self::assertSame($fixture, $returned); - self::assertSame(3, $fixture->page()); - self::assertSame('false', $fixture->flag()); - } + self::assertTrue(WithHandler::handles($fixture, 'WITHPAGE')); + self::assertSame($fixture, WithHandler::call($fixture, 'wItHpAgE', [8])); + self::assertSame(8, $fixture->page()); + } - public function testWithRejectsScalarCoercion(): void - { - $fixture = new ScalarFixture(); + public function testWithItemAppendsAndSupportsWholeArrayWith(): void { + $fixture = new ArrayFixture(); - $this->expectException(TypeError::class); - $fixture->withPage('3'); - } + $returned = $fixture + ->withItems(['first']) + ->withItemsItem('second'); - public function testWithAcceptsNullForNullableProperty(): void - { - $fixture = new ScalarFixture(); + self::assertSame($fixture, $returned); + self::assertSame(['first', 'second'], $fixture->items()); + } + + public function testWithItemSetsAndReplacesMapValue(): void { + $fixture = new ArrayFixture(); + + $fixture + ->withLabelsItem('status', 'ready') + ->withLabelsItem('status', NULL) + ->withLabelsItem(10, 'numeric'); + + self::assertSame(['status' => NULL, 10 => 'numeric'], $fixture->labels()); + } + + public function testMapNormalizesDecimalNumericStringKeys(): void { + $fixture = new ArrayFixture(); + + $fixture->withLabelsItem('10', 'numeric'); + + self::assertSame([10 => 'numeric'], $fixture->labels()); + } + + public function testWithItemSupportsInterfaceAndEnumUnion(): void { + $fixture = new ArrayFixture(); + $object = new ItemObject(); + + $fixture->withObjectsItem($object)->withObjectsItem(ItemState::READY); + + self::assertSame([$object, ItemState::READY], $fixture->objects()); + } + + public function testWithItemSupportsConcreteClassDescriptor(): void { + $fixture = new ConcreteDescriptorFixture(); + $item = new ItemObject(); + + self::assertSame($fixture, $fixture->withItemsItem($item)); + self::assertSame([$item], $fixture->items()); + } + + public function testWithItemSupportsEveryDeclaredBuiltinDescriptor(): void { + $fixture = new BuiltinItemFixture(); + $object = new stdClass(); + $iterable = new ArrayIterator([1]); + $callable = static fn(): string => 'ok'; + + $fixture + ->withFloatsItem(1.5) + ->withBooleansItem(true) + ->withTrueValuesItem(true) + ->withFalseValuesItem(false) + ->withArraysItem(['value']) + ->withObjectsItem($object) + ->withIterablesItem($iterable) + ->withCallablesItem($callable) + ->withMixedValuesItem(NULL); + + self::assertSame([ + 'floats' => [1.5], + 'booleans' => [true], + 'true' => [true], + 'false' => [false], + 'arrays' => [['value']], + 'objects' => [$object], + 'iterables' => [$iterable], + 'callables' => [$callable], + 'mixed' => [NULL], + ], $fixture->values()); + } + + public function testFloatDescriptorRejectsIntegerWithoutCoercion(): void { + $fixture = new BuiltinItemFixture(); + + $this->expectException(TypeError::class); + $fixture->withFloatsItem(1); + } + + public function testInvalidAppendDoesNotMutateArray(): void { + $fixture = new ArrayFixture(); + $fixture->withItemsItem('valid'); + + try { + $fixture->withItemsItem(12); + self::fail('Invalid item type must fail.'); + } catch(TypeError) { + self::assertSame(['valid'], $fixture->items()); + } + } + + public function testInvalidMapValueDoesNotPartiallyMutateArray(): void { + $fixture = new ArrayFixture(); + $fixture->withLabelsItem('status', 'ready'); + + try { + $fixture->withLabelsItem('status', false); + self::fail('Invalid map value must fail.'); + } catch(TypeError) { + self::assertSame(['status' => 'ready'], $fixture->labels()); + } + } + + public function testInvalidMapKeyDoesNotMutateArray(): void { + $fixture = new ArrayFixture(); + $fixture->withLabelsItem('status', 'ready'); + + try { + $fixture->withLabelsItem(false, 'invalid'); + self::fail('Invalid map key must fail.'); + } catch(TypeError) { + self::assertSame(['status' => 'ready'], $fixture->labels()); + } + } + + public function testMapRejectsInvalidArityWithoutMutation(): void { + $fixture = new ArrayFixture(); + + foreach([[], ['status'], ['status', 'ready', 'extra']] as $arguments) { + try { + WithHandler::call($fixture, 'withLabelsItem', $arguments); + self::fail('Invalid map arity must fail.'); + } catch(ArgumentCountError) { + self::assertSame([], $fixture->labels()); + } + } + } + + public function testAppendRejectsInvalidArityWithoutMutation(): void { + $fixture = new ArrayFixture(); + + foreach([[], ['first', 'second']] as $arguments) { + try { + WithHandler::call($fixture, 'withItemsItem', $arguments); + self::fail('Invalid append arity must fail.'); + } catch(ArgumentCountError) { + self::assertSame([], $fixture->items()); + } + } + } + + public function testInheritedPrivateWithAndProtectedWithItemPropertiesWork(): void { + $fixture = new ChildFixture(); + + $fixture->withParentValue('parent')->withParentItemsItem('item'); + + self::assertSame('parent', $fixture->parentValue()); + self::assertSame(['item'], $fixture->parentItems()); + } + + public function testInheritedProtectedWithAndPrivateWithItemPropertiesWork(): void { + $fixture = new InverseChildFixture(); + + $fixture->withInverseValue('inverse')->withInverseItemsItem('item'); + + self::assertSame('inverse', $fixture->inverseValue()); + self::assertSame(['item'], $fixture->inverseItems()); + } + + #[DataProvider('invalidConfigurationProvider')] + public function testInvalidConfigurationsThrowLogicException(object $fixture, string $method): void { + $this->expectException(LogicException::class); + WithHandler::handles($fixture, $method); + } + + public function testRepeatedWithThrowsLogicExceptionIdentifyingAttributeAndProperty(): void { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(sprintf( + '%s is repeated on %s::$%s.', + With::class, + RepeatedWithFixture::class, + 'value', + )); + + WithHandler::handles(new RepeatedWithFixture(), 'withValue'); + } + + public function testRepeatedWithItemThrowsLogicExceptionIdentifyingAttributeAndProperty(): void { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(sprintf( + '%s is repeated on %s::$%s.', + WithItem::class, + RepeatedWithItemFixture::class, + 'items', + )); + + WithHandler::handles(new RepeatedWithItemFixture(), 'withItemsItem'); + } + + public function testUninitializedArrayThrowsLogicException(): void { + $fixture = new UninitializedArrayFixture(); + + $this->expectException(LogicException::class); + $fixture->withItemsItem('item'); + } + + public function testUnknownHandlerCallThrowsBadMethodCallException(): void { + $this->expectException(BadMethodCallException::class); + WithHandler::call(new ScalarFixture(), 'missing', []); + } + + public function testMetadataIsReusedForTheSameRuntimeClass(): void { + WithHandler::handles(new ScalarFixture(), 'withPage'); + $property = new ReflectionProperty(WithHandler::class, 'cache'); + $before = $property->getValue(); + + WithHandler::handles(new ScalarFixture(), 'withFlag'); + $after = $property->getValue(); + + self::assertSame( + $before[ScalarFixture::class]['withpage']['property'], + $after[ScalarFixture::class]['withpage']['property'], + ); + self::assertSame( + $before[ScalarFixture::class]['withpage']['writer'], + $after[ScalarFixture::class]['withpage']['writer'], + ); + self::assertMetadataDoesNotRetainScalarFixture($after[ScalarFixture::class]); + } + + private static function assertMetadataDoesNotRetainScalarFixture(mixed $value): void { + if(is_array($value)) { + foreach($value as $item) { + self::assertMetadataDoesNotRetainScalarFixture($item); + } + + return; + } + + if($value instanceof Closure) { + $closure = new ReflectionFunction($value); + self::assertMetadataDoesNotRetainScalarFixture($closure->getClosureThis()); + + foreach($closure->getStaticVariables() as $item) { + self::assertMetadataDoesNotRetainScalarFixture($item); + } + + return; + } + + if(is_object($value)) { + self::assertNotInstanceOf(ScalarFixture::class, $value); + } + } - self::assertSame($fixture, $fixture->withPage(null)); - self::assertNull($fixture->page()); - } - - public function testSnakeCasePropertyUsesStudlyMethodName(): void - { - $fixture = new ScalarFixture(); - - $fixture->withStartingAfter('cursor'); - - self::assertSame('cursor', $fixture->startingAfter()); - } - - public function testWithRequiresExactlyOneArgument(): void - { - $fixture = new ScalarFixture(); - - try { - $fixture->withPage(); - self::fail('Zero arguments must fail.'); - } catch (ArgumentCountError) { - self::assertNull($fixture->page()); - } - - $this->expectException(ArgumentCountError::class); - $fixture->withPage(1, 2); - } - - public function testLookupIsCaseInsensitive(): void - { - $fixture = new ScalarFixture(); - - self::assertTrue(WithHandler::handles($fixture, 'WITHPAGE')); - self::assertSame($fixture, WithHandler::call($fixture, 'wItHpAgE', [8])); - self::assertSame(8, $fixture->page()); - } - - public function testWithItemAppendsAndSupportsWholeArrayWith(): void - { - $fixture = new ArrayFixture(); - - $returned = $fixture - ->withItems(['first']) - ->withItemsItem('second'); - - self::assertSame($fixture, $returned); - self::assertSame(['first', 'second'], $fixture->items()); - } - - public function testWithItemSetsAndReplacesMapValue(): void - { - $fixture = new ArrayFixture(); - - $fixture - ->withLabelsItem('status', 'ready') - ->withLabelsItem('status', null) - ->withLabelsItem(10, 'numeric'); - - self::assertSame(['status' => null, 10 => 'numeric'], $fixture->labels()); - } - - public function testMapNormalizesDecimalNumericStringKeys(): void - { - $fixture = new ArrayFixture(); - - $fixture->withLabelsItem('10', 'numeric'); - - self::assertSame([10 => 'numeric'], $fixture->labels()); - } - - public function testWithItemSupportsInterfaceAndEnumUnion(): void - { - $fixture = new ArrayFixture(); - $object = new ItemObject(); - - $fixture->withObjectsItem($object)->withObjectsItem(ItemState::READY); - - self::assertSame([$object, ItemState::READY], $fixture->objects()); - } - - public function testWithItemSupportsConcreteClassDescriptor(): void - { - $fixture = new ConcreteDescriptorFixture(); - $item = new ItemObject(); - - self::assertSame($fixture, $fixture->withItemsItem($item)); - self::assertSame([$item], $fixture->items()); - } - - public function testWithItemSupportsEveryDeclaredBuiltinDescriptor(): void - { - $fixture = new BuiltinItemFixture(); - $object = new \stdClass(); - $iterable = new \ArrayIterator([1]); - $callable = static fn (): string => 'ok'; - - $fixture - ->withFloatsItem(1.5) - ->withBooleansItem(true) - ->withTrueValuesItem(true) - ->withFalseValuesItem(false) - ->withArraysItem(['value']) - ->withObjectsItem($object) - ->withIterablesItem($iterable) - ->withCallablesItem($callable) - ->withMixedValuesItem(null); - - self::assertSame([ - 'floats' => [1.5], - 'booleans' => [true], - 'true' => [true], - 'false' => [false], - 'arrays' => [['value']], - 'objects' => [$object], - 'iterables' => [$iterable], - 'callables' => [$callable], - 'mixed' => [null], - ], $fixture->values()); - } - - public function testFloatDescriptorRejectsIntegerWithoutCoercion(): void - { - $fixture = new BuiltinItemFixture(); - - $this->expectException(TypeError::class); - $fixture->withFloatsItem(1); - } - - public function testInvalidAppendDoesNotMutateArray(): void - { - $fixture = new ArrayFixture(); - $fixture->withItemsItem('valid'); - - try { - $fixture->withItemsItem(12); - self::fail('Invalid item type must fail.'); - } catch (TypeError) { - self::assertSame(['valid'], $fixture->items()); - } - } - - public function testInvalidMapValueDoesNotPartiallyMutateArray(): void - { - $fixture = new ArrayFixture(); - $fixture->withLabelsItem('status', 'ready'); - - try { - $fixture->withLabelsItem('status', false); - self::fail('Invalid map value must fail.'); - } catch (TypeError) { - self::assertSame(['status' => 'ready'], $fixture->labels()); - } - } - - public function testInvalidMapKeyDoesNotMutateArray(): void - { - $fixture = new ArrayFixture(); - $fixture->withLabelsItem('status', 'ready'); - - try { - $fixture->withLabelsItem(false, 'invalid'); - self::fail('Invalid map key must fail.'); - } catch (TypeError) { - self::assertSame(['status' => 'ready'], $fixture->labels()); - } - } - - public function testMapRejectsInvalidArityWithoutMutation(): void - { - $fixture = new ArrayFixture(); - - foreach ([[], ['status'], ['status', 'ready', 'extra']] as $arguments) { - try { - WithHandler::call($fixture, 'withLabelsItem', $arguments); - self::fail('Invalid map arity must fail.'); - } catch (ArgumentCountError) { - self::assertSame([], $fixture->labels()); - } - } - } - - public function testAppendRejectsInvalidArityWithoutMutation(): void - { - $fixture = new ArrayFixture(); - - foreach ([[], ['first', 'second']] as $arguments) { - try { - WithHandler::call($fixture, 'withItemsItem', $arguments); - self::fail('Invalid append arity must fail.'); - } catch (ArgumentCountError) { - self::assertSame([], $fixture->items()); - } - } - } - - public function testInheritedPrivateWithAndProtectedWithItemPropertiesWork(): void - { - $fixture = new ChildFixture(); - - $fixture->withParentValue('parent')->withParentItemsItem('item'); - - self::assertSame('parent', $fixture->parentValue()); - self::assertSame(['item'], $fixture->parentItems()); - } - - public function testInheritedProtectedWithAndPrivateWithItemPropertiesWork(): void - { - $fixture = new InverseChildFixture(); - - $fixture->withInverseValue('inverse')->withInverseItemsItem('item'); - - self::assertSame('inverse', $fixture->inverseValue()); - self::assertSame(['item'], $fixture->inverseItems()); - } - - #[DataProvider('invalidConfigurationProvider')] - public function testInvalidConfigurationsThrowLogicException(object $fixture, string $method): void - { - $this->expectException(LogicException::class); - WithHandler::handles($fixture, $method); - } - - public static function invalidConfigurationProvider(): iterable - { - yield 'public' => [new PublicTargetFixture(), 'withValue']; - yield 'static' => [new StaticTargetFixture(), 'withValue']; - yield 'readonly' => [new ReadonlyTargetFixture(), 'withValue']; - yield 'public item' => [new PublicItemTargetFixture(), 'withItemsItem']; - yield 'static item' => [new StaticItemTargetFixture(), 'withItemsItem']; - yield 'readonly item' => [new ReadonlyItemTargetFixture(), 'withItemsItem']; - yield 'nullable array' => [new NullableArrayTargetFixture(), 'withItemsItem']; - yield 'zero descriptors' => [new EmptyDescriptorFixture(), 'withItemsItem']; - yield 'empty union' => [new EmptyUnionFixture(), 'withItemsItem']; - yield 'mixed union' => [new MixedUnionFixture(), 'withItemsItem']; - yield 'invalid key' => [new InvalidKeyFixture(), 'withItemsItem']; - yield 'too many descriptors' => [new TooManyDescriptorsFixture(), 'withItemsItem']; - yield 'unknown type' => [new UnknownTypeFixture(), 'withItemsItem']; - yield 'non-array target' => [new NonArrayTargetFixture(), 'withItemsItem']; - yield 'collision' => [new CollisionFixture(), 'withFooItem']; - yield 'inherited collision' => [new ChildCollisionFixture(), 'withDuplicate']; - yield 'case-insensitive collision' => [new CaseCollisionFixture(), 'withName']; - } - - public function testRepeatedWithThrowsLogicExceptionIdentifyingAttributeAndProperty(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessage(sprintf( - '%s is repeated on %s::$%s.', - With::class, - RepeatedWithFixture::class, - 'value', - )); - - WithHandler::handles(new RepeatedWithFixture(), 'withValue'); - } - - public function testRepeatedWithItemThrowsLogicExceptionIdentifyingAttributeAndProperty(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessage(sprintf( - '%s is repeated on %s::$%s.', - WithItem::class, - RepeatedWithItemFixture::class, - 'items', - )); - - WithHandler::handles(new RepeatedWithItemFixture(), 'withItemsItem'); - } - - public function testUninitializedArrayThrowsLogicException(): void - { - $fixture = new UninitializedArrayFixture(); - - $this->expectException(LogicException::class); - $fixture->withItemsItem('item'); - } - - public function testUnknownHandlerCallThrowsBadMethodCallException(): void - { - $this->expectException(BadMethodCallException::class); - WithHandler::call(new ScalarFixture(), 'missing', []); - } - - public function testMetadataIsReusedForTheSameRuntimeClass(): void - { - WithHandler::handles(new ScalarFixture(), 'withPage'); - $property = new ReflectionProperty(WithHandler::class, 'cache'); - $before = $property->getValue(); - - WithHandler::handles(new ScalarFixture(), 'withFlag'); - $after = $property->getValue(); - - self::assertSame( - $before[ScalarFixture::class]['withpage']['property'], - $after[ScalarFixture::class]['withpage']['property'], - ); - self::assertSame( - $before[ScalarFixture::class]['withpage']['writer'], - $after[ScalarFixture::class]['withpage']['writer'], - ); - self::assertMetadataDoesNotRetainScalarFixture($after[ScalarFixture::class]); - } - - private static function assertMetadataDoesNotRetainScalarFixture(mixed $value): void - { - if (is_array($value)) { - foreach ($value as $item) { - self::assertMetadataDoesNotRetainScalarFixture($item); - } - - return; - } - - if ($value instanceof \Closure) { - $closure = new \ReflectionFunction($value); - self::assertMetadataDoesNotRetainScalarFixture($closure->getClosureThis()); - - foreach ($closure->getStaticVariables() as $item) { - self::assertMetadataDoesNotRetainScalarFixture($item); - } - - return; - } - - if (is_object($value)) { - self::assertNotInstanceOf(ScalarFixture::class, $value); - } - } } diff --git a/tests/Validation/PropertyValidatorTest.php b/tests/Validation/PropertyValidatorTest.php index 54bfc88..cb41cda 100644 --- a/tests/Validation/PropertyValidatorTest.php +++ b/tests/Validation/PropertyValidatorTest.php @@ -4,39 +4,32 @@ namespace Devcraft\DevTools\Tests\Validation; -use Devcraft\Attributes\Filter; -use Devcraft\Attributes\Range; +use ReflectionProperty; use Devcraft\Attributes\Regex; -use Devcraft\Validation\PropertyValidator; +use Devcraft\Attributes\Range; use PHPUnit\Framework\TestCase; -use ReflectionProperty; +use Devcraft\Attributes\Filter; +use Devcraft\Validation\PropertyValidator; -final class PropertyValidatorValueFixture -{ - #[Range(min: 10)] - #[Regex('/^\d+$/')] - public mixed $value = null; -} +final class PropertyValidatorObjectFixture { -final class PropertyValidatorObjectFixture -{ #[Filter(FILTER_VALIDATE_INT)] #[Regex('/^\d+$/')] - public mixed $code = null; + public mixed $code = NULL; #[Range(min: 1)] - public ?int $optional = null; + public ?int $optional = NULL; #[Range(min: 1)] private int $ignored = 0; + } -final class PropertyValidatorTest extends TestCase -{ - public function testValidateValueCollectsErrorsFromMultipleRules(): void - { +final class PropertyValidatorTest extends TestCase { + + public function testValidateValueCollectsErrorsFromMultipleRules(): void { $validator = new PropertyValidator(); - $property = new ReflectionProperty(PropertyValidatorValueFixture::class, 'value'); + $property = new ReflectionProperty(PropertyValidatorValueFixture::class, 'value'); self::assertSame([ 'must be numeric', @@ -44,17 +37,15 @@ public function testValidateValueCollectsErrorsFromMultipleRules(): void ], $validator->validateValue($property, 'abc')); } - public function testValidateValueAllowsNull(): void - { + public function testValidateValueAllowsNull(): void { $validator = new PropertyValidator(); - $property = new ReflectionProperty(PropertyValidatorValueFixture::class, 'value'); + $property = new ReflectionProperty(PropertyValidatorValueFixture::class, 'value'); - self::assertSame([], $validator->validateValue($property, null)); + self::assertSame([], $validator->validateValue($property, NULL)); } - public function testValidateObjectReturnsOnlyPublicInitializedPropertyErrors(): void - { - $fixture = new PropertyValidatorObjectFixture(); + public function testValidateObjectReturnsOnlyPublicInitializedPropertyErrors(): void { + $fixture = new PropertyValidatorObjectFixture(); $fixture->code = 'abc'; $validator = new PropertyValidator(); @@ -66,4 +57,13 @@ public function testValidateObjectReturnsOnlyPublicInitializedPropertyErrors(): ], ], $validator->validateObject($fixture)); } + +} + +final class PropertyValidatorValueFixture { + + #[Range(min: 10)] + #[Regex('/^\d+$/')] + public mixed $value = NULL; + }