diff --git a/.github/workflows/continuous-documentation.yml b/.github/workflows/continuous-documentation.yml index d21c9e6..4caed7f 100644 --- a/.github/workflows/continuous-documentation.yml +++ b/.github/workflows/continuous-documentation.yml @@ -29,8 +29,11 @@ jobs: - name: Install packages run: composer install --prefer-dist --no-progress - - name: Run command - run: php docs.php > documentation.md + - name: Generate general documentation + run: php docs/generators/docs.php > docs/documentation.md + + - name: Generate arg validation documentation + run: php docs/generators/docs.php > docs/arg-validation.md - name: Commit and push if changed run: | diff --git a/composer.json b/composer.json index 4d304d7..fc5998c 100644 --- a/composer.json +++ b/composer.json @@ -23,13 +23,13 @@ "symfony/var-dumper": "^7.4", "phpunit/phpunit": "^11.5", "friendsofphp/php-cs-fixer": "^3.92", - "exan/pudocumenter": "^0.1.7", + "exan/pudocumenter": "^0.1.8", "vimeo/psalm": "^6.16" }, "scripts": { "test": "phpunit tests", - "psalm": "psalm --no-cache", + "psalm": "psalm --no-cache --show-info", "psalm-fix": "psalm --no-cache --alter --issues=MissingReturnType,MissingClosureReturnType,InvalidReturnType,InvalidNullableReturnType,InvalidFalsableReturnType,MissingParamType,MissingPropertyType,MismatchingDocblockParamType,MismatchingDocblockReturnType,LessSpecificReturnType,PossiblyUndefinedVariable,PossiblyUndefinedGlobalVariable,UnusedMethod,PossiblyUnusedMethod,UnusedProperty,PossiblyUnusedProperty,UnusedVariable,UnnecessaryVarAnnotation,ParamNameMismatch", diff --git a/docs/arg-validation.md b/docs/arg-validation.md new file mode 100644 index 0000000..2d0308d --- /dev/null +++ b/docs/arg-validation.md @@ -0,0 +1,589 @@ +# Table of contents + + - [String helpers](#string-helpers) + - [Length](#length) + - [Contains](#contains) + - [Starts With](#starts-with) + - [Ends With](#ends-with) + - [Regex](#regex) + - [Min Length](#min-length) + - [Max Length](#max-length) + - [Alpha](#alpha) + - [Alphanumeric](#alphanumeric) + - [Lowercase](#lowercase) + - [Uppercase](#uppercase) + - [Not Empty](#not-empty) + - [Number helpers](#number-helpers) + - [Less than](#less-than) + - [Greater than](#greater-than) + - [Range](#range) + - [Positive](#positive) + - [Negative](#negative) + - [Even](#even) + - [Odd](#odd) + - [Divisible by](#divisible-by) + - [Approximately](#approximately) + - [Date helpers](#date-helpers) + - [Before](#before) + - [After](#after) + - [Between](#between) + - [Equal](#equal) + - [Same day](#same-day) + - [In past](#in-past) + - [In future](#in-future) + - [Type helpers](#type-helpers) + - [String](#string) + - [Integer](#integer) + - [Float](#float) + - [Array](#array) + - [Resource](#resource) + - [Object](#object) + - [Boolean](#boolean) + - [Null](#null) + - [Callable](#callable) + - [Iterable](#iterable) + - [Scalar](#scalar) + - [Instance of](#instance-of) + - [Array helpers](#array-helpers) + - [Count](#count) + - [Partial match](#partial-match) + - [All](#all) + - [Any](#any) + - [None](#none) + - [Contains](#contains) + - [Keys](#keys) + - [Empty](#empty) + - [Not empty](#not-empty) + - [Indexed](#indexed) + +## String helpers + +```php +use Exan\Moock\Args\Str; +``` + +### Length +Validate exact string length +```php +$validator = Str::length(5); + +$this->assertTrue($validator('hello')); +$this->assertFalse($validator('hell')); +``` + +### Contains +Validate substring presence (case-insensitive) +```php +$validator = Str::contains('world'); + +$this->assertTrue($validator('hello world')); +$this->assertTrue($validator('hello WoRLd')); +$this->assertFalse($validator('hello there')); +``` + +### Starts With +Validate string prefix (case-insensitive) +```php +$validator = Str::startsWith('hel'); + +$this->assertTrue($validator('hello')); +$this->assertFalse($validator('world')); +``` + +### Ends With +Validate string suffix (case-insensitive) +```php +$validator = Str::endsWith('lo'); + +$this->assertTrue($validator('hello')); +$this->assertFalse($validator('world')); +``` + +### Regex +Validate string against regex pattern +```php +$validator = Str::matchesRegex('/^[a-z]+$/'); + +$this->assertTrue($validator('hello')); +$this->assertFalse($validator('hello123')); +``` + +### Min Length +Validate minimum string length +```php +$validator = Str::minLength(3); + +$this->assertTrue($validator('abcd')); +$this->assertFalse($validator('ab')); +``` + +### Max Length +Validate maximum string length +```php +$validator = Str::maxLength(3); + +$this->assertTrue($validator('ab')); +$this->assertFalse($validator('abcd')); +``` + +### Alpha +Validate string contains alphabetical characters only +```php +$validator = Str::alpha(); + +$this->assertTrue($validator('abc')); +$this->assertFalse($validator('abc123')); +``` + +### Alphanumeric +Validate string contains alphaniumeric characters only +```php +$validator = Str::alphanumeric(); + +$this->assertTrue($validator('abc123')); +$this->assertFalse($validator('abc-123')); +``` + +### Lowercase +Validate string is fully lowercase +```php +$validator = Str::lowercase(); + +$this->assertTrue($validator('hello')); +$this->assertFalse($validator('Hello')); +``` + +### Uppercase +Validate string is fully uppercase +```php +$validator = Str::uppercase(); + +$this->assertTrue($validator('HELLO')); +$this->assertFalse($validator('Hello')); +``` + +### Not Empty +Validate string is not empty +```php +$validator = Str::notEmpty(); + +$this->assertTrue($validator('a')); +$this->assertFalse($validator('')); +``` + +--- + +## Number helpers + +```php +use Exan\Moock\Args\Number; +``` + +### Less than +Validate a number is less than a given value +```php +$validator = Number::lt(10); + +$this->assertTrue($validator(5)); +$this->assertFalse($validator(15)); +``` + +### Greater than +Validate a number is greater than a given value +```php +$validator = Number::gt(10); + +$this->assertTrue($validator(15)); +$this->assertFalse($validator(5)); +``` + +### Range +Validate a number is within a range (inclusive) +```php +$validator = Number::range(10, 20); + +$this->assertTrue($validator(10)); +$this->assertTrue($validator(15)); +$this->assertTrue($validator(20)); + +$this->assertFalse($validator(9)); +$this->assertFalse($validator(21)); +``` + +### Positive +Validate a number is positive +```php +$validator = Number::positive(); + +$this->assertTrue($validator(1)); +$this->assertFalse($validator(0)); +$this->assertFalse($validator(-1)); +``` + +### Negative +Validate a number is negative +```php +$validator = Number::negative(); + +$this->assertTrue($validator(-1)); +$this->assertFalse($validator(0)); +$this->assertFalse($validator(1)); +``` + +### Even +Validate a number is even integer +```php +$validator = Number::even(); + +$this->assertTrue($validator(2)); +$this->assertFalse($validator(3)); +$this->assertFalse($validator(2.5)); +``` + +### Odd +Validate a number is odd integer +```php +$validator = Number::odd(); + +$this->assertTrue($validator(3)); +$this->assertFalse($validator(2)); +$this->assertFalse($validator(3.5)); +``` + +### Divisible by +Validate a number is divisible by a given divisor +```php +$validator = Number::divisibleBy(3); + +$this->assertTrue($validator(9)); +$this->assertFalse($validator(10)); +``` + +### Approximately +Validate a number is approximately equal within epsilon +```php +$validator = Number::approx(10.0, 0.01); + +$this->assertTrue($validator(10.005)); +$this->assertFalse($validator(10.1)); +``` + +--- + +## Date helpers + +```php +use Exan\Moock\Args\Date; +``` + +### Before +Validate a date is before a given date +```php +$target = new \DateTimeImmutable('2024-01-10'); +$validator = Date::before($target); + +$this->assertTrue($validator(new \DateTimeImmutable('2024-01-09'))); +$this->assertFalse($validator(new \DateTimeImmutable('2024-01-11'))); +``` + +### After +Validate a date is after a given date +```php +$target = new \DateTimeImmutable('2024-01-10'); +$validator = Date::after($target); + +$this->assertTrue($validator(new \DateTimeImmutable('2024-01-11'))); +$this->assertFalse($validator(new \DateTimeImmutable('2024-01-09'))); +``` + +### Between +Validate a date is within a range (inclusive) +```php +$start = new \DateTimeImmutable('2024-01-10'); +$end = new \DateTimeImmutable('2024-01-20'); + +$validator = Date::between($start, $end); + +$this->assertTrue($validator(new \DateTimeImmutable('2024-01-10'))); +$this->assertTrue($validator(new \DateTimeImmutable('2024-01-15'))); +$this->assertTrue($validator(new \DateTimeImmutable('2024-01-20'))); +$this->assertFalse($validator(new \DateTimeImmutable('2024-01-09'))); +$this->assertFalse($validator(new \DateTimeImmutable('2024-01-21'))); +``` + +### Equal +Validate a date equals another date +```php +$target = new \DateTimeImmutable('2024-01-10'); + +$validator = Date::equal($target); + +$this->assertTrue($validator(new \DateTimeImmutable('2024-01-10'))); +$this->assertFalse($validator(new \DateTimeImmutable('2024-01-11'))); +``` + +### Same day +Validate a date falls on the same calendar day +```php +$target = new \DateTimeImmutable('2024-01-10 10:00:00'); + +$validator = Date::sameDay($target); + +$this->assertTrue($validator(new \DateTimeImmutable('2024-01-10 23:59:59'))); +$this->assertFalse($validator(new \DateTimeImmutable('2024-01-11 00:00:00'))); +``` + +### In past +Validate a date is in the past +```php +$validator = Date::inPast(); + +$this->assertTrue($validator(new \DateTimeImmutable('yesterday'))); +$this->assertFalse($validator(new \DateTimeImmutable('tomorrow'))); +``` + +### In future +Validate a date is in the future +```php +$validator = Date::inFuture(); + +$this->assertTrue($validator(new \DateTimeImmutable('tomorrow'))); +$this->assertFalse($validator(new \DateTimeImmutable('yesterday'))); +``` + +--- + +## Type helpers + +```php +use Exan\Moock\Args\Type; +``` + +### String +Validate that a value is a string +```php +$validator = Type::string(); + +$this->assertTrue($validator('hello')); +$this->assertFalse($validator(123)); +``` + +### Integer +Validate that a value is an integer +```php +$validator = Type::int(); + +$this->assertTrue($validator(123)); +$this->assertFalse($validator('123')); +``` + +### Float +Validate that a value is a float +```php +$validator = Type::float(); + +$this->assertTrue($validator(1.23)); +$this->assertFalse($validator(123)); +``` + +### Array +Validate that a value is an array +```php +$validator = Type::array(); + +$this->assertTrue($validator(['a'])); +$this->assertFalse($validator('a')); +``` + +### Resource +Validate that a value is a resource +```php +$validator = Type::resource(); + +$resource = fopen('php://memory', 'r'); + +$this->assertTrue($validator($resource)); +$this->assertFalse($validator('not a resource')); + +fclose($resource); +``` + +### Object +Validate that a value is an object +```php +$validator = Type::object(); + +$this->assertTrue($validator(new \stdClass())); +$this->assertFalse($validator('not an object')); +``` + +### Boolean +Validate that a value is a boolean +```php +$validator = Type::bool(); + +$this->assertTrue($validator(true)); +$this->assertFalse($validator(1)); +``` + +### Null +Validate that a value is null +```php +$validator = Type::null(); + +$this->assertTrue($validator(null)); +$this->assertFalse($validator(false)); +``` + +### Callable +Validate that a value is callable +```php +$validator = Type::callable(); + +$this->assertTrue($validator(fn () => null)); +$this->assertFalse($validator('not a callable')); +``` + +### Iterable +Validate that a value is iterable +```php +$validator = Type::iterable(); + +$this->assertTrue($validator([1, 2, 3])); +$this->assertFalse($validator(123)); +``` + +### Scalar +Validate that a value is scalar +```php +$validator = Type::scalar(); + +$this->assertTrue($validator('string')); +$this->assertFalse($validator([1, 2, 3])); +``` + +### Instance of +Validate that a value is instance of a class +```php +$validator = Type::instanceOf(Exception::class); + +$this->assertTrue($validator(new Exception())); +$this->assertTrue($validator(new RuntimeException())); + +$this->assertFalse($validator(new DateTime())); +``` + +--- + +## Array helpers + +```php +use Exan\Moock\Args\Arr; +``` + +### Count +Validate the amount of items in an array +```php +$count = Arr::count(3); + +$this->assertTrue($count([1, 2, 3])); +$this->assertFalse($count([1, 2])); +``` + +### Partial match +Validate an array has all specified values. +```php +$partial = Arr::partial(['key' => 'value']); + +$matching = [ + 'key' => 'value', + 'extra-field' => 'extra-value', +]; + +$missingKey = [ + 'other-key' => 'other-value', +]; + +$this->assertTrue($partial($matching)); +$this->assertFalse($partial($missingKey)); +``` + +### All +Validate that all items in an array match a condition. +```php +$validator = Arr::all(fn (mixed $value) => $value % 2 === 0); + +$this->assertTrue($validator([2, 4, 6])); +$this->assertFalse($validator([2, 4, 6, 7])); +``` + +This can also be combined with other helpers. +```php +$validator = Arr::all(Type::int()); + +$this->assertTrue($validator([2, 4, 6])); +$this->assertFalse($validator([2, '4', 6])); +``` + +### Any +Validate that at least one item matches a condition. +```php +$validator = Arr::any(fn (mixed $value) => $value === 3); + +$this->assertTrue($validator([1, 2, 3])); +$this->assertFalse($validator([1, 2, 4])); +``` + +### None +Validate that no items match a condition. +```php +$validator = Arr::none(fn (mixed $value) => $value === 5); + +$this->assertTrue($validator([1, 2, 3])); +$this->assertFalse($validator([1, 5, 3])); +``` + +### Contains +Validate an array contains a value +```php +$validator = Arr::contains(2); + +$this->assertTrue($validator([1, 2, 3])); +$this->assertFalse($validator([1, 3, 4])); +``` + +### Keys +Validate an array has specific keys +```php +$validator = Arr::keys(['a', 'b']); + +$this->assertTrue($validator(['a' => 1, 'b' => 2, 'c' => 3])); +$this->assertFalse($validator(['a' => 1, 'c' => 3])); +``` + +### Empty +Validate an array is empty +```php +$validator = Arr::empty(); + +$this->assertTrue($validator([])); +$this->assertFalse($validator([1])); +``` + +### Not empty +Validate an array is not empty +```php +$validator = Arr::notEmpty(); + +$this->assertTrue($validator([1])); +$this->assertFalse($validator([])); +``` + +### Indexed +Validate an array is sequential indexed +```php +$validator = Arr::indexed(); + +$this->assertTrue($validator([1, 2, 3])); +$this->assertFalse($validator(['key-1' => 'a', 'key-2' => 'b'])); +``` diff --git a/documentation.md b/docs/documentation.md similarity index 90% rename from documentation.md rename to docs/documentation.md index bd90ac4..0e1e8e9 100644 --- a/documentation.md +++ b/docs/documentation.md @@ -284,38 +284,6 @@ Mock::method($this->mock->userExists(...)) ->called(); ``` -`DateTimeInterface` must be before given time -```php -use Exan\Moock\Args\Date; -use DateTime; - -Mock::method($this->mock->getUsersCreatedBefore(...)) - ->returns([]); - -$this->mock->getUsersCreatedBefore(new DateTime('2024-12-31')); - -Mock::method($this->mock->getUsersCreatedBefore(...)) - ->assert() - ->with(Date::before(new DateTime('2025-01-01 12:00:00'))) - ->called(); -``` - -`DateTimeInterface` must be after given time -```php -use Exan\Moock\Args\Date; -use DateTime; - -Mock::method($this->mock->getUsersCreatedBefore(...)) - ->returns([]); - -$this->mock->getUsersCreatedBefore(new DateTime('2025-01-02')); - -Mock::method($this->mock->getUsersCreatedBefore(...)) - ->assert() - ->with(Date::after(new DateTime('2025-01-01 12:00:00'))) - ->called(); -``` - `int|float` must be less than given number ```php use Exan\Moock\Args\Number; @@ -346,51 +314,7 @@ Mock::method($this->mock->getUsersByAge(...)) ->called(); ``` -`int|float` must be within range -```php -use Exan\Moock\Args\Number; - -Mock::method($this->mock->getUsersByAge(...)) - ->returns([]); - -$this->mock->getUsersByAge(15); - -Mock::method($this->mock->getUsersByAge(...)) - ->assert() - ->with(Number::range(10, 20)) - ->called(); -``` - -`array` must have given number of items -```php -use Exan\Moock\Args\Arr; - -$this->mock->deleteUsersByEmail(['a','b','c']); - -Mock::method($this->mock->deleteUsersByEmail(...)) - ->assert() - ->with(Arr::count(3)) - ->called(); -``` - -`array` must be a partial match -```php -use Exan\Moock\Args\Arr; - -$this->mock->deleteUsersByEmail([ - 'some-email@example.com', - 'ignore-this@mail.com', - 'another@example.com', -]); - -Mock::method($this->mock->deleteUsersByEmail(...)) - ->assert() - ->with(Arr::partial([ - 0 => 'some-email@example.com', - 2 => fn ($email) => str_ends_with($email, '@example.com'), - ])) - ->called(); -``` +To see more of these helpers, head on over to the [argument validators](./arg-validation.md) documentation. --- diff --git a/docs/generators/arg-validation.php b/docs/generators/arg-validation.php new file mode 100644 index 0000000..bbeed5c --- /dev/null +++ b/docs/generators/arg-validation.php @@ -0,0 +1,30 @@ +document(new StdOutMarkdownTOCPrinter()); + +echo PHP_EOL; + +$documenter->document(new StdOutMarkdownPrinter()); diff --git a/docs.php b/docs/generators/docs.php similarity index 100% rename from docs.php rename to docs/generators/docs.php diff --git a/readme.md b/readme.md index 540d459..81e667a 100644 --- a/readme.md +++ b/readme.md @@ -16,7 +16,7 @@ Moock is a package to abstract creating test dummies for objects, intended to be Using test dummies allows you to write more specific tests, where you don't have to worry about a class's dependencies. This works best when using the Dependency Injection pattern. -Check out the docs [here!](./documentation.md) +Check out the docs [here!](./docs/documentation.md) ### Sales pitch diff --git a/src/Analyzer/Utilize.php b/src/Analyzer/Utilize.php index 4b40a2a..03b9316 100644 --- a/src/Analyzer/Utilize.php +++ b/src/Analyzer/Utilize.php @@ -50,9 +50,9 @@ public static function fromTokens(?string $declaringNamespace, array $uses): sel /** * @param array $line * - * @return array[] + * @return (mixed|string)[][] * - * @psalm-return array, array> + * @psalm-return array, array> */ private static function parseLine($line): array { @@ -97,6 +97,11 @@ private static function individualUses($tokens): array return $individual; } + /** + * @return (mixed|string)[] + * + * @psalm-return array + */ private static function parseIndividualUse(array $use): array { while (is_array($use[0]) && $use[0][0] === T_WHITESPACE) { diff --git a/src/Args/Arr.php b/src/Args/Arr.php index b59f167..5634c2b 100644 --- a/src/Args/Arr.php +++ b/src/Args/Arr.php @@ -13,7 +13,7 @@ class Arr */ public static function count(int $expectedCount): Closure { - return fn (array $actual): bool => count($actual) === $expectedCount; + return fn (iterable $actual): bool => count($actual) === $expectedCount; } /** @@ -33,15 +33,13 @@ public static function partial(array $expectation): Closure if ($expectedValue($actualValue) !== true) { return false; } - continue; } if (is_array($expectedValue)) { - if (!$validator($actualValue, $expectedValue)) { + if (!is_array($actualValue) || !$validator($actualValue, $expectedValue)) { return false; } - continue; } @@ -55,4 +53,92 @@ public static function partial(array $expectation): Closure return fn (array $actual) => $validator($actual, $expectation); } + + /** + * @psalm-return Closure(array):bool + */ + public static function all(Closure $match): Closure + { + return function (array $actual) use ($match): bool { + foreach ($actual as $item) { + if (!$match($item)) { + return false; + } + } + + return true; + }; + } + + /** + * @psalm-return Closure(array):bool + */ + public static function any(Closure $match): Closure + { + return function (array $actual) use ($match): bool { + foreach ($actual as $item) { + if ($match($item)) { + return true; + } + } + + return false; + }; + } + + /** + * @psalm-return Closure(array):bool + */ + public static function none(Closure $match): Closure + { + return function (array $actual) use ($match): bool { + foreach ($actual as $item) { + if ($match($item)) { + return false; + } + } + + return true; + }; + } + + /** + * @psalm-return Closure(array):bool + */ + public static function contains(mixed $expected): Closure + { + return fn (array $actual): bool => in_array($expected, $actual, true); + } + + /** + * @psalm-return Closure(array):bool + */ + public static function keys(array $expectedKeys): Closure + { + return fn (array $actual): bool => count(array_diff($expectedKeys, array_keys($actual))) === 0; + } + + /** + * @psalm-return Closure(array):bool + */ + public static function empty(): Closure + { + return fn (array $actual): bool => empty($actual); + } + + /** + * @psalm-return Closure(array):bool + */ + public static function notEmpty(): Closure + { + return fn (array $actual): bool => !empty($actual); + } + + /** + * @psalm-return Closure(array):bool + */ + public static function indexed(): Closure + { + return fn (array $actual): bool => array_keys($actual) === range(0, count($actual) - 1); + } } diff --git a/src/Args/Date.php b/src/Args/Date.php index 9f2a11d..78cfa41 100644 --- a/src/Args/Date.php +++ b/src/Args/Date.php @@ -20,8 +20,48 @@ public static function before(DateTimeInterface $beforeDate): Closure /** * @psalm-return Closure(DateTimeInterface):bool */ - public static function after(DateTimeInterface $beforeDate): Closure + public static function after(DateTimeInterface $afterDate): Closure { - return fn (DateTimeInterface $date): bool => $date > $beforeDate; + return fn (DateTimeInterface $date): bool => $date > $afterDate; + } + + /** + * @psalm-return Closure(DateTimeInterface):bool + */ + public static function between(DateTimeInterface $start, DateTimeInterface $end): Closure + { + return fn (DateTimeInterface $date): bool => $date >= $start && $date <= $end; + } + + /** + * @psalm-return Closure(DateTimeInterface):bool + */ + public static function equal(DateTimeInterface $target): Closure + { + return fn (DateTimeInterface $date): bool => $date == $target; + } + + /** + * @psalm-return Closure(DateTimeInterface):bool + */ + public static function sameDay(DateTimeInterface $target): Closure + { + return fn (DateTimeInterface $date): bool => $date->format('Y-m-d') === $target->format('Y-m-d'); + } + + /** + * @psalm-return Closure(DateTimeInterface):bool + */ + public static function inPast(): Closure + { + return fn (DateTimeInterface $date): bool => $date < new \DateTimeImmutable('now'); + } + + /** + * @psalm-return Closure(DateTimeInterface):bool + */ + public static function inFuture(): Closure + { + return fn (DateTimeInterface $date): bool => $date > new \DateTimeImmutable('now'); } } diff --git a/src/Args/Number.php b/src/Args/Number.php index 397c257..c8b8fd7 100644 --- a/src/Args/Number.php +++ b/src/Args/Number.php @@ -31,4 +31,52 @@ public static function range(int|float $min, int|float $max): Closure { return fn (int|float $actual): bool => $actual >= $min && $actual <= $max; } + + /** + * @psalm-return Closure(float|int):bool + */ + public static function positive(): Closure + { + return fn (int|float $actual): bool => $actual > 0; + } + + /** + * @psalm-return Closure(float|int):bool + */ + public static function negative(): Closure + { + return fn (int|float $actual): bool => $actual < 0; + } + + /** + * @psalm-return Closure(float|int):bool + */ + public static function even(): Closure + { + return fn (int|float $actual): bool => is_int($actual) && $actual % 2 === 0; + } + + /** + * @psalm-return Closure(float|int):bool + */ + public static function odd(): Closure + { + return fn (int|float $actual): bool => is_int($actual) && $actual % 2 !== 0; + } + + /** + * @psalm-return Closure(int):bool + */ + public static function divisibleBy(int $divisor): Closure + { + return fn (int $actual): bool => $divisor != 0 && $actual % $divisor === 0; + } + + /** + * @psalm-return Closure(float|int):bool + */ + public static function approx(float $target, float $epsilon = 0.00001): Closure + { + return fn (int|float $actual): bool => abs((float) $actual - $target) <= $epsilon; + } } diff --git a/src/Args/Str.php b/src/Args/Str.php index 3a1edc6..39f8c22 100644 --- a/src/Args/Str.php +++ b/src/Args/Str.php @@ -23,4 +23,84 @@ public static function contains(string $needle): Closure { return fn (string $actual): bool => str_contains(strtolower($actual), strtolower($needle)); } + + /** + * @psalm-return Closure(string):bool + */ + public static function startsWith(string $prefix): Closure + { + return fn (string $actual): bool => str_starts_with(strtolower($actual), strtolower($prefix)); + } + + /** + * @psalm-return Closure(string):bool + */ + public static function endsWith(string $suffix): Closure + { + return fn (string $actual): bool => str_ends_with(strtolower($actual), strtolower($suffix)); + } + + /** + * @psalm-return Closure(string):bool + */ + public static function matchesRegex(string $pattern): Closure + { + return fn (string $actual): bool => preg_match($pattern, $actual) === 1; + } + + /** + * @psalm-return Closure(string):bool + */ + public static function minLength(int $min): Closure + { + return fn (string $actual): bool => strlen($actual) >= $min; + } + + /** + * @psalm-return Closure(string):bool + */ + public static function maxLength(int $max): Closure + { + return fn (string $actual): bool => strlen($actual) <= $max; + } + + /** + * @psalm-return Closure(string):bool + */ + public static function alpha(): Closure + { + return fn (string $actual): bool => ctype_alpha($actual); + } + + /** + * @psalm-return Closure(string):bool + */ + public static function alphanumeric(): Closure + { + return fn (string $actual): bool => ctype_alnum($actual); + } + + /** + * @psalm-return Closure(string):bool + */ + public static function lowercase(): Closure + { + return fn (string $actual): bool => $actual === mb_strtolower($actual); + } + + /** + * @psalm-return Closure(string):bool + */ + public static function uppercase(): Closure + { + return fn (string $actual): bool => $actual === mb_strtoupper($actual); + } + + /** + * @psalm-return Closure(string):bool + */ + public static function notEmpty(): Closure + { + return fn (string $actual): bool => $actual !== ''; + } } diff --git a/src/Args/Type.php b/src/Args/Type.php new file mode 100644 index 0000000..c299f24 --- /dev/null +++ b/src/Args/Type.php @@ -0,0 +1,106 @@ + is_string($actual); + } + + /** + * @psalm-return Closure(mixed):bool + */ + public static function int(): Closure + { + return fn (mixed $actual): bool => is_int($actual); + } + + /** + * @psalm-return Closure(mixed):bool + */ + public static function float(): Closure + { + return fn (mixed $actual): bool => is_float($actual); + } + + /** + * @psalm-return Closure(mixed):bool + */ + public static function array(): Closure + { + return fn (mixed $actual): bool => is_array($actual); + } + + /** + * @psalm-return Closure(mixed):bool + */ + public static function resource(): Closure + { + return fn (mixed $actual): bool => is_resource($actual); + } + + /** + * @psalm-return Closure(mixed):bool + */ + public static function object(): Closure + { + return fn (mixed $actual): bool => is_object($actual); + } + + /** + * @psalm-return Closure(mixed):bool + */ + public static function bool(): Closure + { + return fn (mixed $actual): bool => is_bool($actual); + } + + /** + * @psalm-return Closure(mixed):bool + */ + public static function null(): Closure + { + return fn (mixed $actual): bool => $actual === null; + } + + /** + * @psalm-return Closure(mixed):bool + */ + public static function callable(): Closure + { + return fn (mixed $actual): bool => is_callable($actual); + } + + /** + * @psalm-return Closure(mixed):bool + */ + public static function iterable(): Closure + { + return fn (mixed $actual): bool => is_iterable($actual); + } + + /** + * @psalm-return Closure(mixed):bool + */ + public static function scalar(): Closure + { + return fn (mixed $actual): bool => is_scalar($actual); + } + + /** + * @psalm-return Closure(mixed):bool + */ + public static function instanceOf(string $expected): Closure + { + return fn (mixed $actual): bool => is_object($actual) && $actual instanceof $expected; + } +} diff --git a/src/Formatting/Variables.php b/src/Formatting/Variables.php index 555a03c..ac2399e 100644 --- a/src/Formatting/Variables.php +++ b/src/Formatting/Variables.php @@ -44,7 +44,7 @@ private function formatValue(mixed $value): string : (string) $value; } - private function getTypeSignature(ReflectionType|null $type, ?ReflectionClass $declaringClass = null): string + private function getTypeSignature(?ReflectionType $type, ?ReflectionClass $declaringClass = null): string { if ($type === null) { return ''; diff --git a/tests/Args/ArrTest.php b/tests/Args/ArrTest.php deleted file mode 100644 index fb6000e..0000000 --- a/tests/Args/ArrTest.php +++ /dev/null @@ -1,153 +0,0 @@ - 'value', - 'multi' => [ - 'level' => [ - 'array' => 'other-value', - ], - ], - ]); - - static::assertTrue($validator([ - 'key' => 'value', - 'second-key' => 'another-value', - 'multi' => [ - 'level' => [ - 'array' => 'other-value', - ], - 'additional-key' => 'some-value', - ], - ])); - - static::assertFalse($validator([ - 'key' => 'non matching value', - 'second-key' => 'another-value', - 'multi' => [ - 'level' => [ - 'array' => 'other-value', - ], - 'additional-key' => 'some-value', - ], - ])); - - static::assertFalse($validator([ - // 'key' => 'value', item missing - 'second-key' => 'another-value', - 'multi' => [ - 'level' => [ - 'array' => 'other-value', - ], - 'additional-key' => 'some-value', - ], - ])); - - static::assertFalse($validator([ - 'key' => 'value', - 'second-key' => 'another-value', - 'multi' => [ - 'level' => [ - 'array' => 'non matching in multi level array', - ], - 'additional-key' => 'some-value', - ], - ])); - } - - public function test_it_validates_partial_arrays_with_callables(): void - { - $validator = Arr::partial([ - 'key' => fn (string $value) => $value === 'value', - 'multi' => [ - 'level' => [ - 'array' => fn (string $value) => $value === 'other-value', - ], - ], - ]); - - static::assertTrue($validator([ - 'key' => 'value', - 'second-key' => 'another-value', - 'multi' => [ - 'level' => [ - 'array' => 'other-value', - ], - 'additional-key' => 'some-value', - ], - ])); - - static::assertFalse($validator([ - 'key' => 'non matching value', - 'second-key' => 'another-value', - 'multi' => [ - 'level' => [ - 'array' => 'other-value', - ], - 'additional-key' => 'some-value', - ], - ])); - - static::assertFalse($validator([ - 'key' => 'value', - 'second-key' => 'another-value', - 'multi' => [ - 'level' => [ - 'array' => 'non matching in multi level array', - ], - 'additional-key' => 'some-value', - ], - ])); - } - - public function test_it_validates_arrays_with_callable(): void - { - $validator = Arr::partial([ - 'key' => fn (string $value) => $value === 'value', - 'multi' => fn (array $value) => $value === [ - 'level' => [ - 'array' => 'other-value', - ], - ], - ]); - - static::assertTrue($validator([ - 'key' => 'value', - 'second-key' => 'another-value', - 'multi' => [ - 'level' => [ - 'array' => 'other-value', - ], - ], - ])); - - static::assertFalse($validator([ - 'key' => 'value', - 'multi' => [ - 'level' => [ - 'array' => 'other-value', - ], - 'additional-key' => 'some-value', - ], - ])); - } -} diff --git a/tests/Args/DateTest.php b/tests/Args/DateTest.php deleted file mode 100644 index f72238d..0000000 --- a/tests/Args/DateTest.php +++ /dev/null @@ -1,28 +0,0 @@ -called(); } - #[ShowUse(Date::class)] - #[ShowUse(DateTime::class)] - #[Example(null, '`DateTimeInterface` must be before given time')] - #[Test] - public function it_asserts_date_before_helper(): void - { - Mock::method($this->mock->getUsersCreatedBefore(...)) - ->returns([]); - - $this->mock->getUsersCreatedBefore(new DateTime('2024-12-31')); - - Mock::method($this->mock->getUsersCreatedBefore(...)) - ->assert() - ->with(Date::before(new DateTime('2025-01-01 12:00:00'))) - ->called(); - } - - #[ShowUse(Date::class)] - #[ShowUse(DateTime::class)] - #[Example(null, '`DateTimeInterface` must be after given time')] - #[Test] - public function it_asserts_date_after_helper(): void - { - Mock::method($this->mock->getUsersCreatedBefore(...)) - ->returns([]); - - $this->mock->getUsersCreatedBefore(new DateTime('2025-01-02')); - - Mock::method($this->mock->getUsersCreatedBefore(...)) - ->assert() - ->with(Date::after(new DateTime('2025-01-01 12:00:00'))) - ->called(); - } - #[ShowUse(Number::class)] #[Example(null, '`int|float` must be less than given number')] #[Test] @@ -287,52 +253,6 @@ public function it_asserts_number_gt_helper(): void ->called(); } - #[ShowUse(Number::class)] - #[Example(null, '`int|float` must be within range')] - #[Test] - public function it_asserts_number_range_helper(): void - { - Mock::method($this->mock->getUsersByAge(...)) - ->returns([]); - - $this->mock->getUsersByAge(15); - - Mock::method($this->mock->getUsersByAge(...)) - ->assert() - ->with(Number::range(10, 20)) - ->called(); - } - - #[ShowUse(Arr::class)] - #[Example(null, '`array` must have given number of items')] - #[Test] - public function it_asserts_array_count_helper(): void - { - $this->mock->deleteUsersByEmail(['a','b','c']); - - Mock::method($this->mock->deleteUsersByEmail(...)) - ->assert() - ->with(Arr::count(3)) - ->called(); - } - - #[ShowUse(Arr::class)] - #[Example(null, '`array` must be a partial match')] - #[Test] - public function it_asserts_array_partial_helper(): void - { - $this->mock->deleteUsersByEmail([ - 'some-email@example.com', - 'ignore-this@mail.com', - 'another@example.com', - ]); - - Mock::method($this->mock->deleteUsersByEmail(...)) - ->assert() - ->with(Arr::partial([ - 0 => 'some-email@example.com', - 2 => fn ($email) => str_ends_with($email, '@example.com'), - ])) - ->called(); - } + #[Example(null, 'To see more of these helpers, head on over to the [argument validators](./arg-validation.md) documentation.')] + public function redirect_to_arg_validator_doc(): void {} } diff --git a/tests/Documentation/HelperGenerators/ArrayTest.php b/tests/Documentation/HelperGenerators/ArrayTest.php new file mode 100644 index 0000000..d849e3e --- /dev/null +++ b/tests/Documentation/HelperGenerators/ArrayTest.php @@ -0,0 +1,137 @@ +assertTrue($count([1, 2, 3])); + $this->assertFalse($count([1, 2])); + } + + #[Example('Partial match', 'Validate an array has all specified values.')] + #[Test] + public function it_can_assert_partial(): void + { + $partial = Arr::partial(['key' => 'value']); + + $matching = [ + 'key' => 'value', + 'extra-field' => 'extra-value', + ]; + + $missingKey = [ + 'other-key' => 'other-value', + ]; + + $this->assertTrue($partial($matching)); + $this->assertFalse($partial($missingKey)); + } + + #[Example('All', 'Validate that all items in an array match a condition.')] + #[Test] + public function it_validates_all_items(): void + { + $validator = Arr::all(fn (mixed $value) => $value % 2 === 0); + + $this->assertTrue($validator([2, 4, 6])); + $this->assertFalse($validator([2, 4, 6, 7])); + } + + #[Example(null, 'This can also be combined with other helpers.')] + #[Test] + public function it_validates_all_items_with_type(): void + { + $validator = Arr::all(Type::int()); + + $this->assertTrue($validator([2, 4, 6])); + $this->assertFalse($validator([2, '4', 6])); + } + + #[Example('Any', 'Validate that at least one item matches a condition.')] + #[Test] + public function it_can_assert_any(): void + { + $validator = Arr::any(fn (mixed $value) => $value === 3); + + $this->assertTrue($validator([1, 2, 3])); + $this->assertFalse($validator([1, 2, 4])); + } + + #[Example('None', 'Validate that no items match a condition.')] + #[Test] + public function it_can_assert_none(): void + { + $validator = Arr::none(fn (mixed $value) => $value === 5); + + $this->assertTrue($validator([1, 2, 3])); + $this->assertFalse($validator([1, 5, 3])); + } + + #[Example('Contains', 'Validate an array contains a value')] + #[Test] + public function it_can_assert_contains(): void + { + $validator = Arr::contains(2); + + $this->assertTrue($validator([1, 2, 3])); + $this->assertFalse($validator([1, 3, 4])); + } + + #[Example('Keys', 'Validate an array has specific keys')] + #[Test] + public function it_can_assert_keys(): void + { + $validator = Arr::keys(['a', 'b']); + + $this->assertTrue($validator(['a' => 1, 'b' => 2, 'c' => 3])); + $this->assertFalse($validator(['a' => 1, 'c' => 3])); + } + + #[Example('Empty', 'Validate an array is empty')] + #[Test] + public function it_can_assert_empty(): void + { + $validator = Arr::empty(); + + $this->assertTrue($validator([])); + $this->assertFalse($validator([1])); + } + + #[Example('Not empty', 'Validate an array is not empty')] + #[Test] + public function it_can_assert_not_empty(): void + { + $validator = Arr::notEmpty(); + + $this->assertTrue($validator([1])); + $this->assertFalse($validator([])); + } + + #[Example('Indexed', 'Validate an array is sequential indexed')] + #[Test] + public function it_can_assert_indexed(): void + { + $validator = Arr::indexed(); + + $this->assertTrue($validator([1, 2, 3])); + $this->assertFalse($validator(['key-1' => 'a', 'key-2' => 'b'])); + } +} diff --git a/tests/Documentation/HelperGenerators/DateTest.php b/tests/Documentation/HelperGenerators/DateTest.php new file mode 100644 index 0000000..22cb4d1 --- /dev/null +++ b/tests/Documentation/HelperGenerators/DateTest.php @@ -0,0 +1,99 @@ +assertTrue($validator(new \DateTimeImmutable('2024-01-09'))); + $this->assertFalse($validator(new \DateTimeImmutable('2024-01-11'))); + } + + #[Example('After', 'Validate a date is after a given date')] + #[Test] + public function it_validates_after(): void + { + $target = new \DateTimeImmutable('2024-01-10'); + $validator = Date::after($target); + + $this->assertTrue($validator(new \DateTimeImmutable('2024-01-11'))); + $this->assertFalse($validator(new \DateTimeImmutable('2024-01-09'))); + } + + #[Example('Between', 'Validate a date is within a range (inclusive)')] + #[Test] + public function it_validates_between(): void + { + $start = new \DateTimeImmutable('2024-01-10'); + $end = new \DateTimeImmutable('2024-01-20'); + + $validator = Date::between($start, $end); + + $this->assertTrue($validator(new \DateTimeImmutable('2024-01-10'))); + $this->assertTrue($validator(new \DateTimeImmutable('2024-01-15'))); + $this->assertTrue($validator(new \DateTimeImmutable('2024-01-20'))); + $this->assertFalse($validator(new \DateTimeImmutable('2024-01-09'))); + $this->assertFalse($validator(new \DateTimeImmutable('2024-01-21'))); + } + + #[Example('Equal', 'Validate a date equals another date')] + #[Test] + public function it_validates_equal(): void + { + $target = new \DateTimeImmutable('2024-01-10'); + + $validator = Date::equal($target); + + $this->assertTrue($validator(new \DateTimeImmutable('2024-01-10'))); + $this->assertFalse($validator(new \DateTimeImmutable('2024-01-11'))); + } + + #[Example('Same day', 'Validate a date falls on the same calendar day')] + #[Test] + public function it_validates_same_day(): void + { + $target = new \DateTimeImmutable('2024-01-10 10:00:00'); + + $validator = Date::sameDay($target); + + $this->assertTrue($validator(new \DateTimeImmutable('2024-01-10 23:59:59'))); + $this->assertFalse($validator(new \DateTimeImmutable('2024-01-11 00:00:00'))); + } + + #[Example('In past', 'Validate a date is in the past')] + #[Test] + public function it_validates_in_past(): void + { + $validator = Date::inPast(); + + $this->assertTrue($validator(new \DateTimeImmutable('yesterday'))); + $this->assertFalse($validator(new \DateTimeImmutable('tomorrow'))); + } + + #[Example('In future', 'Validate a date is in the future')] + #[Test] + public function it_validates_in_future(): void + { + $validator = Date::inFuture(); + + $this->assertTrue($validator(new \DateTimeImmutable('tomorrow'))); + $this->assertFalse($validator(new \DateTimeImmutable('yesterday'))); + } +} diff --git a/tests/Documentation/HelperGenerators/NumberTest.php b/tests/Documentation/HelperGenerators/NumberTest.php new file mode 100644 index 0000000..d189a42 --- /dev/null +++ b/tests/Documentation/HelperGenerators/NumberTest.php @@ -0,0 +1,115 @@ +assertTrue($validator(5)); + $this->assertFalse($validator(15)); + } + + #[Example('Greater than', 'Validate a number is greater than a given value')] + #[Test] + public function it_validates_gt(): void + { + $validator = Number::gt(10); + + $this->assertTrue($validator(15)); + $this->assertFalse($validator(5)); + } + + #[Example('Range', 'Validate a number is within a range (inclusive)')] + #[Test] + public function it_validates_range(): void + { + $validator = Number::range(10, 20); + + $this->assertTrue($validator(10)); + $this->assertTrue($validator(15)); + $this->assertTrue($validator(20)); + + $this->assertFalse($validator(9)); + $this->assertFalse($validator(21)); + } + + #[Example('Positive', 'Validate a number is positive')] + #[Test] + public function it_validates_positive(): void + { + $validator = Number::positive(); + + $this->assertTrue($validator(1)); + $this->assertFalse($validator(0)); + $this->assertFalse($validator(-1)); + } + + #[Example('Negative', 'Validate a number is negative')] + #[Test] + public function it_validates_negative(): void + { + $validator = Number::negative(); + + $this->assertTrue($validator(-1)); + $this->assertFalse($validator(0)); + $this->assertFalse($validator(1)); + } + + #[Example('Even', 'Validate a number is even integer')] + #[Test] + public function it_validates_even(): void + { + $validator = Number::even(); + + $this->assertTrue($validator(2)); + $this->assertFalse($validator(3)); + $this->assertFalse($validator(2.5)); + } + + #[Example('Odd', 'Validate a number is odd integer')] + #[Test] + public function it_validates_odd(): void + { + $validator = Number::odd(); + + $this->assertTrue($validator(3)); + $this->assertFalse($validator(2)); + $this->assertFalse($validator(3.5)); + } + + #[Example('Divisible by', 'Validate a number is divisible by a given divisor')] + #[Test] + public function it_validates_divisible_by(): void + { + $validator = Number::divisibleBy(3); + + $this->assertTrue($validator(9)); + $this->assertFalse($validator(10)); + } + + #[Example('Approximately', 'Validate a number is approximately equal within epsilon')] + #[Test] + public function it_validates_approx(): void + { + $validator = Number::approx(10.0, 0.01); + + $this->assertTrue($validator(10.005)); + $this->assertFalse($validator(10.1)); + } +} diff --git a/tests/Documentation/HelperGenerators/StrTest.php b/tests/Documentation/HelperGenerators/StrTest.php new file mode 100644 index 0000000..df7cf5d --- /dev/null +++ b/tests/Documentation/HelperGenerators/StrTest.php @@ -0,0 +1,138 @@ +assertTrue($validator('hello')); + $this->assertFalse($validator('hell')); + } + + #[Example('Contains', 'Validate substring presence (case-insensitive)')] + #[Test] + public function it_validates_contains(): void + { + $validator = Str::contains('world'); + + $this->assertTrue($validator('hello world')); + $this->assertTrue($validator('hello WoRLd')); + $this->assertFalse($validator('hello there')); + } + + #[Example('Starts With', 'Validate string prefix (case-insensitive)')] + #[Test] + public function it_validates_starts_with(): void + { + $validator = Str::startsWith('hel'); + + $this->assertTrue($validator('hello')); + $this->assertFalse($validator('world')); + } + + #[Example('Ends With', 'Validate string suffix (case-insensitive)')] + #[Test] + public function it_validates_ends_with(): void + { + $validator = Str::endsWith('lo'); + + $this->assertTrue($validator('hello')); + $this->assertFalse($validator('world')); + } + + #[Example('Regex', 'Validate string against regex pattern')] + #[Test] + public function it_validates_regex(): void + { + $validator = Str::matchesRegex('/^[a-z]+$/'); + + $this->assertTrue($validator('hello')); + $this->assertFalse($validator('hello123')); + } + + #[Example('Min Length', 'Validate minimum string length')] + #[Test] + public function it_validates_min_length(): void + { + $validator = Str::minLength(3); + + $this->assertTrue($validator('abcd')); + $this->assertFalse($validator('ab')); + } + + #[Example('Max Length', 'Validate maximum string length')] + #[Test] + public function it_validates_max_length(): void + { + $validator = Str::maxLength(3); + + $this->assertTrue($validator('ab')); + $this->assertFalse($validator('abcd')); + } + + #[Example('Alpha', 'Validate string contains alphabetical characters only')] + #[Test] + public function it_validates_alpha(): void + { + $validator = Str::alpha(); + + $this->assertTrue($validator('abc')); + $this->assertFalse($validator('abc123')); + } + + #[Example('Alphanumeric', 'Validate string contains alphaniumeric characters only')] + #[Test] + public function it_validates_alphanumeric(): void + { + $validator = Str::alphanumeric(); + + $this->assertTrue($validator('abc123')); + $this->assertFalse($validator('abc-123')); + } + + #[Example('Lowercase', 'Validate string is fully lowercase')] + #[Test] + public function it_validates_lowercase(): void + { + $validator = Str::lowercase(); + + $this->assertTrue($validator('hello')); + $this->assertFalse($validator('Hello')); + } + + #[Example('Uppercase', 'Validate string is fully uppercase')] + #[Test] + public function it_validates_uppercase(): void + { + $validator = Str::uppercase(); + + $this->assertTrue($validator('HELLO')); + $this->assertFalse($validator('Hello')); + } + + #[Example('Not Empty', 'Validate string is not empty')] + #[Test] + public function it_validates_not_empty(): void + { + $validator = Str::notEmpty(); + + $this->assertTrue($validator('a')); + $this->assertFalse($validator('')); + } +} diff --git a/tests/Documentation/HelperGenerators/TypeTest.php b/tests/Documentation/HelperGenerators/TypeTest.php new file mode 100644 index 0000000..d3522fb --- /dev/null +++ b/tests/Documentation/HelperGenerators/TypeTest.php @@ -0,0 +1,146 @@ +assertTrue($validator('hello')); + $this->assertFalse($validator(123)); + } + + #[Example('Integer', 'Validate that a value is an integer')] + #[Test] + public function it_validates_int(): void + { + $validator = Type::int(); + + $this->assertTrue($validator(123)); + $this->assertFalse($validator('123')); + } + + #[Example('Float', 'Validate that a value is a float')] + #[Test] + public function it_validates_float(): void + { + $validator = Type::float(); + + $this->assertTrue($validator(1.23)); + $this->assertFalse($validator(123)); + } + + #[Example('Array', 'Validate that a value is an array')] + #[Test] + public function it_validates_array(): void + { + $validator = Type::array(); + + $this->assertTrue($validator(['a'])); + $this->assertFalse($validator('a')); + } + + #[Example('Resource', 'Validate that a value is a resource')] + #[Test] + public function it_validates_resource(): void + { + $validator = Type::resource(); + + $resource = fopen('php://memory', 'r'); + + $this->assertTrue($validator($resource)); + $this->assertFalse($validator('not a resource')); + + fclose($resource); + } + + #[Example('Object', 'Validate that a value is an object')] + #[Test] + public function it_validates_object(): void + { + $validator = Type::object(); + + $this->assertTrue($validator(new \stdClass())); + $this->assertFalse($validator('not an object')); + } + + #[Example('Boolean', 'Validate that a value is a boolean')] + #[Test] + public function it_validates_bool(): void + { + $validator = Type::bool(); + + $this->assertTrue($validator(true)); + $this->assertFalse($validator(1)); + } + + #[Example('Null', 'Validate that a value is null')] + #[Test] + public function it_validates_null(): void + { + $validator = Type::null(); + + $this->assertTrue($validator(null)); + $this->assertFalse($validator(false)); + } + + #[Example('Callable', 'Validate that a value is callable')] + #[Test] + public function it_validates_callable(): void + { + $validator = Type::callable(); + + $this->assertTrue($validator(fn () => null)); + $this->assertFalse($validator('not a callable')); + } + + #[Example('Iterable', 'Validate that a value is iterable')] + #[Test] + public function it_validates_iterable(): void + { + $validator = Type::iterable(); + + $this->assertTrue($validator([1, 2, 3])); + $this->assertFalse($validator(123)); + } + + #[Example('Scalar', 'Validate that a value is scalar')] + #[Test] + public function it_validates_scalar(): void + { + $validator = Type::scalar(); + + $this->assertTrue($validator('string')); + $this->assertFalse($validator([1, 2, 3])); + } + + #[Example('Instance of', 'Validate that a value is instance of a class')] + #[Test] + public function it_validates_instance_of(): void + { + $validator = Type::instanceOf(Exception::class); + + $this->assertTrue($validator(new Exception())); + $this->assertTrue($validator(new RuntimeException())); + + $this->assertFalse($validator(new DateTime())); + } +}