From f3371e51fe3c5880a49e80709060adc180b8f2fa Mon Sep 17 00:00:00 2001 From: Fabio Ivona Date: Thu, 18 Nov 2021 01:01:56 +0100 Subject: [PATCH] wip toward lvl9 --- src/Exceptions/ExpectationException.php | 11 + src/Expectation.php | 753 ++++++++++++++++++++++-- src/Functions.php | 5 +- src/HigherOrderExpectation.php | 5 +- src/Plugins/Coverage.php | 5 +- src/Support/Container.php | 8 +- src/Support/ExceptionTrace.php | 2 + src/Support/HigherOrderCallables.php | 5 +- 8 files changed, 726 insertions(+), 68 deletions(-) create mode 100644 src/Exceptions/ExpectationException.php diff --git a/src/Exceptions/ExpectationException.php b/src/Exceptions/ExpectationException.php new file mode 100644 index 00000000..dfe63bb9 --- /dev/null +++ b/src/Exceptions/ExpectationException.php @@ -0,0 +1,11 @@ +coreExpectation = new CoreExpectation($value); + public function __construct( + public mixed $value + ) { + // .. } /** @@ -60,13 +69,17 @@ final class Expectation */ public function json(): Expectation { + if (!is_string($this->value)) { + throw ExpectationException::invalidValue('json', 'string'); + } + return $this->toBeJson()->and(json_decode($this->value, true)); } /** * Dump the expectation value and end the script. * - * @phpstan-return never + * @return never */ public function dd(mixed ...$arguments): void { @@ -122,7 +135,7 @@ final class Expectation * * @template TSequenceValue * - * @phpstan-param (callable(self, self): void)|TSequenceValue ...$callbacks + * @param (callable(self, self): void)|TSequenceValue ...$callbacks */ public function sequence(mixed ...$callbacks): Expectation { @@ -172,7 +185,7 @@ final class Expectation ? $subject : fn () => $subject; - $subject = $subject(); + $subject = $subject(); $matched = false; @@ -203,8 +216,8 @@ final class Expectation /** * Apply the callback if the given "condition" is falsy. * - * @phpstan-param (callable(): bool)|bool $condition - * @phpstan-param callable(Expectation): mixed $callback + * @param (callable(): bool)|bool $condition + * @param callable(Expectation): mixed $callback */ public function unless(callable|bool $condition, callable $callback): Expectation { @@ -220,8 +233,8 @@ final class Expectation /** * Apply the callback if the given "condition" is truthy. * - * @phpstan-param (callable(): bool)|bool $condition - * @phpstan-param callable(Expectation): mixed $callback + * @param (callable(): bool)|bool $condition + * @param callable(Expectation): mixed $callback */ public function when(callable|bool $condition, callable $callback): Expectation { @@ -239,58 +252,685 @@ final class Expectation } /** - * Dynamically handle calls to the class or - * creates a new higher order expectation. - * - * @phpstan-param array $parameters - * - * @return HigherOrderExpectation|Expectation + * Asserts that two variables have the same type and + * value. Used on objects, it asserts that two + * variables reference the same object. */ - public function __call(string $method, array $parameters) + public function toBe(mixed $expected): Expectation { - if (!$this->hasExpectation($method)) { - /* @phpstan-ignore-next-line */ - return new HigherOrderExpectation($this, $this->value->$method(...$parameters)); - } - - ExpectationPipeline::for($method, $this->getExpectationClosure($method)) - ->send(...$parameters) - ->through($this->pipes($method, $this, Expectation::class)) - ->run(); + Assert::assertSame($expected, $this->value); return $this; } - private function getExpectationClosure(string $name): Closure + /** + * Asserts that the value is empty. + */ + public function toBeEmpty(): Expectation { - if (method_exists($this->coreExpectation, $name)) { - //@phpstan-ignore-next-line - return Closure::fromCallable([$this->coreExpectation, $name]); - } + Assert::assertEmpty($this->value); - if (self::hasExtend($name)) { - $extend = self::$extends[$name]->bindTo($this, Expectation::class); + return $this; + } - if ($extend != false) { - return $extend; + /** + * Asserts that the value is true. + */ + public function toBeTrue(): Expectation + { + Assert::assertTrue($this->value); + + return $this; + } + + /** + * Asserts that the value is truthy. + */ + public function toBeTruthy(): Expectation + { + Assert::assertTrue((bool) $this->value); + + return $this; + } + + /** + * Asserts that the value is false. + */ + public function toBeFalse(): Expectation + { + Assert::assertFalse($this->value); + + return $this; + } + + /** + * Asserts that the value is falsy. + */ + public function toBeFalsy(): Expectation + { + Assert::assertFalse((bool) $this->value); + + return $this; + } + + /** + * Asserts that the value is greater than $expected. + */ + public function toBeGreaterThan(int|float $expected): Expectation + { + Assert::assertGreaterThan($expected, $this->value); + + return $this; + } + + /** + * Asserts that the value is greater than or equal to $expected. + */ + public function toBeGreaterThanOrEqual(int|float $expected): Expectation + { + Assert::assertGreaterThanOrEqual($expected, $this->value); + + return $this; + } + + /** + * Asserts that the value is less than or equal to $expected. + */ + public function toBeLessThan(int|float $expected): Expectation + { + Assert::assertLessThan($expected, $this->value); + + return $this; + } + + /** + * Asserts that the value is less than $expected. + */ + public function toBeLessThanOrEqual(int|float $expected): Expectation + { + Assert::assertLessThanOrEqual($expected, $this->value); + + return $this; + } + + /** + * Asserts that $needle is an element of the value. + */ + public function toContain(mixed ...$needles): Expectation + { + foreach ($needles as $needle) { + if (is_string($this->value)) { + Assert::assertStringContainsString($needle, $this->value); + } else { + Assert::assertContains($needle, $this->value); } } - throw PipeException::expectationNotFound($name); + return $this; } - - private function hasExpectation(string $name): bool + /** + * Asserts that the value starts with $expected. + * + * @param non-empty-string $expected + */ + public function toStartWith(string $expected): Expectation { - if (method_exists($this->coreExpectation, $name)) { - return true; + Assert::assertStringStartsWith($expected, $this->value); + + return $this; + } + + /** + * Asserts that the value ends with $expected. + * + * @param non-empty-string $expected + */ + public function toEndWith(string $expected): Expectation + { + Assert::assertStringEndsWith($expected, $this->value); + + return $this; + } + + /** + * Asserts that $number matches value's Length. + */ + public function toHaveLength(int $number): Expectation + { + if (is_string($this->value)) { + Assert::assertEquals($number, mb_strlen($this->value)); + + return $this; } - if (self::hasExtend($name)) { - return true; + if (is_iterable($this->value)) { + return $this->toHaveCount($number); } - return false; + if (is_object($this->value)) { + if (method_exists($this->value, 'toArray')) { + $array = $this->value->toArray(); + } else { + $array = (array) $this->value; + } + + Assert::assertCount($number, $array); + + return $this; + } + + throw new BadMethodCallException('Expectation value length is not countable.'); + } + + /** + * Asserts that $count matches the number of elements of the value. + */ + public function toHaveCount(int $count): Expectation + { + Assert::assertCount($count, $this->value); + + return $this; + } + + /** + * Asserts that the value contains the property $name. + */ + public function toHaveProperty(string $name, mixed $value = null): Expectation + { + $this->toBeObject(); + + Assert::assertTrue(property_exists($this->value, $name)); + + if (func_num_args() > 1) { + /* @phpstan-ignore-next-line */ + Assert::assertEquals($value, $this->value->{$name}); + } + + return $this; + } + + /** + * Asserts that the value contains the provided properties $names. + * + * @param iterable $names + */ + public function toHaveProperties(iterable $names): Expectation + { + foreach ($names as $name) { + $this->toHaveProperty($name); + } + + return $this; + } + + /** + * Asserts that two variables have the same value. + */ + public function toEqual(mixed $expected): Expectation + { + Assert::assertEquals($expected, $this->value); + + return $this; + } + + /** + * Asserts that two variables have the same value. + * The contents of $expected and the $this->value are + * canonicalized before they are compared. For instance, when the two + * variables $expected and $this->value are arrays, then these arrays + * are sorted before they are compared. When $expected and $this->value + * are objects, each object is converted to an array containing all + * private, protected and public attributes. + */ + public function toEqualCanonicalizing(mixed $expected): Expectation + { + Assert::assertEqualsCanonicalizing($expected, $this->value); + + return $this; + } + + /** + * Asserts that the absolute difference between the value and $expected + * is lower than $delta. + */ + public function toEqualWithDelta(mixed $expected, float $delta): Expectation + { + Assert::assertEqualsWithDelta($expected, $this->value, $delta); + + return $this; + } + + /** + * Asserts that the value is one of the given values. + * + * @param iterable $values + */ + public function toBeIn(iterable $values): Expectation + { + Assert::assertContains($this->value, $values); + + return $this; + } + + /** + * Asserts that the value is infinite. + */ + public function toBeInfinite(): Expectation + { + Assert::assertInfinite($this->value); + + return $this; + } + + /** + * Asserts that the value is an instance of $class. + * + * @param class-string $class + */ + public function toBeInstanceOf(string $class): Expectation + { + Assert::assertInstanceOf($class, $this->value); + + return $this; + } + + /** + * Asserts that the value is an array. + */ + public function toBeArray(): Expectation + { + Assert::assertIsArray($this->value); + + return $this; + } + + /** + * Asserts that the value is of type bool. + */ + public function toBeBool(): Expectation + { + Assert::assertIsBool($this->value); + + return $this; + } + + /** + * Asserts that the value is of type callable. + */ + public function toBeCallable(): Expectation + { + Assert::assertIsCallable($this->value); + + return $this; + } + + /** + * Asserts that the value is of type float. + */ + public function toBeFloat(): Expectation + { + Assert::assertIsFloat($this->value); + + return $this; + } + + /** + * Asserts that the value is of type int. + */ + public function toBeInt(): Expectation + { + Assert::assertIsInt($this->value); + + return $this; + } + + /** + * Asserts that the value is of type iterable. + */ + public function toBeIterable(): Expectation + { + Assert::assertIsIterable($this->value); + + return $this; + } + + /** + * Asserts that the value is of type numeric. + */ + public function toBeNumeric(): Expectation + { + Assert::assertIsNumeric($this->value); + + return $this; + } + + /** + * Asserts that the value is of type object. + */ + public function toBeObject(): Expectation + { + Assert::assertIsObject($this->value); + + return $this; + } + + /** + * Asserts that the value is of type resource. + */ + public function toBeResource(): Expectation + { + Assert::assertIsResource($this->value); + + return $this; + } + + /** + * Asserts that the value is of type scalar. + */ + public function toBeScalar(): Expectation + { + Assert::assertIsScalar($this->value); + + return $this; + } + + /** + * Asserts that the value is of type string. + */ + public function toBeString(): Expectation + { + Assert::assertIsString($this->value); + + return $this; + } + + /** + * Asserts that the value is a JSON string. + */ + public function toBeJson(): Expectation + { + Assert::assertIsString($this->value); + Assert::assertJson($this->value); + + return $this; + } + + /** + * Asserts that the value is NAN. + */ + public function toBeNan(): Expectation + { + Assert::assertNan($this->value); + + return $this; + } + + /** + * Asserts that the value is null. + */ + public function toBeNull(): Expectation + { + Assert::assertNull($this->value); + + return $this; + } + + /** + * Asserts that the value array has the provided $key. + */ + public function toHaveKey(string|int $key, mixed $value = null): Expectation + { + if (is_object($this->value) && method_exists($this->value, 'toArray')) { + $array = $this->value->toArray(); + } else { + $array = (array) $this->value; + } + + try { + Assert::assertTrue(Arr::has($array, $key)); + + /* @phpstan-ignore-next-line */ + } catch (ExpectationFailedException $exception) { + throw new ExpectationFailedException("Failed asserting that an array has the key '$key'", $exception->getComparisonFailure()); + } + + if (func_num_args() > 1) { + Assert::assertEquals($value, Arr::get($array, $key)); + } + + return $this; + } + + /** + * Asserts that the value array has the provided $keys. + * + * @param array $keys + */ + public function toHaveKeys(array $keys): Expectation + { + foreach ($keys as $key) { + $this->toHaveKey($key); + } + + return $this; + } + + /** + * Asserts that the value is a directory. + */ + public function toBeDirectory(): Expectation + { + Assert::assertDirectoryExists($this->value); + + return $this; + } + + /** + * Asserts that the value is a directory and is readable. + */ + public function toBeReadableDirectory(): Expectation + { + Assert::assertDirectoryIsReadable($this->value); + + return $this; + } + + /** + * Asserts that the value is a directory and is writable. + */ + public function toBeWritableDirectory(): Expectation + { + Assert::assertDirectoryIsWritable($this->value); + + return $this; + } + + /** + * Asserts that the value is a file. + */ + public function toBeFile(): Expectation + { + Assert::assertFileExists($this->value); + + return $this; + } + + /** + * Asserts that the value is a file and is readable. + */ + public function toBeReadableFile(): Expectation + { + Assert::assertFileIsReadable($this->value); + + return $this; + } + + /** + * Asserts that the value is a file and is writable. + */ + public function toBeWritableFile(): Expectation + { + Assert::assertFileIsWritable($this->value); + + return $this; + } + + /** + * Asserts that the value array matches the given array subset. + * + * @param iterable $array + */ + public function toMatchArray(iterable|object $array): Expectation + { + if (is_object($this->value) && method_exists($this->value, 'toArray')) { + $valueAsArray = $this->value->toArray(); + } else { + $valueAsArray = (array) $this->value; + } + + foreach ($array as $key => $value) { + Assert::assertArrayHasKey($key, $valueAsArray); + + Assert::assertEquals( + $value, + $valueAsArray[$key], + sprintf( + 'Failed asserting that an array has a key %s with the value %s.', + $this->export($key), + $this->export($valueAsArray[$key]), + ), + ); + } + + return $this; + } + + /** + * Asserts that the value object matches a subset + * of the properties of an given object. + * + * @param iterable|object $object + */ + public function toMatchObject(iterable|object $object): Expectation + { + foreach ((array) $object as $property => $value) { + Assert::assertTrue(property_exists($this->value, $property)); + + /* @phpstan-ignore-next-line */ + $propertyValue = $this->value->{$property}; + Assert::assertEquals( + $value, + $propertyValue, + sprintf( + 'Failed asserting that an object has a property %s with the value %s.', + $this->export($property), + $this->export($propertyValue), + ), + ); + } + + return $this; + } + + /** + * Asserts that the value matches a regular expression. + */ + public function toMatch(string $expression): Expectation + { + Assert::assertMatchesRegularExpression($expression, $this->value); + + return $this; + } + + /** + * Asserts that the value matches a constraint. + */ + public function toMatchConstraint(Constraint $constraint): Expectation + { + Assert::assertThat($this->value, $constraint); + + return $this; + } + + /** + * Asserts that executing value throws an exception. + * + * @param (Closure(Throwable): mixed)|string $exception + */ + public function toThrow(callable|string $exception, string $exceptionMessage = null): Expectation + { + $callback = NullClosure::create(); + + if ($exception instanceof Closure) { + $callback = $exception; + $parameters = (new ReflectionFunction($exception))->getParameters(); + + if (1 !== count($parameters)) { + throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.'); + } + + if (!($type = $parameters[0]->getType()) instanceof ReflectionNamedType) { + throw new InvalidArgumentException('The given closure\'s parameter must be type-hinted as the class string.'); + } + + $exception = $type->getName(); + } + + try { + ($this->value)(); + } catch (Throwable $e) { // @phpstan-ignore-line + if (!class_exists($exception)) { + Assert::assertStringContainsString($exception, $e->getMessage()); + + return $this; + } + + if ($exceptionMessage !== null) { + Assert::assertStringContainsString($exceptionMessage, $e->getMessage()); + } + + Assert::assertInstanceOf($exception, $e); + $callback($e); + + return $this; + } + + if (!class_exists($exception)) { + throw new ExpectationFailedException("Exception with message \"{$exception}\" not thrown."); + } + + throw new ExpectationFailedException("Exception \"{$exception}\" not thrown."); + } + + /** + * Exports the given value. + */ + private function export(mixed $value): string + { + if ($this->exporter === null) { + $this->exporter = new Exporter(); + } + + return $this->exporter->export($value); + } + + /** + * Dynamically handle calls to the class or + * creates a new higher order expectation. + * + * @param array $parameters + * + * @return HigherOrderExpectation|mixed + */ + public function __call(string $method, array $parameters) + { + if (!static::hasExtend($method)) { + /* @phpstan-ignore-next-line */ + return new HigherOrderExpectation($this, $this->value->$method(...$parameters)); + } + + return $this->__extendsCall($method, $parameters); } /** @@ -299,20 +939,11 @@ final class Expectation */ public function __get(string $name): Expectation|OppositeExpectation|Each|HigherOrderExpectation { - if ($name === 'value') { - return $this->coreExpectation->value; - } - - if (!method_exists($this, $name) && !method_exists($this->coreExpectation, $name) && !self::hasExtend($name)) { + if (!method_exists($this, $name) && !static::hasExtend($name)) { return new HigherOrderExpectation($this, $this->retrieve($name, $this->value)); } /* @phpstan-ignore-next-line */ return $this->{$name}(); } - - public static function hasMethod(string $name): bool - { - return method_exists(CoreExpectation::class, $name); - } } diff --git a/src/Functions.php b/src/Functions.php index ddc6f3af..0f72cee7 100644 --- a/src/Functions.php +++ b/src/Functions.php @@ -113,7 +113,10 @@ if (!function_exists('it')) { { $description = sprintf('it %s', $description); - return test($description, $closure); + /** @var TestCall $test */ + $test = test($description, $closure); + + return $test; } } diff --git a/src/HigherOrderExpectation.php b/src/HigherOrderExpectation.php index dc8b2d6d..7abd9d1d 100644 --- a/src/HigherOrderExpectation.php +++ b/src/HigherOrderExpectation.php @@ -80,7 +80,10 @@ final class HigherOrderExpectation } if (!$this->expectationHasMethod($name)) { - return new self($this->original, $this->retrieve($name, $this->getValue())); + /** @var array|object $value */ + $value = $this->getValue(); + + return new self($this->original, $this->retrieve($name, $value)); } return $this->performAssertion($name, []); diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index 8febde52..f2ee5457 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -80,7 +80,10 @@ final class Coverage implements AddsOutput, HandlesArguments } if ($input->getOption(self::MIN_OPTION) !== null) { - $this->coverageMin = (float) $input->getOption(self::MIN_OPTION); + /** @var int|float $min_option */ + $min_option = $input->getOption(self::MIN_OPTION); + + $this->coverageMin = (float) $min_option; } return $originals; diff --git a/src/Support/Container.php b/src/Support/Container.php index b87fdd65..6f2afc13 100644 --- a/src/Support/Container.php +++ b/src/Support/Container.php @@ -41,11 +41,13 @@ final class Container */ public function get(string $id) { - if (array_key_exists($id, $this->instances)) { - return $this->instances[$id]; + if (!array_key_exists($id, $this->instances)) { + $this->instances[$id] = $this->build($id); } - $this->instances[$id] = $this->build($id); + if (!is_object($this->instances[$id])) { + throw ShouldNotHappen::fromMessage('Cannot resolve a non-object from container'); + } return $this->instances[$id]; } diff --git a/src/Support/ExceptionTrace.php b/src/Support/ExceptionTrace.php index ec17afc8..17fc2e40 100644 --- a/src/Support/ExceptionTrace.php +++ b/src/Support/ExceptionTrace.php @@ -50,6 +50,8 @@ final class ExceptionTrace $property = new ReflectionProperty($t, 'serializableTrace'); $property->setAccessible(true); + + /** @var array> $trace */ $trace = $property->getValue($t); $cleanedTrace = []; diff --git a/src/Support/HigherOrderCallables.php b/src/Support/HigherOrderCallables.php index 1637ccb2..be02b86d 100644 --- a/src/Support/HigherOrderCallables.php +++ b/src/Support/HigherOrderCallables.php @@ -31,7 +31,10 @@ final class HigherOrderCallables */ public function expect(mixed $value): Expectation { - return new Expectation($value instanceof Closure ? Reflection::bindCallableWithData($value) : $value); + /** @var TValue $value */ + $value = $value instanceof Closure ? Reflection::bindCallableWithData($value) : $value; + + return new Expectation($value); } /**