From 669546713b22875c7f26bc508b2b02833db954eb Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Tue, 25 Mar 2025 20:13:09 +0100 Subject: [PATCH 01/78] Add Test wrapper --- README.md | 64 +++++++++++++++++++ src/AbstractClassHelper.php | 72 +++++++++++++++++++++ src/FileIterator.php | 1 + src/TestCase.php | 6 ++ src/TestMocker.php | 109 +++++++++++++++++++++++++++++++ src/TestUnit.php | 2 +- src/TestWrapper.php | 124 ++++++++++++++++++++++++++++++++++++ src/Unit.php | 12 +--- 8 files changed, 379 insertions(+), 11 deletions(-) create mode 100644 src/AbstractClassHelper.php create mode 100755 src/TestMocker.php create mode 100755 src/TestWrapper.php diff --git a/README.md b/README.md index 2c6b2ea..2400ae2 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,70 @@ php vendor/bin/unitary With that, you are ready to create your own tests! + +## Integration tests: Test Wrapper +The TestWrapper allows you to wrap an existing class, override its methods, and inject dependencies dynamically. +It is useful for integration testing, debugging, and extending existing functionality without the need of +modifying the original class. + +### The problem +Imagine we have a PaymentProcessor class that communicates with an external payment gateway to +capture a customer's payment. We would like to test this with its own functionallity to keep the test useful +but avoid making any charges to customer. +```php +class PaymentProcessor +{ + public function __construct( + private OrderService $orderService, + private PaymentGateway $gateway, + private Logger $logger + ) {} + + public function capture(string $orderID) + { + $order = $this->orderService->getOrder($orderID); + + if (!$order) { + throw new Exception("Order not found: $orderID"); + } + + $this->logger->info("Capturing payment for Order ID: " . $order->id); + + $response = $this->gateway->capture($order->id); + + if ($response['status'] !== 'success') { + throw new Exception("Payment capture failed: " . $response['message']); + } + + return "Transaction ID: " . $response['transaction_id']; + } +} + +``` + +### Use the Test Wrapper +Use wrapper()->bind() to Mock API Calls but Keep Business Logic + +With TestWrapper, we can simulate an order and intercept the payment capture while keeping access to $this inside the closure. + +```php +$dispatch = $this->wrapper(PaymentProcessor::class)->bind(function ($orderID) use ($inst) { + // Simulate order retrieval + $order = $this->orderService->getOrder($orderID); + $response = $inst->mock('gatewayCapture')->capture($order->id); + if ($response['status'] !== 'success') { + // Log action within the PaymentProcessor instance + $this->logger->info("Mocked: Capturing payment for Order ID: " . $order->id ?? 0); + // Has successfully found order and logged message + return true; + } + // Failed to find order + return false; +}); +``` + + + ## Configurations ### Select a Test File to Run diff --git a/src/AbstractClassHelper.php b/src/AbstractClassHelper.php new file mode 100644 index 0000000..a676b60 --- /dev/null +++ b/src/AbstractClassHelper.php @@ -0,0 +1,72 @@ +reflectionPool = new Reflection($className); + $this->reflection = $this->reflection->getReflect(); + //$this->constructor = $this->reflection->getConstructor(); + //$reflectParam = ($this->constructor) ? $this->constructor->getParameters() : []; + if (count($classArgs) > 0) { + $this->instance = $this->reflection->newInstanceArgs($classArgs); + } + } + + public function inspectMethod(string $method): array + { + if (!$this->reflection || !$this->reflection->hasMethod($method)) { + throw new Exception("Method '$method' does not exist."); + } + + $methodReflection = $this->reflection->getMethod($method); + $parameters = []; + foreach ($methodReflection->getParameters() as $param) { + $paramType = $param->hasType() ? $param->getType()->getName() : 'mixed'; + $parameters[] = [ + 'name' => $param->getName(), + 'type' => $paramType, + 'is_optional' => $param->isOptional(), + 'default_value' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null + ]; + } + + return [ + 'name' => $methodReflection->getName(), + 'visibility' => implode(' ', \Reflection::getModifierNames($methodReflection->getModifiers())), + 'is_static' => $methodReflection->isStatic(), + 'return_type' => $methodReflection->hasReturnType() ? $methodReflection->getReturnType()->getName() : 'mixed', + 'parameters' => $parameters + ]; + } + + /** + * Will create the main instance with dependency injection support + * + * @param string $className + * @param array $args + * @return mixed|object + * @throws \ReflectionException + */ + final protected function createInstance(string $className, array $args) + { + if(count($args) === 0) { + return $this->reflection->dependencyInjector(); + } + return new $className(...$args); + } +} \ No newline at end of file diff --git a/src/FileIterator.php b/src/FileIterator.php index d0aaf9c..48e4af4 100755 --- a/src/FileIterator.php +++ b/src/FileIterator.php @@ -150,6 +150,7 @@ private function requireUnitFile(string $file): ?Closure $cli->enableTraceLines(true); } $run = new Run($cli); + $run->setExitCode(1); $run->load(); //ob_start(); diff --git a/src/TestCase.php b/src/TestCase.php index 66a79c8..e1c801f 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -76,6 +76,12 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = return $this; } + public function wrapper($className): TestWrapper + { + return new class($className) extends TestWrapper { + }; + } + /** * Get failed test counts diff --git a/src/TestMocker.php b/src/TestMocker.php new file mode 100755 index 0000000..b76350b --- /dev/null +++ b/src/TestMocker.php @@ -0,0 +1,109 @@ +instance = $this->createInstance($className, $args); + } + + /** + * Will bind Closure to class instance and directly return the Closure + * + * @param Closure $call + * @return Closure + */ + public function bind(Closure $call): Closure + { + return $call->bindTo($this->instance); + } + + /** + * Overrides a method in the instance + * + * @param string $method + * @param Closure $call + * @return $this + */ + public function override(string $method, Closure $call): self + { + if( !method_exists($this->instance, $method)) { + throw new \BadMethodCallException( + "Method '$method' does not exist in the class '" . get_class($this->instance) . + "' and therefore cannot be overridden or called." + ); + } + $call = $call->bindTo($this->instance); + $this->methods[$method] = $call; + return $this; + } + + /** + * Add a method to the instance, allowing it to be called as if it were a real method. + * + * @param string $method + * @param Closure $call + * @return $this + */ + public function add(string $method, Closure $call): self + { + if(method_exists($this->instance, $method)) { + throw new \BadMethodCallException( + "Method '$method' already exists in the class '" . get_class($this->instance) . + "'. Use the 'override' method in TestWrapper instead." + ); + } + $call = $call->bindTo($this->instance); + $this->methods[$method] = $call; + return $this; + } + + /** + * Proxies calls to the wrapped instance or bound methods. + * + * @param string $name + * @param array $arguments + * @return mixed + * @throws Exception + */ + public function __call(string $name, array $arguments): mixed + { + if (isset($this->methods[$name])) { + return $this->methods[$name](...$arguments); + } + + if (method_exists($this->instance, $name)) { + return call_user_func_array([$this->instance, $name], $arguments); + } + throw new Exception("Method $name does not exist."); + } + + +} \ No newline at end of file diff --git a/src/TestUnit.php b/src/TestUnit.php index 70f0a3c..3190c2f 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -137,7 +137,7 @@ public function getReadValue(): string final protected function excerpt(string $value): string { $format = new Str($value); - return (string)$format->excerpt(42)->get(); + return (string)$format->excerpt(70)->get(); } } diff --git a/src/TestWrapper.php b/src/TestWrapper.php new file mode 100755 index 0000000..c325afd --- /dev/null +++ b/src/TestWrapper.php @@ -0,0 +1,124 @@ +instance = $this->createInstance($className, $args); + } + + /** + * Will bind Closure to class instance and directly return the Closure + * + * @param Closure $call + * @return Closure + */ + public function bind(Closure $call): Closure + { + return $call->bindTo($this->instance); + } + + /** + * Overrides a method in the instance + * + * @param string $method + * @param Closure $call + * @return $this + */ + public function override(string $method, Closure $call): self + { + if( !method_exists($this->instance, $method)) { + throw new \BadMethodCallException( + "Method '$method' does not exist in the class '" . get_class($this->instance) . + "' and therefore cannot be overridden or called." + ); + } + $call = $call->bindTo($this->instance); + $this->methods[$method] = $call; + return $this; + } + + /** + * Add a method to the instance, allowing it to be called as if it were a real method. + * + * @param string $method + * @param Closure $call + * @return $this + */ + public function add(string $method, Closure $call): self + { + if(method_exists($this->instance, $method)) { + throw new \BadMethodCallException( + "Method '$method' already exists in the class '" . get_class($this->instance) . + "'. Use the 'override' method in TestWrapper instead." + ); + } + $call = $call->bindTo($this->instance); + $this->methods[$method] = $call; + return $this; + } + + /** + * Proxies calls to the wrapped instance or bound methods. + * + * @param string $name + * @param array $arguments + * @return mixed + * @throws Exception + */ + public function __call(string $name, array $arguments): mixed + { + if (isset($this->methods[$name])) { + return $this->methods[$name](...$arguments); + } + + if (method_exists($this->instance, $name)) { + return call_user_func_array([$this->instance, $name], $arguments); + } + throw new Exception("Method $name does not exist."); + } + + /** + * Will create the main instance with dependency injection support + * + * @param string $className + * @param array $args + * @return mixed|object + * @throws \ReflectionException + */ + final protected function createInstance(string $className, array $args) + { + if(count($args) === 0) { + $ref = new Reflection($className); + return $ref->dependencyInjector(); + } + return new $className(...$args); + } +} \ No newline at end of file diff --git a/src/Unit.php b/src/Unit.php index a87e314..1959eee 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -185,18 +185,10 @@ public function execute(): bool // LOOP through each case ob_start(); foreach($this->cases as $row) { - if(!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); } - - try { - $tests = $row->dispatchTest(); - } catch (Throwable $e) { - $file = $this->formatFileTitle((string)(self::$headers['file'] ?? ""), 5, false); - throw new RuntimeException($e->getMessage() . ". Error originated from: ". $file, (int)$e->getCode(), $e); - } - + $tests = $row->dispatchTest(); $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); if($row->hasFailed()) { @@ -211,7 +203,7 @@ public function execute(): bool $this->command->getAnsi()->style(["bold", $color], (string)$row->getMessage()) ); - foreach($tests as $test) { + if(isset($tests)) foreach($tests as $test) { if(!($test instanceof TestUnit)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); } From 5cd7418d9a530146a7dccbf30415dc0f8d6e38fd Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 30 Mar 2025 21:45:58 +0200 Subject: [PATCH 02/78] Add mocking capabillities --- README.md | 1 - src/TestCase.php | 23 +++- src/TestMocker.php | 241 ++++++++++++++++++++++++++++++-------- src/TestWrapper.php | 11 +- tests/unitary-unitary.php | 43 +++++++ 5 files changed, 262 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 2400ae2..d6cca3d 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,6 @@ I will show you three different ways to test your application below. $unit = new MaplePHP\Unitary\Unit(); -// If you build your library correctly, it will become very easy to mock, as I have below. $request = new MaplePHP\Http\Request( "GET", "https://admin:mypass@example.com:65535/test.php?id=5221&greeting=hello", diff --git a/src/TestCase.php b/src/TestCase.php index e1c801f..6826626 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -8,6 +8,7 @@ use ErrorException; use RuntimeException; use Closure; +use stdClass; use Throwable; use MaplePHP\Validate\Inp; @@ -76,12 +77,32 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = return $this; } - public function wrapper($className): TestWrapper + /** + * Init a test wrapper + * + * @param string $className + * @return TestWrapper + */ + public function wrapper(string $className): TestWrapper { return new class($className) extends TestWrapper { }; } + public function mock(string $className, null|array|Closure $validate = null): object + { + + $mocker = new TestMocker($className); + if(is_array($validate)) { + $mocker->validate($validate); + } + if(is_callable($validate)) { + $fn = $validate->bindTo($mocker); + $fn($mocker); + } + return $mocker->execute(); + } + /** * Get failed test counts diff --git a/src/TestMocker.php b/src/TestMocker.php index b76350b..302bba0 100755 --- a/src/TestMocker.php +++ b/src/TestMocker.php @@ -10,79 +10,201 @@ namespace MaplePHP\Unitary; +use ArrayIterator; use Closure; use Exception; -use MaplePHP\Container\Reflection; +use MaplePHP\Log\InvalidArgumentException; +use ReflectionClass; +use ReflectionIntersectionType; +use ReflectionMethod; +use ReflectionNamedType; +use ReflectionProperty; +use ReflectionUnionType; -abstract class TestMocker +class TestMocker { protected object $instance; - private array $methods = []; + + static private mixed $return; + + protected $reflection; + + protected $methods; + + function __construct(string $className, array $args = []) + { + $this->reflection = new ReflectionClass($className); + $this->methods = $this->reflection->getMethods(ReflectionMethod::IS_PUBLIC); + + } /** - * Pass class and the class arguments if exists + * Executes the creation of a dynamic mock class and returns an instance of the mock. * - * @param string $className - * @param array $args - * @throws Exception + * @return mixed + */ + function execute(): mixed + { + $className = $this->reflection->getName(); + $mockClassName = 'UnitaryMockery_' . uniqid(); + $overrides = $this->overrideMethods(); + $code = " + class {$mockClassName} extends {$className} { + {$overrides} + } + "; + eval($code); + return new $mockClassName(); + } + + function return(mixed $returnValue): self + { + + + self::$return = $returnValue; + return $this; + } + + + static public function getReturn(): mixed + { + return self::$return; + } + + /** + * @param array $types + * @return string + * @throws \ReflectionException */ - public function __construct(string $className, array $args = []) + function getReturnValue(array $types): string { - if (!class_exists($className)) { - throw new Exception("Class $className does not exist."); + $property = new ReflectionProperty($this, 'return'); + if ($property->isInitialized($this)) { + $type = gettype(self::getReturn()); + if($types && !in_array($type, $types) && !in_array("mixed", $types)) { + throw new InvalidArgumentException("Mock value \"" . self::getReturn() . "\" should return data type: " . implode(', ', $types)); + } + + return $this->getMockValueForType($type, self::getReturn()); + } + if ($types) { + return $this->getMockValueForType($types[0]); } - $this->instance = $this->createInstance($className, $args); + return "return 'MockedValue';"; } /** - * Will bind Closure to class instance and directly return the Closure + * Overrides all methods in class * - * @param Closure $call - * @return Closure + * @return string */ - public function bind(Closure $call): Closure + protected function overrideMethods(): string { - return $call->bindTo($this->instance); + $overrides = ''; + foreach ($this->methods as $method) { + if ($method->isConstructor()) { + continue; + } + + $params = []; + $methodName = $method->getName(); + $types = $this->getReturnType($method); + $returnValue = $this->getReturnValue($types); + + foreach ($method->getParameters() as $param) { + $paramStr = ''; + if ($param->hasType()) { + $paramStr .= $param->getType() . ' '; + } + if ($param->isPassedByReference()) { + $paramStr .= '&'; + } + $paramStr .= '$' . $param->getName(); + if ($param->isDefaultValueAvailable()) { + $paramStr .= ' = ' . var_export($param->getDefaultValue(), true); + } + $params[] = $paramStr; + } + + $paramList = implode(', ', $params); + $returnType = ($types) ? ': ' . implode('|', $types) : ''; + $overrides .= " + public function {$methodName}({$paramList}){$returnType} + { + {$returnValue} + } + "; + } + + return $overrides; } /** - * Overrides a method in the instance + * Get expected return types * - * @param string $method - * @param Closure $call - * @return $this + * @param $method + * @return array */ - public function override(string $method, Closure $call): self + protected function getReturnType($method): array { - if( !method_exists($this->instance, $method)) { - throw new \BadMethodCallException( - "Method '$method' does not exist in the class '" . get_class($this->instance) . - "' and therefore cannot be overridden or called." - ); + $types = []; + $returnType = $method->getReturnType(); + if ($returnType instanceof ReflectionNamedType) { + $types[] = $returnType->getName(); + } elseif ($returnType instanceof ReflectionUnionType) { + foreach ($returnType->getTypes() as $type) { + $types[] = $type->getName(); + } + + } elseif ($returnType instanceof ReflectionIntersectionType) { + $intersect = array_map(fn($type) => $type->getName(), $returnType->getTypes()); + $types[] = $intersect; } - $call = $call->bindTo($this->instance); - $this->methods[$method] = $call; - return $this; + if(!in_array("mixed", $types) && $returnType->allowsNull()) { + $types[] = "null"; + } + return $types; } /** - * Add a method to the instance, allowing it to be called as if it were a real method. + * Generates a mock value for the specified type. * - * @param string $method - * @param Closure $call - * @return $this + * @param string $typeName The name of the type for which to generate the mock value. + * @param bool $nullable Indicates if the returned value can be nullable. + * @return mixed Returns a mock value corresponding to the given type, or null if nullable and conditions allow. */ - public function add(string $method, Closure $call): self + protected function getMockValueForType(string $typeName, mixed $value = null, bool $nullable = false): mixed { - if(method_exists($this->instance, $method)) { - throw new \BadMethodCallException( - "Method '$method' already exists in the class '" . get_class($this->instance) . - "'. Use the 'override' method in TestWrapper instead." - ); + $typeName = strtolower($typeName); + if(!is_null($value)) { + return "return \MaplePHP\Unitary\TestMocker::getReturn();"; } - $call = $call->bindTo($this->instance); - $this->methods[$method] = $call; - return $this; + $mock = match ($typeName) { + 'integer' => "return 123456;", + 'double' => "return 3.14;", + 'string' => "return 'mockString';", + 'boolean' => "return true;", + 'array' => "return ['item'];", + 'object' => "return (object)['item'];", + 'resource' => "return fopen('php://memory', 'r+');", + 'callable' => "return fn() => 'called';", + 'iterable' => "return new ArrayIterator(['a', 'b']);", + 'null' => "return null;", + 'void' => "", + default => 'return class_exists($typeName) ? new class($typeName) extends TestMocker {} : null;', + }; + return $nullable && rand(0, 1) ? null : $mock; + } + + + /** + * Will return a streamable content + * @param $resourceValue + * @return string|null + */ + protected function handleResourceContent($resourceValue) + { + return var_export(stream_get_contents($resourceValue), true); } /** @@ -95,15 +217,34 @@ public function add(string $method, Closure $call): self */ public function __call(string $name, array $arguments): mixed { - if (isset($this->methods[$name])) { - return $this->methods[$name](...$arguments); - } - if (method_exists($this->instance, $name)) { - return call_user_func_array([$this->instance, $name], $arguments); - } - throw new Exception("Method $name does not exist."); - } + $types = $this->getReturnType($name); + if(!isset($types[0]) && is_null($this->return)) { + throw new Exception("Could automatically mock Method \"$name\". " . + "You will need to manually mock it with ->return([value]) mock method!"); + } + if (!is_null($this->return)) { + return $this->return; + } + + if(isset($types[0]) && is_array($types[0]) && count($types[0]) > 0) { + $last = end($types[0]); + return new self($last); + } + + $mockValue = $this->getMockValueForType($types[0]); + if($mockValue instanceof self) { + return $mockValue; + } + + if(!in_array(gettype($mockValue), $types)) { + throw new Exception("Mock value $mockValue is not in the return type " . implode(', ', $types)); + } + return $mockValue; + } + + throw new \BadMethodCallException("Method \"$name\" does not exist in class \"" . $this->instance::class . "\"."); + } } \ No newline at end of file diff --git a/src/TestWrapper.php b/src/TestWrapper.php index c325afd..4f31241 100755 --- a/src/TestWrapper.php +++ b/src/TestWrapper.php @@ -16,6 +16,7 @@ abstract class TestWrapper { + protected Reflection $ref; protected object $instance; private array $methods = []; @@ -31,7 +32,8 @@ public function __construct(string $className, array $args = []) if (!class_exists($className)) { throw new Exception("Class $className does not exist."); } - $this->instance = $this->createInstance($className, $args); + $this->ref = new Reflection($className); + $this->instance = $this->createInstance($this->ref, $args); } /** @@ -108,17 +110,16 @@ public function __call(string $name, array $arguments): mixed /** * Will create the main instance with dependency injection support * - * @param string $className + * @param Reflection $ref * @param array $args * @return mixed|object * @throws \ReflectionException */ - final protected function createInstance(string $className, array $args) + final protected function createInstance(Reflection $ref, array $args) { if(count($args) === 0) { - $ref = new Reflection($className); return $ref->dependencyInjector(); } - return new $className(...$args); + return $ref->getReflect()->newInstanceArgs($args); } } \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 37e4984..d07f958 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,10 +1,53 @@ mailer->sendEmail($email)."\n"; + echo $this->mailer->sendEmail($email); + } +} + + $unit = new Unit(); $unit->add("Unitary test", function () { + + $mock = $this->mock(Mailer::class, function ($mock) { + //$mock->method("sendEmail")->return("SENT2121"); + }); + $service = new UserService($mock); + + $service->registerUser('user@example.com'); + + + /* + * $mock = $this->mock(Mailer::class); +echo "ww"; + + $service = new UserService($test); + $service->registerUser('user@example.com'); + var_dump($mock instanceof Mailer); + $service = new UserService($mock); + $service->registerUser('user@example.com'); + */ + $this->add("Lorem ipsum dolor", [ "isString" => [], "length" => [1,200] From 9f9fd4bb874fa5022cb14622ba1d4aa662a8b23c Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Tue, 1 Apr 2025 22:15:22 +0200 Subject: [PATCH 03/78] Mocker and structure improvements --- src/TestCase.php | 63 +++++++++++++++++++++++++++++++-------- src/TestMocker.php | 2 -- src/Unit.php | 8 ++++- tests/unitary-unitary.php | 39 +++++++++++++++++++++--- 4 files changed, 93 insertions(+), 19 deletions(-) diff --git a/src/TestCase.php b/src/TestCase.php index 6826626..b84f024 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -6,9 +6,9 @@ use BadMethodCallException; use ErrorException; +use MaplePHP\Validate\ValidatePool; use RuntimeException; use Closure; -use stdClass; use Throwable; use MaplePHP\Validate\Inp; @@ -48,6 +48,12 @@ public function dispatchTest(): array return $this->test; } + public function validate($expect, Closure $validation): self + { + $this->add($expect, $validation); + return $this; + } + /** * Create a test * @param mixed $expect @@ -61,7 +67,10 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = $this->value = $expect; $test = new TestUnit($this->value, $message); if($validation instanceof Closure) { - $test->setUnit($this->buildClosureTest($validation)); + $list = $this->buildClosureTest($validation); + foreach($list as $method => $valid) { + $test->setUnit(!$list, $method, []); + } } else { foreach($validation as $method => $args) { if(!($args instanceof Closure) && !is_array($args)) { @@ -91,7 +100,6 @@ public function wrapper(string $className): TestWrapper public function mock(string $className, null|array|Closure $validate = null): object { - $mocker = new TestMocker($className); if(is_array($validate)) { $mocker->validate($validate); @@ -169,30 +177,35 @@ public function getTest(): array /** * This will build the closure test + * * @param Closure $validation - * @return bool - * @throws ErrorException + * @return array */ - public function buildClosureTest(Closure $validation): bool + public function buildClosureTest(Closure $validation): array { $bool = false; - $validation = $validation->bindTo($this->valid($this->value)); + $validPool = new ValidatePool($this->value); + $validation = $validation->bindTo($validPool); + + $error = []; if(!is_null($validation)) { - $bool = $validation($this->value); - } - if(!is_bool($bool)) { - throw new RuntimeException("A callable validation must return a boolean!"); + $bool = $validation($validPool, $this->value); + $error = $validPool->getError(); + if(is_bool($bool) && !$bool) { + $error['customError'] = $bool; + } } if(is_null($this->message)) { throw new RuntimeException("When testing with closure the third argument message is required"); } - return $bool; + return $error; } /** * This will build the array test + * * @param string $method * @param array|Closure $args * @return bool @@ -235,6 +248,32 @@ protected function valid(mixed $value): Inp return new Inp($value); } + /** + * This is a helper function that will list all inherited proxy methods + * + * @param string $class + * @return void + * @throws \ReflectionException + */ + public function listAllProxyMethods(string $class, ?string $prefixMethods = null): void + { + $reflection = new \ReflectionClass($class); + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->isConstructor()) continue; + $params = array_map(function($param) { + $type = $param->hasType() ? $param->getType() . ' ' : ''; + return $type . '$' . $param->getName(); + }, $method->getParameters()); + + $name = $method->getName(); + if(!$method->isStatic() && !str_starts_with($name, '__')) { + if(!is_null($prefixMethods)) { + $name = $prefixMethods . ucfirst($name); + } + echo "@method self $name(" . implode(', ', $params) . ")\n"; + } + } + } } diff --git a/src/TestMocker.php b/src/TestMocker.php index 302bba0..41ecad8 100755 --- a/src/TestMocker.php +++ b/src/TestMocker.php @@ -59,8 +59,6 @@ class {$mockClassName} extends {$className} { function return(mixed $returnValue): self { - - self::$return = $returnValue; return $this; } diff --git a/src/Unit.php b/src/Unit.php index 1959eee..fd99e75 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -130,8 +130,9 @@ public function add(string $message, Closure $callback): void /** * Add a test unit/group + * * @param string $message - * @param Closure $callback + * @param Closure(TestCase):void $callback * @return void */ public function case(string $message, Closure $callback): void @@ -142,6 +143,11 @@ public function case(string $message, Closure $callback): void $this->index++; } + public function group(string $message, Closure $callback): void + { + $this->case($message, $callback); + } + public function performance(Closure $func, ?string $title = null): void { $start = new TestMem(); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index d07f958..ee68345 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,7 +1,10 @@ add("Unitary test", function () { +$unit->group("Unitary test", function (TestCase $inst) { - $mock = $this->mock(Mailer::class, function ($mock) { - //$mock->method("sendEmail")->return("SENT2121"); + // Example 1 + /* + $mock = $this->mock(Mailer::class, function ($mock) { + $mock->method("testMethod1")->count(1)->return("lorem1"); + $mock->method("testMethod2")->count(1)->return("lorem1"); }); $service = new UserService($mock); + // Example 2 + $mock = $this->mock(Mailer::class, [ + "testMethod1" => [ + "count" => 1, + "validate" => [ + "equal" => "lorem1", + "contains" => "lorem", + "length" => [1,6] + ] + ] + ]); + $service = new UserService($mock); $service->registerUser('user@example.com'); + */ + + $inst->validate("yourTestValue", function(ValidatePool $inst, mixed $value) { + $inst->isBool(); + $inst->isInt(); + $inst->isJson(); + + return ($value === "yourTestValue1"); + }); + + //$inst->listAllProxyMethods(Inp::class); +//->error("Failed to validate yourTestValue (optional error message)") + /* @@ -50,7 +81,7 @@ public function registerUser(string $email): void { $this->add("Lorem ipsum dolor", [ "isString" => [], - "length" => [1,200] + "length" => [1,300] ])->add(92928, [ "isInt" => [] From f3c05db204ea43897f0213adae3d9eff8a45ba0a Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Wed, 2 Apr 2025 23:03:10 +0200 Subject: [PATCH 04/78] Prompt semantics --- README.md | 8 +++- src/TestCase.php | 73 +++++++++++++++++++++++++++++---- src/TestUnit.php | 86 ++++++++++++++++++++++++++++++++++----- src/Unit.php | 30 +++++++++++--- tests/unitary-unitary.php | 6 ++- 5 files changed, 176 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d6cca3d..f3a7fdd 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ class PaymentProcessor ``` ### Use the Test Wrapper -Use wrapper()->bind() to Mock API Calls but Keep Business Logic +Use wrapper()->bind() to make integration tests easier. Test wrapper will bind a callable to specified class in wrapper, in this case to PaymentProcessor and will be accessible with `$dispatch("OR827262")`. With TestWrapper, we can simulate an order and intercept the payment capture while keeping access to $this inside the closure. @@ -179,9 +179,13 @@ $dispatch = $this->wrapper(PaymentProcessor::class)->bind(function ($orderID) us ``` - ## Configurations +### Show only errors +```bash +php vendor/bin/unitary --errors-only +``` + ### Select a Test File to Run After each test, a hash key is shown, allowing you to run specific tests instead of all. diff --git a/src/TestCase.php b/src/TestCase.php index b84f024..6e4225a 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -4,13 +4,13 @@ namespace MaplePHP\Unitary; +use MaplePHP\Validate\ValidatePool; +use MaplePHP\Validate\Inp; use BadMethodCallException; use ErrorException; -use MaplePHP\Validate\ValidatePool; use RuntimeException; use Closure; use Throwable; -use MaplePHP\Validate\Inp; class TestCase { @@ -19,7 +19,14 @@ class TestCase private array $test = []; private int $count = 0; private ?Closure $bind = null; + private ?string $errorMessage = null; + + /** + * Initialize a new TestCase instance with an optional message. + * + * @param string|null $message A message to associate with the test case. + */ public function __construct(?string $message = null) { $this->message = $message; @@ -27,6 +34,7 @@ public function __construct(?string $message = null) /** * Bind the test case to the Closure + * * @param Closure $bind * @return void */ @@ -37,6 +45,7 @@ public function bind(Closure $bind): void /** * Will dispatch the case tests and return them as an array + * * @return array */ public function dispatchTest(): array @@ -48,21 +57,59 @@ public function dispatchTest(): array return $this->test; } - public function validate($expect, Closure $validation): self + /** + * Add custom error message if validation fails + * + * @param string $message + * @return $this + */ + public function error(string $message): self + { + $this->errorMessage = $message; + return $this; + } + + /** + * Add a test unit validation using the provided expectation and validation logic + * + * @param mixed $expect The expected value + * @param Closure(ValidatePool, mixed): bool $validation The validation logic + * @return $this + * @throws ErrorException + */ + public function validate(mixed $expect, Closure $validation): self { - $this->add($expect, $validation); + $this->addTestUnit($expect, function(mixed $value, ValidatePool $inst) use($validation) { + return $validation($inst, $value); + }, $this->errorMessage); + return $this; } + + /** + * Same as "addTestUnit" but is public and will make sure the validation can be + * properly registered and traceable + * + * @param mixed $expect The expected value + * @param array|Closure $validation The validation logic + * @param string|null $message An optional descriptive message for the test + * @return $this + * @throws ErrorException + */ + public function add(mixed $expect, array|Closure $validation, ?string $message = null) { + return $this->addTestUnit($expect, $validation, $message); + } /** * Create a test + * * @param mixed $expect * @param array|Closure $validation * @param string|null $message * @return TestCase * @throws ErrorException */ - public function add(mixed $expect, array|Closure $validation, ?string $message = null): self + protected function addTestUnit(mixed $expect, array|Closure $validation, ?string $message = null): self { $this->value = $expect; $test = new TestUnit($this->value, $message); @@ -80,12 +127,16 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = } } if(!$test->isValid()) { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + $test->setCodeLine($trace); $this->count++; } $this->test[] = $test; + $this->errorMessage = null; return $this; } + /** * Init a test wrapper * @@ -114,6 +165,7 @@ public function mock(string $className, null|array|Closure $validate = null): ob /** * Get failed test counts + * * @return int */ public function getTotal(): int @@ -123,6 +175,7 @@ public function getTotal(): int /** * Get failed test counts + * * @return int */ public function getCount(): int @@ -132,6 +185,7 @@ public function getCount(): int /** * Get failed test counts + * * @return int */ public function getFailedCount(): int @@ -141,6 +195,7 @@ public function getFailedCount(): int /** * Check if it has failed tests + * * @return bool */ public function hasFailed(): bool @@ -150,6 +205,7 @@ public function hasFailed(): bool /** * Get original value + * * @return mixed */ public function getValue(): mixed @@ -159,6 +215,7 @@ public function getValue(): mixed /** * Get user added message + * * @return string|null */ public function getMessage(): ?string @@ -168,6 +225,7 @@ public function getMessage(): ?string /** * Get test array object + * * @return array */ public function getTest(): array @@ -189,7 +247,7 @@ public function buildClosureTest(Closure $validation): array $error = []; if(!is_null($validation)) { - $bool = $validation($validPool, $this->value); + $bool = $validation($this->value, $validPool); $error = $validPool->getError(); if(is_bool($bool) && !$bool) { $error['customError'] = $bool; @@ -239,6 +297,7 @@ public function buildArrayTest(string $method, array|Closure $args): bool /** * Init MaplePHP validation + * * @param mixed $value * @return Inp * @throws ErrorException @@ -252,6 +311,7 @@ protected function valid(mixed $value): Inp * This is a helper function that will list all inherited proxy methods * * @param string $class + * @param string|null $prefixMethods * @return void * @throws \ReflectionException */ @@ -275,5 +335,4 @@ public function listAllProxyMethods(string $class, ?string $prefixMethods = null } } } - } diff --git a/src/TestUnit.php b/src/TestUnit.php index 3190c2f..a0cebde 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -14,9 +14,12 @@ class TestUnit private ?string $message; private array $unit = []; private int $count = 0; + private int $valLength = 0; + private array $codeLine = ['line' => 0, 'code' => '', 'file' => '']; /** * Initiate the test + * * @param mixed $value * @param string|null $message */ @@ -29,6 +32,7 @@ public function __construct(mixed $value, ?string $message = null) /** * Set the test unit + * * @param bool $valid * @param string|null $validation * @param array $args @@ -40,6 +44,12 @@ public function setUnit(bool $valid, ?string $validation = null, array $args = [ $this->valid = false; $this->count++; } + + $valLength = strlen((string)$validation); + if($validation && $this->valLength < $valLength) { + $this->valLength = $valLength; + } + $this->unit[] = [ 'valid' => $valid, 'validation' => $validation, @@ -48,8 +58,57 @@ public function setUnit(bool $valid, ?string $validation = null, array $args = [ return $this; } + /** + * Get the length of the validation string with the maximum length + * + * @return int + */ + public function getValidationLength(): int + { + return $this->valLength; + } + + /** + * Set the code line from a backtrace + * + * @param array $trace + * @return $this + * @throws ErrorException + */ + function setCodeLine(array $trace): self + { + $this->codeLine = []; + $file = $trace['file'] ?? ''; + $line = $trace['line'] ?? 0; + if ($file && $line) { + $lines = file($file); + $code = trim($lines[$line - 1] ?? ''); + if(str_starts_with($code, '->')) { + $code = substr($code, 2); + } + $code = $this->excerpt($code); + + $this->codeLine['line'] = $line; + $this->codeLine['file'] = $file; + $this->codeLine['code'] = $code; + } + return $this; + } + + + /** + * Get the code line from a backtrace + * + * @return array + */ + public function getCodeLine(): array + { + return $this->codeLine; + } + /** * Get ever test unit item array data + * * @return array */ public function getUnits(): array @@ -59,6 +118,7 @@ public function getUnits(): array /** * Get failed test count + * * @return int */ public function getFailedTestCount(): int @@ -68,6 +128,7 @@ public function getFailedTestCount(): int /** * Get test message + * * @return string|null */ public function getMessage(): ?string @@ -77,6 +138,7 @@ public function getMessage(): ?string /** * Get if test is valid + * * @return bool */ public function isValid(): bool @@ -86,6 +148,7 @@ public function isValid(): bool /** * Gte the original value + * * @return mixed */ public function getValue(): mixed @@ -95,34 +158,35 @@ public function getValue(): mixed /** * Used to get a readable value + * * @return string * @throws ErrorException */ public function getReadValue(): string { if (is_bool($this->value)) { - return "(bool): " . ($this->value ? "true" : "false"); + return '"' . ($this->value ? "true" : "false") . '"' . " (type: bool)"; } if (is_int($this->value)) { - return "(int): " . $this->excerpt((string)$this->value); + return '"' . $this->excerpt((string)$this->value) . '"' . " (type: int)"; } if (is_float($this->value)) { - return "(float): " . $this->excerpt((string)$this->value); + return '"' . $this->excerpt((string)$this->value) . '"' . " (type: float)"; } if (is_string($this->value)) { - return "(string): " . $this->excerpt($this->value); + return '"' . $this->excerpt($this->value) . '"' . " (type: string)"; } if (is_array($this->value)) { - return "(array): " . $this->excerpt(json_encode($this->value)); + return '"' . $this->excerpt(json_encode($this->value)) . '"' . " (type: array)"; } if (is_object($this->value)) { - return "(object): " . $this->excerpt(get_class($this->value)); + return '"' . $this->excerpt(get_class($this->value)) . '"' . " (type: object)"; } if (is_null($this->value)) { - return "(null)"; + return '"null" (type: null)'; } if (is_resource($this->value)) { - return "(resource): " . $this->excerpt(get_resource_type($this->value)); + return '"' . $this->excerpt(get_resource_type($this->value)) . '"' . " (type: resource)"; } return "(unknown type)"; @@ -130,14 +194,16 @@ public function getReadValue(): string /** * Used to get exception to the readable value + * * @param string $value + * @param int $length * @return string * @throws ErrorException */ - final protected function excerpt(string $value): string + final protected function excerpt(string $value, int $length = 80): string { $format = new Str($value); - return (string)$format->excerpt(70)->get(); + return (string)$format->excerpt($length)->get(); } } diff --git a/src/Unit.php b/src/Unit.php index fd99e75..4b69637 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -194,6 +194,8 @@ public function execute(): bool if(!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); } + + $errArg = self::getArgs("errors-only"); $tests = $row->dispatchTest(); $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); @@ -201,6 +203,10 @@ public function execute(): bool $flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); } + if($errArg !== false && !$row->hasFailed()) { + continue; + } + $this->command->message(""); $this->command->message( $flag . " " . @@ -217,17 +223,30 @@ public function execute(): bool if(!$test->isValid()) { $msg = (string)$test->getMessage(); $this->command->message(""); - $this->command->message($this->command->getAnsi()->style(["bold", "brightRed"], "Error: " . $msg)); + $this->command->message( + $this->command->getAnsi()->style(["bold", "brightRed"], "Error: ") . + $this->command->getAnsi()->bold($msg) + ); + $this->command->message(""); + + $trace = $test->getCodeLine(); + if(!empty($trace['code'])) { + $this->command->message($this->command->getAnsi()->style(["bold", "grey"], "Failed on line {$trace['line']}: ")); + $this->command->message($this->command->getAnsi()->style(["grey"], " → {$trace['code']}")); + + } + /** @var array $unit */ foreach($test->getUnits() as $unit) { + $title = str_pad($unit['validation'], $test->getValidationLength() + 1); $this->command->message( - $this->command->getAnsi()->bold("Validation: ") . $this->command->getAnsi()->style( ((!$unit['valid']) ? "brightRed" : null), - $unit['validation'] . ((!$unit['valid']) ? " (fail)" : "") + " " .$title . ((!$unit['valid']) ? " → failed" : "") ) ); } + $this->command->message(""); $this->command->message($this->command->getAnsi()->bold("Value: ") . $test->getReadValue()); } } @@ -322,11 +341,10 @@ private function formatFileTitle(string $file, int $length = 3, bool $removeSuff $pop = array_pop($file); $file[] = substr($pop, (int)strpos($pop, 'unitary') + 8); } - $file = array_chunk(array_reverse($file), $length); $file = implode("\\", array_reverse($file[0])); - $exp = explode('.', $file); - $file = reset($exp); + //$exp = explode('.', $file); + //$file = reset($exp); return ".." . $file; } diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index ee68345..40f47ea 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -59,12 +59,14 @@ public function registerUser(string $email): void { $inst->isBool(); $inst->isInt(); $inst->isJson(); - + $inst->isString(); return ($value === "yourTestValue1"); }); + $inst->validate("yourTestValue", fn(ValidatePool $inst) => $inst->isfloat()); + //$inst->listAllProxyMethods(Inp::class); -//->error("Failed to validate yourTestValue (optional error message)") + //->error("Failed to validate yourTestValue (optional error message)") From a1b38c9c66113c7eae100b53681f2711fe7b13b1 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 13 Apr 2025 17:30:04 +0200 Subject: [PATCH 05/78] Add mocking capabilities --- README.md | 114 +++++++---- src/Mocker/MethodItem.php | 313 ++++++++++++++++++++++++++++ src/Mocker/MethodPool.php | 60 ++++++ src/Mocker/Mocker.php | 351 ++++++++++++++++++++++++++++++++ src/Mocker/MockerController.php | 54 +++++ src/TestCase.php | 192 +++++++++++++---- src/TestMocker.php | 248 ---------------------- src/TestUnit.php | 95 ++++++--- src/Unit.php | 51 +++-- tests/unitary-unitary.php | 85 ++++++-- 10 files changed, 1185 insertions(+), 378 deletions(-) create mode 100644 src/Mocker/MethodItem.php create mode 100644 src/Mocker/MethodPool.php create mode 100755 src/Mocker/Mocker.php create mode 100644 src/Mocker/MockerController.php delete mode 100755 src/TestMocker.php diff --git a/README.md b/README.md index f3a7fdd..48f7504 100644 --- a/README.md +++ b/README.md @@ -117,53 +117,93 @@ php vendor/bin/unitary With that, you are ready to create your own tests! -## Integration tests: Test Wrapper -The TestWrapper allows you to wrap an existing class, override its methods, and inject dependencies dynamically. -It is useful for integration testing, debugging, and extending existing functionality without the need of -modifying the original class. - -### The problem -Imagine we have a PaymentProcessor class that communicates with an external payment gateway to -capture a customer's payment. We would like to test this with its own functionallity to keep the test useful -but avoid making any charges to customer. -```php -class PaymentProcessor -{ - public function __construct( - private OrderService $orderService, - private PaymentGateway $gateway, - private Logger $logger - ) {} - - public function capture(string $orderID) - { - $order = $this->orderService->getOrder($orderID); - - if (!$order) { - throw new Exception("Order not found: $orderID"); - } +## Mocking +Unitary comes with a built-in mocker that makes it super simple for you to mock classes. - $this->logger->info("Capturing payment for Order ID: " . $order->id); - $response = $this->gateway->capture($order->id); +### Auto mocking +What is super cool with Unitary Mocker will try to automatically mock the class that you pass and +it will do it will do it quite accurate as long as the class and its methods that you are mocking is +using data type in arguments and return type. - if ($response['status'] !== 'success') { - throw new Exception("Payment capture failed: " . $response['message']); - } +```php +$unit->group("Testing user service", function (TestCase $inst) { + + // Just call the unitary mock and pass in class name + $mock = $inst->mock(Mailer::class); + // Mailer class is not mocked! + + // Pass argument to Mailer constructor e.g. new Mailer('john.doe@gmail.com', 'John Doe'); + //$mock = $inst->mock([Mailer::class, ['john.doe@gmail.com', 'John Doe']); + // Mailer class is not mocked again! + + // Then just pass the mocked library to what ever service or controller you wish + $service = new UserService($mock); +}); +``` +_Why? Sometimes you just want to quick mock so that a Mailer library will not send a mail_ - return "Transaction ID: " . $response['transaction_id']; - } -} +### Custom mocking +As I said Unitary mocker will try to automatically mock every method but might not successes in some user-cases +then you can just tell Unitary how those failed methods should load. +```php +use MaplePHP\Validate\ValidatePool; +use \MaplePHP\Unitary\Mocker\MethodPool; + +$unit->group("Testing user service", function (TestCase $inst) { + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + // Quick way to tell Unitary that this method should return 'john.doe' + $pool->method("getFromEmail")->return('john.doe@gmail.com'); + + // Or we can acctually pass a callable to it and tell it what it should return + // But we can also validate the argumnets! + $pool->method("addFromEmail")->wrap(function($email) use($inst) { + $inst->validate($email, function(ValidatePool $valid) { + $valid->email(); + $valid->isString(); + }); + return true; + }); + }); + + // Then just pass the mocked library to what ever service or controller you wish + $service = new UserService($mock); +}); ``` -### Use the Test Wrapper -Use wrapper()->bind() to make integration tests easier. Test wrapper will bind a callable to specified class in wrapper, in this case to PaymentProcessor and will be accessible with `$dispatch("OR827262")`. +### Mocking: Add Consistency validation +What is really cool is that you can also use Unitary mocker to make sure consistencies is followed and +validate that the method is built and loaded correctly. + +```php +use \MaplePHP\Unitary\Mocker\MethodPool; + +$unit->group("Unitary test", function (TestCase $inst) { + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + $pool->method("addFromEmail") + ->isPublic() + ->hasDocComment() + ->hasReturnType() + ->count(1); + + $pool->method("addBCC") + ->isPublic() + ->count(3); + }); + $service = new UserService($mock); +}); +``` + + +### Integration tests: Test Wrapper +Test wrapper is great to make integration test easier. -With TestWrapper, we can simulate an order and intercept the payment capture while keeping access to $this inside the closure. +Most libraries or services has a method that executes the service and runs all the logic. The test wrapper we +can high-jack that execution method and overwrite it with our own logic. ```php -$dispatch = $this->wrapper(PaymentProcessor::class)->bind(function ($orderID) use ($inst) { +$dispatch = $this->wrap(PaymentProcessor::class)->bind(function ($orderID) use ($inst) { // Simulate order retrieval $order = $this->orderService->getOrder($orderID); $response = $inst->mock('gatewayCapture')->capture($order->id); diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php new file mode 100644 index 0000000..d44e5e0 --- /dev/null +++ b/src/Mocker/MethodItem.php @@ -0,0 +1,313 @@ +mocker = $mocker; + } + + public function wrap($call): self + { + $inst = $this; + $wrap = new class($this->mocker->getClassName()) extends TestWrapper { + }; + $call->bindTo($this->mocker); + $this->wrapper = $wrap->bind($call); + return $inst; + } + + public function getWrap(): ?Closure + { + return $this->wrapper; + } + + public function hasReturn(): bool + { + return $this->hasReturn; + } + + /** + * Check if method has been called x times + * @param int $count + * @return $this + */ + public function count(int $count): self + { + $inst = $this; + $inst->count = $count; + return $inst; + } + + /** + * Change what the method should return + * + * @param mixed $value + * @return $this + */ + public function return(mixed $value): self + { + $inst = $this; + $inst->hasReturn = true; + $inst->return = $value; + return $inst; + } + + /** + * Set the class name. + * + * @param string $class + * @return self + */ + public function class(string $class): self + { + $inst = $this; + $inst->class = $class; + return $inst; + } + + /** + * Set the method name. + * + * @param string $name + * @return self + */ + public function name(string $name): self + { + $inst = $this; + $inst->name = $name; + return $inst; + } + + /** + * Mark the method as static. + * + * @return self + */ + public function isStatic(): self + { + $inst = $this; + $inst->isStatic = true; + return $inst; + } + + /** + * Mark the method as public. + * + * @return self + */ + public function isPublic(): self + { + $inst = $this; + $inst->isPublic = true; + return $inst; + } + + /** + * Mark the method as private. + * + * @return self + */ + public function isPrivate(): self + { + $inst = $this; + $inst->isPrivate = true; + return $inst; + } + + /** + * Mark the method as protected. + * + * @return self + */ + public function isProtected(): self + { + $inst = $this; + $inst->isProtected = true; + return $inst; + } + + /** + * Mark the method as abstract. + * + * @return self + */ + public function isAbstract(): self + { + $inst = $this; + $inst->isAbstract = true; + return $inst; + } + + /** + * Mark the method as final. + * + * @return self + */ + public function isFinal(): self + { + $inst = $this; + $inst->isFinal = true; + return $inst; + } + + /** + * Mark the method as returning by reference. + * + * @return self + */ + public function returnsReference(): self + { + $inst = $this; + $inst->returnsReference = true; + return $inst; + } + + /** + * Mark the method as having a return type. + * + * @return self + */ + public function hasReturnType(): self + { + $inst = $this; + $inst->hasReturnType = true; + return $inst; + } + + /** + * Set the return type of the method. + * + * @param string $type + * @return self + */ + public function returnType(string $type): self + { + $inst = $this; + $inst->returnType = $type; + return $inst; + } + + /** + * Mark the method as a constructor. + * + * @return self + */ + public function isConstructor(): self + { + $inst = $this; + $inst->isConstructor = true; + return $inst; + } + + /** + * Mark the method as a destructor. + * + * @return self + */ + public function isDestructor(): self + { + $inst = $this; + $inst->isDestructor = true; + return $inst; + } + + /** + * Not yet working + * Set the parameters of the method. + * + * @param array $parameters + * @return self + */ + public function parameters(array $parameters): self + { + throw new \BadMethodCallException('Method Item::parameters() does not "YET" exist.'); + $inst = $this; + $inst->parameters = $parameters; + return $inst; + } + + /** + * Set the doc comment for the method. + * + * @return self + */ + public function hasDocComment(): self + { + $inst = $this; + $inst->hasDocComment = [ + "isString" => [], + "startsWith" => ["/**"] + ]; + return $inst; + } + + /** + * Set the starting line number of the method. + * + * @param int $line + * @return self + */ + public function startLine(int $line): self + { + $inst = $this; + $inst->startLine = $line; + return $inst; + } + + /** + * Set the ending line number of the method. + * + * @param int $line + * @return self + */ + public function endLine(int $line): self + { + $inst = $this; + $inst->endLine = $line; + return $inst; + } + + /** + * Set the file name where the method is declared. + * + * @param string $file + * @return self + */ + public function fileName(string $file): self + { + $inst = $this; + $inst->fileName = $file; + return $inst; + } +} \ No newline at end of file diff --git a/src/Mocker/MethodPool.php b/src/Mocker/MethodPool.php new file mode 100644 index 0000000..9abf45b --- /dev/null +++ b/src/Mocker/MethodPool.php @@ -0,0 +1,60 @@ +mocker = $mocker; + } + + /** + * This method adds a new method to the pool with a given name and + * returns the corresponding MethodItem instance. + * + * @param string $name The name of the method to add. + * @return MethodItem The newly created MethodItem instance. + */ + public function method(string $name): MethodItem + { + $this->methods[$name] = new MethodItem($this->mocker); + return $this->methods[$name]; + } + + /** + * Get method + * + * @param string $key + * @return MethodItem|null + */ + public function get(string $key): MethodItem|null + { + return $this->methods[$key] ?? null; + } + + /** + * Get all methods + * + * @return array True if the method exists, false otherwise. + */ + public function getAll(): array + { + return $this->methods; + } + + /** + * Checks if a method with the given name exists in the pool. + * + * @param string $name The name of the method to check. + * @return bool True if the method exists, false otherwise. + */ + public function has(string $name): bool + { + return isset($this->methods[$name]); + } + +} \ No newline at end of file diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php new file mode 100755 index 0000000..eb31c77 --- /dev/null +++ b/src/Mocker/Mocker.php @@ -0,0 +1,351 @@ +className = $className; + $this->reflection = new ReflectionClass($className); + $this->methods = $this->reflection->getMethods(); + $this->constructorArgs = $args; + } + + public function getClassName(): string + { + return $this->className; + } + + /** + * Override the default method overrides with your own mock logic and validation rules + * + * @return MethodPool + */ + public function getMethodPool(): MethodPool + { + if(is_null(self::$methodPool)) { + self::$methodPool = new MethodPool($this); + } + return self::$methodPool; + } + + public function getMockedClassName(): string + { + return $this->mockClassName; + } + + /** + * Executes the creation of a dynamic mock class and returns an instance of the mock. + * + * @return object An instance of the dynamically created mock class. + * @throws \ReflectionException + */ + public function execute(): object + { + $className = $this->reflection->getName(); + + $shortClassName = explode("\\", $className); + $shortClassName = end($shortClassName); + + $this->mockClassName = 'Unitary_' . uniqid() . "_Mock_" . $shortClassName; + $overrides = $this->generateMockMethodOverrides($this->mockClassName); + $unknownMethod = $this->errorHandleUnknownMethod($className); + $code = " + class {$this->mockClassName} extends {$className} { + {$overrides} + {$unknownMethod} + } + "; + + eval($code); + return new $this->mockClassName(...$this->constructorArgs); + } + + /** + * Handles the situation where an unknown method is called on the mock class. + * If the base class defines a __call method, it will delegate to it. + * Otherwise, it throws a BadMethodCallException. + * + * @param string $className The name of the class for which the mock is created. + * @return string The generated PHP code for handling unknown method calls. + */ + private function errorHandleUnknownMethod(string $className): string + { + if(!in_array('__call', $this->methodList)) { + return " + public function __call(string \$name, array \$arguments) { + if (method_exists(get_parent_class(\$this), '__call')) { + return parent::__call(\$name, \$arguments); + } + throw new \\BadMethodCallException(\"Method '\$name' does not exist in class '{$className}'.\"); + } + "; + } + return ""; + } + + /** + * @param array $types + * @param mixed $method + * @param MethodItem|null $methodItem + * @return string + */ + protected function getReturnValue(array $types, mixed $method, ?MethodItem $methodItem = null): string + { + // Will overwrite the auto generated value + if($methodItem && $methodItem->hasReturn()) { + return "return " . var_export($methodItem->return, true) . ";"; + } + if ($types) { + return $this->getMockValueForType($types[0], $method); + } + return "return 'MockedValue';"; + } + + /** + * Builds and returns PHP code that overrides all public methods in the class being mocked. + * Each overridden method returns a predefined mock value or delegates to the original logic. + * + * @return string PHP code defining the overridden methods. + * @throws \ReflectionException + */ + protected function generateMockMethodOverrides(string $mockClassName): string + { + $overrides = ''; + foreach ($this->methods as $method) { + if ($method->isConstructor() || $method->isFinal()) { + continue; + } + + $methodName = $method->getName(); + $this->methodList[] = $methodName; + + // The MethodItem contains all items that are validatable + $methodItem = $this->getMethodPool()->get($methodName); + $types = $this->getReturnType($method); + $returnValue = $this->getReturnValue($types, $method, $methodItem); + $paramList = $this->generateMethodSignature($method); + $returnType = ($types) ? ': ' . implode('|', $types) : ''; + $modifiersArr = Reflection::getModifierNames($method->getModifiers()); + $modifiers = implode(" ", $modifiersArr); + + $return = ($methodItem && $methodItem->hasReturn()) ? $methodItem->return : eval($returnValue); + $arr = $this->getMethodInfoAsArray($method); + $arr['mocker'] = $mockClassName; + $arr['return'] = $return; + + $info = json_encode($arr); + MockerController::getInstance()->buildMethodData($info); + + if($methodItem && !in_array("void", $types)) { + $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); + } + + $overrides .= " + {$modifiers} function {$methodName}({$paramList}){$returnType} + { + \$obj = \\MaplePHP\\Unitary\\Mocker\\MockerController::getInstance()->buildMethodData('{$info}'); + \$data = \\MaplePHP\\Unitary\\Mocker\\MockerController::getDataItem(\$obj->mocker, \$obj->name); + {$returnValue} + } + "; + } + + return $overrides; + } + + + protected function generateWrapperReturn(?\Closure $wrapper, string $methodName, string $returnValue) { + MockerController::addData($this->mockClassName, $methodName, 'wrapper', $wrapper); + return " + if (isset(\$data->wrapper) && \$data->wrapper instanceof \\Closure) { + return call_user_func_array(\$data->wrapper, func_get_args()); + } + {$returnValue} + "; + } + + /** + * Generates the signature for a method, including type hints, default values, and by-reference indicators. + * + * @param ReflectionMethod $method The reflection object for the method to analyze. + * @return string The generated method signature. + */ + protected function generateMethodSignature(ReflectionMethod $method): string + { + $params = []; + foreach ($method->getParameters() as $param) { + $paramStr = ''; + if ($param->hasType()) { + $paramStr .= $param->getType() . ' '; + } + if ($param->isPassedByReference()) { + $paramStr .= '&'; + } + $paramStr .= '$' . $param->getName(); + if ($param->isDefaultValueAvailable()) { + $paramStr .= ' = ' . var_export($param->getDefaultValue(), true); + } + $params[] = $paramStr; + } + return implode(', ', $params); + } + + /** + * Determines and retrieves the expected return types of a given method. + * + * @param ReflectionMethod $method The reflection object for the method to inspect. + * @return array An array of the expected return types for the given method. + */ + protected function getReturnType($method): array + { + $types = []; + $returnType = $method->getReturnType(); + if ($returnType instanceof ReflectionNamedType) { + $types[] = $returnType->getName(); + } elseif ($returnType instanceof ReflectionUnionType) { + foreach ($returnType->getTypes() as $type) { + $types[] = $type->getName(); + } + + } elseif ($returnType instanceof ReflectionIntersectionType) { + $intersect = array_map(fn($type) => $type->getName(), $returnType->getTypes()); + $types[] = $intersect; + } + + if(!in_array("mixed", $types) && $returnType && $returnType->allowsNull()) { + $types[] = "null"; + } + return $types; + } + + /** + * Generates a mock value for the specified type. + * + * @param string $typeName The name of the type for which to generate the mock value. + * @param bool $nullable Indicates if the returned value can be nullable. + * @return mixed Returns a mock value corresponding to the given type, or null if nullable and conditions allow. + */ + protected function getMockValueForType(string $typeName, mixed $method, mixed $value = null, bool $nullable = false): mixed + { + $typeName = strtolower($typeName); + if(!is_null($value)) { + return "return " . var_export($value, true) . ";"; + } + + $mock = match ($typeName) { + 'int' => "return 123456;", + 'integer' => "return 123456;", + 'float' => "return 3.14;", + 'double' => "return 3.14;", + 'string' => "return 'mockString';", + 'bool' => "return true;", + 'boolean' => "return true;", + 'array' => "return ['item'];", + 'object' => "return (object)['item'];", + 'resource' => "return fopen('php://memory', 'r+');", + 'callable' => "return fn() => 'called';", + 'iterable' => "return new ArrayIterator(['a', 'b']);", + 'null' => "return null;", + 'void' => "", + 'self' => ($method->isStatic()) ? 'return new self();' : 'return $this;', + default => (is_string($typeName) && class_exists($typeName)) + ? "return new class() extends " . $typeName . " {};" + : "return null;", + + }; + return $nullable && rand(0, 1) ? null : $mock; + } + + /** + * Will return a streamable content + * + * @param $resourceValue + * @return string|null + */ + protected function handleResourceContent($resourceValue): ?string + { + return var_export(stream_get_contents($resourceValue), true); + } + + /** + * Build a method information array form ReflectionMethod instance + * + * @param ReflectionMethod $refMethod + * @return array + */ + function getMethodInfoAsArray(ReflectionMethod $refMethod): array + { + $params = []; + foreach ($refMethod->getParameters() as $param) { + $params[] = [ + 'name' => $param->getName(), + 'position' => $param->getPosition(), + 'hasType' => $param->hasType(), + 'type' => $param->hasType() ? $param->getType()->__toString() : null, + 'isOptional' => $param->isOptional(), + 'isVariadic' => $param->isVariadic(), + 'isPassedByReference' => $param->isPassedByReference(), + 'default' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null, + ]; + } + + return [ + 'class' => $refMethod->getDeclaringClass()->getName(), + 'name' => $refMethod->getName(), + 'isStatic' => $refMethod->isStatic(), + 'isPublic' => $refMethod->isPublic(), + 'isPrivate' => $refMethod->isPrivate(), + 'isProtected' => $refMethod->isProtected(), + 'isAbstract' => $refMethod->isAbstract(), + 'isFinal' => $refMethod->isFinal(), + 'returnsReference' => $refMethod->returnsReference(), + 'hasReturnType' => $refMethod->hasReturnType(), + 'returnType' => $refMethod->hasReturnType() ? $refMethod->getReturnType()->__toString() : null, + 'isConstructor' => $refMethod->isConstructor(), + 'isDestructor' => $refMethod->isDestructor(), + 'parameters' => $params, + 'hasDocComment' => $refMethod->getDocComment(), + 'startLine' => $refMethod->getStartLine(), + 'endLine' => $refMethod->getEndLine(), + 'fileName' => $refMethod->getFileName(), + ]; + } + +} \ No newline at end of file diff --git a/src/Mocker/MockerController.php b/src/Mocker/MockerController.php new file mode 100644 index 0000000..5eaf1c2 --- /dev/null +++ b/src/Mocker/MockerController.php @@ -0,0 +1,54 @@ +{$key} = $value; + } + + public function buildMethodData(string $method): object + { + $data = json_decode($method); + if(empty(self::$data[$data->mocker][$data->name])) { + $data->count = 0; + self::$data[$data->mocker][$data->name] = $data; + } else { + self::$data[$data->mocker][$data->name]->count++; + } + return $data; + } + +} \ No newline at end of file diff --git a/src/TestCase.php b/src/TestCase.php index 6e4225a..1cc11eb 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -4,12 +4,14 @@ namespace MaplePHP\Unitary; -use MaplePHP\Validate\ValidatePool; -use MaplePHP\Validate\Inp; use BadMethodCallException; +use Closure; use ErrorException; +use MaplePHP\Unitary\Mocker\Mocker; +use MaplePHP\Unitary\Mocker\MockerController; +use MaplePHP\Validate\Inp; +use MaplePHP\Validate\ValidatePool; use RuntimeException; -use Closure; use Throwable; class TestCase @@ -21,6 +23,8 @@ class TestCase private ?Closure $bind = null; private ?string $errorMessage = null; + private array $deferredValidation = []; + /** * Initialize a new TestCase instance with an optional message. @@ -79,40 +83,35 @@ public function error(string $message): self */ public function validate(mixed $expect, Closure $validation): self { - $this->addTestUnit($expect, function(mixed $value, ValidatePool $inst) use($validation) { + $this->expectAndValidate($expect, function(mixed $value, ValidatePool $inst) use($validation) { return $validation($inst, $value); }, $this->errorMessage); return $this; } - + /** - * Same as "addTestUnit" but is public and will make sure the validation can be - * properly registered and traceable + * Executes a test case at runtime by validating the expected value. * - * @param mixed $expect The expected value - * @param array|Closure $validation The validation logic - * @param string|null $message An optional descriptive message for the test + * Accepts either a validation array (method => arguments) or a Closure + * containing multiple inline assertions. If any validation fails, the test + * is marked as invalid and added to the list of failed tests. + * + * @param mixed $expect The value to test. + * @param array|Closure $validation A list of validation methods with arguments, + * or a closure defining the test logic. + * @param string|null $message Optional custom message for test reporting. * @return $this - * @throws ErrorException + * @throws ErrorException If validation fails during runtime execution. */ - public function add(mixed $expect, array|Closure $validation, ?string $message = null) { - return $this->addTestUnit($expect, $validation, $message); - } - - /** - * Create a test - * - * @param mixed $expect - * @param array|Closure $validation - * @param string|null $message - * @return TestCase - * @throws ErrorException - */ - protected function addTestUnit(mixed $expect, array|Closure $validation, ?string $message = null): self - { + protected function expectAndValidate( + mixed $expect, + array|Closure $validation, + ?string $message = null + ): self { $this->value = $expect; - $test = new TestUnit($this->value, $message); + $test = new TestUnit($message); + $test->setTestValue($this->value); if($validation instanceof Closure) { $list = $this->buildClosureTest($validation); foreach($list as $method => $valid) { @@ -136,6 +135,38 @@ protected function addTestUnit(mixed $expect, array|Closure $validation, ?string return $this; } + /** + * Adds a deferred validation to be executed after all immediate tests. + * + * Use this to queue up validations that depend on external factors or should + * run after the main test suite. These will be executed in the order they were added. + * + * @param Closure $validation A closure containing the deferred test logic. + * @return void + */ + public function deferValidation(Closure $validation) + { + // This will add a cursor to the possible line and file where error occurred + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + $this->deferredValidation[] = [ + "trace" => $trace, + "call" => $validation + ]; + } + + /** + * Same as "addTestUnit" but is public and will make sure the validation can be + * properly registered and traceable + * + * @param mixed $expect The expected value + * @param array|Closure $validation The validation logic + * @param string|null $message An optional descriptive message for the test + * @return $this + * @throws ErrorException + */ + public function add(mixed $expect, array|Closure $validation, ?string $message = null) { + return $this->expectAndValidate($expect, $validation, $message); + } /** * Init a test wrapper @@ -143,25 +174,112 @@ protected function addTestUnit(mixed $expect, array|Closure $validation, ?string * @param string $className * @return TestWrapper */ - public function wrapper(string $className): TestWrapper + public function wrap(string $className): TestWrapper { return new class($className) extends TestWrapper { }; } - public function mock(string $className, null|array|Closure $validate = null): object + /** + * Creates and returns an instance of a dynamically generated mock class. + * + * The mock class is based on the provided class name and optional constructor arguments. + * A validation closure can also be provided to define mock expectations. These + * validations are deferred and will be executed later via runDeferredValidations(). + * + * @param string|array $classArg Either the class name as a string, + * or an array with [className, constructorArgs]. + * @param Closure|null $validate Optional closure to define expectations on the mock. + * @return object An instance of the dynamically created mock class. + * @throws \ReflectionException If the class or constructor cannot be reflected. + */ + public function mock(string|array $classArg, null|Closure $validate = null): object { - $mocker = new TestMocker($className); - if(is_array($validate)) { - $mocker->validate($validate); + $args = []; + $className = $classArg; + if(is_array($classArg)) { + $className = $classArg[0]; + $args = ($classArg[1] ?? []); } + + $mocker = new Mocker($className, $args); if(is_callable($validate)) { - $fn = $validate->bindTo($mocker); - $fn($mocker); + $pool = $mocker->getMethodPool(); + $fn = $validate->bindTo($pool); + $fn($pool); + + $this->deferValidation(function() use($mocker, $pool) { + $error = []; + $data = MockerController::getData($mocker->getMockedClassName()); + + foreach($data as $row) { + $item = $pool->get($row->name); + if($item) { + foreach (get_object_vars($item) as $property => $value) { + if(!is_null($value)) { + + + $currentValue = $row->{$property}; + if(is_array($value)) { + $validPool = new ValidatePool($currentValue); + foreach($value as $method => $args) { + $validPool->{$method}(...$args); + } + $valid = $validPool->isValid(); + } else { + $valid = Inp::value($currentValue)->equal($value); + } + + $error[$row->name][] = [ + "property" => $property, + "currentValue" => $currentValue, + "expectedValue" => $value, + "valid" => $valid + ]; + } + } + } + } + return $error; + }); } return $mocker->execute(); } + /** + * Executes all deferred validations that were registered earlier using deferValidation(). + * + * This method runs each queued validation closure, collects their results, + * and converts them into individual TestUnit instances. If a validation fails, + * it increases the internal failure count and stores the test details for later reporting. + * + * @return TestUnit[] A list of TestUnit results from the deferred validations. + * @throws ErrorException If any validation logic throws an error during execution. + */ + public function runDeferredValidations() + { + foreach($this->deferredValidation as $row) { + $error = $row['call'](); + foreach($error as $method => $arr) { + $test = new TestUnit("Mock method \"{$method}\" failed"); + if(is_array($row['trace'] ?? "")) { + $test->setCodeLine($row['trace']); + } + foreach($arr as $data) { + $test->setUnit($data['valid'], $data['property'], [], [ + $data['expectedValue'], $data['currentValue'] + ]); + if (!$data['valid']) { + $this->count++; + } + } + $this->test[] = $test; + } + } + + return $this->test; + } + /** * Get failed test counts @@ -239,9 +357,9 @@ public function getTest(): array * @param Closure $validation * @return array */ - public function buildClosureTest(Closure $validation): array + protected function buildClosureTest(Closure $validation): array { - $bool = false; + //$bool = false; $validPool = new ValidatePool($this->value); $validation = $validation->bindTo($validPool); @@ -269,7 +387,7 @@ public function buildClosureTest(Closure $validation): array * @return bool * @throws ErrorException */ - public function buildArrayTest(string $method, array|Closure $args): bool + protected function buildArrayTest(string $method, array|Closure $args): bool { if($args instanceof Closure) { $args = $args->bindTo($this->valid($this->value)); diff --git a/src/TestMocker.php b/src/TestMocker.php deleted file mode 100755 index 41ecad8..0000000 --- a/src/TestMocker.php +++ /dev/null @@ -1,248 +0,0 @@ -reflection = new ReflectionClass($className); - $this->methods = $this->reflection->getMethods(ReflectionMethod::IS_PUBLIC); - - } - - /** - * Executes the creation of a dynamic mock class and returns an instance of the mock. - * - * @return mixed - */ - function execute(): mixed - { - $className = $this->reflection->getName(); - $mockClassName = 'UnitaryMockery_' . uniqid(); - $overrides = $this->overrideMethods(); - $code = " - class {$mockClassName} extends {$className} { - {$overrides} - } - "; - eval($code); - return new $mockClassName(); - } - - function return(mixed $returnValue): self - { - self::$return = $returnValue; - return $this; - } - - - static public function getReturn(): mixed - { - return self::$return; - } - - /** - * @param array $types - * @return string - * @throws \ReflectionException - */ - function getReturnValue(array $types): string - { - $property = new ReflectionProperty($this, 'return'); - if ($property->isInitialized($this)) { - $type = gettype(self::getReturn()); - if($types && !in_array($type, $types) && !in_array("mixed", $types)) { - throw new InvalidArgumentException("Mock value \"" . self::getReturn() . "\" should return data type: " . implode(', ', $types)); - } - - return $this->getMockValueForType($type, self::getReturn()); - } - if ($types) { - return $this->getMockValueForType($types[0]); - } - return "return 'MockedValue';"; - } - - /** - * Overrides all methods in class - * - * @return string - */ - protected function overrideMethods(): string - { - $overrides = ''; - foreach ($this->methods as $method) { - if ($method->isConstructor()) { - continue; - } - - $params = []; - $methodName = $method->getName(); - $types = $this->getReturnType($method); - $returnValue = $this->getReturnValue($types); - - foreach ($method->getParameters() as $param) { - $paramStr = ''; - if ($param->hasType()) { - $paramStr .= $param->getType() . ' '; - } - if ($param->isPassedByReference()) { - $paramStr .= '&'; - } - $paramStr .= '$' . $param->getName(); - if ($param->isDefaultValueAvailable()) { - $paramStr .= ' = ' . var_export($param->getDefaultValue(), true); - } - $params[] = $paramStr; - } - - $paramList = implode(', ', $params); - $returnType = ($types) ? ': ' . implode('|', $types) : ''; - $overrides .= " - public function {$methodName}({$paramList}){$returnType} - { - {$returnValue} - } - "; - } - - return $overrides; - } - - /** - * Get expected return types - * - * @param $method - * @return array - */ - protected function getReturnType($method): array - { - $types = []; - $returnType = $method->getReturnType(); - if ($returnType instanceof ReflectionNamedType) { - $types[] = $returnType->getName(); - } elseif ($returnType instanceof ReflectionUnionType) { - foreach ($returnType->getTypes() as $type) { - $types[] = $type->getName(); - } - - } elseif ($returnType instanceof ReflectionIntersectionType) { - $intersect = array_map(fn($type) => $type->getName(), $returnType->getTypes()); - $types[] = $intersect; - } - if(!in_array("mixed", $types) && $returnType->allowsNull()) { - $types[] = "null"; - } - return $types; - } - - /** - * Generates a mock value for the specified type. - * - * @param string $typeName The name of the type for which to generate the mock value. - * @param bool $nullable Indicates if the returned value can be nullable. - * @return mixed Returns a mock value corresponding to the given type, or null if nullable and conditions allow. - */ - protected function getMockValueForType(string $typeName, mixed $value = null, bool $nullable = false): mixed - { - $typeName = strtolower($typeName); - if(!is_null($value)) { - return "return \MaplePHP\Unitary\TestMocker::getReturn();"; - } - $mock = match ($typeName) { - 'integer' => "return 123456;", - 'double' => "return 3.14;", - 'string' => "return 'mockString';", - 'boolean' => "return true;", - 'array' => "return ['item'];", - 'object' => "return (object)['item'];", - 'resource' => "return fopen('php://memory', 'r+');", - 'callable' => "return fn() => 'called';", - 'iterable' => "return new ArrayIterator(['a', 'b']);", - 'null' => "return null;", - 'void' => "", - default => 'return class_exists($typeName) ? new class($typeName) extends TestMocker {} : null;', - }; - return $nullable && rand(0, 1) ? null : $mock; - } - - - /** - * Will return a streamable content - * @param $resourceValue - * @return string|null - */ - protected function handleResourceContent($resourceValue) - { - return var_export(stream_get_contents($resourceValue), true); - } - - /** - * Proxies calls to the wrapped instance or bound methods. - * - * @param string $name - * @param array $arguments - * @return mixed - * @throws Exception - */ - public function __call(string $name, array $arguments): mixed - { - if (method_exists($this->instance, $name)) { - - $types = $this->getReturnType($name); - if(!isset($types[0]) && is_null($this->return)) { - throw new Exception("Could automatically mock Method \"$name\". " . - "You will need to manually mock it with ->return([value]) mock method!"); - } - - if (!is_null($this->return)) { - return $this->return; - } - - if(isset($types[0]) && is_array($types[0]) && count($types[0]) > 0) { - $last = end($types[0]); - return new self($last); - } - - $mockValue = $this->getMockValueForType($types[0]); - if($mockValue instanceof self) { - return $mockValue; - } - - if(!in_array(gettype($mockValue), $types)) { - throw new Exception("Mock value $mockValue is not in the return type " . implode(', ', $types)); - } - return $mockValue; - } - - throw new \BadMethodCallException("Method \"$name\" does not exist in class \"" . $this->instance::class . "\"."); - } -} \ No newline at end of file diff --git a/src/TestUnit.php b/src/TestUnit.php index a0cebde..e02112a 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -10,7 +10,8 @@ class TestUnit { private bool $valid; - private mixed $value; + private mixed $value = null; + private bool $hasValue = false; private ?string $message; private array $unit = []; private int $count = 0; @@ -23,37 +24,70 @@ class TestUnit * @param mixed $value * @param string|null $message */ - public function __construct(mixed $value, ?string $message = null) + public function __construct(?string $message = null) { $this->valid = true; - $this->value = $value; $this->message = is_null($message) ? "Could not validate" : $message; } + /** + * Check if value should be presented + * + * @return bool + */ + public function hasValue(): bool + { + return $this->hasValue; + } + + /** + * Set a test value + * + * @param mixed $value + * @return void + */ + public function setTestValue(mixed $value) + { + $this->value = $value; + $this->hasValue = true; + } + /** * Set the test unit * - * @param bool $valid - * @param string|null $validation + * @param bool|null $valid can be null if validation should execute later + * @param string|null|\Closure $validation * @param array $args + * @param array $compare * @return $this + * @throws ErrorException */ - public function setUnit(bool $valid, ?string $validation = null, array $args = []): self + public function setUnit( + bool|null $valid, + null|string|\Closure $validation = null, + array $args = [], + array $compare = []): self { if(!$valid) { $this->valid = false; $this->count++; } - $valLength = strlen((string)$validation); - if($validation && $this->valLength < $valLength) { - $this->valLength = $valLength; + if(!is_callable($validation)) { + $valLength = strlen((string)$validation); + if($validation && $this->valLength < $valLength) { + $this->valLength = $valLength; + } + } + + if($compare && count($compare) > 0) { + $compare = array_map(fn($value) => $this->getReadValue($value, true), $compare); } - $this->unit[] = [ 'valid' => $valid, 'validation' => $validation, - 'args' => $args + 'args' => $args, + 'compare' => $compare ]; return $this; } @@ -159,34 +193,37 @@ public function getValue(): mixed /** * Used to get a readable value * - * @return string + * @param mixed|null $value + * @param bool $minify + * @return string|bool * @throws ErrorException */ - public function getReadValue(): string + public function getReadValue(mixed $value = null, bool $minify = false): string|bool { - if (is_bool($this->value)) { - return '"' . ($this->value ? "true" : "false") . '"' . " (type: bool)"; + $value = is_null($value) ? $this->value : $value; + if (is_bool($value)) { + return '"' . ($value ? "true" : "false") . '"' . ($minify ? "" : " (type: bool)"); } - if (is_int($this->value)) { - return '"' . $this->excerpt((string)$this->value) . '"' . " (type: int)"; + if (is_int($value)) { + return '"' . $this->excerpt((string)$value) . '"' . ($minify ? "" : " (type: int)"); } - if (is_float($this->value)) { - return '"' . $this->excerpt((string)$this->value) . '"' . " (type: float)"; + if (is_float($value)) { + return '"' . $this->excerpt((string)$value) . '"' . ($minify ? "" : " (type: float)"); } - if (is_string($this->value)) { - return '"' . $this->excerpt($this->value) . '"' . " (type: string)"; + if (is_string($value)) { + return '"' . $this->excerpt($value) . '"' . ($minify ? "" : " (type: string)"); } - if (is_array($this->value)) { - return '"' . $this->excerpt(json_encode($this->value)) . '"' . " (type: array)"; + if (is_array($value)) { + return '"' . $this->excerpt(json_encode($value)) . '"' . ($minify ? "" : " (type: array)"); } - if (is_object($this->value)) { - return '"' . $this->excerpt(get_class($this->value)) . '"' . " (type: object)"; + if (is_object($value)) { + return '"' . $this->excerpt(get_class($value)) . '"' . ($minify ? "" : " (type: object)"); } - if (is_null($this->value)) { - return '"null" (type: null)'; + if (is_null($value)) { + return '"null"'. ($minify ? '' : ' (type: null)'); } - if (is_resource($this->value)) { - return '"' . $this->excerpt(get_resource_type($this->value)) . '"' . " (type: resource)"; + if (is_resource($value)) { + return '"' . $this->excerpt(get_resource_type($value)) . '"' . ($minify ? "" : " (type: resource)"); } return "(unknown type)"; diff --git a/src/Unit.php b/src/Unit.php index 4b69637..826bb14 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -7,6 +7,7 @@ use Closure; use ErrorException; use Exception; +use MaplePHP\Unitary\Mocker\MockerController; use RuntimeException; use MaplePHP\Unitary\Handlers\HandlerInterface; use MaplePHP\Http\Interfaces\StreamInterface; @@ -153,7 +154,7 @@ public function performance(Closure $func, ?string $title = null): void $start = new TestMem(); $func = $func->bindTo($this); if(!is_null($func)) { - $func(); + $func($this); } $line = $this->command->getAnsi()->line(80); $this->command->message(""); @@ -179,6 +180,7 @@ public function performance(Closure $func, ?string $title = null): void /** * Execute tests suite + * * @return bool * @throws ErrorException */ @@ -196,7 +198,8 @@ public function execute(): bool } $errArg = self::getArgs("errors-only"); - $tests = $row->dispatchTest(); + $row->dispatchTest(); + $tests = $row->runDeferredValidations(); $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); if($row->hasFailed()) { @@ -233,21 +236,41 @@ public function execute(): bool if(!empty($trace['code'])) { $this->command->message($this->command->getAnsi()->style(["bold", "grey"], "Failed on line {$trace['line']}: ")); $this->command->message($this->command->getAnsi()->style(["grey"], " → {$trace['code']}")); - } /** @var array $unit */ foreach($test->getUnits() as $unit) { - $title = str_pad($unit['validation'], $test->getValidationLength() + 1); - $this->command->message( - $this->command->getAnsi()->style( - ((!$unit['valid']) ? "brightRed" : null), - " " .$title . ((!$unit['valid']) ? " → failed" : "") - ) - ); + if(is_string($unit['validation']) && !$unit['valid']) { + $lengthA = $test->getValidationLength() + 1; + $title = str_pad($unit['validation'], $lengthA); + + $compare = ""; + if($unit['compare']) { + $expectedValue = array_shift($unit['compare']); + $compare = "Expected: {$expectedValue} | Actual: " . implode(":", $unit['compare']); + } + + $failedMsg = " " .$title . ((!$unit['valid']) ? " → failed" : ""); + $this->command->message( + $this->command->getAnsi()->style( + ((!$unit['valid']) ? "brightRed" : null), + $failedMsg + ) + ); + + if(!$unit['valid'] && $compare) { + $lengthB = (strlen($compare) + strlen($failedMsg) - 8); + $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); + $this->command->message( + $this->command->getAnsi()->style("brightRed", $comparePad) + ); + } + } + } + if($test->hasValue()) { + $this->command->message(""); + $this->command->message($this->command->getAnsi()->bold("Value: ") . $test->getReadValue()); } - $this->command->message(""); - $this->command->message($this->command->getAnsi()->bold("Value: ") . $test->getReadValue()); } } @@ -276,7 +299,8 @@ public function execute(): bool } /** - * Will reset the execute and stream if is a seekable stream. + * Will reset the execute and stream if is a seekable stream + * * @return bool */ public function resetExecute(): bool @@ -293,6 +317,7 @@ public function resetExecute(): bool /** * Validate before execute test + * * @return bool */ private function validate(): bool diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 40f47ea..fdce99c 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,43 +1,96 @@ + isValidEmail($email)) { + throw new \Exception("Invalid email"); + } + return "Sent email"; + } + + public function isValidEmail(string $email): bool + { + return filter_var($email, FILTER_VALIDATE_EMAIL); + } + + public function getFromEmail(string $email): string + { + return $this->from; + } + + /** + * Add from email address + * + * @param string $email + * @return void + */ + public function addFromEmail(string $email): void + { + $this->from = $email; + } + + public function addBCC(string $email): void { - echo "Sent email to $email"; - return "SENT!!"; + $this->bcc = $email; } + } class UserService { public function __construct(private Mailer $mailer) {} - public function registerUser(string $email): void { + public function registerUser(string $email, string $name = "Daniel"): void { // register user logic... - echo $this->mailer->sendEmail($email)."\n"; - echo $this->mailer->sendEmail($email); + + if(!$this->mailer->isValidEmail($email)) { + throw new \Exception("Invalid email"); + } + echo $this->mailer->sendEmail($email, $name)."\n"; + echo $this->mailer->sendEmail($email, $name); } } $unit = new Unit(); +$unit->group("Unitary test 2", function (TestCase $inst) { + + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + $pool->method("addFromEmail") + ->isPublic() + ->hasDocComment() + ->hasReturnType() + ->count(0); + + $pool->method("addBCC") + ->isPublic() + ->hasDocComment() + ->count(0); + }); + $service = new UserService($mock); -$unit->group("Unitary test", function (TestCase $inst) { + $inst->validate("yourTestValue", function(ValidatePool $inst) { + $inst->isBool(); + $inst->isInt(); + $inst->isJson(); + $inst->isString(); + $inst->isResource(); + }); // Example 1 /* - $mock = $this->mock(Mailer::class, function ($mock) { - $mock->method("testMethod1")->count(1)->return("lorem1"); - $mock->method("testMethod2")->count(1)->return("lorem1"); - }); + $service = new UserService($mock); // Example 2 @@ -55,15 +108,18 @@ public function registerUser(string $email): void { $service->registerUser('user@example.com'); */ - $inst->validate("yourTestValue", function(ValidatePool $inst, mixed $value) { + /* + $inst->validate("yourTestValue", function(ValidatePool $inst, mixed $value) { $inst->isBool(); $inst->isInt(); $inst->isJson(); $inst->isString(); + $inst->isResource(); return ($value === "yourTestValue1"); }); $inst->validate("yourTestValue", fn(ValidatePool $inst) => $inst->isfloat()); + */ //$inst->listAllProxyMethods(Inp::class); //->error("Failed to validate yourTestValue (optional error message)") @@ -96,3 +152,4 @@ public function registerUser(string $email): void { ], "The length is not correct!"); }); + From b8a81d8d9540904e97c364c05feb6362980b264e Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Mon, 14 Apr 2025 19:53:24 +0200 Subject: [PATCH 06/78] Add mock validations to method params Add spread support to method in mocked class --- src/Mocker/MethodItem.php | 97 ++++++++++++++++++++++++++++++++++++--- src/Mocker/Mocker.php | 9 +++- src/TestCase.php | 4 +- tests/unitary-unitary.php | 19 +++++++- 4 files changed, 116 insertions(+), 13 deletions(-) diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index d44e5e0..490f05a 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -243,20 +243,103 @@ public function isDestructor(): self } /** - * Not yet working - * Set the parameters of the method. + * Check parameter type for method * - * @param array $parameters - * @return self + * @param int $paramPosition + * @param string $dataType + * @return $this + */ + public function paramType(int $paramPosition, string $dataType): self + { + $inst = $this; + $inst->parameters = [ + "validateInData" => ["{$paramPosition}.type", "equal", [$dataType]], + ]; + return $inst; + } + + /** + * Check parameter default value for method + * + * @param int $paramPosition + * @param string $defaultArgValue + * @return $this + */ + public function paramDefault(int $paramPosition, string $defaultArgValue): self + { + $inst = $this; + $inst->parameters = [ + "validateInData" => ["{$paramPosition}.default", "equal", [$defaultArgValue]], + ]; + return $inst; + } + + /** + * Check parameter type for method + * + * @param int $paramPosition + * @return $this + */ + public function paramHasType(int $paramPosition): self + { + $inst = $this; + $inst->parameters = [ + "validateInData" => ["{$paramPosition}.hasType", "equal", [true]], + ]; + return $inst; + } + + /** + * Check parameter type for method + * + * @param int $paramPosition + * @return $this + */ + public function paramIsOptional(int $paramPosition): self + { + $inst = $this; + $inst->parameters = [ + "validateInData" => ["{$paramPosition}.isOptional", "equal", [true]], + ]; + return $inst; + } + + /** + * Check parameter is Reference for method + * + * @param int $paramPosition + * @return $this */ - public function parameters(array $parameters): self + public function paramIsReference(int $paramPosition): self { - throw new \BadMethodCallException('Method Item::parameters() does not "YET" exist.'); $inst = $this; - $inst->parameters = $parameters; + $inst->parameters = [ + "validateInData" => ["{$paramPosition}.isReference", "equal", [true]], + ]; + return $inst; + } + + /** + * Check parameter is variadic (spread) for method + * + * @param int $paramPosition + * @return $this + */ + public function paramIsVariadic(int $paramPosition): self + { + $inst = $this; + $inst->parameters = [ + "validateInData" => ["{$paramPosition}.isVariadic", "equal", [true]], + ]; return $inst; } + // Symlink to paramIsVariadic + public function paramIsSpread(int $paramPosition): self + { + return $this->paramIsVariadic($paramPosition); + } + /** * Set the doc comment for the method. * diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index eb31c77..58f6a91 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -222,6 +222,11 @@ protected function generateMethodSignature(ReflectionMethod $method): string if ($param->isDefaultValueAvailable()) { $paramStr .= ' = ' . var_export($param->getDefaultValue(), true); } + + if ($param->isVariadic()) { + $paramStr = "...{$paramStr}"; + } + $params[] = $paramStr; } return implode(', ', $params); @@ -233,7 +238,7 @@ protected function generateMethodSignature(ReflectionMethod $method): string * @param ReflectionMethod $method The reflection object for the method to inspect. * @return array An array of the expected return types for the given method. */ - protected function getReturnType($method): array + protected function getReturnType(ReflectionMethod $method): array { $types = []; $returnType = $method->getReturnType(); @@ -321,7 +326,7 @@ function getMethodInfoAsArray(ReflectionMethod $refMethod): array 'type' => $param->hasType() ? $param->getType()->__toString() : null, 'isOptional' => $param->isOptional(), 'isVariadic' => $param->isVariadic(), - 'isPassedByReference' => $param->isPassedByReference(), + 'isReference' => $param->isPassedByReference(), 'default' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null, ]; } diff --git a/src/TestCase.php b/src/TestCase.php index 1cc11eb..80b7ae1 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -217,8 +217,6 @@ public function mock(string|array $classArg, null|Closure $validate = null): obj if($item) { foreach (get_object_vars($item) as $property => $value) { if(!is_null($value)) { - - $currentValue = $row->{$property}; if(is_array($value)) { $validPool = new ValidatePool($currentValue); @@ -226,6 +224,8 @@ public function mock(string|array $classArg, null|Closure $validate = null): obj $validPool->{$method}(...$args); } $valid = $validPool->isValid(); + $currentValue = $validPool->getValue(); + } else { $valid = Inp::value($currentValue)->equal($value); } diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index fdce99c..b58c197 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -35,16 +35,21 @@ public function getFromEmail(string $email): string * @param string $email * @return void */ - public function addFromEmail(string $email): void + public function addFromEmail($email): void { $this->from = $email; } - public function addBCC(string $email): void + public function addBCC(string $email, &$name = "Daniel"): void { $this->bcc = $email; } + public function test(...$params): void + { + + } + } class UserService { @@ -65,6 +70,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { $unit = new Unit(); $unit->group("Unitary test 2", function (TestCase $inst) { + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addFromEmail") ->isPublic() @@ -75,6 +81,15 @@ public function registerUser(string $email, string $name = "Daniel"): void { $pool->method("addBCC") ->isPublic() ->hasDocComment() + ->paramHasType(0) + ->paramType(0, "string") + ->paramDefault(1, "Daniel") + ->paramIsOptional(1) + ->paramIsReference(1) + ->count(0); + + $pool->method("test") + ->paramIsSpread(0) // Same as ->paramIsVariadic() ->count(0); }); $service = new UserService($mock); From 1543977ae12f860daec00613021758fc07007a09 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Mon, 14 Apr 2025 23:30:49 +0200 Subject: [PATCH 07/78] Code quality improvements Add constructor arguments support for wrappers --- src/Mocker/MethodItem.php | 36 ++++++++++++++++++++------ src/Mocker/Mocker.php | 30 +++++++++++++++++++--- src/TestCase.php | 33 ++++++++++++------------ tests/unitary-unitary.php | 53 +++++++++++++++++++++++++-------------- 4 files changed, 104 insertions(+), 48 deletions(-) diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index 490f05a..8397275 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -7,7 +7,7 @@ class MethodItem { - private ?Mocker $mocker = null; + private ?Mocker $mocker; public mixed $return = null; public ?int $count = null; @@ -37,21 +37,41 @@ public function __construct(?Mocker $mocker = null) $this->mocker = $mocker; } + /** + * Will create a method wrapper making it possible to mock + * + * @param $call + * @return $this + */ public function wrap($call): self { $inst = $this; - $wrap = new class($this->mocker->getClassName()) extends TestWrapper { + $wrap = new class($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends TestWrapper { + function __construct(string $class, array $args = []) + { + parent::__construct($class, $args); + } }; $call->bindTo($this->mocker); $this->wrapper = $wrap->bind($call); return $inst; } + /** + * Get the wrapper if added as Closure else null + * + * @return Closure|null + */ public function getWrap(): ?Closure { return $this->wrapper; } + /** + * Check if a return value has been added + * + * @return bool + */ public function hasReturn(): bool { return $this->hasReturn; @@ -253,7 +273,7 @@ public function paramType(int $paramPosition, string $dataType): self { $inst = $this; $inst->parameters = [ - "validateInData" => ["{$paramPosition}.type", "equal", [$dataType]], + "validateInData" => ["$paramPosition.type", "equal", [$dataType]], ]; return $inst; } @@ -269,7 +289,7 @@ public function paramDefault(int $paramPosition, string $defaultArgValue): self { $inst = $this; $inst->parameters = [ - "validateInData" => ["{$paramPosition}.default", "equal", [$defaultArgValue]], + "validateInData" => ["$paramPosition.default", "equal", [$defaultArgValue]], ]; return $inst; } @@ -284,7 +304,7 @@ public function paramHasType(int $paramPosition): self { $inst = $this; $inst->parameters = [ - "validateInData" => ["{$paramPosition}.hasType", "equal", [true]], + "validateInData" => ["$paramPosition.hasType", "equal", [true]], ]; return $inst; } @@ -299,7 +319,7 @@ public function paramIsOptional(int $paramPosition): self { $inst = $this; $inst->parameters = [ - "validateInData" => ["{$paramPosition}.isOptional", "equal", [true]], + "validateInData" => ["$paramPosition.isOptional", "equal", [true]], ]; return $inst; } @@ -314,7 +334,7 @@ public function paramIsReference(int $paramPosition): self { $inst = $this; $inst->parameters = [ - "validateInData" => ["{$paramPosition}.isReference", "equal", [true]], + "validateInData" => ["$paramPosition.isReference", "equal", [true]], ]; return $inst; } @@ -329,7 +349,7 @@ public function paramIsVariadic(int $paramPosition): self { $inst = $this; $inst->parameters = [ - "validateInData" => ["{$paramPosition}.isVariadic", "equal", [true]], + "validateInData" => ["$paramPosition.isVariadic", "equal", [true]], ]; return $inst; } diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index 58f6a91..13674a8 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -43,6 +43,14 @@ public function __construct(string $className, array $args = []) { $this->className = $className; $this->reflection = new ReflectionClass($className); + + /* + // Auto fill the Constructor args! + $test = $this->reflection->getConstructor(); + $test = $this->generateMethodSignature($test); + $param = $test->getParameters(); + */ + $this->methods = $this->reflection->getMethods(); $this->constructorArgs = $args; } @@ -52,6 +60,11 @@ public function getClassName(): string return $this->className; } + public function getClassArgs(): array + { + return $this->constructorArgs; + } + /** * Override the default method overrides with your own mock logic and validation rules * @@ -73,10 +86,10 @@ public function getMockedClassName(): string /** * Executes the creation of a dynamic mock class and returns an instance of the mock. * - * @return object An instance of the dynamically created mock class. + * @return mixed An instance of the dynamically created mock class. * @throws \ReflectionException */ - public function execute(): object + public function execute(?callable $call = null): mixed { $className = $this->reflection->getName(); @@ -173,7 +186,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string $info = json_encode($arr); MockerController::getInstance()->buildMethodData($info); - if($methodItem && !in_array("void", $types)) { + if($methodItem) { $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); } @@ -191,11 +204,20 @@ protected function generateMockMethodOverrides(string $mockClassName): string } + /** + * Will build the wrapper return + * + * @param \Closure|null $wrapper + * @param string $methodName + * @param string $returnValue + * @return string + */ protected function generateWrapperReturn(?\Closure $wrapper, string $methodName, string $returnValue) { MockerController::addData($this->mockClassName, $methodName, 'wrapper', $wrapper); + $return = ($returnValue) ? "return " : ""; return " if (isset(\$data->wrapper) && \$data->wrapper instanceof \\Closure) { - return call_user_func_array(\$data->wrapper, func_get_args()); + {$return}call_user_func_array(\$data->wrapper, func_get_args()); } {$returnValue} "; diff --git a/src/TestCase.php b/src/TestCase.php index 80b7ae1..658ae26 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -171,12 +171,17 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = /** * Init a test wrapper * - * @param string $className + * @param string $class + * @param array $args * @return TestWrapper */ - public function wrap(string $className): TestWrapper + public function wrap(string $class, array $args = []): TestWrapper { - return new class($className) extends TestWrapper { + return new class($class, $args) extends TestWrapper { + function __construct(string $class, array $args = []) + { + parent::__construct($class, $args); + } }; } @@ -187,22 +192,16 @@ public function wrap(string $className): TestWrapper * A validation closure can also be provided to define mock expectations. These * validations are deferred and will be executed later via runDeferredValidations(). * - * @param string|array $classArg Either the class name as a string, - * or an array with [className, constructorArgs]. - * @param Closure|null $validate Optional closure to define expectations on the mock. - * @return object An instance of the dynamically created mock class. - * @throws \ReflectionException If the class or constructor cannot be reflected. + * @template T of object + * @param class-string $class + * @param Closure|null $validate + * @param array $args + * @return T + * @throws \ReflectionException */ - public function mock(string|array $classArg, null|Closure $validate = null): object + public function mock(string $class, ?Closure $validate = null, array $args = []): mixed { - $args = []; - $className = $classArg; - if(is_array($classArg)) { - $className = $classArg[0]; - $args = ($classArg[1] ?? []); - } - - $mocker = new Mocker($className, $args); + $mocker = new Mocker($class, $args); if(is_callable($validate)) { $pool = $mocker->getMethodPool(); $fn = $validate->bindTo($pool); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index b58c197..723128f 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -11,6 +11,13 @@ class Mailer { public $from = ""; public $bcc = ""; + + + public function __construct(string $arg1) + { + + } + public function sendEmail(string $email, string $name = "daniel"): string { if(!$this->isValidEmail($email)) { @@ -47,7 +54,6 @@ public function addBCC(string $email, &$name = "Daniel"): void public function test(...$params): void { - } } @@ -70,7 +76,6 @@ public function registerUser(string $email, string $name = "Daniel"): void { $unit = new Unit(); $unit->group("Unitary test 2", function (TestCase $inst) { - $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addFromEmail") ->isPublic() @@ -90,11 +95,24 @@ public function registerUser(string $email, string $name = "Daniel"): void { $pool->method("test") ->paramIsSpread(0) // Same as ->paramIsVariadic() - ->count(0); - }); + ->wrap(function($args) use($inst) { + echo "World -> $args\n"; + }) + ->count(1); + + }, ["Arg 1"]); + + $mock->test("Hello"); $service = new UserService($mock); + + + // Example 1 + /* + + + $inst->validate("yourTestValue", function(ValidatePool $inst) { $inst->isBool(); $inst->isInt(); @@ -103,8 +121,18 @@ public function registerUser(string $email, string $name = "Daniel"): void { $inst->isResource(); }); - // Example 1 - /* + $arr = [ + "user" => [ + "name" => "John Doe", + "email" => "john.doe@gmail.com", + ] + ]; + + $inst->validate($arr, function(ValidatePool $inst) { + $inst->validateInData("user.name", "email"); + $inst->validateInData("user.email", "length", [1, 200]); + }); + $service = new UserService($mock); @@ -152,19 +180,6 @@ public function registerUser(string $email, string $name = "Daniel"): void { $service->registerUser('user@example.com'); */ - $this->add("Lorem ipsum dolor", [ - "isString" => [], - "length" => [1,300] - - ])->add(92928, [ - "isInt" => [] - - ])->add("Lorem", [ - "isString" => [], - "length" => function () { - return $this->length(1, 50); - } - ], "The length is not correct!"); }); From 7ab63d623de58420a8d47752327fdf3b18c7233d Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Tue, 15 Apr 2025 22:54:31 +0200 Subject: [PATCH 08/78] Add more validations --- src/Mocker/MethodItem.php | 61 +++++++++++++++++++++++++++++++++++++-- tests/unitary-unitary.php | 17 +++++++++-- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index 8397275..812180f 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -262,6 +262,63 @@ public function isDestructor(): self return $inst; } + /** + * Check if parameter exists + * + * @return $this + */ + public function hasParams(): self + { + $inst = $this; + $inst->parameters = [ + "isCountMoreThan" => [0], + ]; + return $inst; + } + + /** + * Check if all parameters has a data type + * + * @return $this + */ + public function hasParamsTypes(): self + { + $inst = $this; + $inst->parameters = [ + "itemsAreTruthy" => ['hasType', true], + ]; + return $inst; + } + + /** + * Check if parameter do not exist + * + * @return $this + */ + public function hasNotParams(): self + { + $inst = $this; + $inst->parameters = [ + "isArrayEmpty" => [], + ]; + return $inst; + } + + /** + * Check parameter type for method + * + * @param int $length + * @return $this + */ + public function hasParamsCount(int $length): self + { + $inst = $this; + $inst->parameters = [ + "isCountEqualTo" => [$length], + ]; + return $inst; + } + /** * Check parameter type for method * @@ -269,7 +326,7 @@ public function isDestructor(): self * @param string $dataType * @return $this */ - public function paramType(int $paramPosition, string $dataType): self + public function paramIsType(int $paramPosition, string $dataType): self { $inst = $this; $inst->parameters = [ @@ -285,7 +342,7 @@ public function paramType(int $paramPosition, string $dataType): self * @param string $defaultArgValue * @return $this */ - public function paramDefault(int $paramPosition, string $defaultArgValue): self + public function paramHasDefault(int $paramPosition, string $defaultArgValue): self { $inst = $this; $inst->parameters = [ diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 723128f..dbc032d 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -42,7 +42,7 @@ public function getFromEmail(string $email): string * @param string $email * @return void */ - public function addFromEmail($email): void + public function addFromEmail(string $email, string $name = ""): void { $this->from = $email; } @@ -56,6 +56,10 @@ public function test(...$params): void { } + public function test2(): void + { + } + } class UserService { @@ -78,6 +82,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addFromEmail") + ->hasParamsTypes() ->isPublic() ->hasDocComment() ->hasReturnType() @@ -86,20 +91,26 @@ public function registerUser(string $email, string $name = "Daniel"): void { $pool->method("addBCC") ->isPublic() ->hasDocComment() + ->hasParams() ->paramHasType(0) - ->paramType(0, "string") - ->paramDefault(1, "Daniel") + ->paramIsType(0, "string") + ->paramHasDefault(1, "Daniel") ->paramIsOptional(1) ->paramIsReference(1) ->count(0); $pool->method("test") + ->hasParams() ->paramIsSpread(0) // Same as ->paramIsVariadic() ->wrap(function($args) use($inst) { echo "World -> $args\n"; }) ->count(1); + $pool->method("test2") + ->hasNotParams() + ->count(0); + }, ["Arg 1"]); $mock->test("Hello"); From fed292004fd4db9048fbd58e9b81f80dd884df3b Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Thu, 17 Apr 2025 23:06:21 +0200 Subject: [PATCH 09/78] Add method parameter validation --- src/Mocker/MethodItem.php | 20 ++++----- src/TestCase.php | 85 +++++++++++++++++++++++++++++++------ tests/unitary-unitary.php | 89 ++++++++++----------------------------- 3 files changed, 103 insertions(+), 91 deletions(-) diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index 812180f..cae940f 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -270,7 +270,7 @@ public function isDestructor(): self public function hasParams(): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "isCountMoreThan" => [0], ]; return $inst; @@ -284,7 +284,7 @@ public function hasParams(): self public function hasParamsTypes(): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "itemsAreTruthy" => ['hasType', true], ]; return $inst; @@ -298,7 +298,7 @@ public function hasParamsTypes(): self public function hasNotParams(): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "isArrayEmpty" => [], ]; return $inst; @@ -313,7 +313,7 @@ public function hasNotParams(): self public function hasParamsCount(int $length): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "isCountEqualTo" => [$length], ]; return $inst; @@ -329,7 +329,7 @@ public function hasParamsCount(int $length): self public function paramIsType(int $paramPosition, string $dataType): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "validateInData" => ["$paramPosition.type", "equal", [$dataType]], ]; return $inst; @@ -345,7 +345,7 @@ public function paramIsType(int $paramPosition, string $dataType): self public function paramHasDefault(int $paramPosition, string $defaultArgValue): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "validateInData" => ["$paramPosition.default", "equal", [$defaultArgValue]], ]; return $inst; @@ -360,7 +360,7 @@ public function paramHasDefault(int $paramPosition, string $defaultArgValue): se public function paramHasType(int $paramPosition): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "validateInData" => ["$paramPosition.hasType", "equal", [true]], ]; return $inst; @@ -375,7 +375,7 @@ public function paramHasType(int $paramPosition): self public function paramIsOptional(int $paramPosition): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "validateInData" => ["$paramPosition.isOptional", "equal", [true]], ]; return $inst; @@ -390,7 +390,7 @@ public function paramIsOptional(int $paramPosition): self public function paramIsReference(int $paramPosition): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "validateInData" => ["$paramPosition.isReference", "equal", [true]], ]; return $inst; @@ -405,7 +405,7 @@ public function paramIsReference(int $paramPosition): self public function paramIsVariadic(int $paramPosition): self { $inst = $this; - $inst->parameters = [ + $inst->parameters[] = [ "validateInData" => ["$paramPosition.isVariadic", "equal", [true]], ]; return $inst; diff --git a/src/TestCase.php b/src/TestCase.php index 658ae26..89b29ee 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -7,10 +7,14 @@ use BadMethodCallException; use Closure; use ErrorException; +use MaplePHP\DTO\Traverse; use MaplePHP\Unitary\Mocker\Mocker; use MaplePHP\Unitary\Mocker\MockerController; use MaplePHP\Validate\Inp; use MaplePHP\Validate\ValidatePool; +use ReflectionClass; +use ReflectionException; +use ReflectionMethod; use RuntimeException; use Throwable; @@ -115,7 +119,7 @@ protected function expectAndValidate( if($validation instanceof Closure) { $list = $this->buildClosureTest($validation); foreach($list as $method => $valid) { - $test->setUnit(!$list, $method, []); + $test->setUnit(!$list, $method); } } else { foreach($validation as $method => $args) { @@ -144,7 +148,7 @@ protected function expectAndValidate( * @param Closure $validation A closure containing the deferred test logic. * @return void */ - public function deferValidation(Closure $validation) + public function deferValidation(Closure $validation): void { // This will add a cursor to the possible line and file where error occurred $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; @@ -164,7 +168,8 @@ public function deferValidation(Closure $validation) * @return $this * @throws ErrorException */ - public function add(mixed $expect, array|Closure $validation, ?string $message = null) { + public function add(mixed $expect, array|Closure $validation, ?string $message = null): static + { return $this->expectAndValidate($expect, $validation, $message); } @@ -197,7 +202,7 @@ function __construct(string $class, array $args = []) * @param Closure|null $validate * @param array $args * @return T - * @throws \ReflectionException + * @throws ReflectionException */ public function mock(string $class, ?Closure $validate = null, array $args = []): mixed { @@ -206,11 +211,9 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) $pool = $mocker->getMethodPool(); $fn = $validate->bindTo($pool); $fn($pool); - $this->deferValidation(function() use($mocker, $pool) { $error = []; $data = MockerController::getData($mocker->getMockedClassName()); - foreach($data as $row) { $item = $pool->get($row->name); if($item) { @@ -220,15 +223,31 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) if(is_array($value)) { $validPool = new ValidatePool($currentValue); foreach($value as $method => $args) { - $validPool->{$method}(...$args); + if(is_int($method)) { + foreach($args as $methodB => $argsB) { + $validPool + ->mapErrorToKey($argsB[0]) + ->mapErrorValidationName($argsB[1]) + ->{$methodB}(...$argsB); + } + } else { + $validPool->{$method}(...$args); + } } $valid = $validPool->isValid(); - $currentValue = $validPool->getValue(); } else { $valid = Inp::value($currentValue)->equal($value); } + if(is_array($value)) { + $this->compareFromValidCollection( + $validPool, + $value, + $currentValue + ); + } + $error[$row->name][] = [ "property" => $property, "currentValue" => $currentValue, @@ -245,6 +264,44 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) return $mocker->execute(); } + /** + * Create a comparison from a validation collection + * + * @param ValidatePool $validPool + * @param array $value + * @param array $currentValue + * @return void + */ + protected function compareFromValidCollection(ValidatePool $validPool, array &$value, array &$currentValue): void + { + $new = []; + $error = $validPool->getError(); + $value = $this->mapValueToCollectionError($error, $value); + foreach($value as $eqIndex => $validator) { + $new[] = Traverse::value($currentValue)->eq($eqIndex)->get(); + } + $currentValue = $new; + } + + /** + * Will map collection value to error + * + * @param array $error + * @param array $value + * @return array + */ + protected function mapValueToCollectionError(array $error, array $value): array + { + foreach($value as $item) { + foreach($item as $value) { + if(isset($error[$value[0]])) { + $error[$value[0]] = $value[2]; + } + } + } + return $error; + } + /** * Executes all deferred validations that were registered earlier using deferValidation(). * @@ -255,12 +312,12 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) * @return TestUnit[] A list of TestUnit results from the deferred validations. * @throws ErrorException If any validation logic throws an error during execution. */ - public function runDeferredValidations() + public function runDeferredValidations(): array { foreach($this->deferredValidation as $row) { $error = $row['call'](); foreach($error as $method => $arr) { - $test = new TestUnit("Mock method \"{$method}\" failed"); + $test = new TestUnit("Mock method \"$method\" failed"); if(is_array($row['trace'] ?? "")) { $test->setCodeLine($row['trace']); } @@ -367,7 +424,7 @@ protected function buildClosureTest(Closure $validation): array $bool = $validation($this->value, $validPool); $error = $validPool->getError(); if(is_bool($bool) && !$bool) { - $error['customError'] = $bool; + $error['customError'] = false; } } @@ -430,12 +487,12 @@ protected function valid(mixed $value): Inp * @param string $class * @param string|null $prefixMethods * @return void - * @throws \ReflectionException + * @throws ReflectionException */ public function listAllProxyMethods(string $class, ?string $prefixMethods = null): void { - $reflection = new \ReflectionClass($class); - foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + $reflection = new ReflectionClass($class); + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { if ($method->isConstructor()) continue; $params = array_map(function($param) { $type = $param->hasType() ? $param->getType() . ' ' : ''; diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index dbc032d..aa984de 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -76,6 +76,20 @@ public function registerUser(string $email, string $name = "Daniel"): void { } } +$unit = new Unit(); +$unit->group("Unitary test 2", function (TestCase $inst) { + + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + $pool->method("addBCC") + ->paramIsType(0, "striwng") + ->paramHasDefault(1, "Daniwel") + ->paramIsReference(1) + ->count(1); + }, ["Arg 1"]); + $mock->addBCC("World"); +}); + +/* $unit = new Unit(); $unit->group("Unitary test 2", function (TestCase $inst) { @@ -116,13 +130,12 @@ public function registerUser(string $email, string $name = "Daniel"): void { $mock->test("Hello"); $service = new UserService($mock); - - - - // Example 1 - /* - - + $validPool = new ValidatePool("dwqdqw"); + $validPool + ->isEmail() + ->length(1, 200) + ->endsWith(".com"); + $isValid = $validPool->isValid(); $inst->validate("yourTestValue", function(ValidatePool $inst) { $inst->isBool(); @@ -132,65 +145,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { $inst->isResource(); }); - $arr = [ - "user" => [ - "name" => "John Doe", - "email" => "john.doe@gmail.com", - ] - ]; - - $inst->validate($arr, function(ValidatePool $inst) { - $inst->validateInData("user.name", "email"); - $inst->validateInData("user.email", "length", [1, 200]); - }); - - - $service = new UserService($mock); - - // Example 2 - $mock = $this->mock(Mailer::class, [ - "testMethod1" => [ - "count" => 1, - "validate" => [ - "equal" => "lorem1", - "contains" => "lorem", - "length" => [1,6] - ] - ] - ]); - $service = new UserService($mock); - $service->registerUser('user@example.com'); - */ - - /* - $inst->validate("yourTestValue", function(ValidatePool $inst, mixed $value) { - $inst->isBool(); - $inst->isInt(); - $inst->isJson(); - $inst->isString(); - $inst->isResource(); - return ($value === "yourTestValue1"); - }); - - $inst->validate("yourTestValue", fn(ValidatePool $inst) => $inst->isfloat()); - */ - - //$inst->listAllProxyMethods(Inp::class); - //->error("Failed to validate yourTestValue (optional error message)") - - - - /* - * $mock = $this->mock(Mailer::class); -echo "ww"; - - $service = new UserService($test); - $service->registerUser('user@example.com'); - var_dump($mock instanceof Mailer); - $service = new UserService($mock); - $service->registerUser('user@example.com'); - */ - - }); +*/ + From 06b5c4862bead4ab4575cd8c1e1c115b778bc8d1 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 27 Apr 2025 21:14:36 +0200 Subject: [PATCH 10/78] Add default variable values to method generator --- src/TestCase.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/TestCase.php b/src/TestCase.php index 89b29ee..26159c7 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -7,6 +7,7 @@ use BadMethodCallException; use Closure; use ErrorException; +use MaplePHP\DTO\Format\Str; use MaplePHP\DTO\Traverse; use MaplePHP\Unitary\Mocker\Mocker; use MaplePHP\Unitary\Mocker\MockerController; @@ -494,13 +495,14 @@ public function listAllProxyMethods(string $class, ?string $prefixMethods = null $reflection = new ReflectionClass($class); foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { if ($method->isConstructor()) continue; + $params = array_map(function($param) { $type = $param->hasType() ? $param->getType() . ' ' : ''; - return $type . '$' . $param->getName(); + $value = $param->isDefaultValueAvailable() ? ' = ' . Str::value($param->getDefaultValue())->exportReadableValue()->get() : null; + return $type . '$' . $param->getName() . $value; }, $method->getParameters()); $name = $method->getName(); - if(!$method->isStatic() && !str_starts_with($name, '__')) { if(!is_null($prefixMethods)) { $name = $prefixMethods . ucfirst($name); From 23ed4ba98386ae21d4fe219af3aa3a9668a505c3 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Mon, 28 Apr 2025 22:40:35 +0200 Subject: [PATCH 11/78] Change validation class name --- README.md | 6 ++-- src/TestCase.php | 58 +++++++++++++++++++++++++++------------ tests/unitary-unitary.php | 6 ++-- 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 48f7504..c0811f5 100644 --- a/README.md +++ b/README.md @@ -145,10 +145,10 @@ _Why? Sometimes you just want to quick mock so that a Mailer library will not se ### Custom mocking As I said Unitary mocker will try to automatically mock every method but might not successes in some user-cases -then you can just tell Unitary how those failed methods should load. +then you can just tell Unitary how those failed methods should load. ```php -use MaplePHP\Validate\ValidatePool; +use MaplePHP\Validate\ValidationChain; use \MaplePHP\Unitary\Mocker\MethodPool; $unit->group("Testing user service", function (TestCase $inst) { @@ -159,7 +159,7 @@ $unit->group("Testing user service", function (TestCase $inst) { // Or we can acctually pass a callable to it and tell it what it should return // But we can also validate the argumnets! $pool->method("addFromEmail")->wrap(function($email) use($inst) { - $inst->validate($email, function(ValidatePool $valid) { + $inst->validate($email, function(ValidationChain $valid) { $valid->email(); $valid->isString(); }); diff --git a/src/TestCase.php b/src/TestCase.php index 26159c7..690b3c7 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -11,8 +11,8 @@ use MaplePHP\DTO\Traverse; use MaplePHP\Unitary\Mocker\Mocker; use MaplePHP\Unitary\Mocker\MockerController; -use MaplePHP\Validate\Inp; -use MaplePHP\Validate\ValidatePool; +use MaplePHP\Validate\Validator; +use MaplePHP\Validate\ValidationChain; use ReflectionClass; use ReflectionException; use ReflectionMethod; @@ -82,13 +82,13 @@ public function error(string $message): self * Add a test unit validation using the provided expectation and validation logic * * @param mixed $expect The expected value - * @param Closure(ValidatePool, mixed): bool $validation The validation logic + * @param Closure(ValidationChain, mixed): bool $validation The validation logic * @return $this * @throws ErrorException */ public function validate(mixed $expect, Closure $validation): self { - $this->expectAndValidate($expect, function(mixed $value, ValidatePool $inst) use($validation) { + $this->expectAndValidate($expect, function(mixed $value, ValidationChain $inst) use($validation) { return $validation($inst, $value); }, $this->errorMessage); @@ -222,7 +222,7 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) if(!is_null($value)) { $currentValue = $row->{$property}; if(is_array($value)) { - $validPool = new ValidatePool($currentValue); + $validPool = new ValidationChain($currentValue); foreach($value as $method => $args) { if(is_int($method)) { foreach($args as $methodB => $argsB) { @@ -238,7 +238,7 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) $valid = $validPool->isValid(); } else { - $valid = Inp::value($currentValue)->equal($value); + $valid = Validator::value($currentValue)->equal($value); } if(is_array($value)) { @@ -268,12 +268,12 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) /** * Create a comparison from a validation collection * - * @param ValidatePool $validPool + * @param ValidationChain $validPool * @param array $value * @param array $currentValue * @return void */ - protected function compareFromValidCollection(ValidatePool $validPool, array &$value, array &$currentValue): void + protected function compareFromValidCollection(ValidationChain $validPool, array &$value, array &$currentValue): void { $new = []; $error = $validPool->getError(); @@ -417,7 +417,7 @@ public function getTest(): array protected function buildClosureTest(Closure $validation): array { //$bool = false; - $validPool = new ValidatePool($this->value); + $validPool = new ValidationChain($this->value); $validation = $validation->bindTo($validPool); $error = []; @@ -456,7 +456,7 @@ protected function buildArrayTest(string $method, array|Closure $args): bool throw new RuntimeException("A callable validation must return a boolean!"); } } else { - if(!method_exists(Inp::class, $method)) { + if(!method_exists(Validator::class, $method)) { throw new BadMethodCallException("The validation $method does not exist!"); } @@ -474,12 +474,12 @@ protected function buildArrayTest(string $method, array|Closure $args): bool * Init MaplePHP validation * * @param mixed $value - * @return Inp + * @return Validator * @throws ErrorException */ - protected function valid(mixed $value): Inp + protected function valid(mixed $value): Validator { - return new Inp($value); + return new Validator($value); } /** @@ -490,11 +490,23 @@ protected function valid(mixed $value): Inp * @return void * @throws ReflectionException */ - public function listAllProxyMethods(string $class, ?string $prefixMethods = null): void + public function listAllProxyMethods(string $class, ?string $prefixMethods = null, bool $isolateClass = false): void { $reflection = new ReflectionClass($class); + $traitMethods = $isolateClass ? $this->getAllTraitMethods($reflection) : []; + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { - if ($method->isConstructor()) continue; + if ($method->isConstructor()) { + continue; + } + + if (in_array($method->getName(), $traitMethods, true)) { + continue; + } + + if ($isolateClass && $method->getDeclaringClass()->getName() !== $class) { + continue; + } $params = array_map(function($param) { $type = $param->hasType() ? $param->getType() . ' ' : ''; @@ -503,12 +515,24 @@ public function listAllProxyMethods(string $class, ?string $prefixMethods = null }, $method->getParameters()); $name = $method->getName(); - if(!$method->isStatic() && !str_starts_with($name, '__')) { - if(!is_null($prefixMethods)) { + if (!$method->isStatic() && !str_starts_with($name, '__')) { + if (!is_null($prefixMethods)) { $name = $prefixMethods . ucfirst($name); } echo "@method self $name(" . implode(', ', $params) . ")\n"; } } } + + public function getAllTraitMethods(ReflectionClass $reflection): array + { + $traitMethods = []; + foreach ($reflection->getTraits() as $trait) { + foreach ($trait->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $traitMethods[] = $method->getName(); + } + } + return $traitMethods; + } + } diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index aa984de..b42e0fe 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -3,7 +3,7 @@ use MaplePHP\Unitary\TestCase; use MaplePHP\Unitary\Unit; -use MaplePHP\Validate\ValidatePool; +use MaplePHP\Validate\ValidationChain; use MaplePHP\Unitary\Mocker\MethodPool; @@ -130,14 +130,14 @@ public function registerUser(string $email, string $name = "Daniel"): void { $mock->test("Hello"); $service = new UserService($mock); - $validPool = new ValidatePool("dwqdqw"); + $validPool = new ValidationChain("dwqdqw"); $validPool ->isEmail() ->length(1, 200) ->endsWith(".com"); $isValid = $validPool->isValid(); - $inst->validate("yourTestValue", function(ValidatePool $inst) { + $inst->validate("yourTestValue", function(ValidationChain $inst) { $inst->isBool(); $inst->isInt(); $inst->isJson(); From 9f6cadea2510b8a001f777934f5eaada635b8600 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Wed, 30 Apr 2025 23:37:22 +0200 Subject: [PATCH 12/78] List validation error names collected from closure --- src/TestCase.php | 8 +++++--- src/Unit.php | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/TestCase.php b/src/TestCase.php index 690b3c7..44c1f5a 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -118,9 +118,11 @@ protected function expectAndValidate( $test = new TestUnit($message); $test->setTestValue($this->value); if($validation instanceof Closure) { - $list = $this->buildClosureTest($validation); - foreach($list as $method => $valid) { - $test->setUnit(!$list, $method); + $listArr = $this->buildClosureTest($validation); + foreach($listArr as $list) { + foreach($list as $method => $valid) { + $test->setUnit(!$list, $method); + } } } else { foreach($validation as $method => $args) { diff --git a/src/Unit.php b/src/Unit.php index 826bb14..d5245f7 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -186,7 +186,7 @@ public function performance(Closure $func, ?string $title = null): void */ public function execute(): bool { - if($this->executed || !$this->validate()) { + if($this->executed || !$this->createValidate()) { return false; } @@ -315,12 +315,25 @@ public function resetExecute(): bool return false; } + + /** + * Validate method that must be called within a group method + * + * @return self + * @throws RuntimeException When called outside a group method + */ + public function validate(): self + { + throw new RuntimeException("The validate() method must be called inside a group() method! " . + "Move this validate() call inside your group() callback function."); + } + /** * Validate before execute test * * @return bool */ - private function validate(): bool + private function createValidate(): bool { $args = (array)(self::$headers['args'] ?? []); $manual = isset($args['show']) ? (string)$args['show'] : ""; From 725ea9b1fe6941af2f0c3ae3295ada840ddbafe2 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Thu, 1 May 2025 21:41:04 +0200 Subject: [PATCH 13/78] Code quality improvements --- src/FileIterator.php | 13 +-- src/Mocker/Mocker.php | 49 +++++------ src/TestCase.php | 185 +++++++++++++++++++++++++++++------------- src/TestWrapper.php | 2 +- src/Unit.php | 9 ++ 5 files changed, 170 insertions(+), 88 deletions(-) diff --git a/src/FileIterator.php b/src/FileIterator.php index 48e4af4..68d2318 100755 --- a/src/FileIterator.php +++ b/src/FileIterator.php @@ -5,6 +5,7 @@ namespace MaplePHP\Unitary; use Closure; +use Exception; use RuntimeException; use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\Blunder\Run; @@ -33,7 +34,7 @@ public function executeAll(string $directory): void { $files = $this->findFiles($directory); if (empty($files)) { - throw new RuntimeException("No files found matching the pattern \"" . (string)(static::PATTERN ?? "") . "\" in directory \"$directory\" "); + throw new RuntimeException("No files found matching the pattern \"" . (static::PATTERN ?? "") . "\" in directory \"$directory\" "); } else { foreach ($files as $file) { extract($this->args, EXTR_PREFIX_SAME, "wddx"); @@ -107,7 +108,7 @@ public function exclude(): array } /** - * Validate a exclude path + * Validate an exclude path * @param array $exclArr * @param string $relativeDir * @param string $file @@ -117,7 +118,7 @@ public function findExcluded(array $exclArr, string $relativeDir, string $file): { $file = $this->getNaturalPath($file); foreach ($exclArr as $excl) { - $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . (string)$excl); + $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . $excl); if(fnmatch($relativeExclPath, $file)) { return true; } @@ -126,7 +127,7 @@ public function findExcluded(array $exclArr, string $relativeDir, string $file): } /** - * Get path as natural path + * Get a path as a natural path * @param string $path * @return string */ @@ -136,7 +137,7 @@ public function getNaturalPath(string $path): string } /** - * Require file without inheriting any class information + * Require a file without inheriting any class information * @param string $file * @return Closure|null */ @@ -173,7 +174,7 @@ private function requireUnitFile(string $file): ?Closure /** * @return Unit - * @throws RuntimeException|\Exception + * @throws RuntimeException|Exception */ protected function getUnit(): Unit { diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index 13674a8..b9787e8 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -8,8 +8,10 @@ namespace MaplePHP\Unitary\Mocker; +use Closure; use Reflection; use ReflectionClass; +use ReflectionException; use ReflectionIntersectionType; use ReflectionMethod; use ReflectionNamedType; @@ -19,8 +21,6 @@ class Mocker { protected object $instance; - static private mixed $return; - protected ReflectionClass $reflection; protected string $className; @@ -37,7 +37,7 @@ class Mocker /** * @param string $className * @param array $args - * @throws \ReflectionException + * @throws ReflectionException */ public function __construct(string $className, array $args = []) { @@ -87,9 +87,9 @@ public function getMockedClassName(): string * Executes the creation of a dynamic mock class and returns an instance of the mock. * * @return mixed An instance of the dynamically created mock class. - * @throws \ReflectionException + * @throws ReflectionException */ - public function execute(?callable $call = null): mixed + public function execute(): mixed { $className = $this->reflection->getName(); @@ -100,7 +100,7 @@ public function execute(?callable $call = null): mixed $overrides = $this->generateMockMethodOverrides($this->mockClassName); $unknownMethod = $this->errorHandleUnknownMethod($className); $code = " - class {$this->mockClassName} extends {$className} { + class $this->mockClassName extends $className { {$overrides} {$unknownMethod} } @@ -126,7 +126,7 @@ public function __call(string \$name, array \$arguments) { if (method_exists(get_parent_class(\$this), '__call')) { return parent::__call(\$name, \$arguments); } - throw new \\BadMethodCallException(\"Method '\$name' does not exist in class '{$className}'.\"); + throw new \\BadMethodCallException(\"Method '\$name' does not exist in class '$className'.\"); } "; } @@ -156,7 +156,7 @@ protected function getReturnValue(array $types, mixed $method, ?MethodItem $meth * Each overridden method returns a predefined mock value or delegates to the original logic. * * @return string PHP code defining the overridden methods. - * @throws \ReflectionException + * @throws ReflectionException */ protected function generateMockMethodOverrides(string $mockClassName): string { @@ -191,9 +191,9 @@ protected function generateMockMethodOverrides(string $mockClassName): string } $overrides .= " - {$modifiers} function {$methodName}({$paramList}){$returnType} + $modifiers function $methodName($paramList){$returnType} { - \$obj = \\MaplePHP\\Unitary\\Mocker\\MockerController::getInstance()->buildMethodData('{$info}'); + \$obj = \\MaplePHP\\Unitary\\Mocker\\MockerController::getInstance()->buildMethodData('$info'); \$data = \\MaplePHP\\Unitary\\Mocker\\MockerController::getDataItem(\$obj->mocker, \$obj->name); {$returnValue} } @@ -207,12 +207,13 @@ protected function generateMockMethodOverrides(string $mockClassName): string /** * Will build the wrapper return * - * @param \Closure|null $wrapper + * @param Closure|null $wrapper * @param string $methodName * @param string $returnValue * @return string */ - protected function generateWrapperReturn(?\Closure $wrapper, string $methodName, string $returnValue) { + protected function generateWrapperReturn(?Closure $wrapper, string $methodName, string $returnValue): string + { MockerController::addData($this->mockClassName, $methodName, 'wrapper', $wrapper); $return = ($returnValue) ? "return " : ""; return " @@ -246,7 +247,7 @@ protected function generateMethodSignature(ReflectionMethod $method): string } if ($param->isVariadic()) { - $paramStr = "...{$paramStr}"; + $paramStr = "...$paramStr"; } $params[] = $paramStr; @@ -272,7 +273,10 @@ protected function getReturnType(ReflectionMethod $method): array } } elseif ($returnType instanceof ReflectionIntersectionType) { - $intersect = array_map(fn($type) => $type->getName(), $returnType->getTypes()); + $intersect = array_map( + fn($type) => method_exists($type, "getName") ? $type->getName() : null, + $returnType->getTypes() + ); $types[] = $intersect; } @@ -287,9 +291,9 @@ protected function getReturnType(ReflectionMethod $method): array * * @param string $typeName The name of the type for which to generate the mock value. * @param bool $nullable Indicates if the returned value can be nullable. - * @return mixed Returns a mock value corresponding to the given type, or null if nullable and conditions allow. + * @return string|null Returns a mock value corresponding to the given type, or null if nullable and conditions allow. */ - protected function getMockValueForType(string $typeName, mixed $method, mixed $value = null, bool $nullable = false): mixed + protected function getMockValueForType(string $typeName, mixed $method, mixed $value = null, bool $nullable = false): ?string { $typeName = strtolower($typeName); if(!is_null($value)) { @@ -297,13 +301,10 @@ protected function getMockValueForType(string $typeName, mixed $method, mixed $v } $mock = match ($typeName) { - 'int' => "return 123456;", - 'integer' => "return 123456;", - 'float' => "return 3.14;", - 'double' => "return 3.14;", + 'int', 'integer' => "return 123456;", + 'float', 'double' => "return 3.14;", 'string' => "return 'mockString';", - 'bool' => "return true;", - 'boolean' => "return true;", + 'bool', 'boolean' => "return true;", 'array' => "return ['item'];", 'object' => "return (object)['item'];", 'resource' => "return fopen('php://memory', 'r+');", @@ -312,7 +313,7 @@ protected function getMockValueForType(string $typeName, mixed $method, mixed $v 'null' => "return null;", 'void' => "", 'self' => ($method->isStatic()) ? 'return new self();' : 'return $this;', - default => (is_string($typeName) && class_exists($typeName)) + default => (class_exists($typeName)) ? "return new class() extends " . $typeName . " {};" : "return null;", @@ -332,7 +333,7 @@ protected function handleResourceContent($resourceValue): ?string } /** - * Build a method information array form ReflectionMethod instance + * Build a method information array from a ReflectionMethod instance * * @param ReflectionMethod $refMethod * @return array diff --git a/src/TestCase.php b/src/TestCase.php index 44c1f5a..21cb019 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -9,6 +9,7 @@ use ErrorException; use MaplePHP\DTO\Format\Str; use MaplePHP\DTO\Traverse; +use MaplePHP\Unitary\Mocker\MethodPool; use MaplePHP\Unitary\Mocker\Mocker; use MaplePHP\Unitary\Mocker\MockerController; use MaplePHP\Validate\Validator; @@ -121,7 +122,7 @@ protected function expectAndValidate( $listArr = $this->buildClosureTest($validation); foreach($listArr as $list) { foreach($list as $method => $valid) { - $test->setUnit(!$list, $method); + $test->setUnit(false, $method); } } } else { @@ -153,7 +154,7 @@ protected function expectAndValidate( */ public function deferValidation(Closure $validation): void { - // This will add a cursor to the possible line and file where error occurred + // This will add a cursor to the possible line and file where the error occurred $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; $this->deferredValidation[] = [ "trace" => $trace, @@ -210,62 +211,131 @@ function __construct(string $class, array $args = []) public function mock(string $class, ?Closure $validate = null, array $args = []): mixed { $mocker = new Mocker($class, $args); - if(is_callable($validate)) { - $pool = $mocker->getMethodPool(); - $fn = $validate->bindTo($pool); - $fn($pool); - $this->deferValidation(function() use($mocker, $pool) { - $error = []; - $data = MockerController::getData($mocker->getMockedClassName()); - foreach($data as $row) { - $item = $pool->get($row->name); - if($item) { - foreach (get_object_vars($item) as $property => $value) { - if(!is_null($value)) { - $currentValue = $row->{$property}; - if(is_array($value)) { - $validPool = new ValidationChain($currentValue); - foreach($value as $method => $args) { - if(is_int($method)) { - foreach($args as $methodB => $argsB) { - $validPool - ->mapErrorToKey($argsB[0]) - ->mapErrorValidationName($argsB[1]) - ->{$methodB}(...$argsB); - } - } else { - $validPool->{$method}(...$args); - } - } - $valid = $validPool->isValid(); - - } else { - $valid = Validator::value($currentValue)->equal($value); - } - - if(is_array($value)) { - $this->compareFromValidCollection( - $validPool, - $value, - $currentValue - ); - } - - $error[$row->name][] = [ - "property" => $property, - "currentValue" => $currentValue, - "expectedValue" => $value, - "valid" => $valid - ]; - } - } - } - } - return $error; - }); + + if (is_callable($validate)) { + $this->prepareValidation($mocker, $validate); } + return $mocker->execute(); } + + /** + * Prepares validation for a mock object by binding validation rules and deferring their execution + * + * This method takes a mocker instance and a validation closure, binds the validation + * to the method pool, and schedules the validation to run later via deferValidation. + * This allows for mock expectations to be defined and validated after the test execution. + * + * @param Mocker $mocker The mocker instance containing the mock object + * @param Closure $validate The closure containing validation rules + * @return void + */ + private function prepareValidation(Mocker $mocker, Closure $validate): void + { + $pool = $mocker->getMethodPool(); + $fn = $validate->bindTo($pool); + $fn($pool); + + $this->deferValidation(fn() => $this->runValidation($mocker, $pool)); + } + + /** + * Executes validation for a mocked class by comparing actual method calls against expectations + * + * This method retrieves all method call data for a mocked class and validates each call + * against the expectations defined in the method pool. The validation results are collected + * and returned as an array of errors indexed by method name. + * + * @param Mocker $mocker The mocker instance containing the mocked class + * @param MethodPool $pool The pool containing method expectations + * @return array An array of validation errors indexed by method name + * @throws ErrorException + */ + private function runValidation(Mocker $mocker, MethodPool $pool): array + { + $error = []; + $data = MockerController::getData($mocker->getMockedClassName()); + foreach ($data as $row) { + $error[$row->name] = $this->validateRow($row, $pool); + } + return $error; + } + + /** + * Validates a specific method row against the method pool expectations + * + * This method compares the actual method call data with the expected validation + * rules defined in the method pool. It handles both simple value comparisons + * and complex array validations. + * + * @param object $row The method call data to validate + * @param MethodPool $pool The pool containing validation expectations + * @return array Array of validation results containing property comparisons + * @throws ErrorException + */ + private function validateRow(object $row, MethodPool $pool): array + { + $item = $pool->get($row->name); + if (!$item) { + return []; + } + + $errors = []; + + foreach (get_object_vars($item) as $property => $value) { + if (is_null($value)) { + continue; + } + + $currentValue = $row->{$property}; + + if (is_array($value)) { + $validPool = $this->validateArrayValue($value, $currentValue); + $valid = $validPool->isValid(); + $this->compareFromValidCollection($validPool, $value, $currentValue); + } else { + $valid = Validator::value($currentValue)->equal($value); + } + + $errors[] = [ + "property" => $property, + "currentValue" => $currentValue, + "expectedValue" => $value, + "valid" => $valid + ]; + } + + return $errors; + } + + /** + * Validates an array value against a validation chain configuration. + * + * This method processes an array of validation rules and applies them to the current value. + * It handles both direct method calls and nested validation configurations. + * + * @param array $value The validation configuration array + * @param mixed $currentValue The value to validate + * @return ValidationChain The validation chain instance with applied validations + */ + private function validateArrayValue(array $value, mixed $currentValue): ValidationChain + { + $validPool = new ValidationChain($currentValue); + foreach ($value as $method => $args) { + if (is_int($method)) { + foreach ($args as $methodB => $argsB) { + $validPool + ->mapErrorToKey($argsB[0]) + ->mapErrorValidationName($argsB[1]) + ->{$methodB}(...$argsB); + } + } else { + $validPool->{$method}(...$args); + } + } + + return $validPool; + } /** * Create a comparison from a validation collection @@ -306,7 +376,7 @@ protected function mapValueToCollectionError(array $error, array $value): array } /** - * Executes all deferred validations that were registered earlier using deferValidation(). + * Executes all deferred validations registered earlier using deferValidation(). * * This method runs each queued validation closure, collects their results, * and converts them into individual TestUnit instances. If a validation fails, @@ -401,7 +471,7 @@ public function getMessage(): ?string } /** - * Get test array object + * Get a test array object * * @return array */ @@ -489,6 +559,7 @@ protected function valid(mixed $value): Validator * * @param string $class * @param string|null $prefixMethods + * @param bool $isolateClass * @return void * @throws ReflectionException */ diff --git a/src/TestWrapper.php b/src/TestWrapper.php index 4f31241..e3ac096 100755 --- a/src/TestWrapper.php +++ b/src/TestWrapper.php @@ -115,7 +115,7 @@ public function __call(string $name, array $arguments): mixed * @return mixed|object * @throws \ReflectionException */ - final protected function createInstance(Reflection $ref, array $args) + final protected function createInstance(Reflection $ref, array $args): mixed { if(count($args) === 0) { return $ref->dependencyInjector(); diff --git a/src/Unit.php b/src/Unit.php index d5245f7..cff3a3b 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -29,6 +29,15 @@ class Unit public static int $totalPassedTests = 0; public static int $totalTests = 0; + + /** + * Initialize Unit test instance with optional handler + * + * @param HandlerInterface|StreamInterface|null $handler Optional handler for test execution + * If HandlerInterface is provided, uses its command + * If StreamInterface is provided, creates a new Command with it + * If null, creates a new Command without a stream + */ public function __construct(HandlerInterface|StreamInterface|null $handler = null) { if($handler instanceof HandlerInterface) { From 19c373bc31cd78d4ab4605bc3c4415bd6875b04b Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Thu, 1 May 2025 21:43:02 +0200 Subject: [PATCH 14/78] Code quality improvements --- src/TestCase.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/TestCase.php b/src/TestCase.php index 21cb019..2c5b0ab 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -268,7 +268,7 @@ private function runValidation(Mocker $mocker, MethodPool $pool): array * rules defined in the method pool. It handles both simple value comparisons * and complex array validations. * - * @param object $row The method call data to validate + * @param object $row The method calls data to validate * @param MethodPool $pool The pool containing validation expectations * @return array Array of validation results containing property comparisons * @throws ErrorException @@ -597,6 +597,15 @@ public function listAllProxyMethods(string $class, ?string $prefixMethods = null } } + /** + * Retrieves all public methods from the traits used by a given class. + * + * This method collects and returns the names of all public methods + * defined in the traits used by the provided ReflectionClass instance. + * + * @param ReflectionClass $reflection The reflection instance of the class to inspect + * @return array An array of method names defined in the traits + */ public function getAllTraitMethods(ReflectionClass $reflection): array { $traitMethods = []; From 27d8dde0ce91dc323450f4d67eb27b25e61b3408 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Thu, 1 May 2025 23:37:10 +0200 Subject: [PATCH 15/78] Code quality improvements --- src/AbstractClassHelper.php | 72 ----------------- src/FileIterator.php | 20 ++--- src/Handlers/FileHandler.php | 2 +- src/Handlers/HtmlHandler.php | 2 +- src/Mocker/MethodItem.php | 37 +++++---- src/Mocker/MethodPool.php | 3 +- src/Mocker/Mocker.php | 95 +++++++++++++++-------- src/Mocker/MockerController.php | 16 ++-- src/TestCase.php | 64 +++++++-------- src/TestUnit.php | 20 ++--- src/TestWrapper.php | 9 ++- src/Unit.php | 133 ++++++++++++++++---------------- tests/unitary-unitary.php | 3 +- 13 files changed, 226 insertions(+), 250 deletions(-) delete mode 100644 src/AbstractClassHelper.php diff --git a/src/AbstractClassHelper.php b/src/AbstractClassHelper.php deleted file mode 100644 index a676b60..0000000 --- a/src/AbstractClassHelper.php +++ /dev/null @@ -1,72 +0,0 @@ -reflectionPool = new Reflection($className); - $this->reflection = $this->reflection->getReflect(); - //$this->constructor = $this->reflection->getConstructor(); - //$reflectParam = ($this->constructor) ? $this->constructor->getParameters() : []; - if (count($classArgs) > 0) { - $this->instance = $this->reflection->newInstanceArgs($classArgs); - } - } - - public function inspectMethod(string $method): array - { - if (!$this->reflection || !$this->reflection->hasMethod($method)) { - throw new Exception("Method '$method' does not exist."); - } - - $methodReflection = $this->reflection->getMethod($method); - $parameters = []; - foreach ($methodReflection->getParameters() as $param) { - $paramType = $param->hasType() ? $param->getType()->getName() : 'mixed'; - $parameters[] = [ - 'name' => $param->getName(), - 'type' => $paramType, - 'is_optional' => $param->isOptional(), - 'default_value' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null - ]; - } - - return [ - 'name' => $methodReflection->getName(), - 'visibility' => implode(' ', \Reflection::getModifierNames($methodReflection->getModifiers())), - 'is_static' => $methodReflection->isStatic(), - 'return_type' => $methodReflection->hasReturnType() ? $methodReflection->getReturnType()->getName() : 'mixed', - 'parameters' => $parameters - ]; - } - - /** - * Will create the main instance with dependency injection support - * - * @param string $className - * @param array $args - * @return mixed|object - * @throws \ReflectionException - */ - final protected function createInstance(string $className, array $args) - { - if(count($args) === 0) { - return $this->reflection->dependencyInjector(); - } - return new $className(...$args); - } -} \ No newline at end of file diff --git a/src/FileIterator.php b/src/FileIterator.php index 68d2318..057ea99 100755 --- a/src/FileIterator.php +++ b/src/FileIterator.php @@ -13,7 +13,7 @@ use RecursiveIteratorIterator; use SplFileInfo; -class FileIterator +final class FileIterator { public const PATTERN = 'unitary-*.php'; @@ -34,6 +34,7 @@ public function executeAll(string $directory): void { $files = $this->findFiles($directory); if (empty($files)) { + /* @var string static::PATTERN */ throw new RuntimeException("No files found matching the pattern \"" . (static::PATTERN ?? "") . "\" in directory \"$directory\" "); } else { foreach ($files as $file) { @@ -49,7 +50,7 @@ public function executeAll(string $directory): void if (!is_null($call)) { $call(); } - if(!Unit::hasUnit()) { + if (!Unit::hasUnit()) { throw new RuntimeException("The Unitary Unit class has not been initiated inside \"$file\"."); } } @@ -67,7 +68,7 @@ private function findFiles(string $dir): array { $files = []; $realDir = realpath($dir); - if($realDir === false) { + if ($realDir === false) { throw new RuntimeException("Directory \"$dir\" does not exist. Try using a absolut path!"); } $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)); @@ -77,7 +78,7 @@ private function findFiles(string $dir): array foreach ($iterator as $file) { if (($file instanceof SplFileInfo) && fnmatch($pattern, $file->getFilename()) && (isset($this->args['path']) || !str_contains($file->getPathname(), DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR))) { - if(!$this->findExcluded($this->exclude(), $dir, $file->getPathname())) { + if (!$this->findExcluded($this->exclude(), $dir, $file->getPathname())) { $files[] = $file->getPathname(); } } @@ -92,13 +93,13 @@ private function findFiles(string $dir): array public function exclude(): array { $excl = []; - if(isset($this->args['exclude']) && is_string($this->args['exclude'])) { + if (isset($this->args['exclude']) && is_string($this->args['exclude'])) { $exclude = explode(',', $this->args['exclude']); foreach ($exclude as $file) { $file = str_replace(['"', "'"], "", $file); $new = trim($file); $lastChar = substr($new, -1); - if($lastChar === DIRECTORY_SEPARATOR) { + if ($lastChar === DIRECTORY_SEPARATOR) { $new .= "*"; } $excl[] = trim($new); @@ -118,8 +119,9 @@ public function findExcluded(array $exclArr, string $relativeDir, string $file): { $file = $this->getNaturalPath($file); foreach ($exclArr as $excl) { - $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . $excl); - if(fnmatch($relativeExclPath, $file)) { + /* @var string $excl */ + $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . (string)$excl); + if (fnmatch($relativeExclPath, $file)) { return true; } } @@ -147,7 +149,7 @@ private function requireUnitFile(string $file): ?Closure $call = function () use ($file, $clone): void { $cli = new CliHandler(); - if(Unit::getArgs('trace') !== false) { + if (Unit::getArgs('trace') !== false) { $cli->enableTraceLines(true); } $run = new Run($cli); diff --git a/src/Handlers/FileHandler.php b/src/Handlers/FileHandler.php index f599c03..f77472c 100755 --- a/src/Handlers/FileHandler.php +++ b/src/Handlers/FileHandler.php @@ -8,7 +8,7 @@ use MaplePHP\Http\UploadedFile; use MaplePHP\Prompts\Command; -class FileHandler implements HandlerInterface +final class FileHandler implements HandlerInterface { private string $file; private Stream $stream; diff --git a/src/Handlers/HtmlHandler.php b/src/Handlers/HtmlHandler.php index c3f729b..ded9229 100755 --- a/src/Handlers/HtmlHandler.php +++ b/src/Handlers/HtmlHandler.php @@ -7,7 +7,7 @@ use MaplePHP\Http\Stream; use MaplePHP\Prompts\Command; -class HtmlHandler implements HandlerInterface +final class HtmlHandler implements HandlerInterface { private Stream $stream; private Command $command; diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index cae940f..350b339 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -2,10 +2,14 @@ namespace MaplePHP\Unitary\Mocker; +use BadMethodCallException; use Closure; use MaplePHP\Unitary\TestWrapper; -class MethodItem +/** + * @psalm-suppress PossiblyUnusedProperty + */ +final class MethodItem { private ?Mocker $mocker; public mixed $return = null; @@ -40,19 +44,22 @@ public function __construct(?Mocker $mocker = null) /** * Will create a method wrapper making it possible to mock * - * @param $call + * @param Closure $call * @return $this */ - public function wrap($call): self + public function wrap(Closure $call): self { + if(is_null($this->mocker)) { + throw new BadMethodCallException('Mocker is not set. Use the method "mock" to set the mocker.'); + } + $inst = $this; - $wrap = new class($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends TestWrapper { - function __construct(string $class, array $args = []) + $wrap = new class ($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends TestWrapper { + public function __construct(string $class, array $args = []) { parent::__construct($class, $args); } }; - $call->bindTo($this->mocker); $this->wrapper = $wrap->bind($call); return $inst; } @@ -78,7 +85,7 @@ public function hasReturn(): bool } /** - * Check if method has been called x times + * Check if a method has been called x times * @param int $count * @return $this */ @@ -277,7 +284,7 @@ public function hasParams(): self } /** - * Check if all parameters has a data type + * Check if all parameters have a data type * * @return $this */ @@ -291,7 +298,7 @@ public function hasParamsTypes(): self } /** - * Check if parameter do not exist + * Check if parameter does not exist * * @return $this */ @@ -305,7 +312,7 @@ public function hasNotParams(): self } /** - * Check parameter type for method + * Check a parameter type for method * * @param int $length * @return $this @@ -320,7 +327,7 @@ public function hasParamsCount(int $length): self } /** - * Check parameter type for method + * Check a parameter type for method * * @param int $paramPosition * @param string $dataType @@ -352,7 +359,7 @@ public function paramHasDefault(int $paramPosition, string $defaultArgValue): se } /** - * Check parameter type for method + * Check a parameter type for method * * @param int $paramPosition * @return $this @@ -367,7 +374,7 @@ public function paramHasType(int $paramPosition): self } /** - * Check parameter type for method + * Check a parameter type for method * * @param int $paramPosition * @return $this @@ -397,7 +404,7 @@ public function paramIsReference(int $paramPosition): self } /** - * Check parameter is variadic (spread) for method + * Check the parameter is variadic (spread) for a method * * @param int $paramPosition * @return $this @@ -470,4 +477,4 @@ public function fileName(string $file): self $inst->fileName = $file; return $inst; } -} \ No newline at end of file +} diff --git a/src/Mocker/MethodPool.php b/src/Mocker/MethodPool.php index 9abf45b..6a3a007 100644 --- a/src/Mocker/MethodPool.php +++ b/src/Mocker/MethodPool.php @@ -5,6 +5,7 @@ class MethodPool { private ?Mocker $mocker = null; + /** @var array */ private array $methods = []; public function __construct(?Mocker $mocker = null) @@ -57,4 +58,4 @@ public function has(string $name): bool return isset($this->methods[$name]); } -} \ No newline at end of file +} diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index b9787e8..67cf7f6 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -1,4 +1,5 @@ */ protected array $constructorArgs = []; - - protected array $overrides = []; - protected array $methods; protected array $methodList = []; - protected static ?MethodPool $methodPool = null; /** * @param string $className * @param array $args - * @throws ReflectionException */ public function __construct(string $className, array $args = []) { $this->className = $className; + /** @var class-string $className */ $this->reflection = new ReflectionClass($className); /* @@ -72,14 +71,20 @@ public function getClassArgs(): array */ public function getMethodPool(): MethodPool { - if(is_null(self::$methodPool)) { + if (is_null(self::$methodPool)) { self::$methodPool = new MethodPool($this); } return self::$methodPool; } + /** + * @throws Exception + */ public function getMockedClassName(): string { + if(!$this->mockClassName) { + throw new Exception("Mock class name is not set"); + } return $this->mockClassName; } @@ -87,7 +92,7 @@ public function getMockedClassName(): string * Executes the creation of a dynamic mock class and returns an instance of the mock. * * @return mixed An instance of the dynamically created mock class. - * @throws ReflectionException + * @throws Exception */ public function execute(): mixed { @@ -96,6 +101,10 @@ public function execute(): mixed $shortClassName = explode("\\", $className); $shortClassName = end($shortClassName); + /** + * @var class-string $shortClassName + * @psalm-suppress PropertyTypeCoercion + */ $this->mockClassName = 'Unitary_' . uniqid() . "_Mock_" . $shortClassName; $overrides = $this->generateMockMethodOverrides($this->mockClassName); $unknownMethod = $this->errorHandleUnknownMethod($className); @@ -107,6 +116,11 @@ class $this->mockClassName extends $className { "; eval($code); + + /** + * @psalm-suppress MixedMethodCall + * @psalm-suppress InvalidStringClass + */ return new $this->mockClassName(...$this->constructorArgs); } @@ -120,7 +134,7 @@ class $this->mockClassName extends $className { */ private function errorHandleUnknownMethod(string $className): string { - if(!in_array('__call', $this->methodList)) { + if (!in_array('__call', $this->methodList)) { return " public function __call(string \$name, array \$arguments) { if (method_exists(get_parent_class(\$this), '__call')) { @@ -142,11 +156,11 @@ public function __call(string \$name, array \$arguments) { protected function getReturnValue(array $types, mixed $method, ?MethodItem $methodItem = null): string { // Will overwrite the auto generated value - if($methodItem && $methodItem->hasReturn()) { + if ($methodItem && $methodItem->hasReturn()) { return "return " . var_export($methodItem->return, true) . ";"; } if ($types) { - return $this->getMockValueForType($types[0], $method); + return (string)$this->getMockValueForType((string)$types[0], $method); } return "return 'MockedValue';"; } @@ -155,13 +169,19 @@ protected function getReturnValue(array $types, mixed $method, ?MethodItem $meth * Builds and returns PHP code that overrides all public methods in the class being mocked. * Each overridden method returns a predefined mock value or delegates to the original logic. * + * @param string $mockClassName * @return string PHP code defining the overridden methods. - * @throws ReflectionException + * @throws Exception */ protected function generateMockMethodOverrides(string $mockClassName): string { $overrides = ''; foreach ($this->methods as $method) { + + if(!($method instanceof ReflectionMethod)) { + throw new Exception("Method is not a ReflectionMethod"); + } + if ($method->isConstructor() || $method->isFinal()) { continue; } @@ -184,9 +204,12 @@ protected function generateMockMethodOverrides(string $mockClassName): string $arr['return'] = $return; $info = json_encode($arr); + if ($info === false) { + throw new RuntimeException('JSON encoding failed: ' . json_last_error_msg(), json_last_error()); + } MockerController::getInstance()->buildMethodData($info); - if($methodItem) { + if ($methodItem) { $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); } @@ -214,7 +237,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string */ protected function generateWrapperReturn(?Closure $wrapper, string $methodName, string $returnValue): string { - MockerController::addData($this->mockClassName, $methodName, 'wrapper', $wrapper); + MockerController::addData((string)$this->mockClassName, $methodName, 'wrapper', $wrapper); $return = ($returnValue) ? "return " : ""; return " if (isset(\$data->wrapper) && \$data->wrapper instanceof \\Closure) { @@ -236,7 +259,8 @@ protected function generateMethodSignature(ReflectionMethod $method): string foreach ($method->getParameters() as $param) { $paramStr = ''; if ($param->hasType()) { - $paramStr .= $param->getType() . ' '; + $getType = (string)$param->getType(); + $paramStr .= $getType . ' '; } if ($param->isPassedByReference()) { $paramStr .= '&'; @@ -269,18 +293,19 @@ protected function getReturnType(ReflectionMethod $method): array $types[] = $returnType->getName(); } elseif ($returnType instanceof ReflectionUnionType) { foreach ($returnType->getTypes() as $type) { - $types[] = $type->getName(); + if(method_exists($type, "getName")) { + $types[] = $type->getName(); + } } } elseif ($returnType instanceof ReflectionIntersectionType) { $intersect = array_map( - fn($type) => method_exists($type, "getName") ? $type->getName() : null, - $returnType->getTypes() + fn ($type) => $type->getName(), $returnType->getTypes() ); $types[] = $intersect; } - if(!in_array("mixed", $types) && $returnType && $returnType->allowsNull()) { + if (!in_array("mixed", $types) && $returnType && $returnType->allowsNull()) { $types[] = "null"; } return $types; @@ -295,12 +320,12 @@ protected function getReturnType(ReflectionMethod $method): array */ protected function getMockValueForType(string $typeName, mixed $method, mixed $value = null, bool $nullable = false): ?string { - $typeName = strtolower($typeName); - if(!is_null($value)) { + $dataTypeName = strtolower($typeName); + if (!is_null($value)) { return "return " . var_export($value, true) . ";"; } - $mock = match ($typeName) { + $mock = match ($dataTypeName) { 'int', 'integer' => "return 123456;", 'float', 'double' => "return 3.14;", 'string' => "return 'mockString';", @@ -312,7 +337,8 @@ protected function getMockValueForType(string $typeName, mixed $method, mixed $v 'iterable' => "return new ArrayIterator(['a', 'b']);", 'null' => "return null;", 'void' => "", - 'self' => ($method->isStatic()) ? 'return new self();' : 'return $this;', + 'self' => (is_object($method) && method_exists($method, "isStatic") && $method->isStatic()) ? 'return new self();' : 'return $this;', + /** @var class-string $typeName */ default => (class_exists($typeName)) ? "return new class() extends " . $typeName . " {};" : "return null;", @@ -323,12 +349,15 @@ protected function getMockValueForType(string $typeName, mixed $method, mixed $v /** * Will return a streamable content - * - * @param $resourceValue + * + * @param mixed $resourceValue * @return string|null */ - protected function handleResourceContent($resourceValue): ?string + protected function handleResourceContent(mixed $resourceValue): ?string { + if (!is_resource($resourceValue)) { + return null; + } return var_export(stream_get_contents($resourceValue), true); } @@ -338,7 +367,7 @@ protected function handleResourceContent($resourceValue): ?string * @param ReflectionMethod $refMethod * @return array */ - function getMethodInfoAsArray(ReflectionMethod $refMethod): array + public function getMethodInfoAsArray(ReflectionMethod $refMethod): array { $params = []; foreach ($refMethod->getParameters() as $param) { @@ -376,4 +405,4 @@ function getMethodInfoAsArray(ReflectionMethod $refMethod): array ]; } -} \ No newline at end of file +} diff --git a/src/Mocker/MockerController.php b/src/Mocker/MockerController.php index 5eaf1c2..90e0d7b 100644 --- a/src/Mocker/MockerController.php +++ b/src/Mocker/MockerController.php @@ -2,17 +2,17 @@ namespace MaplePHP\Unitary\Mocker; -class MockerController extends MethodPool +final class MockerController extends MethodPool { private static ?MockerController $instance = null; private static array $data = []; - private array $methods = []; + //private array $methods = []; public static function getInstance(): self { - if(is_null(self::$instance)) { + if (is_null(self::$instance)) { self::$instance = new self(); } return self::$instance; @@ -26,7 +26,11 @@ public static function getInstance(): self */ public static function getData(string $mockIdentifier): array|bool { - return (self::$data[$mockIdentifier] ?? false); + $data = isset(self::$data[$mockIdentifier]) ? self::$data[$mockIdentifier] : false; + if(!is_array($data)) { + return false; + } + return $data; } public static function getDataItem(string $mockIdentifier, string $method): mixed @@ -42,7 +46,7 @@ public static function addData(string $mockIdentifier, string $method, string $k public function buildMethodData(string $method): object { $data = json_decode($method); - if(empty(self::$data[$data->mocker][$data->name])) { + if (empty(self::$data[$data->mocker][$data->name])) { $data->count = 0; self::$data[$data->mocker][$data->name] = $data; } else { @@ -51,4 +55,4 @@ public function buildMethodData(string $method): object return $data; } -} \ No newline at end of file +} diff --git a/src/TestCase.php b/src/TestCase.php index 2c5b0ab..7b77d89 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -44,7 +44,7 @@ public function __construct(?string $message = null) /** * Bind the test case to the Closure - * + * * @param Closure $bind * @return void */ @@ -55,7 +55,7 @@ public function bind(Closure $bind): void /** * Will dispatch the case tests and return them as an array - * + * * @return array */ public function dispatchTest(): array @@ -69,7 +69,7 @@ public function dispatchTest(): array /** * Add custom error message if validation fails - * + * * @param string $message * @return $this */ @@ -89,7 +89,7 @@ public function error(string $message): self */ public function validate(mixed $expect, Closure $validation): self { - $this->expectAndValidate($expect, function(mixed $value, ValidationChain $inst) use($validation) { + $this->expectAndValidate($expect, function (mixed $value, ValidationChain $inst) use ($validation) { return $validation($inst, $value); }, $this->errorMessage); @@ -118,22 +118,22 @@ protected function expectAndValidate( $this->value = $expect; $test = new TestUnit($message); $test->setTestValue($this->value); - if($validation instanceof Closure) { + if ($validation instanceof Closure) { $listArr = $this->buildClosureTest($validation); - foreach($listArr as $list) { - foreach($list as $method => $valid) { + foreach ($listArr as $list) { + foreach ($list as $method => $valid) { $test->setUnit(false, $method); } } } else { - foreach($validation as $method => $args) { - if(!($args instanceof Closure) && !is_array($args)) { + foreach ($validation as $method => $args) { + if (!($args instanceof Closure) && !is_array($args)) { $args = [$args]; } $test->setUnit($this->buildArrayTest($method, $args), $method, (is_array($args) ? $args : [])); } } - if(!$test->isValid()) { + if (!$test->isValid()) { $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; $test->setCodeLine($trace); $this->count++; @@ -161,7 +161,7 @@ public function deferValidation(Closure $validation): void "call" => $validation ]; } - + /** * Same as "addTestUnit" but is public and will make sure the validation can be * properly registered and traceable @@ -186,8 +186,8 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = */ public function wrap(string $class, array $args = []): TestWrapper { - return new class($class, $args) extends TestWrapper { - function __construct(string $class, array $args = []) + return new class ($class, $args) extends TestWrapper { + public function __construct(string $class, array $args = []) { parent::__construct($class, $args); } @@ -218,7 +218,7 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) return $mocker->execute(); } - + /** * Prepares validation for a mock object by binding validation rules and deferring their execution * @@ -236,7 +236,7 @@ private function prepareValidation(Mocker $mocker, Closure $validate): void $fn = $validate->bindTo($pool); $fn($pool); - $this->deferValidation(fn() => $this->runValidation($mocker, $pool)); + $this->deferValidation(fn () => $this->runValidation($mocker, $pool)); } /** @@ -307,7 +307,7 @@ private function validateRow(object $row, MethodPool $pool): array return $errors; } - + /** * Validates an array value against a validation chain configuration. * @@ -350,7 +350,7 @@ protected function compareFromValidCollection(ValidationChain $validPool, array $new = []; $error = $validPool->getError(); $value = $this->mapValueToCollectionError($error, $value); - foreach($value as $eqIndex => $validator) { + foreach ($value as $eqIndex => $validator) { $new[] = Traverse::value($currentValue)->eq($eqIndex)->get(); } $currentValue = $new; @@ -365,9 +365,9 @@ protected function compareFromValidCollection(ValidationChain $validPool, array */ protected function mapValueToCollectionError(array $error, array $value): array { - foreach($value as $item) { - foreach($item as $value) { - if(isset($error[$value[0]])) { + foreach ($value as $item) { + foreach ($item as $value) { + if (isset($error[$value[0]])) { $error[$value[0]] = $value[2]; } } @@ -387,14 +387,14 @@ protected function mapValueToCollectionError(array $error, array $value): array */ public function runDeferredValidations(): array { - foreach($this->deferredValidation as $row) { + foreach ($this->deferredValidation as $row) { $error = $row['call'](); - foreach($error as $method => $arr) { + foreach ($error as $method => $arr) { $test = new TestUnit("Mock method \"$method\" failed"); - if(is_array($row['trace'] ?? "")) { + if (is_array($row['trace'] ?? "")) { $test->setCodeLine($row['trace']); } - foreach($arr as $data) { + foreach ($arr as $data) { $test->setUnit($data['valid'], $data['property'], [], [ $data['expectedValue'], $data['currentValue'] ]); @@ -493,15 +493,15 @@ protected function buildClosureTest(Closure $validation): array $validation = $validation->bindTo($validPool); $error = []; - if(!is_null($validation)) { + if (!is_null($validation)) { $bool = $validation($this->value, $validPool); $error = $validPool->getError(); - if(is_bool($bool) && !$bool) { + if (is_bool($bool) && !$bool) { $error['customError'] = false; } } - if(is_null($this->message)) { + if (is_null($this->message)) { throw new RuntimeException("When testing with closure the third argument message is required"); } @@ -518,17 +518,17 @@ protected function buildClosureTest(Closure $validation): array */ protected function buildArrayTest(string $method, array|Closure $args): bool { - if($args instanceof Closure) { + if ($args instanceof Closure) { $args = $args->bindTo($this->valid($this->value)); - if(is_null($args)) { + if (is_null($args)) { throw new ErrorException("The argument is not returning a callable Closure!"); } $bool = $args($this->value); - if(!is_bool($bool)) { + if (!is_bool($bool)) { throw new RuntimeException("A callable validation must return a boolean!"); } } else { - if(!method_exists(Validator::class, $method)) { + if (!method_exists(Validator::class, $method)) { throw new BadMethodCallException("The validation $method does not exist!"); } @@ -581,7 +581,7 @@ public function listAllProxyMethods(string $class, ?string $prefixMethods = null continue; } - $params = array_map(function($param) { + $params = array_map(function ($param) { $type = $param->hasType() ? $param->getType() . ' ' : ''; $value = $param->isDefaultValueAvailable() ? ' = ' . Str::value($param->getDefaultValue())->exportReadableValue()->get() : null; return $type . '$' . $param->getName() . $value; diff --git a/src/TestUnit.php b/src/TestUnit.php index e02112a..76de644 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -66,22 +66,22 @@ public function setUnit( bool|null $valid, null|string|\Closure $validation = null, array $args = [], - array $compare = []): self - { - if(!$valid) { + array $compare = [] + ): self { + if (!$valid) { $this->valid = false; $this->count++; } - - if(!is_callable($validation)) { + + if (!is_callable($validation)) { $valLength = strlen((string)$validation); - if($validation && $this->valLength < $valLength) { + if ($validation && $this->valLength < $valLength) { $this->valLength = $valLength; } } - if($compare && count($compare) > 0) { - $compare = array_map(fn($value) => $this->getReadValue($value, true), $compare); + if ($compare && count($compare) > 0) { + $compare = array_map(fn ($value) => $this->getReadValue($value, true), $compare); } $this->unit[] = [ 'valid' => $valid, @@ -109,7 +109,7 @@ public function getValidationLength(): int * @return $this * @throws ErrorException */ - function setCodeLine(array $trace): self + public function setCodeLine(array $trace): self { $this->codeLine = []; $file = $trace['file'] ?? ''; @@ -117,7 +117,7 @@ function setCodeLine(array $trace): self if ($file && $line) { $lines = file($file); $code = trim($lines[$line - 1] ?? ''); - if(str_starts_with($code, '->')) { + if (str_starts_with($code, '->')) { $code = substr($code, 2); } $code = $this->excerpt($code); diff --git a/src/TestWrapper.php b/src/TestWrapper.php index e3ac096..2b8a2e4 100755 --- a/src/TestWrapper.php +++ b/src/TestWrapper.php @@ -1,4 +1,5 @@ instance, $method)) { + if (!method_exists($this->instance, $method)) { throw new \BadMethodCallException( "Method '$method' does not exist in the class '" . get_class($this->instance) . "' and therefore cannot be overridden or called." @@ -76,7 +77,7 @@ public function override(string $method, Closure $call): self */ public function add(string $method, Closure $call): self { - if(method_exists($this->instance, $method)) { + if (method_exists($this->instance, $method)) { throw new \BadMethodCallException( "Method '$method' already exists in the class '" . get_class($this->instance) . "'. Use the 'override' method in TestWrapper instead." @@ -117,9 +118,9 @@ public function __call(string $name, array $arguments): mixed */ final protected function createInstance(Reflection $ref, array $args): mixed { - if(count($args) === 0) { + if (count($args) === 0) { return $ref->dependencyInjector(); } return $ref->getReflect()->newInstanceArgs($args); } -} \ No newline at end of file +} diff --git a/src/Unit.php b/src/Unit.php index cff3a3b..b0238fb 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -40,7 +40,7 @@ class Unit */ public function __construct(HandlerInterface|StreamInterface|null $handler = null) { - if($handler instanceof HandlerInterface) { + if ($handler instanceof HandlerInterface) { $this->handler = $handler; $this->command = $this->handler->getCommand(); } else { @@ -67,7 +67,7 @@ public function skip(bool $skip): self */ public function manual(string $key): self { - if(isset(self::$manual[$key])) { + if (isset(self::$manual[$key])) { $file = (string)(self::$headers['file'] ?? "none"); throw new RuntimeException("The manual key \"$key\" already exists. Please set a unique key in the " . $file. " file."); @@ -145,7 +145,7 @@ public function add(string $message, Closure $callback): void * @param Closure(TestCase):void $callback * @return void */ - public function case(string $message, Closure $callback): void + public function group(string $message, Closure $callback): void { $testCase = new TestCase($message); $testCase->bind($callback); @@ -153,16 +153,17 @@ public function case(string $message, Closure $callback): void $this->index++; } - public function group(string $message, Closure $callback): void + // Alias to group + public function case(string $message, Closure $callback): void { - $this->case($message, $callback); + $this->group($message, $callback); } public function performance(Closure $func, ?string $title = null): void { $start = new TestMem(); $func = $func->bindTo($this); - if(!is_null($func)) { + if (!is_null($func)) { $func($this); } $line = $this->command->getAnsi()->line(80); @@ -195,14 +196,14 @@ public function performance(Closure $func, ?string $title = null): void */ public function execute(): bool { - if($this->executed || !$this->createValidate()) { + if ($this->executed || !$this->createValidate()) { return false; } // LOOP through each case ob_start(); - foreach($this->cases as $row) { - if(!($row instanceof TestCase)) { + foreach ($this->cases as $row) { + if (!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); } @@ -211,11 +212,11 @@ public function execute(): bool $tests = $row->runDeferredValidations(); $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); - if($row->hasFailed()) { + if ($row->hasFailed()) { $flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); } - if($errArg !== false && !$row->hasFailed()) { + if ($errArg !== false && !$row->hasFailed()) { continue; } @@ -227,58 +228,60 @@ public function execute(): bool $this->command->getAnsi()->style(["bold", $color], (string)$row->getMessage()) ); - if(isset($tests)) foreach($tests as $test) { - if(!($test instanceof TestUnit)) { - throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); - } - - if(!$test->isValid()) { - $msg = (string)$test->getMessage(); - $this->command->message(""); - $this->command->message( - $this->command->getAnsi()->style(["bold", "brightRed"], "Error: ") . - $this->command->getAnsi()->bold($msg) - ); - $this->command->message(""); - - $trace = $test->getCodeLine(); - if(!empty($trace['code'])) { - $this->command->message($this->command->getAnsi()->style(["bold", "grey"], "Failed on line {$trace['line']}: ")); - $this->command->message($this->command->getAnsi()->style(["grey"], " → {$trace['code']}")); + if (isset($tests)) { + foreach ($tests as $test) { + if (!($test instanceof TestUnit)) { + throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); } - /** @var array $unit */ - foreach($test->getUnits() as $unit) { - if(is_string($unit['validation']) && !$unit['valid']) { - $lengthA = $test->getValidationLength() + 1; - $title = str_pad($unit['validation'], $lengthA); + if (!$test->isValid()) { + $msg = (string)$test->getMessage(); + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["bold", "brightRed"], "Error: ") . + $this->command->getAnsi()->bold($msg) + ); + $this->command->message(""); - $compare = ""; - if($unit['compare']) { - $expectedValue = array_shift($unit['compare']); - $compare = "Expected: {$expectedValue} | Actual: " . implode(":", $unit['compare']); - } + $trace = $test->getCodeLine(); + if (!empty($trace['code'])) { + $this->command->message($this->command->getAnsi()->style(["bold", "grey"], "Failed on line {$trace['line']}: ")); + $this->command->message($this->command->getAnsi()->style(["grey"], " → {$trace['code']}")); + } + + /** @var array $unit */ + foreach ($test->getUnits() as $unit) { + if (is_string($unit['validation']) && !$unit['valid']) { + $lengthA = $test->getValidationLength() + 1; + $title = str_pad($unit['validation'], $lengthA); + + $compare = ""; + if ($unit['compare']) { + $expectedValue = array_shift($unit['compare']); + $compare = "Expected: {$expectedValue} | Actual: " . implode(":", $unit['compare']); + } - $failedMsg = " " .$title . ((!$unit['valid']) ? " → failed" : ""); - $this->command->message( - $this->command->getAnsi()->style( - ((!$unit['valid']) ? "brightRed" : null), - $failedMsg - ) - ); - - if(!$unit['valid'] && $compare) { - $lengthB = (strlen($compare) + strlen($failedMsg) - 8); - $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); + $failedMsg = " " .$title . ((!$unit['valid']) ? " → failed" : ""); $this->command->message( - $this->command->getAnsi()->style("brightRed", $comparePad) + $this->command->getAnsi()->style( + ((!$unit['valid']) ? "brightRed" : null), + $failedMsg + ) ); + + if (!$unit['valid'] && $compare) { + $lengthB = (strlen($compare) + strlen($failedMsg) - 8); + $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); + $this->command->message( + $this->command->getAnsi()->style("brightRed", $comparePad) + ); + } } } - } - if($test->hasValue()) { - $this->command->message(""); - $this->command->message($this->command->getAnsi()->bold("Value: ") . $test->getReadValue()); + if ($test->hasValue()) { + $this->command->message(""); + $this->command->message($this->command->getAnsi()->bold("Value: ") . $test->getReadValue()); + } } } } @@ -297,10 +300,10 @@ public function execute(): bool } $this->output .= ob_get_clean(); - if($this->output) { + if ($this->output) { $this->buildNotice("Note:", $this->output, 80); } - if(!is_null($this->handler)) { + if (!is_null($this->handler)) { $this->handler->execute(); } $this->executed = true; @@ -314,8 +317,8 @@ public function execute(): bool */ public function resetExecute(): bool { - if($this->executed) { - if($this->getStream()->isSeekable()) { + if ($this->executed) { + if ($this->getStream()->isSeekable()) { $this->getStream()->rewind(); } $this->executed = false; @@ -324,10 +327,10 @@ public function resetExecute(): bool return false; } - + /** * Validate method that must be called within a group method - * + * * @return self * @throws RuntimeException When called outside a group method */ @@ -346,10 +349,10 @@ private function createValidate(): bool { $args = (array)(self::$headers['args'] ?? []); $manual = isset($args['show']) ? (string)$args['show'] : ""; - if(isset($args['show'])) { + if (isset($args['show'])) { return !((self::$manual[$manual] ?? "") !== self::$headers['checksum'] && $manual !== self::$headers['checksum']); } - if($this->skip) { + if ($this->skip) { return false; } return true; @@ -451,7 +454,7 @@ public static function hasUnit(): bool */ public static function getUnit(): ?Unit { - if(is_null(self::hasUnit())) { + if (is_null(self::hasUnit())) { throw new Exception("Unit has not been set yet. It needs to be set first."); } return self::$current; @@ -463,7 +466,7 @@ public static function getUnit(): ?Unit */ public static function completed(): void { - if(!is_null(self::$current) && is_null(self::$current->handler)) { + if (!is_null(self::$current) && is_null(self::$current->handler)) { $dot = self::$current->command->getAnsi()->middot(); self::$current->command->message(""); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index b42e0fe..c3abe47 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -77,7 +77,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { } $unit = new Unit(); -$unit->group("Unitary test 2", function (TestCase $inst) { +$unit->group("Unitary test 2", function (TestCase $inst) use($unit) { $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addBCC") @@ -87,6 +87,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->count(1); }, ["Arg 1"]); $mock->addBCC("World"); + }); /* From 365ffb11da2c0f293eb3e9bc21e63ef4460f7518 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Fri, 2 May 2025 00:24:31 +0200 Subject: [PATCH 16/78] Code quality improvements --- src/Mocker/MockerController.php | 60 ++++++++++++++++++++++++++------- src/TestCase.php | 33 ++++++++++++------ 2 files changed, 71 insertions(+), 22 deletions(-) diff --git a/src/Mocker/MockerController.php b/src/Mocker/MockerController.php index 90e0d7b..27c8caa 100644 --- a/src/Mocker/MockerController.php +++ b/src/Mocker/MockerController.php @@ -5,11 +5,15 @@ final class MockerController extends MethodPool { private static ?MockerController $instance = null; - + /** @var array> */ private static array $data = []; - //private array $methods = []; - + /** + * Get singleton instance of MockerController + * Creates new instance if none exists + * + * @return static The singleton instance of MockerController + */ public static function getInstance(): self { if (is_null(self::$instance)) { @@ -32,25 +36,57 @@ public static function getData(string $mockIdentifier): array|bool } return $data; } - + + /** + * Get specific data item by mock identifier and method name + * + * @param string $mockIdentifier The identifier of the mock + * @param string $method The method name to retrieve + * @return mixed Returns the data item if found, false otherwise + */ public static function getDataItem(string $mockIdentifier, string $method): mixed { - return self::$data[$mockIdentifier][$method]; + return self::$data[$mockIdentifier][$method] ?? false; } + + /** + * Add or update data for a specific mock method + * + * @param string $mockIdentifier The identifier of the mock + * @param string $method The method name to add data to + * @param string $key The key of the data to add + * @param mixed $value The value to add + * @return void + */ public static function addData(string $mockIdentifier, string $method, string $key, mixed $value): void { - self::$data[$mockIdentifier][$method]->{$key} = $value; + if(isset(self::$data[$mockIdentifier][$method])) { + self::$data[$mockIdentifier][$method]->{$key} = $value; + } } + /** + * Builds and manages method data for mocking + * Decodes JSON method string and handles mock data storage with count tracking + * + * @param string $method JSON string containing mock method data + * @return object Decoded method data object with updated count if applicable + */ public function buildMethodData(string $method): object { - $data = json_decode($method); - if (empty(self::$data[$data->mocker][$data->name])) { - $data->count = 0; - self::$data[$data->mocker][$data->name] = $data; - } else { - self::$data[$data->mocker][$data->name]->count++; + $data = (object)json_decode($method); + if(isset($data->mocker) && isset($data->name)) { + $mocker = (string)$data->mocker; + $name = (string)$data->name; + if (empty(self::$data[$mocker][$name])) { + $data->count = 0; + self::$data[$mocker][$name] = $data; + } else { + if (isset(self::$data[$mocker][$name])) { + self::$data[$mocker][$name]->count = (int)self::$data[$mocker][$name]->count + 1; + } + } } return $data; } diff --git a/src/TestCase.php b/src/TestCase.php index 7b77d89..8a2d83d 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -20,7 +20,7 @@ use RuntimeException; use Throwable; -class TestCase +final class TestCase { private mixed $value; private ?string $message; @@ -121,8 +121,8 @@ protected function expectAndValidate( if ($validation instanceof Closure) { $listArr = $this->buildClosureTest($validation); foreach ($listArr as $list) { - foreach ($list as $method => $valid) { - $test->setUnit(false, $method); + foreach ($list as $method => $_valid) { + $test->setUnit(false, (string)$method); } } } else { @@ -216,6 +216,7 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) $this->prepareValidation($mocker, $validate); } + /** @psalm-suppress MixedReturnStatement */ return $mocker->execute(); } @@ -234,6 +235,9 @@ private function prepareValidation(Mocker $mocker, Closure $validate): void { $pool = $mocker->getMethodPool(); $fn = $validate->bindTo($pool); + if(is_null($fn)) { + throw new ErrorException("A callable Closure could not be bound to the method pool!"); + } $fn($pool); $this->deferValidation(fn () => $this->runValidation($mocker, $pool)); @@ -255,8 +259,13 @@ private function runValidation(Mocker $mocker, MethodPool $pool): array { $error = []; $data = MockerController::getData($mocker->getMockedClassName()); + if(!is_array($data)) { + throw new ErrorException("Could not get data from mocker!"); + } foreach ($data as $row) { - $error[$row->name] = $this->validateRow($row, $pool); + if (is_object($row) && isset($row->name)) { + $error[(string)$row->name] = $this->validateRow($row, $pool); + } } return $error; } @@ -275,7 +284,7 @@ private function runValidation(Mocker $mocker, MethodPool $pool): array */ private function validateRow(object $row, MethodPool $pool): array { - $item = $pool->get($row->name); + $item = $pool->get((string)($row->name ?? "")); if (!$item) { return []; } @@ -290,10 +299,12 @@ private function validateRow(object $row, MethodPool $pool): array $currentValue = $row->{$property}; if (is_array($value)) { + assert(is_array($currentValue), 'The $currentValue variable is not!'); $validPool = $this->validateArrayValue($value, $currentValue); $valid = $validPool->isValid(); $this->compareFromValidCollection($validPool, $value, $currentValue); } else { + /** @psalm-suppress MixedArgument */ $valid = Validator::value($currentValue)->equal($value); } @@ -324,10 +335,12 @@ private function validateArrayValue(array $value, mixed $currentValue): Validati foreach ($value as $method => $args) { if (is_int($method)) { foreach ($args as $methodB => $argsB) { - $validPool - ->mapErrorToKey($argsB[0]) - ->mapErrorValidationName($argsB[1]) - ->{$methodB}(...$argsB); + if(is_array($argsB) && count($argsB) >= 2) { + $validPool + ->mapErrorToKey((string)$argsB[0]) + ->mapErrorValidationName((string)$argsB[1]) + ->{$methodB}(...$argsB); + } } } else { $validPool->{$method}(...$args); @@ -350,7 +363,7 @@ protected function compareFromValidCollection(ValidationChain $validPool, array $new = []; $error = $validPool->getError(); $value = $this->mapValueToCollectionError($error, $value); - foreach ($value as $eqIndex => $validator) { + foreach ($value as $eqIndex => $_validator) { $new[] = Traverse::value($currentValue)->eq($eqIndex)->get(); } $currentValue = $new; From a9b89df1043893f10130c35618c1c2278f7db3e8 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sat, 3 May 2025 00:34:18 +0200 Subject: [PATCH 17/78] Add mocking improvements --- src/Mocker/MethodItem.php | 2 +- src/Mocker/Mocker.php | 28 +++++++++++++------ src/Mocker/MockerController.php | 15 +++++----- src/TestCase.php | 49 +++++++++++++++++++++------------ tests/unitary-unitary.php | 4 ++- 5 files changed, 64 insertions(+), 34 deletions(-) diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index 350b339..6a29a00 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -49,7 +49,7 @@ public function __construct(?Mocker $mocker = null) */ public function wrap(Closure $call): self { - if(is_null($this->mocker)) { + if (is_null($this->mocker)) { throw new BadMethodCallException('Mocker is not set. Use the method "mock" to set the mocker.'); } diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index 67cf7f6..23063fc 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -82,7 +82,7 @@ public function getMethodPool(): MethodPool */ public function getMockedClassName(): string { - if(!$this->mockClassName) { + if (!$this->mockClassName) { throw new Exception("Mock class name is not set"); } return $this->mockClassName; @@ -108,15 +108,22 @@ public function execute(): mixed $this->mockClassName = 'Unitary_' . uniqid() . "_Mock_" . $shortClassName; $overrides = $this->generateMockMethodOverrides($this->mockClassName); $unknownMethod = $this->errorHandleUnknownMethod($className); + $code = " class $this->mockClassName extends $className { {$overrides} {$unknownMethod} + public static function __set_state(array \$an_array): self + { + \$obj = new self(..." . var_export($this->constructorArgs, true) . "); + return \$obj; + } } "; eval($code); + /** * @psalm-suppress MixedMethodCall * @psalm-suppress InvalidStringClass @@ -178,11 +185,11 @@ protected function generateMockMethodOverrides(string $mockClassName): string $overrides = ''; foreach ($this->methods as $method) { - if(!($method instanceof ReflectionMethod)) { + if (!($method instanceof ReflectionMethod)) { throw new Exception("Method is not a ReflectionMethod"); } - if ($method->isConstructor() || $method->isFinal()) { + if ($method->isFinal()) { continue; } @@ -193,6 +200,10 @@ protected function generateMockMethodOverrides(string $mockClassName): string $methodItem = $this->getMethodPool()->get($methodName); $types = $this->getReturnType($method); $returnValue = $this->getReturnValue($types, $method, $methodItem); + if($method->isConstructor()) { + $types = []; + $returnValue = ""; + } $paramList = $this->generateMethodSignature($method); $returnType = ($types) ? ': ' . implode('|', $types) : ''; $modifiersArr = Reflection::getModifierNames($method->getModifiers()); @@ -212,11 +223,11 @@ protected function generateMockMethodOverrides(string $mockClassName): string if ($methodItem) { $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); } - + $safeJson = base64_encode($info); $overrides .= " $modifiers function $methodName($paramList){$returnType} { - \$obj = \\MaplePHP\\Unitary\\Mocker\\MockerController::getInstance()->buildMethodData('$info'); + \$obj = \\MaplePHP\\Unitary\\Mocker\\MockerController::getInstance()->buildMethodData('$safeJson', true); \$data = \\MaplePHP\\Unitary\\Mocker\\MockerController::getDataItem(\$obj->mocker, \$obj->name); {$returnValue} } @@ -293,14 +304,15 @@ protected function getReturnType(ReflectionMethod $method): array $types[] = $returnType->getName(); } elseif ($returnType instanceof ReflectionUnionType) { foreach ($returnType->getTypes() as $type) { - if(method_exists($type, "getName")) { + if (method_exists($type, "getName")) { $types[] = $type->getName(); } } } elseif ($returnType instanceof ReflectionIntersectionType) { $intersect = array_map( - fn ($type) => $type->getName(), $returnType->getTypes() + fn ($type) => $type->getName(), + $returnType->getTypes() ); $types[] = $intersect; } @@ -308,7 +320,7 @@ protected function getReturnType(ReflectionMethod $method): array if (!in_array("mixed", $types) && $returnType && $returnType->allowsNull()) { $types[] = "null"; } - return $types; + return array_unique($types); } /** diff --git a/src/Mocker/MockerController.php b/src/Mocker/MockerController.php index 27c8caa..ea2c2b9 100644 --- a/src/Mocker/MockerController.php +++ b/src/Mocker/MockerController.php @@ -31,15 +31,15 @@ public static function getInstance(): self public static function getData(string $mockIdentifier): array|bool { $data = isset(self::$data[$mockIdentifier]) ? self::$data[$mockIdentifier] : false; - if(!is_array($data)) { + if (!is_array($data)) { return false; } return $data; } - + /** * Get specific data item by mock identifier and method name - * + * * @param string $mockIdentifier The identifier of the mock * @param string $method The method name to retrieve * @return mixed Returns the data item if found, false otherwise @@ -48,7 +48,7 @@ public static function getDataItem(string $mockIdentifier, string $method): mixe { return self::$data[$mockIdentifier][$method] ?? false; } - + /** * Add or update data for a specific mock method @@ -61,7 +61,7 @@ public static function getDataItem(string $mockIdentifier, string $method): mixe */ public static function addData(string $mockIdentifier, string $method, string $key, mixed $value): void { - if(isset(self::$data[$mockIdentifier][$method])) { + if (isset(self::$data[$mockIdentifier][$method])) { self::$data[$mockIdentifier][$method]->{$key} = $value; } } @@ -73,10 +73,11 @@ public static function addData(string $mockIdentifier, string $method, string $k * @param string $method JSON string containing mock method data * @return object Decoded method data object with updated count if applicable */ - public function buildMethodData(string $method): object + public function buildMethodData(string $method, bool $isBase64Encoded = false): object { + $method = $isBase64Encoded ? base64_decode($method) : $method; $data = (object)json_decode($method); - if(isset($data->mocker) && isset($data->name)) { + if (isset($data->mocker) && isset($data->name)) { $mocker = (string)$data->mocker; $name = (string)$data->name; if (empty(self::$data[$mocker][$name])) { diff --git a/src/TestCase.php b/src/TestCase.php index 8a2d83d..0503f9c 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -4,9 +4,6 @@ namespace MaplePHP\Unitary; -use BadMethodCallException; -use Closure; -use ErrorException; use MaplePHP\DTO\Format\Str; use MaplePHP\DTO\Traverse; use MaplePHP\Unitary\Mocker\MethodPool; @@ -15,10 +12,14 @@ use MaplePHP\Validate\Validator; use MaplePHP\Validate\ValidationChain; use ReflectionClass; -use ReflectionException; use ReflectionMethod; -use RuntimeException; use Throwable; +use Exception; +use ReflectionException; +use RuntimeException; +use BadMethodCallException; +use ErrorException; +use Closure; final class TestCase { @@ -172,7 +173,7 @@ public function deferValidation(Closure $validation): void * @return $this * @throws ErrorException */ - public function add(mixed $expect, array|Closure $validation, ?string $message = null): static + public function add(mixed $expect, array|Closure $validation, ?string $message = null): TestCase { return $this->expectAndValidate($expect, $validation, $message); } @@ -206,7 +207,7 @@ public function __construct(string $class, array $args = []) * @param Closure|null $validate * @param array $args * @return T - * @throws ReflectionException + * @throws Exception */ public function mock(string $class, ?Closure $validate = null, array $args = []): mixed { @@ -230,12 +231,13 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) * @param Mocker $mocker The mocker instance containing the mock object * @param Closure $validate The closure containing validation rules * @return void + * @throws ErrorException */ private function prepareValidation(Mocker $mocker, Closure $validate): void { $pool = $mocker->getMethodPool(); $fn = $validate->bindTo($pool); - if(is_null($fn)) { + if (is_null($fn)) { throw new ErrorException("A callable Closure could not be bound to the method pool!"); } $fn($pool); @@ -254,12 +256,13 @@ private function prepareValidation(Mocker $mocker, Closure $validate): void * @param MethodPool $pool The pool containing method expectations * @return array An array of validation errors indexed by method name * @throws ErrorException + * @throws Exception */ private function runValidation(Mocker $mocker, MethodPool $pool): array { $error = []; $data = MockerController::getData($mocker->getMockedClassName()); - if(!is_array($data)) { + if (!is_array($data)) { throw new ErrorException("Could not get data from mocker!"); } foreach ($data as $row) { @@ -299,7 +302,9 @@ private function validateRow(object $row, MethodPool $pool): array $currentValue = $row->{$property}; if (is_array($value)) { - assert(is_array($currentValue), 'The $currentValue variable is not!'); + if (!is_array($currentValue)) { + throw new ErrorException("The $property property is not an array!"); + } $validPool = $this->validateArrayValue($value, $currentValue); $valid = $validPool->isValid(); $this->compareFromValidCollection($validPool, $value, $currentValue); @@ -335,7 +340,7 @@ private function validateArrayValue(array $value, mixed $currentValue): Validati foreach ($value as $method => $args) { if (is_int($method)) { foreach ($args as $methodB => $argsB) { - if(is_array($argsB) && count($argsB) >= 2) { + if (is_array($argsB) && count($argsB) >= 2) { $validPool ->mapErrorToKey((string)$argsB[0]) ->mapErrorValidationName((string)$argsB[1]) @@ -380,8 +385,8 @@ protected function mapValueToCollectionError(array $error, array $value): array { foreach ($value as $item) { foreach ($item as $value) { - if (isset($error[$value[0]])) { - $error[$value[0]] = $value[2]; + if (isset($value[0]) && isset($value[2]) && isset($error[(string)$value[0]])) { + $error[(string)$value[0]] = $value[2]; } } } @@ -395,23 +400,32 @@ protected function mapValueToCollectionError(array $error, array $value): array * and converts them into individual TestUnit instances. If a validation fails, * it increases the internal failure count and stores the test details for later reporting. * - * @return TestUnit[] A list of TestUnit results from the deferred validations. + * @return array A list of TestUnit results from the deferred validations. * @throws ErrorException If any validation logic throws an error during execution. */ public function runDeferredValidations(): array { foreach ($this->deferredValidation as $row) { + + if (!isset($row['call']) || !is_callable($row['call'])) { + throw new ErrorException("The validation call is not callable!"); + } + /** @var callable $row['call'] */ $error = $row['call'](); foreach ($error as $method => $arr) { $test = new TestUnit("Mock method \"$method\" failed"); - if (is_array($row['trace'] ?? "")) { + if (isset($row['trace']) && is_array($row['trace'])) { $test->setCodeLine($row['trace']); } + foreach ($arr as $data) { - $test->setUnit($data['valid'], $data['property'], [], [ + $obj = new Traverse($data); + $isValid = $obj->valid->toBool(); + /** @var array{expectedValue: mixed, currentValue: mixed} $data */ + $test->setUnit($isValid, $obj->propert->acceptType(['string', 'closure', 'null']), [], [ $data['expectedValue'], $data['currentValue'] ]); - if (!$data['valid']) { + if (!$isValid) { $this->count++; } } @@ -578,6 +592,7 @@ protected function valid(mixed $value): Validator */ public function listAllProxyMethods(string $class, ?string $prefixMethods = null, bool $isolateClass = false): void { + /** @var class-string $class */ $reflection = new ReflectionClass($class); $traitMethods = $isolateClass ? $this->getAllTraitMethods($reflection) : []; diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index c3abe47..d78dfb7 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -79,7 +79,8 @@ public function registerUser(string $email, string $name = "Daniel"): void { $unit = new Unit(); $unit->group("Unitary test 2", function (TestCase $inst) use($unit) { - $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + /* + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addBCC") ->paramIsType(0, "striwng") ->paramHasDefault(1, "Daniwel") @@ -87,6 +88,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->count(1); }, ["Arg 1"]); $mock->addBCC("World"); + */ }); From 1f240f148c2f47b8b6738e3b1eda74c7bfcf659f Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sat, 3 May 2025 00:47:47 +0200 Subject: [PATCH 18/78] Allow setting mock constructor args --- src/Mocker/Mocker.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index 23063fc..575e597 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -200,11 +200,14 @@ protected function generateMockMethodOverrides(string $mockClassName): string $methodItem = $this->getMethodPool()->get($methodName); $types = $this->getReturnType($method); $returnValue = $this->getReturnValue($types, $method, $methodItem); + $paramList = $this->generateMethodSignature($method); if($method->isConstructor()) { $types = []; $returnValue = ""; + if(count($this->constructorArgs) === 0) { + $paramList = ""; + } } - $paramList = $this->generateMethodSignature($method); $returnType = ($types) ? ': ' . implode('|', $types) : ''; $modifiersArr = Reflection::getModifierNames($method->getModifiers()); $modifiers = implode(" ", $modifiersArr); From eeb68e21e9b534ceea6be6fcbdda0469de68a253 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 4 May 2025 00:24:16 +0200 Subject: [PATCH 19/78] Fix validation count for mock Update README.md Add help command --- README.md | 207 ++--------------------------------- src/Handlers/FileHandler.php | 3 + src/Mocker/Mocker.php | 6 +- src/TestCase.php | 18 ++- src/Unit.php | 60 +++++++++- tests/unitary-unitary.php | 15 +-- 6 files changed, 91 insertions(+), 218 deletions(-) diff --git a/README.md b/README.md index c0811f5..a569351 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ PHP Unitary is a **user-friendly** and robust unit testing library designed to make writing and running tests for your PHP code easy. With an intuitive CLI interface that works on all platforms and robust validation options, Unitary makes it easy for you as a developer to ensure your code is reliable and functions as intended. ![Prompt demo](http://wazabii.se/github-assets/maplephp-unitary.png) +_Do you like the CLI theme? [Download it here](https://github.com/MaplePHP/DarkBark)_ + ### Syntax You Will Love ```php @@ -221,6 +223,11 @@ $dispatch = $this->wrap(PaymentProcessor::class)->bind(function ($orderID) use ( ## Configurations +### Show help +```bash +php vendor/bin/unitary --help +``` + ### Show only errors ```bash php vendor/bin/unitary --errors-only @@ -267,201 +274,7 @@ The exclude argument will always be a relative path from the `--path` argument's php vendor/bin/unitary --exclude="./tests/unitary-query-php, tests/otherTests/*, */extras/*" ``` +## Like The CLI Theme? +That’s DarkBark. Dark, quiet, confident, like a rainy-night synthwave playlist for your CLI. -## Validation List - -Each prompt can have validation rules and custom error messages. Validation can be defined using built-in rules (e.g., length, email) or custom functions. Errors can be specified as static messages or dynamic functions based on the error type. - -### Data Type Checks -1. **isString** - - **Description**: Checks if the value is a string. - - **Usage**: `"isString" => []` - -2. **isInt** - - **Description**: Checks if the value is an integer. - - **Usage**: `"isInt" => []` - -3. **isFloat** - - **Description**: Checks if the value is a float. - - **Usage**: `"isFloat" => []` - -4. **isBool** - - **Description**: Checks if the value is a boolean. - - **Usage**: `"isBool" => []` - -5. **isArray** - - **Description**: Checks if the value is an array. - - **Usage**: `"isArray" => []` - -6. **isObject** - - **Description**: Checks if the value is an object. - - **Usage**: `"isObject" => []` - -7. **isFile** - - **Description**: Checks if the value is a valid file. - - **Usage**: `"isFile" => []` - -8. **isDir** - - **Description**: Checks if the value is a valid directory. - - **Usage**: `"isDir" => []` - -9. **isResource** - - **Description**: Checks if the value is a valid resource. - - **Usage**: `"isResource" => []` - -10. **number** - - **Description**: Checks if the value is numeric. - - **Usage**: `"number" => []` - -### Equality and Length Checks -11. **equal** - - **Description**: Checks if the value is equal to a specified value. - - **Usage**: `"equal" => ["someValue"]` - -12. **notEqual** - - **Description**: Checks if the value is not equal to a specified value. - - **Usage**: `"notEqual" => ["someValue"]` - -13. **length** - - **Description**: Checks if the string length is between a specified start and end length. - - **Usage**: `"length" => [1, 200]` - -14. **equalLength** - - **Description**: Checks if the string length is equal to a specified length. - - **Usage**: `"equalLength" => [10]` - -### Numeric Range Checks -15. **min** - - **Description**: Checks if the value is greater than or equal to a specified minimum. - - **Usage**: `"min" => [10]` - -16. **max** - - **Description**: Checks if the value is less than or equal to a specified maximum. - - **Usage**: `"max" => [100]` - -17. **positive** - - **Description**: Checks if the value is a positive number. - - **Usage**: `"positive" => []` - -18. **negative** - - **Description**: Checks if the value is a negative number. - - **Usage**: `"negative" => []` - -### String and Pattern Checks -19. **pregMatch** - - **Description**: Validates if the value matches a given regular expression pattern. - - **Usage**: `"pregMatch" => ["a-zA-Z"]` - -20. **atoZ (lower and upper)** - - **Description**: Checks if the value consists of characters between `a-z` or `A-Z`. - - **Usage**: `"atoZ" => []` - -21. **lowerAtoZ** - - **Description**: Checks if the value consists of lowercase characters between `a-z`. - - **Usage**: `"lowerAtoZ" => []` - -22. **upperAtoZ** - - **Description**: Checks if the value consists of uppercase characters between `A-Z`. - - **Usage**: `"upperAtoZ" => []` - -23. **hex** - - **Description**: Checks if the value is a valid hex color code. - - **Usage**: `"hex" => []` - -24. **email** - - **Description**: Validates email addresses. - - **Usage**: `"email" => []` - -25. **url** - - **Description**: Checks if the value is a valid URL (http|https is required). - - **Usage**: `"url" => []` - -26. **phone** - - **Description**: Validates phone numbers. - - **Usage**: `"phone" => []` - -27. **zip** - - **Description**: Validates ZIP codes within a specified length range. - - **Usage**: `"zip" => [5, 9]` - -28. **domain** - - **Description**: Checks if the value is a valid domain. - - **Usage**: `"domain" => [true]` - -29. **dns** - - **Description**: Checks if the host/domain has a valid DNS record (A, AAAA, MX). - - **Usage**: `"dns" => []` - -30. **matchDNS** - - **Description**: Matches DNS records by searching for a specific type and value. - - **Usage**: `"matchDNS" => [DNS_A]` - -31. **lossyPassword** - - **Description**: Validates a password with allowed characters `[a-zA-Z\d$@$!%*?&]` and a minimum length. - - **Usage**: `"lossyPassword" => [8]` - -32. **strictPassword** - - **Description**: Validates a strict password with specific character requirements and a minimum length. - - **Usage**: `"strictPassword" => [8]` - -### Required and Boolean-Like Checks -33. **required** - - **Description**: Checks if the value is not empty (e.g., not `""`, `0`, `NULL`). - - **Usage**: `"required" => []` - -34. **isBoolVal** - - **Description**: Checks if the value is a boolean-like value (e.g., "on", "yes", "1", "true"). - - **Usage**: `"isBoolVal" => []` - -35. **hasValue** - - **Description**: Checks if the value itself is interpreted as having value (e.g., 0 is valid). - - **Usage**: `"hasValue" => []` - -36. **isNull** - - **Description**: Checks if the value is null. - - **Usage**: `"isNull" => []` - -### Date and Time Checks -37. **date** - - **Description**: Checks if the value is a valid date with the specified format. - - **Usage**: `"date" => ["Y-m-d"]` - -38. **dateTime** - - **Description**: Checks if the value is a valid date and time with the specified format. - - **Usage**: `"dateTime" => ["Y-m-d H:i"]` - -39. **time** - - **Description**: Checks if the value is a valid time with the specified format. - - **Usage**: `"time" => ["H:i"]` - -40. **age** - - **Description**: Checks if the value represents an age equal to or greater than the specified minimum. - - **Usage**: `"age" => [18]` - -### Version Checks -41. **validVersion** - - **Description**: Checks if the value is a valid version number. - - **Usage**: `"validVersion" => [true]` - -42. **versionCompare** - - **Description**: Validates and compares if a version is equal/more/equalMore/less than a specified version. - - **Usage**: `"versionCompare" => ["1.0.0", ">="]` - -### Logical Checks -43. **oneOf** - - **Description**: Validates if one of the provided conditions is met. - - **Usage**: `"oneOf" => [["length", [1, 200]], "email"]` - -44. **allOf** - - **Description**: Validates if all the provided conditions are met. - - **Usage**: `"allOf" => [["length", [1, 200]], "email"]` - -### Additional Validations - -45. **creditCard** - - **Description**: Validates credit card numbers. - - **Usage**: `"creditCard" => []` - -56. **vatNumber** - - **Description**: Validates Swedish VAT numbers. - - **Usage**: `"vatNumber" => []` +[Download it here](https://github.com/MaplePHP/DarkBark) \ No newline at end of file diff --git a/src/Handlers/FileHandler.php b/src/Handlers/FileHandler.php index f77472c..2acc8dd 100755 --- a/src/Handlers/FileHandler.php +++ b/src/Handlers/FileHandler.php @@ -17,6 +17,7 @@ final class FileHandler implements HandlerInterface /** * Construct the file handler * The handler will pass stream to a file + * * @param string $file */ public function __construct(string $file) @@ -29,6 +30,7 @@ public function __construct(string $file) /** * Access the command stream + * * @return Command */ public function getCommand(): Command @@ -39,6 +41,7 @@ public function getCommand(): Command /** * Execute the handler * This will automatically be called inside the Unit execution + * * @return void */ public function execute(): void diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index 575e597..f08c167 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -184,11 +184,9 @@ protected function generateMockMethodOverrides(string $mockClassName): string { $overrides = ''; foreach ($this->methods as $method) { - if (!($method instanceof ReflectionMethod)) { throw new Exception("Method is not a ReflectionMethod"); } - if ($method->isFinal()) { continue; } @@ -221,11 +219,12 @@ protected function generateMockMethodOverrides(string $mockClassName): string if ($info === false) { throw new RuntimeException('JSON encoding failed: ' . json_last_error_msg(), json_last_error()); } - MockerController::getInstance()->buildMethodData($info); + MockerController::getInstance()->buildMethodData($info); if ($methodItem) { $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); } + $safeJson = base64_encode($info); $overrides .= " $modifiers function $methodName($paramList){$returnType} @@ -236,7 +235,6 @@ protected function generateMockMethodOverrides(string $mockClassName): string } "; } - return $overrides; } diff --git a/src/TestCase.php b/src/TestCase.php index 0503f9c..1045f92 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -156,7 +156,7 @@ protected function expectAndValidate( public function deferValidation(Closure $validation): void { // This will add a cursor to the possible line and file where the error occurred - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]; $this->deferredValidation[] = [ "trace" => $trace, "call" => $validation @@ -179,7 +179,11 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = } /** - * Init a test wrapper + * initialize a test wrapper + * + * NOTICE: When mocking a class with required constructor arguments, those arguments must be + * specified in the mock initialization method or it will fail. This is because the mock + * creates and simulates an actual instance of the original class with its real constructor. * * @param string $class * @param array $args @@ -266,7 +270,7 @@ private function runValidation(Mocker $mocker, MethodPool $pool): array throw new ErrorException("Could not get data from mocker!"); } foreach ($data as $row) { - if (is_object($row) && isset($row->name)) { + if (is_object($row) && isset($row->name) && $pool->has($row->name)) { $error[(string)$row->name] = $this->validateRow($row, $pool); } } @@ -410,22 +414,24 @@ public function runDeferredValidations(): array if (!isset($row['call']) || !is_callable($row['call'])) { throw new ErrorException("The validation call is not callable!"); } + /** @var callable $row['call'] */ $error = $row['call'](); + $hasValidated = []; foreach ($error as $method => $arr) { $test = new TestUnit("Mock method \"$method\" failed"); if (isset($row['trace']) && is_array($row['trace'])) { $test->setCodeLine($row['trace']); } - foreach ($arr as $data) { $obj = new Traverse($data); $isValid = $obj->valid->toBool(); /** @var array{expectedValue: mixed, currentValue: mixed} $data */ - $test->setUnit($isValid, $obj->propert->acceptType(['string', 'closure', 'null']), [], [ + $test->setUnit($isValid, $obj->property->acceptType(['string', 'closure', 'null']), [], [ $data['expectedValue'], $data['currentValue'] ]); - if (!$isValid) { + if (!isset($hasValidated[$method]) && !$isValid) { + $hasValidated[$method] = true; $this->count++; } } diff --git a/src/Unit.php b/src/Unit.php index b0238fb..fa98f4b 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -7,12 +7,11 @@ use Closure; use ErrorException; use Exception; -use MaplePHP\Unitary\Mocker\MockerController; -use RuntimeException; -use MaplePHP\Unitary\Handlers\HandlerInterface; use MaplePHP\Http\Interfaces\StreamInterface; use MaplePHP\Prompts\Command; -use Throwable; +use MaplePHP\Prompts\Themes\Blocks; +use MaplePHP\Unitary\Handlers\HandlerInterface; +use RuntimeException; class Unit { @@ -196,6 +195,9 @@ public function performance(Closure $func, ?string $title = null): void */ public function execute(): bool { + + $this->help(); + if ($this->executed || !$this->createValidate()) { return false; } @@ -489,6 +491,56 @@ public static function isSuccessful(): bool return (self::$totalPassedTests !== self::$totalTests); } + /** + * Display help information for the Unitary testing tool + * Shows usage instructions, available options and examples + * Only displays if --help argument is provided + * + * @return void True if help was displayed, false otherwise + */ + private function help(): void + { + if (self::getArgs("help") !== false) { + + $blocks = new Blocks($this->command); + $blocks->addHeadline("Unitary - Help"); + $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); + + $blocks->addSection("Options", function(Blocks $inst) { + $inst = $inst + ->addOption("help", "Show this help message") + ->addOption("show=", "Run a specific test by hash or manual test name") + ->addOption("errors-only", "Show only failing tests and skip passed test output") + ->addOption("path=", "Specify test path (absolute or relative)") + ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); + return $inst; + }); + + $blocks->addSection("Examples", function(Blocks $inst) { + $inst = $inst + ->addExamples( + "php vendor/bin/unitary", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983", + "Run the test with a specific hash ID" + )->addExamples( + "php vendor/bin/unitary --errors-only", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=maplePHPRequest", + "Run a manually named test case" + )->addExamples( + 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', + 'Run all tests under "tests/" excluding specified directories' + ) + ; + return $inst; + }); + exit(0); + } + } + /** * DEPRECATED: Not used anymore * @return $this diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index d78dfb7..aaed9c4 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -78,18 +78,19 @@ public function registerUser(string $email, string $name = "Daniel"): void { $unit = new Unit(); $unit->group("Unitary test 2", function (TestCase $inst) use($unit) { - - /* $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addBCC") - ->paramIsType(0, "striwng") - ->paramHasDefault(1, "Daniwel") + ->isAbstract() + ->paramIsType(0, "string") + ->paramHasDefault(1, "Daniel") + ->paramIsOptional(0) ->paramIsReference(1) ->count(1); - }, ["Arg 1"]); - $mock->addBCC("World"); - */ + $pool->method("test") + ->count(1); + }); + $mock->addBCC("World"); }); /* From 84fc6ea59d59355867648b6140a1450499dbc4ed Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 4 May 2025 14:01:47 +0200 Subject: [PATCH 20/78] Add Default data type mock --- src/Mocker/Mocker.php | 72 +++++++++----- src/TestUtils/DataTypeMock.php | 167 +++++++++++++++++++++++++++++++++ tests/unitary-unitary.php | 20 +++- 3 files changed, 230 insertions(+), 29 deletions(-) create mode 100644 src/TestUtils/DataTypeMock.php diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index f08c167..c8d776f 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -9,8 +9,10 @@ namespace MaplePHP\Unitary\Mocker; +use ArrayIterator; use Closure; use Exception; +use MaplePHP\Unitary\TestUtils\DataTypeMock; use Reflection; use ReflectionClass; use ReflectionIntersectionType; @@ -32,6 +34,8 @@ final class Mocker protected array $methods; protected array $methodList = []; protected static ?MethodPool $methodPool = null; + protected array $defaultArguments = []; + private DataTypeMock $dataTypeMock; /** * @param string $className @@ -43,6 +47,7 @@ public function __construct(string $className, array $args = []) /** @var class-string $className */ $this->reflection = new ReflectionClass($className); + $this->dataTypeMock = new DataTypeMock(); /* // Auto fill the Constructor args! $test = $this->reflection->getConstructor(); @@ -88,6 +93,25 @@ public function getMockedClassName(): string return $this->mockClassName; } + /** + * Sets a custom mock value for a specific data type. The mock value can be bound to a specific method + * or used as a global default for the data type. + * + * @param string $dataType The data type to mock (e.g., 'int', 'string', 'bool') + * @param mixed $value The value to use when mocking this data type + * @param string|null $bindToMethod Optional method name to bind this mock value to + * @return self Returns the current instance for method chaining + */ + public function mockDataType(string $dataType, mixed $value, ?string $bindToMethod = null): self + { + if($bindToMethod) { + $this->dataTypeMock = $this->dataTypeMock->withCustomBoundDefault($bindToMethod, $dataType, $value); + } else { + $this->dataTypeMock = $this->dataTypeMock->withCustomDefault($dataType, $value); + } + return $this; + } + /** * Executes the creation of a dynamic mock class and returns an instance of the mock. * @@ -333,22 +357,33 @@ protected function getReturnType(ReflectionMethod $method): array */ protected function getMockValueForType(string $typeName, mixed $method, mixed $value = null, bool $nullable = false): ?string { + + $dataTypeName = strtolower($typeName); if (!is_null($value)) { - return "return " . var_export($value, true) . ";"; + return "return " . DataTypeMock::exportValue($value) . ";"; + } + + $methodName = ($method instanceof ReflectionMethod) ? $method->getName() : null; + + /* + $this->dataTypeMock = $this->dataTypeMock->withCustomDefault('int', $value); + if($method instanceof ReflectionMethod) { + $this->dataTypeMock = $this->dataTypeMock->withCustomBoundDefault($method->getName(), 'int', $value); } + */ $mock = match ($dataTypeName) { - 'int', 'integer' => "return 123456;", - 'float', 'double' => "return 3.14;", - 'string' => "return 'mockString';", - 'bool', 'boolean' => "return true;", - 'array' => "return ['item'];", - 'object' => "return (object)['item'];", - 'resource' => "return fopen('php://memory', 'r+');", - 'callable' => "return fn() => 'called';", - 'iterable' => "return new ArrayIterator(['a', 'b']);", - 'null' => "return null;", + 'int', 'integer' => "return " . $this->dataTypeMock->getDataTypeValue('int', $methodName) . ";", + 'float', 'double' => "return " . $this->dataTypeMock->getDataTypeValue('float', $methodName) . ";", + 'string' => "return " . $this->dataTypeMock->getDataTypeValue('string', $methodName) . ";", + 'bool', 'boolean' => "return " . $this->dataTypeMock->getDataTypeValue('bool', $methodName) . ";", + 'array' => "return " . $this->dataTypeMock->getDataTypeValue('array', $methodName) . ";", + 'object' => "return " . $this->dataTypeMock->getDataTypeValue('object', $methodName) . ";", + 'resource' => "return " . $this->dataTypeMock->getDataTypeValue('resource', $methodName) . ";", + 'callable' => "return " . $this->dataTypeMock->getDataTypeValue('callable', $methodName) . ";", + 'iterable' => "return " . $this->dataTypeMock->getDataTypeValue('iterable', $methodName) . ";", + 'null' => "return " . $this->dataTypeMock->getDataTypeValue('null', $methodName) . ";", 'void' => "", 'self' => (is_object($method) && method_exists($method, "isStatic") && $method->isStatic()) ? 'return new self();' : 'return $this;', /** @var class-string $typeName */ @@ -360,20 +395,6 @@ protected function getMockValueForType(string $typeName, mixed $method, mixed $v return $nullable && rand(0, 1) ? null : $mock; } - /** - * Will return a streamable content - * - * @param mixed $resourceValue - * @return string|null - */ - protected function handleResourceContent(mixed $resourceValue): ?string - { - if (!is_resource($resourceValue)) { - return null; - } - return var_export(stream_get_contents($resourceValue), true); - } - /** * Build a method information array from a ReflectionMethod instance * @@ -417,5 +438,4 @@ public function getMethodInfoAsArray(ReflectionMethod $refMethod): array 'fileName' => $refMethod->getFileName(), ]; } - } diff --git a/src/TestUtils/DataTypeMock.php b/src/TestUtils/DataTypeMock.php new file mode 100644 index 0000000..e61e7c7 --- /dev/null +++ b/src/TestUtils/DataTypeMock.php @@ -0,0 +1,167 @@ + 123456, + 'float' => 3.14, + 'string' => "mockString", + 'bool' => true, + 'array' => ['item'], + 'object' => (object)['item'], + 'resource' => "fopen('php://memory', 'r+')", + 'callable' => fn() => 'called', + 'iterable' => new ArrayIterator(['a', 'b']), + 'null' => null, + ], $this->defaultArguments); + } + + /** + * Exports a value to a parsable string representation + * + * @param mixed $value The value to be exported + * @return string The string representation of the value + */ + public static function exportValue(mixed $value): string + { + return var_export($value, true); + + } + + /** + * Creates a new instance with merged default and custom arguments. + * Handles resource type arguments separately by converting them to string content. + * + * @param array $dataTypeArgs Custom arguments to merge with defaults + * @return self New instance with updated arguments + */ + public function withCustomDefaults(array $dataTypeArgs): self + { + $inst = clone $this; + foreach($dataTypeArgs as $key => $value) { + $inst = $this->withCustomDefault($key, $value); + } + return $inst; + } + + + /** + * Sets a custom default value for a specific data type. + * If the value is a resource, it will be converted to its string content. + * + * @param string $dataType The data type to set the custom default for + * @param mixed $value The value to set as default for the data type + * @return self New instance with updated custom default + */ + public function withCustomDefault(string $dataType, mixed $value): self + { + $inst = clone $this; + if(isset($value) && is_resource($value)) { + $value= $this->handleResourceContent($value); + } + $inst->defaultArguments[$dataType] = $value; + return $inst; + } + + /** + * Sets a custom default value for a specific data type with a binding key. + * Creates a new instance with the bound value stored in bindArguments array. + * + * @param string $key The binding key to store the value under + * @param string $dataType The data type to set the custom default for + * @param mixed $value The value to set as default for the data type + * @return self New instance with the bound value + */ + public function withCustomBoundDefault(string $key, string $dataType, mixed $value): self + { + $inst = clone $this; + $tempInst = $this->withCustomDefault($dataType, $value); + $inst->bindArguments[$key][$dataType] = $tempInst->defaultArguments[$dataType]; + return $inst; + } + + /** + * Converts default argument values to their string representations + * using var_export for each value in the default arguments array + * + * @return array Array of stringify default argument values + */ + public function getDataTypeListToString(): array + { + return array_map(fn($value) => self::exportValue($value), $this->getMockValues()); + } + + /** + * Retrieves the string representation of a value for a given data type + * Initializes types' array if not already set + * + * @param string $dataType The data type to get the value for + * @return mixed The string representation of the value for the specified data type + * @throws InvalidArgumentException If the specified data type is invalid + */ + public function getDataTypeValue(string $dataType, ?string $bindKey = null): mixed + { + if(is_string($bindKey) && isset($this->bindArguments[$bindKey][$dataType])) { + return $this->bindArguments[$bindKey][$dataType]; + } + + if(is_null($this->types)) { + $this->types = $this->getDataTypeListToString(); + } + + if(!isset($this->types[$dataType])) { + throw new InvalidArgumentException("Invalid data type: $dataType"); + } + return $this->types[$dataType]; + + } + + /** + * Will return a streamable content + * + * @param mixed $resourceValue + * @return string|null + */ + public function handleResourceContent(mixed $resourceValue): ?string + { + if (!is_resource($resourceValue)) { + return null; + } + return var_export(stream_get_contents($resourceValue), true); + } +} \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index aaed9c4..c7c9429 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -77,7 +77,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { } $unit = new Unit(); -$unit->group("Unitary test 2", function (TestCase $inst) use($unit) { +$unit->group("Unitary test 1", function (TestCase $inst) use($unit) { $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addBCC") ->isAbstract() @@ -86,12 +86,26 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->paramIsOptional(0) ->paramIsReference(1) ->count(1); + }); + $mock->addBCC("World"); +}); - $pool->method("test") - ->count(1); + +/* + $unit->group("Unitary test 2", function (TestCase $inst) use($unit) { + $mock = $inst->mocker(Mailer::class)->mock(function (MethodPool $pool) use($inst) { + $pool->method("addBCC") + ->paramHasDefault(1, "DanielRonkainen") + ->count(1); }); + + $mock->mockDataType(); $mock->addBCC("World"); }); + */ + + + /* From 4c84d09703bca86fd1cd960cc426a0965d65f18b Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 4 May 2025 16:52:42 +0200 Subject: [PATCH 21/78] Improve mocking and error handling --- src/Mocker/Mocker.php | 4 +- src/TestCase.php | 88 +++++++++++++++++++++++++-------- src/TestUnit.php | 2 +- src/TestUtils/DataTypeMock.php | 18 +++++-- src/Unit.php | 2 +- tests/unitary-unitary.php | 89 +++++++++++++++++++++++++++------- 6 files changed, 157 insertions(+), 46 deletions(-) diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index c8d776f..22eb427 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -147,7 +147,6 @@ public static function __set_state(array \$an_array): self eval($code); - /** * @psalm-suppress MixedMethodCall * @psalm-suppress InvalidStringClass @@ -223,6 +222,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string $types = $this->getReturnType($method); $returnValue = $this->getReturnValue($types, $method, $methodItem); $paramList = $this->generateMethodSignature($method); + if($method->isConstructor()) { $types = []; $returnValue = ""; @@ -357,8 +357,6 @@ protected function getReturnType(ReflectionMethod $method): array */ protected function getMockValueForType(string $typeName, mixed $method, mixed $value = null, bool $nullable = false): ?string { - - $dataTypeName = strtolower($typeName); if (!is_null($value)) { return "return " . DataTypeMock::exportValue($value) . ";"; diff --git a/src/TestCase.php b/src/TestCase.php index 1045f92..d8c3745 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -4,6 +4,7 @@ namespace MaplePHP\Unitary; +use MaplePHP\Blunder\BlunderErrorException; use MaplePHP\DTO\Format\Str; use MaplePHP\DTO\Traverse; use MaplePHP\Unitary\Mocker\MethodPool; @@ -21,6 +22,9 @@ use ErrorException; use Closure; +/** + * @template T of object + */ final class TestCase { private mixed $value; @@ -29,9 +33,9 @@ final class TestCase private int $count = 0; private ?Closure $bind = null; private ?string $errorMessage = null; - private array $deferredValidation = []; - + /** @var Mocker */ + private Mocker $mocker; /** * Initialize a new TestCase instance with an optional message. @@ -57,13 +61,23 @@ public function bind(Closure $bind): void /** * Will dispatch the case tests and return them as an array * + * @param self $row * @return array + * @throws BlunderErrorException */ - public function dispatchTest(): array + public function dispatchTest(self &$row): array { + $row = $this; $test = $this->bind; if (!is_null($test)) { - $test($this); + try { + $newInst = $test($this); + } catch (Throwable $e) { + throw new BlunderErrorException($e->getMessage(), $e->getCode()); + } + if ($newInst instanceof self) { + $row = $newInst; + } } return $this->test; } @@ -199,6 +213,39 @@ public function __construct(string $class, array $args = []) }; } + /** + * @param class-string $class + * @param array $args + * @return self + */ + public function withMock(string $class, array $args = []): self + { + $inst = clone $this; + $inst->mocker = new Mocker($class, $args); + return $inst; + } + + /** + * @param Closure|null $validate + * @return T + * @throws ErrorException + * @throws Exception + */ + public function buildMock(?Closure $validate = null): mixed + { + if (is_callable($validate)) { + $this->prepareValidation($this->mocker, $validate); + } + + try { + /** @psalm-suppress MixedReturnStatement */ + return $this->mocker->execute(); + } catch (Throwable $e) { + throw new BlunderErrorException($e->getMessage(), $e->getCode()); + } + } + + /** * Creates and returns an instance of a dynamically generated mock class. * @@ -213,16 +260,16 @@ public function __construct(string $class, array $args = []) * @return T * @throws Exception */ - public function mock(string $class, ?Closure $validate = null, array $args = []): mixed + public function mock(string $class, ?Closure $validate = null, array $args = []) { - $mocker = new Mocker($class, $args); + $this->mocker = new Mocker($class, $args); + return $this->buildMock($validate); + } - if (is_callable($validate)) { - $this->prepareValidation($mocker, $validate); - } - /** @psalm-suppress MixedReturnStatement */ - return $mocker->execute(); + public function getMocker(): Mocker + { + return $this->mocker; } /** @@ -424,15 +471,16 @@ public function runDeferredValidations(): array $test->setCodeLine($row['trace']); } foreach ($arr as $data) { - $obj = new Traverse($data); - $isValid = $obj->valid->toBool(); - /** @var array{expectedValue: mixed, currentValue: mixed} $data */ - $test->setUnit($isValid, $obj->property->acceptType(['string', 'closure', 'null']), [], [ - $data['expectedValue'], $data['currentValue'] - ]); - if (!isset($hasValidated[$method]) && !$isValid) { - $hasValidated[$method] = true; - $this->count++; + // We do not want to validate the return here automatically + if($data['property'] !== "return") { + /** @var array{expectedValue: mixed, currentValue: mixed} $data */ + $test->setUnit($data['valid'], $data['property'], [], [ + $data['expectedValue'], $data['currentValue'] + ]); + if (!isset($hasValidated[$method]) && !$data['valid']) { + $hasValidated[$method] = true; + $this->count++; + } } } $this->test[] = $test; diff --git a/src/TestUnit.php b/src/TestUnit.php index 76de644..a87b1db 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -21,7 +21,6 @@ class TestUnit /** * Initiate the test * - * @param mixed $value * @param string|null $message */ public function __construct(?string $message = null) @@ -68,6 +67,7 @@ public function setUnit( array $args = [], array $compare = [] ): self { + if (!$valid) { $this->valid = false; $this->count++; diff --git a/src/TestUtils/DataTypeMock.php b/src/TestUtils/DataTypeMock.php index e61e7c7..be8d24a 100644 --- a/src/TestUtils/DataTypeMock.php +++ b/src/TestUtils/DataTypeMock.php @@ -30,6 +30,16 @@ class DataTypeMock */ private ?array $bindArguments = null; + private static ?self $inst = null; + + public static function inst(): self + { + if (is_null(self::$inst)) { + self::$inst = new self(); + } + return self::$inst; + } + /** * Returns an array of default arguments for different data types * @@ -42,8 +52,8 @@ public function getMockValues(): array 'float' => 3.14, 'string' => "mockString", 'bool' => true, - 'array' => ['item'], - 'object' => (object)['item'], + 'array' => ['item1', 'item2', 'item3'], + 'object' => (object)['item1' => 'value1', 'item2' => 'value2', 'item3' => 'value3'], 'resource' => "fopen('php://memory', 'r+')", 'callable' => fn() => 'called', 'iterable' => new ArrayIterator(['a', 'b']), @@ -92,7 +102,7 @@ public function withCustomDefault(string $dataType, mixed $value): self { $inst = clone $this; if(isset($value) && is_resource($value)) { - $value= $this->handleResourceContent($value); + $value = $this->handleResourceContent($value); } $inst->defaultArguments[$dataType] = $value; return $inst; @@ -137,7 +147,7 @@ public function getDataTypeListToString(): array public function getDataTypeValue(string $dataType, ?string $bindKey = null): mixed { if(is_string($bindKey) && isset($this->bindArguments[$bindKey][$dataType])) { - return $this->bindArguments[$bindKey][$dataType]; + return self::exportValue($this->bindArguments[$bindKey][$dataType]); } if(is_null($this->types)) { diff --git a/src/Unit.php b/src/Unit.php index fa98f4b..869ea15 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -210,7 +210,7 @@ public function execute(): bool } $errArg = self::getArgs("errors-only"); - $row->dispatchTest(); + $row->dispatchTest($row); $tests = $row->runDeferredValidations(); $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index c7c9429..02552e7 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -5,6 +5,8 @@ use MaplePHP\Unitary\Unit; use MaplePHP\Validate\ValidationChain; use MaplePHP\Unitary\Mocker\MethodPool; +use MaplePHP\Http\Response; +use MaplePHP\Http\Stream; class Mailer @@ -77,32 +79,85 @@ public function registerUser(string $email, string $name = "Daniel"): void { } $unit = new Unit(); -$unit->group("Unitary test 1", function (TestCase $inst) use($unit) { - $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { - $pool->method("addBCC") - ->isAbstract() - ->paramIsType(0, "string") - ->paramHasDefault(1, "Daniel") - ->paramIsOptional(0) - ->paramIsReference(1) - ->count(1); + + + +$unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { + + + // Quickly mock the Stream class + $stream = $case->mock(Stream::class); + + // Mock with configuration + // + // Notice: this will handle TestCase as immutable, and because of this + // the new instance of TestCase must be return to the group callable below + // + // By passing the mocked Stream class to the Response constructor, we + // will actually also test that the argument has the right data type + $case = $case->withMock(Response::class, [$stream]); + + // We can override all "default" mocking values tide to TestCase Instance + // to use later on in out in the validations, you can also tie the mock + // value to a method + $case->getMocker() + ->mockDataType("string", "myCustomMockStringValue") + ->mockDataType("array", ["myCustomMockArrayItem"]) + ->mockDataType("int", 200, "getStatusCode"); + + // List all default mock values that will be automatically used in + // parameters and return values + //print_r(\MaplePHP\Unitary\TestUtils\DataTypeMock::inst()->getMockValues()); + + $response = $case->buildMock(function (MethodPool $pool) use($stream) { + // Even tho Unitary mocker tries to automatically mock the return type of methods, + // it might fail if the return type is an expected Class instance, then you will + // need to manually set the return type to tell Unitary mocker what class to expect, + // which is in this example a class named "Stream". + // You can do this by either passing the expected class directly into the `return` method + // or even better by mocking the expected class and then passing the mocked class. + $pool->method("getBody")->return($stream); }); - $mock->addBCC("World"); + + $case->validate($response->getHeader("lorem"), function(ValidationChain $inst) { + // Validate against the new default array item value + // If we weren't overriding the default the array would be ['item1', 'item2', 'item3'] + $inst->isInArray(["myCustomMockArrayItem"]); + }); + + $case->validate($response->getStatusCode(), function(ValidationChain $inst) { + // Will validate to the default int data type set above + // and bounded to "getStatusCode" method + $inst->isEqualTo(200); + }); + + $case->validate($response->getProtocolVersion(), function(ValidationChain $inst) { + // MockedValue is the default value that the mocked class will return + // if you do not specify otherwise, either by specify what the method should return + // or buy overrides the default mocking data type values. + $inst->isEqualTo("MockedValue"); + }); + + $case->validate($response->getBody(), function(ValidationChain $inst) { + $inst->isInstanceOf(Stream::class); + }); + + // You need to return a new instance of TestCase for new mocking settings + return $case; }); -/* - $unit->group("Unitary test 2", function (TestCase $inst) use($unit) { - $mock = $inst->mocker(Mailer::class)->mock(function (MethodPool $pool) use($inst) { +$unit->group("Mailer test", function (TestCase $inst) use($unit) { + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addBCC") - ->paramHasDefault(1, "DanielRonkainen") + ->paramIsType(0, "string") + ->paramHasDefault(1, "Daniel") + ->paramIsOptional(1) + ->paramIsReference(1) ->count(1); }); - - $mock->mockDataType(); $mock->addBCC("World"); }); - */ From 9e8abaf426b5a700d929b756b61d3a17afb603c4 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Mon, 5 May 2025 09:59:22 +0200 Subject: [PATCH 22/78] Add DTO traverse to value in validate --- src/TestCase.php | 15 +++++++++++---- tests/unitary-unitary.php | 25 +++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/TestCase.php b/src/TestCase.php index d8c3745..d95cbb9 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -73,7 +73,10 @@ public function dispatchTest(self &$row): array try { $newInst = $test($this); } catch (Throwable $e) { - throw new BlunderErrorException($e->getMessage(), $e->getCode()); + if(str_contains($e->getFile(), "eval()")) { + throw new BlunderErrorException($e->getMessage(), $e->getCode()); + } + throw $e; } if ($newInst instanceof self) { $row = $newInst; @@ -105,7 +108,7 @@ public function error(string $message): self public function validate(mixed $expect, Closure $validation): self { $this->expectAndValidate($expect, function (mixed $value, ValidationChain $inst) use ($validation) { - return $validation($inst, $value); + return $validation($inst, new Traverse($value)); }, $this->errorMessage); return $this; @@ -136,8 +139,12 @@ protected function expectAndValidate( if ($validation instanceof Closure) { $listArr = $this->buildClosureTest($validation); foreach ($listArr as $list) { - foreach ($list as $method => $_valid) { - $test->setUnit(false, (string)$method); + if(is_bool($list)) { + $test->setUnit($list, "Validation"); + } else { + foreach ($list as $method => $_valid) { + $test->setUnit(false, (string)$method); + } } } } else { diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 02552e7..407208e 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,6 +1,7 @@ group("Advanced App Response Test", function (TestCase $case) use($unit) { + + $stream = $case->mock(Stream::class); + $response = new Response($stream); + + $case->validate($response->getBody()->getContents(), function(ValidationChain $inst) { + $inst->hasResponse(); + }); +}); + $unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { // Quickly mock the Stream class - $stream = $case->mock(Stream::class); + $stream = $case->mock(Stream::class, function (MethodPool $pool) { + $pool->method("getContents") + ->return('{"test":"test"}'); + }); // Mock with configuration // @@ -119,6 +133,13 @@ public function registerUser(string $email, string $name = "Daniel"): void { $pool->method("getBody")->return($stream); }); + + $case->validate($response->getBody()->getContents(), function(ValidationChain $inst, Traverse $collection) { + $inst->isString(); + $inst->isJson(); + return $collection->strJsonDecode()->test->valid("isString"); + }); + $case->validate($response->getHeader("lorem"), function(ValidationChain $inst) { // Validate against the new default array item value // If we weren't overriding the default the array would be ['item1', 'item2', 'item3'] @@ -128,7 +149,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { $case->validate($response->getStatusCode(), function(ValidationChain $inst) { // Will validate to the default int data type set above // and bounded to "getStatusCode" method - $inst->isEqualTo(200); + $inst->isHttpSuccess(); }); $case->validate($response->getProtocolVersion(), function(ValidationChain $inst) { From bbb5c125dfc580f70b9ecd9bb56f6b6c6d0b5f05 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Wed, 7 May 2025 21:34:24 +0200 Subject: [PATCH 23/78] Fix Mocking and MethodPool inheritance Minor code quality improvements --- README.md | 8 +-- src/Mocker/MethodItem.php | 107 +++++++++++++++++++++++++------- src/Mocker/MethodPool.php | 23 +++++-- src/Mocker/Mocker.php | 77 +++++++++++++---------- src/Mocker/MockerController.php | 17 +++-- src/TestCase.php | 18 ++++-- tests/unitary-unitary.php | 47 ++++++++++---- 7 files changed, 206 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index a569351..2f3e153 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ use \MaplePHP\Unitary\Mocker\MethodPool; $unit->group("Testing user service", function (TestCase $inst) { $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { // Quick way to tell Unitary that this method should return 'john.doe' - $pool->method("getFromEmail")->return('john.doe@gmail.com'); + $pool->method("getFromEmail")->willReturn('john.doe@gmail.com'); // Or we can acctually pass a callable to it and tell it what it should return // But we can also validate the argumnets! @@ -176,7 +176,7 @@ $unit->group("Testing user service", function (TestCase $inst) { ### Mocking: Add Consistency validation What is really cool is that you can also use Unitary mocker to make sure consistencies is followed and -validate that the method is built and loaded correctly. +validate that the method is built and loaded correctly. ```php use \MaplePHP\Unitary\Mocker\MethodPool; @@ -187,11 +187,11 @@ $unit->group("Unitary test", function (TestCase $inst) { ->isPublic() ->hasDocComment() ->hasReturnType() - ->count(1); + ->isTimes(1); $pool->method("addBCC") ->isPublic() - ->count(3); + ->isTimes(3); }); $service = new UserService($mock); }); diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index 6a29a00..a3723ec 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -13,7 +13,7 @@ final class MethodItem { private ?Mocker $mocker; public mixed $return = null; - public ?int $count = null; + public int|array|null $called = null; public ?string $class = null; public ?string $name = null; @@ -33,6 +33,7 @@ final class MethodItem public ?int $startLine = null; public ?int $endLine = null; public ?string $fileName = null; + public bool $keepOriginal = false; protected bool $hasReturn = false; protected ?Closure $wrapper = null; @@ -42,10 +43,12 @@ public function __construct(?Mocker $mocker = null) } /** - * Will create a method wrapper making it possible to mock + * Creates a proxy wrapper around a method to enable integration testing. + * The wrapper allows intercepting and modifying method behavior during tests. * - * @param Closure $call - * @return $this + * @param Closure $call The closure to be executed as the wrapper function + * @return $this Method chain + * @throws BadMethodCallException When mocker is not set */ public function wrap(Closure $call): self { @@ -84,15 +87,73 @@ public function hasReturn(): bool return $this->hasReturn; } + /** + * Preserve the original method functionality instead of mocking it. + * When this is set, the method will execute its original implementation instead of any mock behavior. + * + * @return $this Method chain + */ + public function keepOriginal(): self + { + $inst = $this; + $inst->keepOriginal = true; + return $inst; + } + /** * Check if a method has been called x times - * @param int $count + * + * @param int $times * @return $this */ - public function count(int $count): self + public function called(int $times): self { $inst = $this; - $inst->count = $count; + $inst->called = $times; + return $inst; + } + + /** + * Check if a method has been called x times + * + * @return $this + */ + public function hasBeenCalled(): self + { + $inst = $this; + $inst->called = [ + "isAtLeast" => [1], + ]; + return $inst; + } + + /** + * Check if a method has been called x times + * + * @param int $times + * @return $this + */ + public function calledAtLeast(int $times): self + { + $inst = $this; + $inst->called = [ + "isAtLeast" => [$times], + ]; + return $inst; + } + + /** + * Check if a method has been called x times + * + * @param int $times + * @return $this + */ + public function calledAtMost(int $times): self + { + $inst = $this; + $inst->called = [ + "isAtMost" => [$times], + ]; return $inst; } @@ -102,7 +163,7 @@ public function count(int $count): self * @param mixed $value * @return $this */ - public function return(mixed $value): self + public function willReturn(mixed $value): self { $inst = $this; $inst->hasReturn = true; @@ -116,7 +177,7 @@ public function return(mixed $value): self * @param string $class * @return self */ - public function class(string $class): self + public function hasClass(string $class): self { $inst = $this; $inst->class = $class; @@ -129,7 +190,7 @@ public function class(string $class): self * @param string $name * @return self */ - public function name(string $name): self + public function hasName(string $name): self { $inst = $this; $inst->name = $name; @@ -238,7 +299,7 @@ public function hasReturnType(): self * @param string $type * @return self */ - public function returnType(string $type): self + public function isReturnType(string $type): self { $inst = $this; $inst->returnType = $type; @@ -317,7 +378,7 @@ public function hasNotParams(): self * @param int $length * @return $this */ - public function hasParamsCount(int $length): self + public function paramsHasCount(int $length): self { $inst = $this; $inst->parameters[] = [ @@ -440,41 +501,41 @@ public function hasDocComment(): self } /** - * Set the starting line number of the method. + * Set the file name where the method is declared. * - * @param int $line + * @param string $file * @return self */ - public function startLine(int $line): self + public function hasFileName(string $file): self { $inst = $this; - $inst->startLine = $line; + $inst->fileName = $file; return $inst; } /** - * Set the ending line number of the method. + * Set the starting line number of the method. * * @param int $line * @return self */ - public function endLine(int $line): self + public function startLine(int $line): self { $inst = $this; - $inst->endLine = $line; + $inst->startLine = $line; return $inst; } /** - * Set the file name where the method is declared. + * Set the ending line number of the method. * - * @param string $file + * @param int $line * @return self */ - public function fileName(string $file): self + public function endLine(int $line): self { $inst = $this; - $inst->fileName = $file; + $inst->endLine = $line; return $inst; } } diff --git a/src/Mocker/MethodPool.php b/src/Mocker/MethodPool.php index 6a3a007..a45b5f4 100644 --- a/src/Mocker/MethodPool.php +++ b/src/Mocker/MethodPool.php @@ -6,13 +6,24 @@ class MethodPool { private ?Mocker $mocker = null; /** @var array */ - private array $methods = []; + private static array $methods = []; public function __construct(?Mocker $mocker = null) { $this->mocker = $mocker; } + /** + * Access method pool + * @param string $class + * @param string $name + * @return MethodItem|null + */ + public static function getMethod(string $class, string $name): ?MethodItem + { + return self::$methods[$class][$name] ?? null; + } + /** * This method adds a new method to the pool with a given name and * returns the corresponding MethodItem instance. @@ -22,8 +33,8 @@ public function __construct(?Mocker $mocker = null) */ public function method(string $name): MethodItem { - $this->methods[$name] = new MethodItem($this->mocker); - return $this->methods[$name]; + self::$methods[$this->mocker->getClassName()][$name] = new MethodItem($this->mocker); + return self::$methods[$this->mocker->getClassName()][$name]; } /** @@ -34,7 +45,7 @@ public function method(string $name): MethodItem */ public function get(string $key): MethodItem|null { - return $this->methods[$key] ?? null; + return self::$methods[$this->mocker->getClassName()][$key] ?? null; } /** @@ -44,7 +55,7 @@ public function get(string $key): MethodItem|null */ public function getAll(): array { - return $this->methods; + return self::$methods; } /** @@ -55,7 +66,7 @@ public function getAll(): array */ public function has(string $name): bool { - return isset($this->methods[$name]); + return isset(self::$methods[$this->mocker->getClassName()][$name]); } } diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index 22eb427..ce6051c 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -9,7 +9,6 @@ namespace MaplePHP\Unitary\Mocker; -use ArrayIterator; use Closure; use Exception; use MaplePHP\Unitary\TestUtils\DataTypeMock; @@ -23,8 +22,6 @@ final class Mocker { - //protected object $instance; - //protected array $overrides = []; protected ReflectionClass $reflection; protected string $className; /** @var class-string|null */ @@ -33,8 +30,6 @@ final class Mocker protected array $constructorArgs = []; protected array $methods; protected array $methodList = []; - protected static ?MethodPool $methodPool = null; - protected array $defaultArguments = []; private DataTypeMock $dataTypeMock; /** @@ -46,44 +41,61 @@ public function __construct(string $className, array $args = []) $this->className = $className; /** @var class-string $className */ $this->reflection = new ReflectionClass($className); - $this->dataTypeMock = new DataTypeMock(); + $this->methods = $this->reflection->getMethods(); + $this->constructorArgs = $args; /* // Auto fill the Constructor args! $test = $this->reflection->getConstructor(); $test = $this->generateMethodSignature($test); $param = $test->getParameters(); */ - - $this->methods = $this->reflection->getMethods(); - $this->constructorArgs = $args; } - public function getClassName(): string + /** + * Adds metadata to the mock method, including the mock class name, return value, + * and a flag indicating whether to keep the original method implementation. + * + * @param array $data The base data array to add metadata to + * @param string $mockClassName The name of the mock class + * @param mixed $return The return value to be stored in metadata + * @return array The data array with added metadata + */ + protected function addMockMetadata(array $data, string $mockClassName, mixed $return): array { - return $this->className; + $data['mocker'] = $mockClassName; + $data['return'] = $return; + $data['keepOriginal'] = false; + return $data; } - - public function getClassArgs(): array + + /** + * Gets the fully qualified name of the class being mocked. + * + * @return string The class name that was provided during instantiation + */ + public function getClassName(): string { - return $this->constructorArgs; + return $this->className; } + /** - * Override the default method overrides with your own mock logic and validation rules + * Returns the constructor arguments provided during instantiation. * - * @return MethodPool + * @return array The array of constructor arguments used to create the mock instance */ - public function getMethodPool(): MethodPool + public function getClassArgs(): array { - if (is_null(self::$methodPool)) { - self::$methodPool = new MethodPool($this); - } - return self::$methodPool; + return $this->constructorArgs; } /** - * @throws Exception + * Gets the mock class name generated during mock creation. + * This method should only be called after execute() has been invoked. + * + * @return string The generated mock class name + * @throws Exception If the mock class name has not been set (execute() hasn't been called) */ public function getMockedClassName(): string { @@ -194,7 +206,7 @@ protected function getReturnValue(array $types, mixed $method, ?MethodItem $meth } return "return 'MockedValue';"; } - + /** * Builds and returns PHP code that overrides all public methods in the class being mocked. * Each overridden method returns a predefined mock value or delegates to the original logic. @@ -218,7 +230,11 @@ protected function generateMockMethodOverrides(string $mockClassName): string $this->methodList[] = $methodName; // The MethodItem contains all items that are validatable - $methodItem = $this->getMethodPool()->get($methodName); + $methodItem = MethodPool::getMethod($this->getClassName(), $methodName); + if($methodItem && $methodItem->keepOriginal) { + continue; + } + $types = $this->getReturnType($method); $returnValue = $this->getReturnValue($types, $method, $methodItem); $paramList = $this->generateMethodSignature($method); @@ -236,8 +252,8 @@ protected function generateMockMethodOverrides(string $mockClassName): string $return = ($methodItem && $methodItem->hasReturn()) ? $methodItem->return : eval($returnValue); $arr = $this->getMethodInfoAsArray($method); - $arr['mocker'] = $mockClassName; - $arr['return'] = $return; + $arr = $this->addMockMetadata($arr, $mockClassName, $return); + $info = json_encode($arr); if ($info === false) { @@ -336,7 +352,7 @@ protected function getReturnType(ReflectionMethod $method): array } elseif ($returnType instanceof ReflectionIntersectionType) { $intersect = array_map( - fn ($type) => $type->getName(), + fn ($type) => $type instanceof ReflectionNamedType ? $type->getName() : (string) $type, $returnType->getTypes() ); $types[] = $intersect; @@ -364,13 +380,6 @@ protected function getMockValueForType(string $typeName, mixed $method, mixed $v $methodName = ($method instanceof ReflectionMethod) ? $method->getName() : null; - /* - $this->dataTypeMock = $this->dataTypeMock->withCustomDefault('int', $value); - if($method instanceof ReflectionMethod) { - $this->dataTypeMock = $this->dataTypeMock->withCustomBoundDefault($method->getName(), 'int', $value); - } - */ - $mock = match ($dataTypeName) { 'int', 'integer' => "return " . $this->dataTypeMock->getDataTypeValue('int', $methodName) . ";", 'float', 'double' => "return " . $this->dataTypeMock->getDataTypeValue('float', $methodName) . ";", diff --git a/src/Mocker/MockerController.php b/src/Mocker/MockerController.php index ea2c2b9..6709c36 100644 --- a/src/Mocker/MockerController.php +++ b/src/Mocker/MockerController.php @@ -2,6 +2,10 @@ namespace MaplePHP\Unitary\Mocker; +/** + * A controller class responsible for managing mock data for methods. + * Provides methods to add, retrieve, and track mock data, including support for singleton access. + */ final class MockerController extends MethodPool { private static ?MockerController $instance = null; @@ -9,8 +13,8 @@ final class MockerController extends MethodPool private static array $data = []; /** - * Get singleton instance of MockerController - * Creates new instance if none exists + * Get a singleton instance of MockerController + * Creates a new instance if none exists * * @return static The singleton instance of MockerController */ @@ -48,8 +52,7 @@ public static function getDataItem(string $mockIdentifier, string $method): mixe { return self::$data[$mockIdentifier][$method] ?? false; } - - + /** * Add or update data for a specific mock method * @@ -81,11 +84,13 @@ public function buildMethodData(string $method, bool $isBase64Encoded = false): $mocker = (string)$data->mocker; $name = (string)$data->name; if (empty(self::$data[$mocker][$name])) { - $data->count = 0; + $data->called = 0; self::$data[$mocker][$name] = $data; + // Mocked method has trigger "once"! } else { if (isset(self::$data[$mocker][$name])) { - self::$data[$mocker][$name]->count = (int)self::$data[$mocker][$name]->count + 1; + self::$data[$mocker][$name]->called = (int)self::$data[$mocker][$name]->called + 1; + // Mocked method has trigger "More Than" once! } } } diff --git a/src/TestCase.php b/src/TestCase.php index d95cbb9..143f13f 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -293,7 +293,7 @@ public function getMocker(): Mocker */ private function prepareValidation(Mocker $mocker, Closure $validate): void { - $pool = $mocker->getMethodPool(); + $pool = new MethodPool($mocker); $fn = $validate->bindTo($pool); if (is_null($fn)) { throw new ErrorException("A callable Closure could not be bound to the method pool!"); @@ -357,15 +357,21 @@ private function validateRow(object $row, MethodPool $pool): array continue; } + if(!property_exists($row, $property)) { + throw new ErrorException( + "The mock method meta data property name '$property' is undefined in mock object. " . + "To resolve this either use MockerController::buildMethodData() to add the property dynamically " . + "or define a default value through Mocker::addMockMetadata()" + ); + } $currentValue = $row->{$property}; - if (is_array($value)) { - if (!is_array($currentValue)) { - throw new ErrorException("The $property property is not an array!"); - } $validPool = $this->validateArrayValue($value, $currentValue); $valid = $validPool->isValid(); - $this->compareFromValidCollection($validPool, $value, $currentValue); + + if (is_array($currentValue)) { + $this->compareFromValidCollection($validPool, $value, $currentValue); + } } else { /** @psalm-suppress MixedArgument */ $valid = Validator::value($currentValue)->equal($value); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 407208e..3aebc2c 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -16,11 +16,16 @@ class Mailer public $bcc = ""; - public function __construct(string $arg1) + public function __construct() { } + public function send() + { + echo $this->sendEmail($this->getFromEmail()); + } + public function sendEmail(string $email, string $name = "daniel"): string { if(!$this->isValidEmail($email)) { @@ -34,9 +39,15 @@ public function isValidEmail(string $email): bool return filter_var($email, FILTER_VALIDATE_EMAIL); } - public function getFromEmail(string $email): string + public function setFromEmail(string $email): self { - return $this->from; + $this->from = $email; + return $this; + } + + public function getFromEmail(): string + { + return !empty($this->from) ? $this->from : "empty email"; } /** @@ -57,10 +68,12 @@ public function addBCC(string $email, &$name = "Daniel"): void public function test(...$params): void { + $this->test2(); } public function test2(): void { + echo "Hello World\n"; } } @@ -82,6 +95,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { $unit = new Unit(); + $unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { $stream = $case->mock(Stream::class); @@ -93,15 +107,25 @@ public function registerUser(string $email, string $name = "Daniel"): void { }); +$unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { + $mail = $case->mock(Mailer::class, function (MethodPool $pool) { + $pool->method("send")->keepOriginal(); + $pool->method("sendEmail")->keepOriginal(); + }); + $mail->send(); +}); + $unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { // Quickly mock the Stream class $stream = $case->mock(Stream::class, function (MethodPool $pool) { $pool->method("getContents") - ->return('{"test":"test"}'); - }); + ->willReturn('{"test":"test"}') + ->calledAtLeast(1); + $pool->method("fopen")->isPrivate(); + }); // Mock with configuration // // Notice: this will handle TestCase as immutable, and because of this @@ -130,7 +154,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { // which is in this example a class named "Stream". // You can do this by either passing the expected class directly into the `return` method // or even better by mocking the expected class and then passing the mocked class. - $pool->method("getBody")->return($stream); + $pool->method("getBody")->willReturn($stream); }); @@ -175,9 +199,12 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->paramHasDefault(1, "Daniel") ->paramIsOptional(1) ->paramIsReference(1) - ->count(1); + ->called(1); + + //$pool->method("test2")->called(1); }); $mock->addBCC("World"); + $mock->test(1); }); @@ -190,11 +217,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { $pool->method("addFromEmail") - ->hasParamsTypes() - ->isPublic() - ->hasDocComment() - ->hasReturnType() - ->count(0); + ->isPublic(); $pool->method("addBCC") ->isPublic() From 8ba1ce97dfc48a01d02d5565bc35b7f70d805c68 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Fri, 9 May 2025 23:35:02 +0200 Subject: [PATCH 24/78] refactor: replace function null checks with strict comparisons --- src/FileIterator.php | 4 ++-- src/Mocker/MethodItem.php | 2 +- src/Mocker/Mocker.php | 2 +- src/Mocker/MockerController.php | 2 +- src/TestCase.php | 14 +++++++------- src/TestUnit.php | 6 +++--- src/TestUtils/DataTypeMock.php | 4 ++-- src/Unit.php | 12 ++++++------ 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/FileIterator.php b/src/FileIterator.php index 057ea99..346e7c0 100755 --- a/src/FileIterator.php +++ b/src/FileIterator.php @@ -47,7 +47,7 @@ public function executeAll(string $directory): void ]); $call = $this->requireUnitFile((string)$file); - if (!is_null($call)) { + if ($call !== null) { $call(); } if (!Unit::hasUnit()) { @@ -181,7 +181,7 @@ private function requireUnitFile(string $file): ?Closure protected function getUnit(): Unit { $unit = Unit::getUnit(); - if (is_null($unit)) { + if ($unit === null) { throw new RuntimeException("The Unit instance has not been initiated."); } return $unit; diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MethodItem.php index a3723ec..ebdb2aa 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MethodItem.php @@ -52,7 +52,7 @@ public function __construct(?Mocker $mocker = null) */ public function wrap(Closure $call): self { - if (is_null($this->mocker)) { + if ($this->mocker === null) { throw new BadMethodCallException('Mocker is not set. Use the method "mock" to set the mocker.'); } diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php index ce6051c..2637f4b 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/Mocker.php @@ -374,7 +374,7 @@ protected function getReturnType(ReflectionMethod $method): array protected function getMockValueForType(string $typeName, mixed $method, mixed $value = null, bool $nullable = false): ?string { $dataTypeName = strtolower($typeName); - if (!is_null($value)) { + if ($value !== null) { return "return " . DataTypeMock::exportValue($value) . ";"; } diff --git a/src/Mocker/MockerController.php b/src/Mocker/MockerController.php index 6709c36..78fdb1e 100644 --- a/src/Mocker/MockerController.php +++ b/src/Mocker/MockerController.php @@ -20,7 +20,7 @@ final class MockerController extends MethodPool */ public static function getInstance(): self { - if (is_null(self::$instance)) { + if (self::$instance === null) { self::$instance = new self(); } return self::$instance; diff --git a/src/TestCase.php b/src/TestCase.php index 143f13f..907e7ea 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -69,7 +69,7 @@ public function dispatchTest(self &$row): array { $row = $this; $test = $this->bind; - if (!is_null($test)) { + if ($test !== null) { try { $newInst = $test($this); } catch (Throwable $e) { @@ -295,7 +295,7 @@ private function prepareValidation(Mocker $mocker, Closure $validate): void { $pool = new MethodPool($mocker); $fn = $validate->bindTo($pool); - if (is_null($fn)) { + if ($fn === null) { throw new ErrorException("A callable Closure could not be bound to the method pool!"); } $fn($pool); @@ -353,7 +353,7 @@ private function validateRow(object $row, MethodPool $pool): array $errors = []; foreach (get_object_vars($item) as $property => $value) { - if (is_null($value)) { + if ($value === null) { continue; } @@ -587,7 +587,7 @@ protected function buildClosureTest(Closure $validation): array $validation = $validation->bindTo($validPool); $error = []; - if (!is_null($validation)) { + if ($validation !== null) { $bool = $validation($this->value, $validPool); $error = $validPool->getError(); if (is_bool($bool) && !$bool) { @@ -595,7 +595,7 @@ protected function buildClosureTest(Closure $validation): array } } - if (is_null($this->message)) { + if ($this->message === null) { throw new RuntimeException("When testing with closure the third argument message is required"); } @@ -614,7 +614,7 @@ protected function buildArrayTest(string $method, array|Closure $args): bool { if ($args instanceof Closure) { $args = $args->bindTo($this->valid($this->value)); - if (is_null($args)) { + if ($args === null) { throw new ErrorException("The argument is not returning a callable Closure!"); } $bool = $args($this->value); @@ -684,7 +684,7 @@ public function listAllProxyMethods(string $class, ?string $prefixMethods = null $name = $method->getName(); if (!$method->isStatic() && !str_starts_with($name, '__')) { - if (!is_null($prefixMethods)) { + if ($prefixMethods !== null) { $name = $prefixMethods . ucfirst($name); } echo "@method self $name(" . implode(', ', $params) . ")\n"; diff --git a/src/TestUnit.php b/src/TestUnit.php index a87b1db..e1ab3f8 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -26,7 +26,7 @@ class TestUnit public function __construct(?string $message = null) { $this->valid = true; - $this->message = is_null($message) ? "Could not validate" : $message; + $this->message = $message === null ? "Could not validate" : $message; } /** @@ -200,7 +200,7 @@ public function getValue(): mixed */ public function getReadValue(mixed $value = null, bool $minify = false): string|bool { - $value = is_null($value) ? $this->value : $value; + $value = $value === null ? $this->value : $value; if (is_bool($value)) { return '"' . ($value ? "true" : "false") . '"' . ($minify ? "" : " (type: bool)"); } @@ -219,7 +219,7 @@ public function getReadValue(mixed $value = null, bool $minify = false): string| if (is_object($value)) { return '"' . $this->excerpt(get_class($value)) . '"' . ($minify ? "" : " (type: object)"); } - if (is_null($value)) { + if ($value === null) { return '"null"'. ($minify ? '' : ' (type: null)'); } if (is_resource($value)) { diff --git a/src/TestUtils/DataTypeMock.php b/src/TestUtils/DataTypeMock.php index be8d24a..b694994 100644 --- a/src/TestUtils/DataTypeMock.php +++ b/src/TestUtils/DataTypeMock.php @@ -34,7 +34,7 @@ class DataTypeMock public static function inst(): self { - if (is_null(self::$inst)) { + if (self::$inst === null) { self::$inst = new self(); } return self::$inst; @@ -150,7 +150,7 @@ public function getDataTypeValue(string $dataType, ?string $bindKey = null): mix return self::exportValue($this->bindArguments[$bindKey][$dataType]); } - if(is_null($this->types)) { + if($this->types === null) { $this->types = $this->getDataTypeListToString(); } diff --git a/src/Unit.php b/src/Unit.php index 869ea15..68dffcb 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -162,12 +162,12 @@ public function performance(Closure $func, ?string $title = null): void { $start = new TestMem(); $func = $func->bindTo($this); - if (!is_null($func)) { + if ($func !== null) { $func($this); } $line = $this->command->getAnsi()->line(80); $this->command->message(""); - $this->command->message($this->command->getAnsi()->style(["bold", "yellow"], "Performance" . (!is_null($title) ? " - $title:" : ":"))); + $this->command->message($this->command->getAnsi()->style(["bold", "yellow"], "Performance" . ($title !== null ? " - $title:" : ":"))); $this->command->message($line); $this->command->message( @@ -305,7 +305,7 @@ public function execute(): bool if ($this->output) { $this->buildNotice("Note:", $this->output, 80); } - if (!is_null($this->handler)) { + if ($this->handler !== null) { $this->handler->execute(); } $this->executed = true; @@ -446,7 +446,7 @@ public static function resetUnit(): void */ public static function hasUnit(): bool { - return !is_null(self::$current); + return self::$current !== null; } /** @@ -456,7 +456,7 @@ public static function hasUnit(): bool */ public static function getUnit(): ?Unit { - if (is_null(self::hasUnit())) { + if (self::hasUnit() === null) { throw new Exception("Unit has not been set yet. It needs to be set first."); } return self::$current; @@ -468,7 +468,7 @@ public static function getUnit(): ?Unit */ public static function completed(): void { - if (!is_null(self::$current) && is_null(self::$current->handler)) { + if (self::$current !== null && self::$current->handler === null) { $dot = self::$current->command->getAnsi()->middot(); self::$current->command->message(""); From 2c811db6e0fbc6a988f2a00f345f14fc92072a61 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Wed, 14 May 2025 21:03:02 +0200 Subject: [PATCH 25/78] refactor: restructure project layout and improve file naming for clarity --- README.md | 10 +- bin/unitary | 2 +- src/Expect.php | 10 ++ .../{MethodPool.php => MethodRegistry.php} | 22 ++-- src/Mocker/{Mocker.php => MockBuilder.php} | 16 +-- ...ockerController.php => MockController.php} | 8 +- .../{MethodItem.php => MockedMethod.php} | 10 +- src/TestCase.php | 104 ++++++++++-------- src/TestConfig.php | 64 +++++++++++ src/TestUnit.php | 12 +- src/TestUtils/DataTypeMock.php | 4 +- .../ExecutionWrapper.php} | 6 +- src/Unit.php | 102 ++++++++++------- src/{ => Utils}/FileIterator.php | 11 +- src/{TestMem.php => Utils/Performance.php} | 4 +- tests/unitary-unitary.php | 74 +++++++------ 16 files changed, 288 insertions(+), 171 deletions(-) create mode 100644 src/Expect.php rename src/Mocker/{MethodPool.php => MethodRegistry.php} (75%) rename src/Mocker/{Mocker.php => MockBuilder.php} (96%) rename src/Mocker/{MockerController.php => MockController.php} (93%) rename src/Mocker/{MethodItem.php => MockedMethod.php} (98%) create mode 100644 src/TestConfig.php rename src/{TestWrapper.php => TestUtils/ExecutionWrapper.php} (96%) rename src/{ => Utils}/FileIterator.php (95%) rename src/{TestMem.php => Utils/Performance.php} (94%) diff --git a/README.md b/README.md index 2f3e153..ed4c695 100644 --- a/README.md +++ b/README.md @@ -151,10 +151,10 @@ then you can just tell Unitary how those failed methods should load. ```php use MaplePHP\Validate\ValidationChain; -use \MaplePHP\Unitary\Mocker\MethodPool; +use \MaplePHP\Unitary\Mocker\MethodRegistry; $unit->group("Testing user service", function (TestCase $inst) { - $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + $mock = $inst->mock(Mailer::class, function (MethodRegistry $pool) use($inst) { // Quick way to tell Unitary that this method should return 'john.doe' $pool->method("getFromEmail")->willReturn('john.doe@gmail.com'); @@ -179,10 +179,10 @@ What is really cool is that you can also use Unitary mocker to make sure consist validate that the method is built and loaded correctly. ```php -use \MaplePHP\Unitary\Mocker\MethodPool; +use \MaplePHP\Unitary\Mocker\MethodRegistry; $unit->group("Unitary test", function (TestCase $inst) { - $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + $mock = $inst->mock(Mailer::class, function (MethodRegistry $pool) use($inst) { $pool->method("addFromEmail") ->isPublic() ->hasDocComment() @@ -246,7 +246,7 @@ php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983 You can also mark a test case to run manually, excluding it from the main test batch. ```php -$unit->manual('maplePHPRequest')->case("MaplePHP Request URI path test", function() { +$unit->hide('maplePHPRequest')->case("MaplePHP Request URI path test", function() { ... }); ``` diff --git a/bin/unitary b/bin/unitary index 0ea0f98..699015c 100755 --- a/bin/unitary +++ b/bin/unitary @@ -11,7 +11,7 @@ use MaplePHP\Http\Environment; use MaplePHP\Http\ServerRequest; use MaplePHP\Http\Uri; use MaplePHP\Prompts\Command; -use MaplePHP\Unitary\FileIterator; +use MaplePHP\Unitary\Utils\FileIterator; $command = new Command(); $env = new Environment(); diff --git a/src/Expect.php b/src/Expect.php new file mode 100644 index 0000000..ce4433a --- /dev/null +++ b/src/Expect.php @@ -0,0 +1,10 @@ + */ + private ?MockBuilder $mocker = null; + /** @var array */ private static array $methods = []; - public function __construct(?Mocker $mocker = null) + public function __construct(?MockBuilder $mocker = null) { $this->mocker = $mocker; } @@ -17,9 +17,9 @@ public function __construct(?Mocker $mocker = null) * Access method pool * @param string $class * @param string $name - * @return MethodItem|null + * @return MockedMethod|null */ - public static function getMethod(string $class, string $name): ?MethodItem + public static function getMethod(string $class, string $name): ?MockedMethod { return self::$methods[$class][$name] ?? null; } @@ -29,11 +29,11 @@ public static function getMethod(string $class, string $name): ?MethodItem * returns the corresponding MethodItem instance. * * @param string $name The name of the method to add. - * @return MethodItem The newly created MethodItem instance. + * @return MockedMethod The newly created MethodItem instance. */ - public function method(string $name): MethodItem + public function method(string $name): MockedMethod { - self::$methods[$this->mocker->getClassName()][$name] = new MethodItem($this->mocker); + self::$methods[$this->mocker->getClassName()][$name] = new MockedMethod($this->mocker); return self::$methods[$this->mocker->getClassName()][$name]; } @@ -41,9 +41,9 @@ public function method(string $name): MethodItem * Get method * * @param string $key - * @return MethodItem|null + * @return MockedMethod|null */ - public function get(string $key): MethodItem|null + public function get(string $key): MockedMethod|null { return self::$methods[$this->mocker->getClassName()][$key] ?? null; } diff --git a/src/Mocker/Mocker.php b/src/Mocker/MockBuilder.php similarity index 96% rename from src/Mocker/Mocker.php rename to src/Mocker/MockBuilder.php index 2637f4b..1e02d3b 100755 --- a/src/Mocker/Mocker.php +++ b/src/Mocker/MockBuilder.php @@ -20,7 +20,7 @@ use ReflectionUnionType; use RuntimeException; -final class Mocker +final class MockBuilder { protected ReflectionClass $reflection; protected string $className; @@ -192,10 +192,10 @@ public function __call(string \$name, array \$arguments) { /** * @param array $types * @param mixed $method - * @param MethodItem|null $methodItem + * @param MockedMethod|null $methodItem * @return string */ - protected function getReturnValue(array $types, mixed $method, ?MethodItem $methodItem = null): string + protected function getReturnValue(array $types, mixed $method, ?MockedMethod $methodItem = null): string { // Will overwrite the auto generated value if ($methodItem && $methodItem->hasReturn()) { @@ -230,7 +230,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string $this->methodList[] = $methodName; // The MethodItem contains all items that are validatable - $methodItem = MethodPool::getMethod($this->getClassName(), $methodName); + $methodItem = MethodRegistry::getMethod($this->getClassName(), $methodName); if($methodItem && $methodItem->keepOriginal) { continue; } @@ -260,7 +260,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string throw new RuntimeException('JSON encoding failed: ' . json_last_error_msg(), json_last_error()); } - MockerController::getInstance()->buildMethodData($info); + MockController::getInstance()->buildMethodData($info); if ($methodItem) { $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); } @@ -269,8 +269,8 @@ protected function generateMockMethodOverrides(string $mockClassName): string $overrides .= " $modifiers function $methodName($paramList){$returnType} { - \$obj = \\MaplePHP\\Unitary\\Mocker\\MockerController::getInstance()->buildMethodData('$safeJson', true); - \$data = \\MaplePHP\\Unitary\\Mocker\\MockerController::getDataItem(\$obj->mocker, \$obj->name); + \$obj = \\MaplePHP\\Unitary\\Mocker\\MockController::getInstance()->buildMethodData('$safeJson', true); + \$data = \\MaplePHP\\Unitary\\Mocker\\MockController::getDataItem(\$obj->mocker, \$obj->name); {$returnValue} } "; @@ -289,7 +289,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string */ protected function generateWrapperReturn(?Closure $wrapper, string $methodName, string $returnValue): string { - MockerController::addData((string)$this->mockClassName, $methodName, 'wrapper', $wrapper); + MockController::addData((string)$this->mockClassName, $methodName, 'wrapper', $wrapper); $return = ($returnValue) ? "return " : ""; return " if (isset(\$data->wrapper) && \$data->wrapper instanceof \\Closure) { diff --git a/src/Mocker/MockerController.php b/src/Mocker/MockController.php similarity index 93% rename from src/Mocker/MockerController.php rename to src/Mocker/MockController.php index 78fdb1e..d486ff6 100644 --- a/src/Mocker/MockerController.php +++ b/src/Mocker/MockController.php @@ -6,17 +6,17 @@ * A controller class responsible for managing mock data for methods. * Provides methods to add, retrieve, and track mock data, including support for singleton access. */ -final class MockerController extends MethodPool +final class MockController extends MethodRegistry { - private static ?MockerController $instance = null; + private static ?MockController $instance = null; /** @var array> */ private static array $data = []; /** - * Get a singleton instance of MockerController + * Get a singleton instance of MockController * Creates a new instance if none exists * - * @return static The singleton instance of MockerController + * @return static The singleton instance of MockController */ public static function getInstance(): self { diff --git a/src/Mocker/MethodItem.php b/src/Mocker/MockedMethod.php similarity index 98% rename from src/Mocker/MethodItem.php rename to src/Mocker/MockedMethod.php index ebdb2aa..90bf0dd 100644 --- a/src/Mocker/MethodItem.php +++ b/src/Mocker/MockedMethod.php @@ -4,14 +4,14 @@ use BadMethodCallException; use Closure; -use MaplePHP\Unitary\TestWrapper; +use MaplePHP\Unitary\TestUtils\ExecutionWrapper; /** * @psalm-suppress PossiblyUnusedProperty */ -final class MethodItem +final class MockedMethod { - private ?Mocker $mocker; + private ?MockBuilder $mocker; public mixed $return = null; public int|array|null $called = null; @@ -37,7 +37,7 @@ final class MethodItem protected bool $hasReturn = false; protected ?Closure $wrapper = null; - public function __construct(?Mocker $mocker = null) + public function __construct(?MockBuilder $mocker = null) { $this->mocker = $mocker; } @@ -57,7 +57,7 @@ public function wrap(Closure $call): self } $inst = $this; - $wrap = new class ($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends TestWrapper { + $wrap = new class ($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends ExecutionWrapper { public function __construct(string $class, array $args = []) { parent::__construct($class, $args); diff --git a/src/TestCase.php b/src/TestCase.php index 907e7ea..5dc6659 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -4,23 +4,23 @@ namespace MaplePHP\Unitary; +use BadMethodCallException; +use Closure; +use ErrorException; +use Exception; use MaplePHP\Blunder\BlunderErrorException; use MaplePHP\DTO\Format\Str; use MaplePHP\DTO\Traverse; -use MaplePHP\Unitary\Mocker\MethodPool; -use MaplePHP\Unitary\Mocker\Mocker; -use MaplePHP\Unitary\Mocker\MockerController; +use MaplePHP\Unitary\Mocker\MethodRegistry; +use MaplePHP\Unitary\Mocker\MockBuilder; +use MaplePHP\Unitary\Mocker\MockController; +use MaplePHP\Unitary\TestUtils\ExecutionWrapper; use MaplePHP\Validate\Validator; -use MaplePHP\Validate\ValidationChain; use ReflectionClass; -use ReflectionMethod; -use Throwable; -use Exception; use ReflectionException; +use ReflectionMethod; use RuntimeException; -use BadMethodCallException; -use ErrorException; -use Closure; +use Throwable; /** * @template T of object @@ -28,23 +28,28 @@ final class TestCase { private mixed $value; - private ?string $message; + private TestConfig $config; + private ?string $message = null; private array $test = []; private int $count = 0; private ?Closure $bind = null; private ?string $errorMessage = null; private array $deferredValidation = []; - /** @var Mocker */ - private Mocker $mocker; + /** @var MockBuilder */ + private MockBuilder $mocker; /** * Initialize a new TestCase instance with an optional message. * - * @param string|null $message A message to associate with the test case. + * @param TestConfig|string|null $config */ - public function __construct(?string $message = null) + public function __construct(TestConfig|string|null $config = null) { - $this->message = $message; + if (!($config instanceof TestConfig)) { + $this->config = new TestConfig($config); + } else { + $this->config = $config; + } } /** @@ -64,6 +69,7 @@ public function bind(Closure $bind): void * @param self $row * @return array * @throws BlunderErrorException + * @throws Throwable */ public function dispatchTest(self &$row): array { @@ -101,13 +107,13 @@ public function error(string $message): self * Add a test unit validation using the provided expectation and validation logic * * @param mixed $expect The expected value - * @param Closure(ValidationChain, mixed): bool $validation The validation logic + * @param Closure(Expect, mixed): bool $validation The validation logic * @return $this * @throws ErrorException */ public function validate(mixed $expect, Closure $validation): self { - $this->expectAndValidate($expect, function (mixed $value, ValidationChain $inst) use ($validation) { + $this->expectAndValidate($expect, function (mixed $value, Expect $inst) use ($validation) { return $validation($inst, new Traverse($value)); }, $this->errorMessage); @@ -208,11 +214,11 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = * * @param string $class * @param array $args - * @return TestWrapper + * @return ExecutionWrapper */ - public function wrap(string $class, array $args = []): TestWrapper + public function wrap(string $class, array $args = []): ExecutionWrapper { - return new class ($class, $args) extends TestWrapper { + return new class ($class, $args) extends ExecutionWrapper { public function __construct(string $class, array $args = []) { parent::__construct($class, $args); @@ -228,7 +234,7 @@ public function __construct(string $class, array $args = []) public function withMock(string $class, array $args = []): self { $inst = clone $this; - $inst->mocker = new Mocker($class, $args); + $inst->mocker = new MockBuilder($class, $args); return $inst; } @@ -252,7 +258,6 @@ public function buildMock(?Closure $validate = null): mixed } } - /** * Creates and returns an instance of a dynamically generated mock class. * @@ -269,12 +274,11 @@ public function buildMock(?Closure $validate = null): mixed */ public function mock(string $class, ?Closure $validate = null, array $args = []) { - $this->mocker = new Mocker($class, $args); + $this->mocker = new MockBuilder($class, $args); return $this->buildMock($validate); } - - public function getMocker(): Mocker + public function getMocker(): MockBuilder { return $this->mocker; } @@ -286,14 +290,14 @@ public function getMocker(): Mocker * to the method pool, and schedules the validation to run later via deferValidation. * This allows for mock expectations to be defined and validated after the test execution. * - * @param Mocker $mocker The mocker instance containing the mock object + * @param MockBuilder $mocker The mocker instance containing the mock object * @param Closure $validate The closure containing validation rules * @return void * @throws ErrorException */ - private function prepareValidation(Mocker $mocker, Closure $validate): void + private function prepareValidation(MockBuilder $mocker, Closure $validate): void { - $pool = new MethodPool($mocker); + $pool = new MethodRegistry($mocker); $fn = $validate->bindTo($pool); if ($fn === null) { throw new ErrorException("A callable Closure could not be bound to the method pool!"); @@ -310,16 +314,16 @@ private function prepareValidation(Mocker $mocker, Closure $validate): void * against the expectations defined in the method pool. The validation results are collected * and returned as an array of errors indexed by method name. * - * @param Mocker $mocker The mocker instance containing the mocked class - * @param MethodPool $pool The pool containing method expectations + * @param MockBuilder $mocker The mocker instance containing the mocked class + * @param MethodRegistry $pool The pool containing method expectations * @return array An array of validation errors indexed by method name * @throws ErrorException * @throws Exception */ - private function runValidation(Mocker $mocker, MethodPool $pool): array + private function runValidation(MockBuilder $mocker, MethodRegistry $pool): array { $error = []; - $data = MockerController::getData($mocker->getMockedClassName()); + $data = MockController::getData($mocker->getMockedClassName()); if (!is_array($data)) { throw new ErrorException("Could not get data from mocker!"); } @@ -339,11 +343,11 @@ private function runValidation(Mocker $mocker, MethodPool $pool): array * and complex array validations. * * @param object $row The method calls data to validate - * @param MethodPool $pool The pool containing validation expectations + * @param MethodRegistry $pool The pool containing validation expectations * @return array Array of validation results containing property comparisons * @throws ErrorException */ - private function validateRow(object $row, MethodPool $pool): array + private function validateRow(object $row, MethodRegistry $pool): array { $item = $pool->get((string)($row->name ?? "")); if (!$item) { @@ -360,7 +364,7 @@ private function validateRow(object $row, MethodPool $pool): array if(!property_exists($row, $property)) { throw new ErrorException( "The mock method meta data property name '$property' is undefined in mock object. " . - "To resolve this either use MockerController::buildMethodData() to add the property dynamically " . + "To resolve this either use MockController::buildMethodData() to add the property dynamically " . "or define a default value through Mocker::addMockMetadata()" ); } @@ -396,11 +400,11 @@ private function validateRow(object $row, MethodPool $pool): array * * @param array $value The validation configuration array * @param mixed $currentValue The value to validate - * @return ValidationChain The validation chain instance with applied validations + * @return Expect The validation chain instance with applied validations */ - private function validateArrayValue(array $value, mixed $currentValue): ValidationChain + private function validateArrayValue(array $value, mixed $currentValue): Expect { - $validPool = new ValidationChain($currentValue); + $validPool = new Expect($currentValue); foreach ($value as $method => $args) { if (is_int($method)) { foreach ($args as $methodB => $argsB) { @@ -422,12 +426,12 @@ private function validateArrayValue(array $value, mixed $currentValue): Validati /** * Create a comparison from a validation collection * - * @param ValidationChain $validPool + * @param Expect $validPool * @param array $value * @param array $currentValue * @return void */ - protected function compareFromValidCollection(ValidationChain $validPool, array &$value, array &$currentValue): void + protected function compareFromValidCollection(Expect $validPool, array &$value, array &$currentValue): void { $new = []; $error = $validPool->getError(); @@ -554,6 +558,16 @@ public function getValue(): mixed return $this->value; } + /** + * Get the test configuration + * + * @return TestConfig + */ + public function getConfig(): TestConfig + { + return $this->config; + } + /** * Get user added message * @@ -561,7 +575,7 @@ public function getValue(): mixed */ public function getMessage(): ?string { - return $this->message; + return $this->config->message; } /** @@ -583,7 +597,7 @@ public function getTest(): array protected function buildClosureTest(Closure $validation): array { //$bool = false; - $validPool = new ValidationChain($this->value); + $validPool = new Expect($this->value); $validation = $validation->bindTo($validPool); $error = []; @@ -595,8 +609,8 @@ protected function buildClosureTest(Closure $validation): array } } - if ($this->message === null) { - throw new RuntimeException("When testing with closure the third argument message is required"); + if ($this->getMessage() === null) { + throw new RuntimeException("You need to specify a \"message\" in first parameter of ->group(string|TestConfig \$message, ...)."); } return $error; diff --git a/src/TestConfig.php b/src/TestConfig.php new file mode 100644 index 0000000..4f97a33 --- /dev/null +++ b/src/TestConfig.php @@ -0,0 +1,64 @@ +message = $message; + } + + /** + * Statically make instance. + * + * @param string $message + * @return self + */ + public static function make(string $message): self + { + return new self($message); + } + + /** + * Sets the select state for the current instance. + * + * @param string $key The key to set. + * @return self + */ + public function setSelect(string $key): self + { + $this->select = $key; + return $this; + } + + /** + * Sets the message for the current instance. + * + * @param string $message The message to set. + * @return self + */ + public function setMessage(string $message): self + { + $this->message = $message; + return $this; + } + + /** + * Sets the skip state for the current instance. + * + * @param bool $bool Optional. The value to set for the skip state. Defaults to true. + * @return self + */ + public function setSkip(bool $bool = true): self + { + $this->skip = $bool; + return $this; + } + +} \ No newline at end of file diff --git a/src/TestUnit.php b/src/TestUnit.php index e1ab3f8..4f816ff 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -30,7 +30,7 @@ public function __construct(?string $message = null) } /** - * Check if value should be presented + * Check if the value should be presented * * @return bool */ @@ -45,7 +45,7 @@ public function hasValue(): bool * @param mixed $value * @return void */ - public function setTestValue(mixed $value) + public function setTestValue(mixed $value): void { $this->value = $value; $this->hasValue = true; @@ -129,7 +129,6 @@ public function setCodeLine(array $trace): self return $this; } - /** * Get the code line from a backtrace * @@ -151,7 +150,7 @@ public function getUnits(): array } /** - * Get failed test count + * Get a failed test count * * @return int */ @@ -161,7 +160,7 @@ public function getFailedTestCount(): int } /** - * Get test message + * Get a test message * * @return string|null */ @@ -171,7 +170,7 @@ public function getMessage(): ?string } /** - * Get if test is valid + * Get if the test is valid * * @return bool */ @@ -242,5 +241,4 @@ final protected function excerpt(string $value, int $length = 80): string $format = new Str($value); return (string)$format->excerpt($length)->get(); } - } diff --git a/src/TestUtils/DataTypeMock.php b/src/TestUtils/DataTypeMock.php index b694994..6e328b7 100644 --- a/src/TestUtils/DataTypeMock.php +++ b/src/TestUtils/DataTypeMock.php @@ -21,7 +21,7 @@ class DataTypeMock private array $defaultArguments = []; /** - * @var array|null Cache of stringified data type values + * @var array|null Cache of stringifies data type values */ private ?array $types = null; @@ -110,7 +110,7 @@ public function withCustomDefault(string $dataType, mixed $value): self /** * Sets a custom default value for a specific data type with a binding key. - * Creates a new instance with the bound value stored in bindArguments array. + * Creates a new instance with the bound value stored in the bindArguments array. * * @param string $key The binding key to store the value under * @param string $dataType The data type to set the custom default for diff --git a/src/TestWrapper.php b/src/TestUtils/ExecutionWrapper.php similarity index 96% rename from src/TestWrapper.php rename to src/TestUtils/ExecutionWrapper.php index 2b8a2e4..e1dab81 100755 --- a/src/TestWrapper.php +++ b/src/TestUtils/ExecutionWrapper.php @@ -9,13 +9,13 @@ * Don't delete this comment, it's part of the license. */ -namespace MaplePHP\Unitary; +namespace MaplePHP\Unitary\TestUtils; use Closure; use Exception; use MaplePHP\Container\Reflection; -abstract class TestWrapper +abstract class ExecutionWrapper { protected Reflection $ref; protected object $instance; @@ -38,7 +38,7 @@ public function __construct(string $className, array $args = []) } /** - * Will bind Closure to class instance and directly return the Closure + * Will bind Closure to a class instance and directly return the Closure * * @param Closure $call * @return Closure diff --git a/src/Unit.php b/src/Unit.php index 68dffcb..a86f823 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -7,10 +7,12 @@ use Closure; use ErrorException; use Exception; +use MaplePHP\Blunder\BlunderErrorException; use MaplePHP\Http\Interfaces\StreamInterface; use MaplePHP\Prompts\Command; use MaplePHP\Prompts\Themes\Blocks; use MaplePHP\Unitary\Handlers\HandlerInterface; +use MaplePHP\Unitary\Utils\Performance; use RuntimeException; class Unit @@ -21,14 +23,15 @@ class Unit private int $index = 0; private array $cases = []; private bool $skip = false; + private string $select = ""; private bool $executed = false; private static array $headers = []; private static ?Unit $current; - private static array $manual = []; public static int $totalPassedTests = 0; public static int $totalTests = 0; + /** * Initialize Unit test instance with optional handler * @@ -49,7 +52,9 @@ public function __construct(HandlerInterface|StreamInterface|null $handler = nul } /** - * Skip you can add this if you want to turn of validation of a unit case + * This will skip "ALL" tests in the test file + * If you want to skip a specific test, use the TestConfig class instead + * * @param bool $skip * @return $this */ @@ -60,19 +65,28 @@ public function skip(bool $skip): self } /** - * Make script manually callable + * WIll hide the test case from the output but show it as a warning + * * @param string $key * @return $this */ - public function manual(string $key): self + public function select(string $key): self { - if (isset(self::$manual[$key])) { - $file = (string)(self::$headers['file'] ?? "none"); - throw new RuntimeException("The manual key \"$key\" already exists. - Please set a unique key in the " . $file. " file."); - } - self::$manual[$key] = self::$headers['checksum']; - return $this->skip(true); + $this->select = $key; + return $this; + } + + /** + * DEPRECATED: Use TestConfig::setSelect instead + * See documentation for more information + * + * @param string|null $key + * @return void + */ + public function manual(?string $key = null): void + { + throw new RuntimeException("Manual method has been deprecated, use TestConfig::setSelect instead. " . + "See documentation for more information."); } /** @@ -140,11 +154,11 @@ public function add(string $message, Closure $callback): void /** * Add a test unit/group * - * @param string $message + * @param string|TestConfig $message * @param Closure(TestCase):void $callback * @return void */ - public function group(string $message, Closure $callback): void + public function group(string|TestConfig $message, Closure $callback): void { $testCase = new TestCase($message); $testCase->bind($callback); @@ -160,7 +174,7 @@ public function case(string $message, Closure $callback): void public function performance(Closure $func, ?string $title = null): void { - $start = new TestMem(); + $start = new Performance(); $func = $func->bindTo($this); if ($func !== null) { $func($this); @@ -192,13 +206,16 @@ public function performance(Closure $func, ?string $title = null): void * * @return bool * @throws ErrorException + * @throws BlunderErrorException + * @throws \Throwable */ public function execute(): bool { $this->help(); - if ($this->executed || !$this->createValidate()) { + $show = self::getArgs('show') === (string)self::$headers['checksum']; + if ($this->executed || $this->skip) { return false; } @@ -217,7 +234,20 @@ public function execute(): bool if ($row->hasFailed()) { $flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); } + if ($row->getConfig()->skip) { + $color = "yellow"; + $flag = $this->command->getAnsi()->style(['yellowBg', 'black'], " WARN "); + } + + // Will show test by hash or key, will open warn tests + if((self::getArgs('show') !== false && !$show) && $row->getConfig()->select !== self::getArgs('show')) { + continue; + } + if($row->getConfig()->select === self::getArgs('show')) { + $show = $row->getConfig()->select === self::getArgs('show'); + } + // Success, no need to try to show errors, continue with next test if ($errArg !== false && !$row->hasFailed()) { continue; } @@ -230,7 +260,7 @@ public function execute(): bool $this->command->getAnsi()->style(["bold", $color], (string)$row->getMessage()) ); - if (isset($tests)) { + if (isset($tests) && ($show || !$row->getConfig()->skip)) { foreach ($tests as $test) { if (!($test instanceof TestUnit)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); @@ -240,7 +270,7 @@ public function execute(): bool $msg = (string)$test->getMessage(); $this->command->message(""); $this->command->message( - $this->command->getAnsi()->style(["bold", "brightRed"], "Error: ") . + $this->command->getAnsi()->style(["bold", $color], "Error: ") . $this->command->getAnsi()->bold($msg) ); $this->command->message(""); @@ -266,7 +296,7 @@ public function execute(): bool $failedMsg = " " .$title . ((!$unit['valid']) ? " → failed" : ""); $this->command->message( $this->command->getAnsi()->style( - ((!$unit['valid']) ? "brightRed" : null), + ((!$unit['valid']) ? $color : null), $failedMsg ) ); @@ -275,7 +305,7 @@ public function execute(): bool $lengthB = (strlen($compare) + strlen($failedMsg) - 8); $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); $this->command->message( - $this->command->getAnsi()->style("brightRed", $comparePad) + $this->command->getAnsi()->style($color, $comparePad) ); } } @@ -292,13 +322,21 @@ public function execute(): bool self::$totalTests += $row->getTotal(); $checksum = (string)(self::$headers['checksum'] ?? ""); + + if ($row->getConfig()->select) { + $checksum .= " (" . $row->getConfig()->select . ")"; + } + $this->command->message(""); - $this->command->message( - $this->command->getAnsi()->bold("Passed: ") . + $footer = $this->command->getAnsi()->bold("Passed: ") . $this->command->getAnsi()->style([$color], $row->getCount() . "/" . $row->getTotal()) . - $this->command->getAnsi()->style(["italic", "grey"], " - ". $checksum) - ); + $this->command->getAnsi()->style(["italic", "grey"], " - ". $checksum); + if (!$show && $row->getConfig()->skip) { + $footer = $this->command->getAnsi()->style(["italic", "grey"], $checksum); + } + + $this->command->message($footer); } $this->output .= ob_get_clean(); @@ -342,24 +380,6 @@ public function validate(): self "Move this validate() call inside your group() callback function."); } - /** - * Validate before execute test - * - * @return bool - */ - private function createValidate(): bool - { - $args = (array)(self::$headers['args'] ?? []); - $manual = isset($args['show']) ? (string)$args['show'] : ""; - if (isset($args['show'])) { - return !((self::$manual[$manual] ?? "") !== self::$headers['checksum'] && $manual !== self::$headers['checksum']); - } - if ($this->skip) { - return false; - } - return true; - } - /** * Build the notification stream * @param string $title diff --git a/src/FileIterator.php b/src/Utils/FileIterator.php similarity index 95% rename from src/FileIterator.php rename to src/Utils/FileIterator.php index 346e7c0..3614d9c 100755 --- a/src/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -2,15 +2,16 @@ declare(strict_types=1); -namespace MaplePHP\Unitary; +namespace MaplePHP\Unitary\Utils; use Closure; use Exception; -use RuntimeException; use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\Blunder\Run; +use MaplePHP\Unitary\Unit; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use RuntimeException; use SplFileInfo; final class FileIterator @@ -35,7 +36,7 @@ public function executeAll(string $directory): void $files = $this->findFiles($directory); if (empty($files)) { /* @var string static::PATTERN */ - throw new RuntimeException("No files found matching the pattern \"" . (static::PATTERN ?? "") . "\" in directory \"$directory\" "); + throw new RuntimeException("No files found matching the pattern \"" . (FileIterator::PATTERN ?? "") . "\" in directory \"$directory\" "); } else { foreach ($files as $file) { extract($this->args, EXTR_PREFIX_SAME, "wddx"); @@ -74,7 +75,7 @@ private function findFiles(string $dir): array $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)); /** @var string $pattern */ - $pattern = static::PATTERN; + $pattern = FileIterator::PATTERN; foreach ($iterator as $file) { if (($file instanceof SplFileInfo) && fnmatch($pattern, $file->getFilename()) && (isset($this->args['path']) || !str_contains($file->getPathname(), DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR))) { @@ -120,7 +121,7 @@ public function findExcluded(array $exclArr, string $relativeDir, string $file): $file = $this->getNaturalPath($file); foreach ($exclArr as $excl) { /* @var string $excl */ - $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . (string)$excl); + $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . $excl); if (fnmatch($relativeExclPath, $file)) { return true; } diff --git a/src/TestMem.php b/src/Utils/Performance.php similarity index 94% rename from src/TestMem.php rename to src/Utils/Performance.php index fb516e2..9cbee9f 100755 --- a/src/TestMem.php +++ b/src/Utils/Performance.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace MaplePHP\Unitary; +namespace MaplePHP\Unitary\Utils; -class TestMem +class Performance { private float $startTime; private int $startMemory; diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 3aebc2c..69521f7 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,14 +1,10 @@ setSkip() + ->setSelect('unitary'); + +$unit->group($config, function (TestCase $case) use($unit) { + + $request = new Request("HSHHS", "https://example.com:443/?cat=25&page=1622"); + + $case->validate($request->getMethod(), function(Expect $inst) { + $inst->isRequestMethod(); + }); + + $case->validate($request->getPort(), function(Expect $inst) { + $inst->isEqualTo(443); + }); + + $case->validate($request->getUri()->getQuery(), function(Expect $inst) { + $inst->hasQueryParam("cat"); + $inst->hasQueryParam("page", 1622); + }); +}); + + $unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { $stream = $case->mock(Stream::class); $response = new Response($stream); - $case->validate($response->getBody()->getContents(), function(ValidationChain $inst) { + $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->hasResponse(); }); }); +/* + + $unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { @@ -158,32 +180,32 @@ public function registerUser(string $email, string $name = "Daniel"): void { }); - $case->validate($response->getBody()->getContents(), function(ValidationChain $inst, Traverse $collection) { + $case->validate($response->getBody()->getContents(), function(Validate $inst, Traverse $collection) { $inst->isString(); $inst->isJson(); return $collection->strJsonDecode()->test->valid("isString"); }); - $case->validate($response->getHeader("lorem"), function(ValidationChain $inst) { + $case->validate($response->getHeader("lorem"), function(Validate $inst) { // Validate against the new default array item value // If we weren't overriding the default the array would be ['item1', 'item2', 'item3'] $inst->isInArray(["myCustomMockArrayItem"]); }); - $case->validate($response->getStatusCode(), function(ValidationChain $inst) { + $case->validate($response->getStatusCode(), function(Validate $inst) { // Will validate to the default int data type set above // and bounded to "getStatusCode" method $inst->isHttpSuccess(); }); - $case->validate($response->getProtocolVersion(), function(ValidationChain $inst) { + $case->validate($response->getProtocolVersion(), function(Validate $inst) { // MockedValue is the default value that the mocked class will return // if you do not specify otherwise, either by specify what the method should return // or buy overrides the default mocking data type values. $inst->isEqualTo("MockedValue"); }); - $case->validate($response->getBody(), function(ValidationChain $inst) { + $case->validate($response->getBody(), function(Validate $inst) { $inst->isInstanceOf(Stream::class); }); @@ -208,11 +230,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { }); - - -/* - -$unit = new Unit(); +//$unit = new Unit(); $unit->group("Unitary test 2", function (TestCase $inst) { $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { @@ -228,7 +246,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->paramHasDefault(1, "Daniel") ->paramIsOptional(1) ->paramIsReference(1) - ->count(0); + ->called(0); $pool->method("test") ->hasParams() @@ -236,25 +254,18 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->wrap(function($args) use($inst) { echo "World -> $args\n"; }) - ->count(1); + ->called(1); $pool->method("test2") ->hasNotParams() - ->count(0); + ->called(0); }, ["Arg 1"]); - $mock->test("Hello"); - $service = new UserService($mock); + //$mock->test("Hello"); + //$service = new UserService($mock); - $validPool = new ValidationChain("dwqdqw"); - $validPool - ->isEmail() - ->length(1, 200) - ->endsWith(".com"); - $isValid = $validPool->isValid(); - - $inst->validate("yourTestValue", function(ValidationChain $inst) { + $inst->validate("yourTestValue", function(Validate $inst) { $inst->isBool(); $inst->isInt(); $inst->isJson(); @@ -264,5 +275,4 @@ public function registerUser(string $email, string $name = "Daniel"): void { }); -*/ - + */ \ No newline at end of file From 9b1b2426e827908b2e6712ae24d1994752351625 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sat, 17 May 2025 13:22:29 +0200 Subject: [PATCH 26/78] Add assert support Add unit test boilerplate code --- README.md | 2 +- composer.json | 3 + src/Setup/assert-polyfill.php | 22 +++++++ src/TestCase.php | 63 ++++++++++++++++--- src/TestConfig.php | 8 ++- src/Unit.php | 115 +++++++++++++++++++--------------- tests/unitary-unitary.php | 86 +++++++++++-------------- 7 files changed, 188 insertions(+), 111 deletions(-) create mode 100644 src/Setup/assert-polyfill.php diff --git a/README.md b/README.md index ed4c695..08e6bdb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MaplePHP - Unitary -PHP Unitary is a **user-friendly** and robust unit testing library designed to make writing and running tests for your PHP code easy. With an intuitive CLI interface that works on all platforms and robust validation options, Unitary makes it easy for you as a developer to ensure your code is reliable and functions as intended. +PHP Unitary is a **user-friendly** and robust unit testing framework designed to make writing and running tests for your PHP code easy. With an intuitive CLI interface that works on all platforms and robust validation options, Unitary makes it easy for you as a developer to ensure your code is reliable and functions as intended. ![Prompt demo](http://wazabii.se/github-assets/maplephp-unitary.png) _Do you like the CLI theme? [Download it here](https://github.com/MaplePHP/DarkBark)_ diff --git a/composer.json b/composer.json index 58ebb2d..c003376 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,9 @@ "maplephp/prompts": "^1.0" }, "autoload": { + "files": [ + "src/Setup/assert-polyfill.php" + ], "psr-4": { "MaplePHP\\Unitary\\": "src" } diff --git a/src/Setup/assert-polyfill.php b/src/Setup/assert-polyfill.php new file mode 100644 index 0000000..17db104 --- /dev/null +++ b/src/Setup/assert-polyfill.php @@ -0,0 +1,22 @@ + */ private MockBuilder $mocker; + /** + * @var true + */ + private bool $hasAssertError = false; /** * Initialize a new TestCase instance with an optional message. @@ -63,6 +68,26 @@ public function bind(Closure $bind): void $this->bind = $bind->bindTo($this); } + /** + * Sets the assertion error flag to true + * + * @return void + */ + function setHasAssertError(): void + { + $this->hasAssertError = true; + } + + /** + * Gets the current state of the assertion error flag + * + * @return bool + */ + function getHasAssertError(): bool + { + return $this->hasAssertError; + } + /** * Will dispatch the case tests and return them as an array * @@ -78,6 +103,13 @@ public function dispatchTest(self &$row): array if ($test !== null) { try { $newInst = $test($this); + } catch (AssertionError $e) { + $newInst = clone $this; + $newInst->setHasAssertError(); + $msg = "Assertion failed"; + $newInst->expectAndValidate( + true, fn() => false, $msg, $e->getMessage(), $e->getTrace()[0] + ); } catch (Throwable $e) { if(str_contains($e->getFile(), "eval()")) { throw new BlunderErrorException($e->getMessage(), $e->getCode()); @@ -107,7 +139,7 @@ public function error(string $message): self * Add a test unit validation using the provided expectation and validation logic * * @param mixed $expect The expected value - * @param Closure(Expect, mixed): bool $validation The validation logic + * @param Closure(Expect, Traverse): bool $validation * @return $this * @throws ErrorException */ @@ -137,13 +169,15 @@ public function validate(mixed $expect, Closure $validation): self protected function expectAndValidate( mixed $expect, array|Closure $validation, - ?string $message = null + ?string $message = null, + ?string $description = null, + ?array $trace = null ): self { $this->value = $expect; $test = new TestUnit($message); $test->setTestValue($this->value); if ($validation instanceof Closure) { - $listArr = $this->buildClosureTest($validation); + $listArr = $this->buildClosureTest($validation, $description); foreach ($listArr as $list) { if(is_bool($list)) { $test->setUnit($list, "Validation"); @@ -162,7 +196,10 @@ protected function expectAndValidate( } } if (!$test->isValid()) { - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + if(!$trace) { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + } + $test->setCodeLine($trace); $this->count++; } @@ -592,19 +629,29 @@ public function getTest(): array * This will build the closure test * * @param Closure $validation + * @param string|null $message * @return array */ - protected function buildClosureTest(Closure $validation): array + protected function buildClosureTest(Closure $validation, ?string $message = null): array { //$bool = false; $validPool = new Expect($this->value); $validation = $validation->bindTo($validPool); - $error = []; if ($validation !== null) { - $bool = $validation($this->value, $validPool); + try { + $bool = $validation($this->value, $validPool); + } catch (AssertionError $e) { + $bool = false; + $message = $e->getMessage(); + } + $error = $validPool->getError(); - if (is_bool($bool) && !$bool) { + if($bool === false && $message !== null) { + $error[] = [ + $message => true + ]; + } else if (is_bool($bool) && !$bool) { $error['customError'] = false; } } diff --git a/src/TestConfig.php b/src/TestConfig.php index 4f97a33..6fd67a3 100644 --- a/src/TestConfig.php +++ b/src/TestConfig.php @@ -31,12 +31,18 @@ public static function make(string $message): self * @param string $key The key to set. * @return self */ - public function setSelect(string $key): self + public function setName(string $key): self { $this->select = $key; return $this; } + // Alias for setName() + public function setSelect(string $key): self + { + return $this->setName($key); + } + /** * Sets the message for the current instance. * diff --git a/src/Unit.php b/src/Unit.php index a86f823..d78aa10 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -23,7 +23,6 @@ class Unit private int $index = 0; private array $cases = []; private bool $skip = false; - private string $select = ""; private bool $executed = false; private static array $headers = []; private static ?Unit $current; @@ -64,26 +63,13 @@ public function skip(bool $skip): self return $this; } - /** - * WIll hide the test case from the output but show it as a warning - * - * @param string $key - * @return $this - */ - public function select(string $key): self - { - $this->select = $key; - return $this; - } - /** * DEPRECATED: Use TestConfig::setSelect instead * See documentation for more information * - * @param string|null $key * @return void */ - public function manual(?string $key = null): void + public function manual(): void { throw new RuntimeException("Manual method has been deprecated, use TestConfig::setSelect instead. " . "See documentation for more information."); @@ -211,17 +197,15 @@ public function performance(Closure $func, ?string $title = null): void */ public function execute(): bool { - + $this->template(); $this->help(); - - $show = self::getArgs('show') === (string)self::$headers['checksum']; if ($this->executed || $this->skip) { return false; } // LOOP through each case ob_start(); - foreach ($this->cases as $row) { + foreach ($this->cases as $index => $row) { if (!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); } @@ -229,6 +213,7 @@ public function execute(): bool $errArg = self::getArgs("errors-only"); $row->dispatchTest($row); $tests = $row->runDeferredValidations(); + $checksum = (self::$headers['checksum'] ?? "") . $index; $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); if ($row->hasFailed()) { @@ -236,22 +221,21 @@ public function execute(): bool } if ($row->getConfig()->skip) { $color = "yellow"; - $flag = $this->command->getAnsi()->style(['yellowBg', 'black'], " WARN "); + $flag = $this->command->getAnsi()->style(['yellowBg', 'black'], " SKIP "); } - // Will show test by hash or key, will open warn tests - if((self::getArgs('show') !== false && !$show) && $row->getConfig()->select !== self::getArgs('show')) { + $show = ($row->getConfig()->select === self::getArgs('show') || self::getArgs('show') === $checksum); + if((self::getArgs('show') !== false) && !$show) { continue; } - if($row->getConfig()->select === self::getArgs('show')) { - $show = $row->getConfig()->select === self::getArgs('show'); - } - // Success, no need to try to show errors, continue with next test + // Success, no need to try to show errors, continue with the next test if ($errArg !== false && !$row->hasFailed()) { continue; } + + $this->command->message(""); $this->command->message( $flag . " " . @@ -260,7 +244,7 @@ public function execute(): bool $this->command->getAnsi()->style(["bold", $color], (string)$row->getMessage()) ); - if (isset($tests) && ($show || !$row->getConfig()->skip)) { + if (($show || !$row->getConfig()->skip)) { foreach ($tests as $test) { if (!($test instanceof TestUnit)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); @@ -290,7 +274,7 @@ public function execute(): bool $compare = ""; if ($unit['compare']) { $expectedValue = array_shift($unit['compare']); - $compare = "Expected: {$expectedValue} | Actual: " . implode(":", $unit['compare']); + $compare = "Expected: $expectedValue | Actual: " . implode(":", $unit['compare']); } $failedMsg = " " .$title . ((!$unit['valid']) ? " → failed" : ""); @@ -312,7 +296,7 @@ public function execute(): bool } if ($test->hasValue()) { $this->command->message(""); - $this->command->message($this->command->getAnsi()->bold("Value: ") . $test->getReadValue()); + $this->command->message($this->command->getAnsi()->bold("Input value: ") . $test->getReadValue()); } } } @@ -320,17 +304,18 @@ public function execute(): bool self::$totalPassedTests += $row->getCount(); self::$totalTests += $row->getTotal(); - - $checksum = (string)(self::$headers['checksum'] ?? ""); - if ($row->getConfig()->select) { $checksum .= " (" . $row->getConfig()->select . ")"; } - $this->command->message(""); - $footer = $this->command->getAnsi()->bold("Passed: ") . - $this->command->getAnsi()->style([$color], $row->getCount() . "/" . $row->getTotal()) . + $passed = $this->command->getAnsi()->bold("Passed: "); + if ($row->getHasAssertError()) { + $passed .= $this->command->getAnsi()->style(["grey"], "N/A"); + } else { + $passed .= $this->command->getAnsi()->style([$color], $row->getCount() . "/" . $row->getTotal()); + } + $footer = $passed . $this->command->getAnsi()->style(["italic", "grey"], " - ". $checksum); if (!$show && $row->getConfig()->skip) { $footer = $this->command->getAnsi()->style(["italic", "grey"], $checksum); @@ -343,15 +328,13 @@ public function execute(): bool if ($this->output) { $this->buildNotice("Note:", $this->output, 80); } - if ($this->handler !== null) { - $this->handler->execute(); - } + $this->handler?->execute(); $this->executed = true; return true; } /** - * Will reset the execute and stream if is a seekable stream + * Will reset the executing and stream if is a seekable stream * * @return bool */ @@ -400,7 +383,7 @@ public function buildNotice(string $title, string $output, int $lineLength): voi } /** - * Make file path into a title + * Make a file path into a title * @param string $file * @param int $length * @param bool $removeSuffix @@ -452,7 +435,7 @@ public static function appendHeader(string $key, mixed $value): void } /** - * Used to reset current instance + * Used to reset the current instance * @return void */ public static function resetUnit(): void @@ -461,7 +444,7 @@ public static function resetUnit(): void } /** - * Used to check if instance is set + * Used to check if an instance is set * @return bool */ public static function hasUnit(): bool @@ -511,6 +494,37 @@ public static function isSuccessful(): bool return (self::$totalPassedTests !== self::$totalTests); } + /** + * Display a template for the Unitary testing tool + * Shows a basic template for the Unitary testing tool + * Only displays if --template argument is provided + * + * @return void + */ + private function template(): void + { + if (self::getArgs("template") !== false) { + + $blocks = new Blocks($this->command); + $blocks->addHeadline("\n--- Unitary template ---"); + $blocks->addCode( + <<<'PHP' + use MaplePHP\Unitary\{Unit, TestCase, TestConfig, Expect}; + + $unit = new Unit(); + $unit->group("Your test subject", function (TestCase $case) { + + $case->validate("Your test value", function(Expect $valid) { + $valid->isString(); + }); + + }); + PHP + ); + exit(0); + } + } + /** * Display help information for the Unitary testing tool * Shows usage instructions, available options and examples @@ -523,21 +537,21 @@ private function help(): void if (self::getArgs("help") !== false) { $blocks = new Blocks($this->command); - $blocks->addHeadline("Unitary - Help"); + $blocks->addHeadline("\n--- Unitary Help ---"); $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); $blocks->addSection("Options", function(Blocks $inst) { - $inst = $inst + return $inst ->addOption("help", "Show this help message") ->addOption("show=", "Run a specific test by hash or manual test name") ->addOption("errors-only", "Show only failing tests and skip passed test output") + ->addOption("template", "Will give you a boilerplate test code") ->addOption("path=", "Specify test path (absolute or relative)") ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); - return $inst; }); $blocks->addSection("Examples", function(Blocks $inst) { - $inst = $inst + return $inst ->addExamples( "php vendor/bin/unitary", "Run all tests in the default path (./tests)" @@ -548,14 +562,15 @@ private function help(): void "php vendor/bin/unitary --errors-only", "Run all tests in the default path (./tests)" )->addExamples( - "php vendor/bin/unitary --show=maplePHPRequest", + "php vendor/bin/unitary --show=YourNameHere", "Run a manually named test case" + )->addExamples( + "php vendor/bin/unitary --template", + "Run a and will give you template code for a new test" )->addExamples( 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', 'Run all tests under "tests/" excluding specified directories' - ) - ; - return $inst; + ); }); exit(0); } diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 69521f7..089773f 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,10 +1,11 @@ sendEmail($this->getFromEmail()); + $this->sendEmail($this->getFromEmail()); } public function sendEmail(string $email, string $name = "daniel"): string @@ -91,48 +92,33 @@ public function registerUser(string $email, string $name = "Daniel"): void { $unit = new Unit(); -$config = TestConfig::make("This is a test message") - ->setSkip() - ->setSelect('unitary'); +$unit->group(TestConfig::make("Test 1")->setName("unitary")->setSkip(), function (TestCase $case) use($unit) { -$unit->group($config, function (TestCase $case) use($unit) { - - $request = new Request("HSHHS", "https://example.com:443/?cat=25&page=1622"); - - $case->validate($request->getMethod(), function(Expect $inst) { - $inst->isRequestMethod(); - }); - - $case->validate($request->getPort(), function(Expect $inst) { - $inst->isEqualTo(443); - }); + $stream = $case->mock(Stream::class); + $response = new Response($stream); - $case->validate($request->getUri()->getQuery(), function(Expect $inst) { - $inst->hasQueryParam("cat"); - $inst->hasQueryParam("page", 1622); + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + assert(1 === 2, "Lore"); + $inst->notHasResponse(); }); }); - - -$unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { - +$unit->group("Advanced App Response Test 2", function (TestCase $case) use($unit) { $stream = $case->mock(Stream::class); $response = new Response($stream); - $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->hasResponse(); }); }); -/* +/* $unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { - $mail = $case->mock(Mailer::class, function (MethodPool $pool) { - $pool->method("send")->keepOriginal(); - $pool->method("sendEmail")->keepOriginal(); + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("send")->keepOriginal(); + $method->method("sendEmail")->keepOriginal(); }); $mail->send(); }); @@ -141,12 +127,12 @@ public function registerUser(string $email, string $name = "Daniel"): void { // Quickly mock the Stream class - $stream = $case->mock(Stream::class, function (MethodPool $pool) { - $pool->method("getContents") + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") ->willReturn('{"test":"test"}') ->calledAtLeast(1); - $pool->method("fopen")->isPrivate(); + $method->method("fopen")->isPrivate(); }); // Mock with configuration // @@ -169,43 +155,42 @@ public function registerUser(string $email, string $name = "Daniel"): void { // parameters and return values //print_r(\MaplePHP\Unitary\TestUtils\DataTypeMock::inst()->getMockValues()); - $response = $case->buildMock(function (MethodPool $pool) use($stream) { + $response = $case->buildMock(function (MethodRegistry $method) use($stream) { // Even tho Unitary mocker tries to automatically mock the return type of methods, // it might fail if the return type is an expected Class instance, then you will // need to manually set the return type to tell Unitary mocker what class to expect, // which is in this example a class named "Stream". // You can do this by either passing the expected class directly into the `return` method // or even better by mocking the expected class and then passing the mocked class. - $pool->method("getBody")->willReturn($stream); + $method->method("getBody")->willReturn($stream); }); - $case->validate($response->getBody()->getContents(), function(Validate $inst, Traverse $collection) { + $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->isString(); $inst->isJson(); - return $collection->strJsonDecode()->test->valid("isString"); }); - $case->validate($response->getHeader("lorem"), function(Validate $inst) { + $case->validate($response->getHeader("lorem"), function(Expect $inst) { // Validate against the new default array item value // If we weren't overriding the default the array would be ['item1', 'item2', 'item3'] $inst->isInArray(["myCustomMockArrayItem"]); }); - $case->validate($response->getStatusCode(), function(Validate $inst) { + $case->validate($response->getStatusCode(), function(Expect $inst) { // Will validate to the default int data type set above // and bounded to "getStatusCode" method $inst->isHttpSuccess(); }); - $case->validate($response->getProtocolVersion(), function(Validate $inst) { + $case->validate($response->getProtocolVersion(), function(Expect $inst) { // MockedValue is the default value that the mocked class will return // if you do not specify otherwise, either by specify what the method should return // or buy overrides the default mocking data type values. $inst->isEqualTo("MockedValue"); }); - $case->validate($response->getBody(), function(Validate $inst) { + $case->validate($response->getBody(), function(Expect $inst) { $inst->isInstanceOf(Stream::class); }); @@ -215,29 +200,26 @@ public function registerUser(string $email, string $name = "Daniel"): void { $unit->group("Mailer test", function (TestCase $inst) use($unit) { - $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { - $pool->method("addBCC") + $mock = $inst->mock(Mailer::class, function (MethodRegistry $method) use($inst) { + $method->method("addBCC") ->paramIsType(0, "string") ->paramHasDefault(1, "Daniel") ->paramIsOptional(1) ->paramIsReference(1) ->called(1); - - //$pool->method("test2")->called(1); }); $mock->addBCC("World"); $mock->test(1); }); -//$unit = new Unit(); $unit->group("Unitary test 2", function (TestCase $inst) { - $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { - $pool->method("addFromEmail") + $mock = $inst->mock(Mailer::class, function (MethodRegistry $method) use($inst) { + $method->method("addFromEmail") ->isPublic(); - $pool->method("addBCC") + $method->method("addBCC") ->isPublic() ->hasDocComment() ->hasParams() @@ -248,7 +230,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->paramIsReference(1) ->called(0); - $pool->method("test") + $method->method("test") ->hasParams() ->paramIsSpread(0) // Same as ->paramIsVariadic() ->wrap(function($args) use($inst) { @@ -256,7 +238,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { }) ->called(1); - $pool->method("test2") + $method->method("test2") ->hasNotParams() ->called(0); @@ -265,7 +247,7 @@ public function registerUser(string $email, string $name = "Daniel"): void { //$mock->test("Hello"); //$service = new UserService($mock); - $inst->validate("yourTestValue", function(Validate $inst) { + $inst->validate("yourTestValue", function(Expect $inst) { $inst->isBool(); $inst->isInt(); $inst->isJson(); @@ -275,4 +257,6 @@ public function registerUser(string $email, string $name = "Daniel"): void { }); - */ \ No newline at end of file + */ + + From 77235926aa00ed1c419a5f20e259b62bafe03a33 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Mon, 19 May 2025 22:55:36 +0200 Subject: [PATCH 27/78] refactor: remove closure binding from group --- src/TestCase.php | 8 ++-- src/Unit.php | 46 +++++++++++++++------ tests/unitary-unitary.php | 86 ++++++++++++++++++++++++++------------- 3 files changed, 96 insertions(+), 44 deletions(-) diff --git a/src/TestCase.php b/src/TestCase.php index ae9e9de..43cf7ad 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -61,11 +61,13 @@ public function __construct(TestConfig|string|null $config = null) * Bind the test case to the Closure * * @param Closure $bind + * @param bool $bindToClosure choose bind to closure or not (Not recommended) + * Used primary as a fallback for older versions of Unitary * @return void */ - public function bind(Closure $bind): void + public function bind(Closure $bind, bool $bindToClosure = false): void { - $this->bind = $bind->bindTo($this); + $this->bind = ($bindToClosure) ? $bind->bindTo($this) : $bind; } /** @@ -220,7 +222,7 @@ protected function expectAndValidate( public function deferValidation(Closure $validation): void { // This will add a cursor to the possible line and file where the error occurred - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]; + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4)[3]; $this->deferredValidation[] = [ "trace" => $trace, "call" => $validation diff --git a/src/Unit.php b/src/Unit.php index d78aa10..5cb9cd7 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -125,37 +125,43 @@ public function confirm(string $message = "Do you wish to continue?"): bool } /** - * DEPRECATED: Name has been changed to case + * Name has been changed to case + * WILL BECOME DEPRECATED VERY SOON * @param string $message * @param Closure $callback * @return void */ public function add(string $message, Closure $callback): void { - // Might be trigger in future //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); $this->case($message, $callback); } /** - * Add a test unit/group + * Adds a test case to the collection (group() is preferred over case()) + * The key difference from group() is that this TestCase will NOT be bound the Closure * - * @param string|TestConfig $message - * @param Closure(TestCase):void $callback + * @param string|TestConfig $message The message or configuration for the test case. + * @param Closure $callback The closure containing the test case logic. * @return void */ public function group(string|TestConfig $message, Closure $callback): void { - $testCase = new TestCase($message); - $testCase->bind($callback); - $this->cases[$this->index] = $testCase; - $this->index++; + $this->addCase($message, $callback); } - // Alias to group - public function case(string $message, Closure $callback): void + /** + * Adds a test case to the collection. + * The key difference from group() is that this TestCase will be bound the Closure + * Not Deprecated but might be in the far future + * + * @param string|TestConfig $message The message or configuration for the test case. + * @param Closure $callback The closure containing the test case logic. + * @return void + */ + public function case(string|TestConfig $message, Closure $callback): void { - $this->group($message, $callback); + $this->addCase($message, $callback, true); } public function performance(Closure $func, ?string $title = null): void @@ -576,6 +582,22 @@ private function help(): void } } + /** + * Adds a test case to the collection. + * + * @param string|TestConfig $message The description or configuration of the test case. + * @param Closure $callback The closure that defines the test case logic. + * @param bool $bindToClosure Indicates whether the closure should be bound to TestCase. + * @return void + */ + protected function addCase(string|TestConfig $message, Closure $callback, bool $bindToClosure = false): void + { + $testCase = new TestCase($message); + $testCase->bind($callback, $bindToClosure); + $this->cases[$this->index] = $testCase; + $this->index++; + } + /** * DEPRECATED: Not used anymore * @return $this diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 089773f..a654f7a 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -58,7 +58,15 @@ public function addFromEmail(string $email, string $name = ""): void $this->from = $email; } - public function addBCC(string $email, &$name = "Daniel"): void + /** + * Add a BCC (blind carbon copy) email address + * + * @param string $email The email address to be added as BCC + * @param string $name The name associated with the email address, default is "Daniel" + * @param mixed $testRef A reference variable, default is "Daniel" + * @return void + */ + public function addBCC(string $email, string $name = "Daniel", &$testRef = "Daniel"): void { $this->bcc = $email; } @@ -78,14 +86,19 @@ public function test2(): void class UserService { public function __construct(private Mailer $mailer) {} - public function registerUser(string $email, string $name = "Daniel"): void { + public function registerUser(string $email): bool { // register user logic... - if(!$this->mailer->isValidEmail($email)) { + $this->mailer->addFromEmail($email); + $this->mailer->addBCC("jane.doe@hotmail.com", "Jane Doe"); + $this->mailer->addBCC("lane.doe@hotmail.com", "Lane Doe"); + + if(!filter_var($this->mailer->getFromEmail(), FILTER_VALIDATE_EMAIL)) { throw new \Exception("Invalid email"); } - echo $this->mailer->sendEmail($email, $name)."\n"; - echo $this->mailer->sendEmail($email, $name); + //echo $this->mailer->sendEmail($email, $name)."\n"; + //echo $this->mailer->sendEmail($email, $name); + return true; } } @@ -213,11 +226,11 @@ public function registerUser(string $email, string $name = "Daniel"): void { }); -$unit->group("Unitary test 2", function (TestCase $inst) { +$unit->group("Testing User service", function (TestCase $inst) { - $mock = $inst->mock(Mailer::class, function (MethodRegistry $method) use($inst) { + $mailer = $inst->mock(Mailer::class, function (MethodRegistry $method) use($inst) { $method->method("addFromEmail") - ->isPublic(); + ->called(1); $method->method("addBCC") ->isPublic() @@ -228,31 +241,17 @@ public function registerUser(string $email, string $name = "Daniel"): void { ->paramHasDefault(1, "Daniel") ->paramIsOptional(1) ->paramIsReference(1) - ->called(0); + ->called(2); - $method->method("test") - ->hasParams() - ->paramIsSpread(0) // Same as ->paramIsVariadic() - ->wrap(function($args) use($inst) { - echo "World -> $args\n"; - }) - ->called(1); - - $method->method("test2") - ->hasNotParams() - ->called(0); + $method->method("getFromEmail") + ->willReturn("john.doe@gmail.com"); - }, ["Arg 1"]); + }, [true // <- Mailer class constructor argument, enable debug]); - //$mock->test("Hello"); - //$service = new UserService($mock); + $service = new UserService($mailer); - $inst->validate("yourTestValue", function(Expect $inst) { - $inst->isBool(); - $inst->isInt(); - $inst->isJson(); - $inst->isString(); - $inst->isResource(); + $case->validate($service->send(), function(Expect $inst) { + $inst->isTrue(); }); }); @@ -260,3 +259,32 @@ public function registerUser(string $email, string $name = "Daniel"): void { */ +$unit->group("Testing User service", function (TestCase $case) { + + $mailer = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("addFromEmail") + ->called(1); + + $method->method("addBCC") + ->isPublic() + ->hasDocComment() + ->hasParams() + ->paramHasType(0) + ->paramIsType(0, "string") + ->paramHasDefault(2, "Daniel") + ->paramIsOptional(2) + ->paramIsReference(2) + ->called(1); + + $method->method("getFromEmail") + ->willReturn("john.doe@gmail.com"); + + }, [true]); // <-- true is passed as argument 1 to Mailer constructor + + $service = new UserService($mailer); + $case->validate($service->registerUser("john.doe@gmail.com"), function(Expect $inst) { + $inst->isTrue(); + }); + +}); + From 1f472d3753f691aee29a31c397d1a5bf6d2d2024 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Tue, 20 May 2025 22:17:12 +0200 Subject: [PATCH 28/78] Add more dynamic test search --- bin/unitary | 2 +- src/Utils/FileIterator.php | 65 +++++++++++++++++++++++++++----------- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/bin/unitary b/bin/unitary index 699015c..0ca8671 100755 --- a/bin/unitary +++ b/bin/unitary @@ -33,7 +33,7 @@ try { throw new Exception("Test directory '$testDir' does not exist"); } $unit = new FileIterator($data); - $unit->executeAll($testDir); + $unit->executeAll($testDir, $defaultPath); } catch (Exception $e) { $command->error($e->getMessage()); diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index 3614d9c..9061ba8 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -6,6 +6,7 @@ use Closure; use Exception; +use MaplePHP\Blunder\Exceptions\BlunderSoftException; use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\Blunder\Run; use MaplePHP\Unitary\Unit; @@ -27,16 +28,27 @@ public function __construct(array $args = []) /** * Will Execute all unitary test files. - * @param string $directory + * @param string $path + * @param string|bool $rootDir * @return void - * @throws RuntimeException + * @throws BlunderSoftException */ - public function executeAll(string $directory): void + public function executeAll(string $path, string|bool $rootDir = false): void { - $files = $this->findFiles($directory); + + $rootDir = $rootDir !== false ? realpath($rootDir) : false; + $path = (!$path && $rootDir) ? $rootDir : $path; + + if($rootDir !== false && !str_starts_with($path, $rootDir)) { + throw new RuntimeException("The test search path (\$path) \"" . $path . "\" does not have the root director (\$rootDir) of \"" . $rootDir . "\"."); + } + + $files = $this->findFiles($path, $rootDir); if (empty($files)) { /* @var string static::PATTERN */ - throw new RuntimeException("No files found matching the pattern \"" . (FileIterator::PATTERN ?? "") . "\" in directory \"$directory\" "); + throw new BlunderSoftException("Unitary could not find any test files matching the pattern \"" . + (FileIterator::PATTERN ?? "") . "\" in directory \"" . dirname($path) . + "\" and its subdirectories."); } else { foreach ($files as $file) { extract($this->args, EXTR_PREFIX_SAME, "wddx"); @@ -62,28 +74,45 @@ public function executeAll(string $directory): void /** * Will Scan and find all unitary test files - * @param string $dir + * @param string $path + * @param string|false $rootDir * @return array */ - private function findFiles(string $dir): array + private function findFiles(string $path, string|bool $rootDir = false): array { $files = []; - $realDir = realpath($dir); + $realDir = realpath($path); if ($realDir === false) { - throw new RuntimeException("Directory \"$dir\" does not exist. Try using a absolut path!"); + throw new RuntimeException("Directory \"$path\" does not exist. Try using a absolut path!"); } - $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)); - - /** @var string $pattern */ - $pattern = FileIterator::PATTERN; - foreach ($iterator as $file) { - if (($file instanceof SplFileInfo) && fnmatch($pattern, $file->getFilename()) && - (isset($this->args['path']) || !str_contains($file->getPathname(), DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR))) { - if (!$this->findExcluded($this->exclude(), $dir, $file->getPathname())) { - $files[] = $file->getPathname(); + + if (is_file($path) && str_starts_with(basename($path), "unitary-")) { + $files[] = $path; + } else { + if(is_file($path)) { + $path = dirname($path) . "/"; + } + + if(is_dir($path)) { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); + /** @var string $pattern */ + $pattern = FileIterator::PATTERN; + foreach ($iterator as $file) { + if (($file instanceof SplFileInfo) && fnmatch($pattern, $file->getFilename()) && + (!empty($this->args['exclude']) || !str_contains($file->getPathname(), DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR))) { + if (!$this->findExcluded($this->exclude(), $path, $file->getPathname())) { + $files[] = $file->getPathname(); + } + } } } } + + if($rootDir && count($files) <= 0 && str_starts_with($path, $rootDir)) { + $path = realpath($path . "/..") . "/"; + return $this->findFiles($path, $rootDir); + } + return $files; } From 24f8a8a6b1fa5a6bcb9a6393a91dc1e8b6b86a76 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Tue, 20 May 2025 22:30:37 +0200 Subject: [PATCH 29/78] Alow both absolute and relative argv path when root dir is present --- src/Utils/FileIterator.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index 9061ba8..8ff0f59 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -35,14 +35,11 @@ public function __construct(array $args = []) */ public function executeAll(string $path, string|bool $rootDir = false): void { - $rootDir = $rootDir !== false ? realpath($rootDir) : false; $path = (!$path && $rootDir) ? $rootDir : $path; - - if($rootDir !== false && !str_starts_with($path, $rootDir)) { - throw new RuntimeException("The test search path (\$path) \"" . $path . "\" does not have the root director (\$rootDir) of \"" . $rootDir . "\"."); + if($rootDir !== false && !str_starts_with($path, "/") && !str_starts_with($path, $rootDir)) { + $path = $rootDir . "/" . $path; } - $files = $this->findFiles($path, $rootDir); if (empty($files)) { /* @var string static::PATTERN */ From 555fd20307aad26b075967011aacbf31ffe3bd5e Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Wed, 21 May 2025 22:07:24 +0200 Subject: [PATCH 30/78] Improve CLI styling Make TestConfig immutable Improve IDE interactions --- src/TestCase.php | 4 ++-- src/TestConfig.php | 15 +++++++++------ src/TestUnit.php | 5 +++-- src/Unit.php | 28 ++++++++++++++++++++-------- src/Utils/FileIterator.php | 2 +- tests/unitary-unitary.php | 37 +++++++++++++++++++++++++++++-------- 6 files changed, 64 insertions(+), 27 deletions(-) diff --git a/src/TestCase.php b/src/TestCase.php index 43cf7ad..97ddbfb 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -184,8 +184,8 @@ protected function expectAndValidate( if(is_bool($list)) { $test->setUnit($list, "Validation"); } else { - foreach ($list as $method => $_valid) { - $test->setUnit(false, (string)$method); + foreach ($list as $method => $valid) { + $test->setUnit(false, (string)$method, $valid); } } } diff --git a/src/TestConfig.php b/src/TestConfig.php index 6fd67a3..090e674 100644 --- a/src/TestConfig.php +++ b/src/TestConfig.php @@ -33,8 +33,9 @@ public static function make(string $message): self */ public function setName(string $key): self { - $this->select = $key; - return $this; + $inst = clone $this; + $inst->select = $key; + return $inst; } // Alias for setName() @@ -51,8 +52,9 @@ public function setSelect(string $key): self */ public function setMessage(string $message): self { - $this->message = $message; - return $this; + $inst = clone $this; + $inst->message = $message; + return $inst; } /** @@ -63,8 +65,9 @@ public function setMessage(string $message): self */ public function setSkip(bool $bool = true): self { - $this->skip = $bool; - return $this; + $inst = clone $this; + $inst->skip = $bool; + return $inst; } } \ No newline at end of file diff --git a/src/TestUnit.php b/src/TestUnit.php index 4f816ff..fb11bba 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -64,7 +64,7 @@ public function setTestValue(mixed $value): void public function setUnit( bool|null $valid, null|string|\Closure $validation = null, - array $args = [], + array|bool $args = [], array $compare = [] ): self { @@ -74,7 +74,8 @@ public function setUnit( } if (!is_callable($validation)) { - $valLength = strlen((string)$validation); + $addArgs = is_array($args) ? "(" . implode(", ", $args) . ")" : ""; + $valLength = strlen($validation . $addArgs); if ($validation && $this->valLength < $valLength) { $this->valLength = $valLength; } diff --git a/src/Unit.php b/src/Unit.php index 5cb9cd7..5dfaea3 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -211,6 +211,7 @@ public function execute(): bool // LOOP through each case ob_start(); + $countCases = count($this->cases); foreach ($this->cases as $index => $row) { if (!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); @@ -240,8 +241,6 @@ public function execute(): bool continue; } - - $this->command->message(""); $this->command->message( $flag . " " . @@ -249,6 +248,12 @@ public function execute(): bool " - " . $this->command->getAnsi()->style(["bold", $color], (string)$row->getMessage()) ); + if($show && !$row->hasFailed()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["italic", $color], "Test file: " . self::$headers['file']) + ); + } if (($show || !$row->getConfig()->skip)) { foreach ($tests as $test) { @@ -267,15 +272,17 @@ public function execute(): bool $trace = $test->getCodeLine(); if (!empty($trace['code'])) { - $this->command->message($this->command->getAnsi()->style(["bold", "grey"], "Failed on line {$trace['line']}: ")); + $this->command->message($this->command->getAnsi()->style(["bold", "grey"], "Failed on {$trace['file']}:{$trace['line']}")); $this->command->message($this->command->getAnsi()->style(["grey"], " → {$trace['code']}")); } /** @var array $unit */ foreach ($test->getUnits() as $unit) { if (is_string($unit['validation']) && !$unit['valid']) { - $lengthA = $test->getValidationLength() + 1; - $title = str_pad($unit['validation'], $lengthA); + $lengthA = $test->getValidationLength(); + $addArgs = is_array($unit['args']) ? "(" . implode(", ", $unit['args']) . ")" : ""; + $validation = "{$unit['validation']}{$addArgs}"; + $title = str_pad($validation, $lengthA); $compare = ""; if ($unit['compare']) { @@ -302,7 +309,10 @@ public function execute(): bool } if ($test->hasValue()) { $this->command->message(""); - $this->command->message($this->command->getAnsi()->bold("Input value: ") . $test->getReadValue()); + $this->command->message( + $this->command->getAnsi()->bold("Input value: ") . + $test->getReadValue() + ); } } } @@ -321,13 +331,14 @@ public function execute(): bool } else { $passed .= $this->command->getAnsi()->style([$color], $row->getCount() . "/" . $row->getTotal()); } + $footer = $passed . $this->command->getAnsi()->style(["italic", "grey"], " - ". $checksum); if (!$show && $row->getConfig()->skip) { $footer = $this->command->getAnsi()->style(["italic", "grey"], $checksum); } - $this->command->message($footer); + $this->command->message(""); } $this->output .= ob_get_clean(); @@ -480,7 +491,7 @@ public static function completed(): void if (self::$current !== null && self::$current->handler === null) { $dot = self::$current->command->getAnsi()->middot(); - self::$current->command->message(""); + //self::$current->command->message(""); self::$current->command->message( self::$current->command->getAnsi()->style( ["italic", "grey"], @@ -488,6 +499,7 @@ public static function completed(): void "Peak memory usage: " . round(memory_get_peak_usage() / 1024, 2) . " KB" ) ); + self::$current->command->message(""); } } diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index 8ff0f59..c276264 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -105,7 +105,7 @@ private function findFiles(string $path, string|bool $rootDir = false): array } } - if($rootDir && count($files) <= 0 && str_starts_with($path, $rootDir)) { + if($rootDir && count($files) <= 0 && str_starts_with($path, $rootDir) && isset($this->args['smart-search'])) { $path = realpath($path . "/..") . "/"; return $this->findFiles($path, $rootDir); } diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index a654f7a..7b5c41f 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -104,19 +104,19 @@ public function registerUser(string $email): bool { $unit = new Unit(); +$config = TestConfig::make("Test 1")->setName("unitary"); -$unit->group(TestConfig::make("Test 1")->setName("unitary")->setSkip(), function (TestCase $case) use($unit) { +$unit->group($config->setSkip(), function (TestCase $case) use($unit) { $stream = $case->mock(Stream::class); $response = new Response($stream); $case->validate($response->getBody()->getContents(), function(Expect $inst) { - assert(1 === 2, "Lore"); + assert(1 == 1, "Lore"); $inst->notHasResponse(); }); -}); -$unit->group("Advanced App Response Test 2", function (TestCase $case) use($unit) { + $stream = $case->mock(Stream::class); $response = new Response($stream); $case->validate($response->getBody()->getContents(), function(Expect $inst) { @@ -124,6 +124,27 @@ public function registerUser(string $email): bool { }); }); +$unit->case($config->setMessage("Testing custom validations"), function ($case) { + + $case->validate("GET", function(Expect $inst) { + assert($inst->isEqualTo("GET")->isValid(), "Assert has failed"); + }); + +}); + +$unit->case($config->setMessage("Validate old Unitary case syntax"), function ($case) { + + $case->add("HelloWorld", [ + "isString" => [], + "User validation" => function($value) { + return $value === "HelloWorld"; + } + ], "Is not a valid port number"); + + $this->add("HelloWorld", [ + "isEqualTo" => ["HelloWorld"], + ], "Failed to validate");; +}); /* @@ -255,10 +276,6 @@ public function registerUser(string $email): bool { }); }); - - */ - - $unit->group("Testing User service", function (TestCase $case) { $mailer = $case->mock(Mailer::class, function (MethodRegistry $method) { @@ -287,4 +304,8 @@ public function registerUser(string $email): bool { }); }); + */ + + + From 702070fd8b8957650ce166b8e9723ca0145fb882 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Fri, 23 May 2025 23:36:34 +0200 Subject: [PATCH 31/78] Mock class identifiers --- src/Mocker/MethodRegistry.php | 9 +++++++++ src/TestCase.php | 9 +++++++++ tests/unitary-unitary.php | 24 +++++++++++++++--------- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/Mocker/MethodRegistry.php b/src/Mocker/MethodRegistry.php index 338ef84..a9b5d16 100644 --- a/src/Mocker/MethodRegistry.php +++ b/src/Mocker/MethodRegistry.php @@ -13,6 +13,15 @@ public function __construct(?MockBuilder $mocker = null) $this->mocker = $mocker; } + /** + * @param string $class + * @return void + */ + public static function reset(string $class): void + { + self::$methods[$class] = []; + } + /** * Access method pool * @param string $class diff --git a/src/TestCase.php b/src/TestCase.php index 97ddbfb..0abcc49 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -285,9 +285,18 @@ public function withMock(string $class, array $args = []): self */ public function buildMock(?Closure $validate = null): mixed { + // We can't make mock immutable as it would reduce usability if (is_callable($validate)) { $this->prepareValidation($this->mocker, $validate); } + /* + if (is_callable($validate)) { + $this->prepareValidation($this->mocker, $validate); + } else { + // However, tests execute linearly and are contained within groups, making reset an effective solution + MethodRegistry::reset($this->mocker->getClassName()); + } + */ try { /** @psalm-suppress MixedReturnStatement */ diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 7b5c41f..ebfdb46 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -104,19 +104,23 @@ public function registerUser(string $email): bool { $unit = new Unit(); -$config = TestConfig::make("Test 1")->setName("unitary"); +$config = TestConfig::make("Testing mocking library")->setName("unitary"); -$unit->group($config->setSkip(), function (TestCase $case) use($unit) { +$unit->group($config, function (TestCase $case) use($unit) { - $stream = $case->mock(Stream::class); + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('') + ->calledAtLeast(1); + }); $response = new Response($stream); $case->validate($response->getBody()->getContents(), function(Expect $inst) { - assert(1 == 1, "Lore"); - $inst->notHasResponse(); + $inst->hasResponse(); }); +}); - +$unit->group($config, function (TestCase $case) use($unit) { $stream = $case->mock(Stream::class); $response = new Response($stream); $case->validate($response->getBody()->getContents(), function(Expect $inst) { @@ -124,12 +128,14 @@ public function registerUser(string $email): bool { }); }); -$unit->case($config->setMessage("Testing custom validations"), function ($case) { +$unit->group($config->setMessage("Testing custom validations"), function ($case) { - $case->validate("GET", function(Expect $inst) { - assert($inst->isEqualTo("GET")->isValid(), "Assert has failed"); + $case->validate("HelloWorld", function(Expect $inst) { + assert($inst->isEqualTo("HelloWorld")->isValid(), "Assert has failed"); }); + assert(1 === 1, "Assert has failed"); + }); $unit->case($config->setMessage("Validate old Unitary case syntax"), function ($case) { From fea5132698b44a26ff259ae865529417798178b4 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Fri, 23 May 2025 23:45:51 +0200 Subject: [PATCH 32/78] Add unique mock identifier --- src/Mocker/MethodRegistry.php | 10 +++++----- src/Mocker/MockBuilder.php | 23 +++++++++-------------- src/Mocker/MockedMethod.php | 2 +- src/TestCase.php | 14 ++------------ tests/unitary-unitary.php | 3 +-- 5 files changed, 18 insertions(+), 34 deletions(-) diff --git a/src/Mocker/MethodRegistry.php b/src/Mocker/MethodRegistry.php index a9b5d16..7080349 100644 --- a/src/Mocker/MethodRegistry.php +++ b/src/Mocker/MethodRegistry.php @@ -4,7 +4,7 @@ class MethodRegistry { - private ?MockBuilder $mocker = null; + private ?MockBuilder $mocker; /** @var array */ private static array $methods = []; @@ -42,8 +42,8 @@ public static function getMethod(string $class, string $name): ?MockedMethod */ public function method(string $name): MockedMethod { - self::$methods[$this->mocker->getClassName()][$name] = new MockedMethod($this->mocker); - return self::$methods[$this->mocker->getClassName()][$name]; + self::$methods[$this->mocker->getMockedClassName()][$name] = new MockedMethod($this->mocker); + return self::$methods[$this->mocker->getMockedClassName()][$name]; } /** @@ -54,7 +54,7 @@ public function method(string $name): MockedMethod */ public function get(string $key): MockedMethod|null { - return self::$methods[$this->mocker->getClassName()][$key] ?? null; + return self::$methods[$this->mocker->getMockedClassName()][$key] ?? null; } /** @@ -75,7 +75,7 @@ public function getAll(): array */ public function has(string $name): bool { - return isset(self::$methods[$this->mocker->getClassName()][$name]); + return isset(self::$methods[$this->mocker->getMockedClassName()][$name]); } } diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 1e02d3b..276fc56 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -44,6 +44,14 @@ public function __construct(string $className, array $args = []) $this->dataTypeMock = new DataTypeMock(); $this->methods = $this->reflection->getMethods(); $this->constructorArgs = $args; + + $shortClassName = explode("\\", $className); + $shortClassName = end($shortClassName); + /** + * @var class-string $shortClassName + * @psalm-suppress PropertyTypeCoercion + */ + $this->mockClassName = 'Unitary_' . uniqid() . "_Mock_" . $shortClassName; /* // Auto fill the Constructor args! $test = $this->reflection->getConstructor(); @@ -95,13 +103,9 @@ public function getClassArgs(): array * This method should only be called after execute() has been invoked. * * @return string The generated mock class name - * @throws Exception If the mock class name has not been set (execute() hasn't been called) */ public function getMockedClassName(): string { - if (!$this->mockClassName) { - throw new Exception("Mock class name is not set"); - } return $this->mockClassName; } @@ -133,15 +137,6 @@ public function mockDataType(string $dataType, mixed $value, ?string $bindToMeth public function execute(): mixed { $className = $this->reflection->getName(); - - $shortClassName = explode("\\", $className); - $shortClassName = end($shortClassName); - - /** - * @var class-string $shortClassName - * @psalm-suppress PropertyTypeCoercion - */ - $this->mockClassName = 'Unitary_' . uniqid() . "_Mock_" . $shortClassName; $overrides = $this->generateMockMethodOverrides($this->mockClassName); $unknownMethod = $this->errorHandleUnknownMethod($className); @@ -230,7 +225,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string $this->methodList[] = $methodName; // The MethodItem contains all items that are validatable - $methodItem = MethodRegistry::getMethod($this->getClassName(), $methodName); + $methodItem = MethodRegistry::getMethod($this->getMockedClassName(), $methodName); if($methodItem && $methodItem->keepOriginal) { continue; } diff --git a/src/Mocker/MockedMethod.php b/src/Mocker/MockedMethod.php index 90bf0dd..7d1f38c 100644 --- a/src/Mocker/MockedMethod.php +++ b/src/Mocker/MockedMethod.php @@ -57,7 +57,7 @@ public function wrap(Closure $call): self } $inst = $this; - $wrap = new class ($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends ExecutionWrapper { + $wrap = new class ($this->mocker->getMockedClassName(), $this->mocker->getClassArgs()) extends ExecutionWrapper { public function __construct(string $class, array $args = []) { parent::__construct($class, $args); diff --git a/src/TestCase.php b/src/TestCase.php index 0abcc49..5bbbdad 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -61,7 +61,7 @@ public function __construct(TestConfig|string|null $config = null) * Bind the test case to the Closure * * @param Closure $bind - * @param bool $bindToClosure choose bind to closure or not (Not recommended) + * @param bool $bindToClosure choose bind to closure or not (recommended) * Used primary as a fallback for older versions of Unitary * @return void */ @@ -245,7 +245,7 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = } /** - * initialize a test wrapper + * Initialize a test wrapper * * NOTICE: When mocking a class with required constructor arguments, those arguments must be * specified in the mock initialization method or it will fail. This is because the mock @@ -285,19 +285,9 @@ public function withMock(string $class, array $args = []): self */ public function buildMock(?Closure $validate = null): mixed { - // We can't make mock immutable as it would reduce usability if (is_callable($validate)) { $this->prepareValidation($this->mocker, $validate); } - /* - if (is_callable($validate)) { - $this->prepareValidation($this->mocker, $validate); - } else { - // However, tests execute linearly and are contained within groups, making reset an effective solution - MethodRegistry::reset($this->mocker->getClassName()); - } - */ - try { /** @psalm-suppress MixedReturnStatement */ return $this->mocker->execute(); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index ebfdb46..7130097 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -118,9 +118,8 @@ public function registerUser(string $email): bool { $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->hasResponse(); }); -}); -$unit->group($config, function (TestCase $case) use($unit) { + $stream = $case->mock(Stream::class); $response = new Response($stream); $case->validate($response->getBody()->getContents(), function(Expect $inst) { From 0b3ab9340e12c7c88011e9aeb41a15b1e74e653b Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 25 May 2025 13:13:22 +0200 Subject: [PATCH 33/78] Pass meta data to mocked and keep orignial Change TestConfig method names --- src/Mocker/MockBuilder.php | 27 ++++++++++++---------- src/Mocker/MockedMethod.php | 2 +- src/TestConfig.php | 12 +++++----- tests/unitary-unitary.php | 46 ++++++++++++++++++++++++++++--------- 4 files changed, 57 insertions(+), 30 deletions(-) diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 276fc56..3cae060 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -61,19 +61,20 @@ public function __construct(string $className, array $args = []) } /** - * Adds metadata to the mock method, including the mock class name, return value, - * and a flag indicating whether to keep the original method implementation. + * Adds metadata to the mock method, including the mock class name, return value. + * This is possible custom-added data that "has to" validate against the MockedMethod instance * * @param array $data The base data array to add metadata to * @param string $mockClassName The name of the mock class - * @param mixed $return The return value to be stored in metadata + * @param mixed $returnValue + * @param mixed $methodItem * @return array The data array with added metadata */ - protected function addMockMetadata(array $data, string $mockClassName, mixed $return): array + protected function addMockMetadata(array $data, string $mockClassName, mixed $returnValue, mixed $methodItem): array { $data['mocker'] = $mockClassName; - $data['return'] = $return; - $data['keepOriginal'] = false; + $data['return'] = ($methodItem && $methodItem->hasReturn()) ? $methodItem->return : eval($returnValue); + $data['keepOriginal'] = ($methodItem && $methodItem->keepOriginal) ? $methodItem->keepOriginal : false; return $data; } @@ -226,9 +227,6 @@ protected function generateMockMethodOverrides(string $mockClassName): string // The MethodItem contains all items that are validatable $methodItem = MethodRegistry::getMethod($this->getMockedClassName(), $methodName); - if($methodItem && $methodItem->keepOriginal) { - continue; - } $types = $this->getReturnType($method); $returnValue = $this->getReturnValue($types, $method, $methodItem); @@ -245,10 +243,8 @@ protected function generateMockMethodOverrides(string $mockClassName): string $modifiersArr = Reflection::getModifierNames($method->getModifiers()); $modifiers = implode(" ", $modifiersArr); - $return = ($methodItem && $methodItem->hasReturn()) ? $methodItem->return : eval($returnValue); $arr = $this->getMethodInfoAsArray($method); - $arr = $this->addMockMetadata($arr, $mockClassName, $return); - + $arr = $this->addMockMetadata($arr, $mockClassName, $returnValue, $methodItem); $info = json_encode($arr); if ($info === false) { @@ -260,6 +256,13 @@ protected function generateMockMethodOverrides(string $mockClassName): string $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); } + if($methodItem && $methodItem->keepOriginal) { + $returnValue = "parent::$methodName($paramList);"; + if (!in_array('void', $types)) { + $returnValue = "return $returnValue"; + } + } + $safeJson = base64_encode($info); $overrides .= " $modifiers function $methodName($paramList){$returnType} diff --git a/src/Mocker/MockedMethod.php b/src/Mocker/MockedMethod.php index 7d1f38c..90bf0dd 100644 --- a/src/Mocker/MockedMethod.php +++ b/src/Mocker/MockedMethod.php @@ -57,7 +57,7 @@ public function wrap(Closure $call): self } $inst = $this; - $wrap = new class ($this->mocker->getMockedClassName(), $this->mocker->getClassArgs()) extends ExecutionWrapper { + $wrap = new class ($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends ExecutionWrapper { public function __construct(string $class, array $args = []) { parent::__construct($class, $args); diff --git a/src/TestConfig.php b/src/TestConfig.php index 090e674..9f7c702 100644 --- a/src/TestConfig.php +++ b/src/TestConfig.php @@ -31,7 +31,7 @@ public static function make(string $message): self * @param string $key The key to set. * @return self */ - public function setName(string $key): self + public function withName(string $key): self { $inst = clone $this; $inst->select = $key; @@ -41,19 +41,19 @@ public function setName(string $key): self // Alias for setName() public function setSelect(string $key): self { - return $this->setName($key); + return $this->withName($key); } /** * Sets the message for the current instance. * - * @param string $message The message to set. + * @param string $subject The message to set. * @return self */ - public function setMessage(string $message): self + public function withSubject(string $subject): self { $inst = clone $this; - $inst->message = $message; + $inst->message = $subject; return $inst; } @@ -63,7 +63,7 @@ public function setMessage(string $message): self * @param bool $bool Optional. The value to set for the skip state. Defaults to true. * @return self */ - public function setSkip(bool $bool = true): self + public function withSkip(bool $bool = true): self { $inst = clone $this; $inst->skip = $bool; diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 7130097..ba91d0c 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -18,9 +18,11 @@ public function __construct() } - public function send() + public function send(): string { $this->sendEmail($this->getFromEmail()); + + return $this->privateMethod(); } public function sendEmail(string $email, string $name = "daniel"): string @@ -47,6 +49,11 @@ public function getFromEmail(): string return !empty($this->from) ? $this->from : "empty email"; } + private function privateMethod(): string + { + return "HEHEHE"; + } + /** * Add from email address * @@ -104,7 +111,30 @@ public function registerUser(string $email): bool { $unit = new Unit(); -$config = TestConfig::make("Testing mocking library")->setName("unitary"); + +$unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("send")->keepOriginal(); + + }); + echo $mail->send(); +}); + +$unit->group("Example API Request", function(TestCase $case) { + + $request = new Request("GET", "https://example.com/?page=1&slug=hello-world"); + + $case->validate($request->getMethod(), function(Expect $expect) { + $expect->isRequestMethod(); + }); + + $case->validate($request->getUri()->getQuery(), function(Expect $expect) { + $expect->hasQueryParam("page", 1); + $expect->hasQueryParam("slug", "hello-world"); + }); +}); + +$config = TestConfig::make("Testing mocking library")->withName("unitary")->withSkip(); $unit->group($config, function (TestCase $case) use($unit) { @@ -118,16 +148,9 @@ public function registerUser(string $email): bool { $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->hasResponse(); }); - - - $stream = $case->mock(Stream::class); - $response = new Response($stream); - $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->hasResponse(); - }); }); -$unit->group($config->setMessage("Testing custom validations"), function ($case) { +$unit->group($config->withSubject("Testing custom validations"), function ($case) { $case->validate("HelloWorld", function(Expect $inst) { assert($inst->isEqualTo("HelloWorld")->isValid(), "Assert has failed"); @@ -137,7 +160,7 @@ public function registerUser(string $email): bool { }); -$unit->case($config->setMessage("Validate old Unitary case syntax"), function ($case) { +$unit->case($config->withSubject("Validate old Unitary case syntax"), function ($case) { $case->add("HelloWorld", [ "isString" => [], @@ -151,6 +174,7 @@ public function registerUser(string $email): bool { ], "Failed to validate");; }); + /* From 26f8180e0e804e0a8b74aafc267f8c534a271d77 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 25 May 2025 19:58:24 +0200 Subject: [PATCH 34/78] Add interface support for mocking --- src/Mocker/MockBuilder.php | 21 +++++++++++++++------ tests/unitary-unitary.php | 12 ++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 3cae060..e6cfef2 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -139,10 +139,11 @@ public function execute(): mixed { $className = $this->reflection->getName(); $overrides = $this->generateMockMethodOverrides($this->mockClassName); - $unknownMethod = $this->errorHandleUnknownMethod($className); + $unknownMethod = $this->errorHandleUnknownMethod($className, !$this->reflection->isInterface()); + $extends = $this->reflection->isInterface() ? "implements $className" : "extends $className"; $code = " - class $this->mockClassName extends $className { + class $this->mockClassName $extends { {$overrides} {$unknownMethod} public static function __set_state(array \$an_array): self @@ -153,6 +154,8 @@ public static function __set_state(array \$an_array): self } "; + //print_r($code); + //die; eval($code); /** @@ -170,14 +173,19 @@ public static function __set_state(array \$an_array): self * @param string $className The name of the class for which the mock is created. * @return string The generated PHP code for handling unknown method calls. */ - private function errorHandleUnknownMethod(string $className): string + private function errorHandleUnknownMethod(string $className, bool $checkOriginal = true): string { if (!in_array('__call', $this->methodList)) { - return " - public function __call(string \$name, array \$arguments) { - if (method_exists(get_parent_class(\$this), '__call')) { + + $checkOriginalCall = $checkOriginal ? " + if (method_exists(get_parent_class(\$this), '__call')) { return parent::__call(\$name, \$arguments); } + " : ""; + + return " + public function __call(string \$name, array \$arguments) { + {$checkOriginalCall} throw new \\BadMethodCallException(\"Method '\$name' does not exist in class '$className'.\"); } "; @@ -241,6 +249,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string } $returnType = ($types) ? ': ' . implode('|', $types) : ''; $modifiersArr = Reflection::getModifierNames($method->getModifiers()); + $modifiersArr = array_filter($modifiersArr, fn($val) => $val !== "abstract"); $modifiers = implode(" ", $modifiersArr); $arr = $this->getMethodInfoAsArray($method); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index ba91d0c..a71f3e5 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -2,6 +2,7 @@ group("Mocking a PSR-7 Stream", function(TestCase $case) { + // Create a mock of a PSR-7 StreamInterface + $stream = $case->mock(StreamInterface::class); + + // Inject the mock into a Response object + $response = new Response($stream); + + $case->validate($response->getBody(), function(Expect $expect) { + $expect->isInstanceOf(StreamInterface::class); + }); +}); $unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { From 823c9d63b6b9fb3f1f50dfd40f30258dfea10e0d Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 25 May 2025 22:10:11 +0200 Subject: [PATCH 35/78] feat: Add argument check to mocker --- src/Mocker/MockBuilder.php | 13 +++++-- src/Mocker/MockController.php | 9 ++++- src/Mocker/MockedMethod.php | 64 +++++++++++++++++++++++++++++++++++ src/TestCase.php | 4 ++- tests/unitary-unitary.php | 21 +++++++----- 5 files changed, 99 insertions(+), 12 deletions(-) diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index e6cfef2..350b31f 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -77,7 +77,16 @@ protected function addMockMetadata(array $data, string $mockClassName, mixed $re $data['keepOriginal'] = ($methodItem && $methodItem->keepOriginal) ? $methodItem->keepOriginal : false; return $data; } - + + /** + * Get reflection of the expected class + * @return ReflectionClass + */ + public function getReflectionClass(): ReflectionClass + { + return $this->reflection; + } + /** * Gets the fully qualified name of the class being mocked. * @@ -276,7 +285,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string $overrides .= " $modifiers function $methodName($paramList){$returnType} { - \$obj = \\MaplePHP\\Unitary\\Mocker\\MockController::getInstance()->buildMethodData('$safeJson', true); + \$obj = \\MaplePHP\\Unitary\\Mocker\\MockController::getInstance()->buildMethodData('$safeJson', func_get_args(), true); \$data = \\MaplePHP\\Unitary\\Mocker\\MockController::getDataItem(\$obj->mocker, \$obj->name); {$returnValue} } diff --git a/src/Mocker/MockController.php b/src/Mocker/MockController.php index d486ff6..eb917ef 100644 --- a/src/Mocker/MockController.php +++ b/src/Mocker/MockController.php @@ -76,19 +76,26 @@ public static function addData(string $mockIdentifier, string $method, string $k * @param string $method JSON string containing mock method data * @return object Decoded method data object with updated count if applicable */ - public function buildMethodData(string $method, bool $isBase64Encoded = false): object + public function buildMethodData(string $method, array $args = [], bool $isBase64Encoded = false): object { $method = $isBase64Encoded ? base64_decode($method) : $method; $data = (object)json_decode($method); + if (isset($data->mocker) && isset($data->name)) { $mocker = (string)$data->mocker; $name = (string)$data->name; if (empty(self::$data[$mocker][$name])) { + // This is outside the mocked method + // You can prepare values here with defaults $data->called = 0; + $data->arguments = []; self::$data[$mocker][$name] = $data; // Mocked method has trigger "once"! } else { + // This is the mocked method + // You can overwrite the default with the expected mocked values here if (isset(self::$data[$mocker][$name])) { + self::$data[$mocker][$name]->arguments[] = $args; self::$data[$mocker][$name]->called = (int)self::$data[$mocker][$name]->called + 1; // Mocked method has trigger "More Than" once! } diff --git a/src/Mocker/MockedMethod.php b/src/Mocker/MockedMethod.php index 90bf0dd..e20a965 100644 --- a/src/Mocker/MockedMethod.php +++ b/src/Mocker/MockedMethod.php @@ -3,6 +3,7 @@ namespace MaplePHP\Unitary\Mocker; use BadMethodCallException; +use InvalidArgumentException; use Closure; use MaplePHP\Unitary\TestUtils\ExecutionWrapper; @@ -17,6 +18,7 @@ final class MockedMethod public ?string $class = null; public ?string $name = null; + public array $arguments = []; public ?bool $isStatic = null; public ?bool $isPublic = null; public ?bool $isPrivate = null; @@ -56,6 +58,10 @@ public function wrap(Closure $call): self throw new BadMethodCallException('Mocker is not set. Use the method "mock" to set the mocker.'); } + if($this->mocker->getReflectionClass()->isInterface()) { + throw new BadMethodCallException('You only use "wrap()" on regular classes and not "interfaces".'); + } + $inst = $this; $wrap = new class ($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends ExecutionWrapper { public function __construct(string $class, array $args = []) @@ -113,6 +119,64 @@ public function called(int $times): self return $inst; } + /** + * Validates arguments for the first called method + * + * @example method('addEmail')->withArguments('john.doe@gmail.com', 'John Doe') + * @param mixed ...$args + * @return $this + */ + public function withArguments(mixed ...$args): self + { + $inst = $this; + foreach ($args as $key => $value) { + $inst = $inst->withArgumentAt($key, $value); + } + return $inst; + } + + /** + * Validates arguments for multiple method calls with different argument sets + * + * @example method('addEmail')->withArguments( + * ['john.doe@gmail.com', 'John Doe'], ['jane.doe@gmail.com', 'Jane Doe'] + * ) + * @param mixed ...$args + * @return $this + */ + public function withArgumentsForCalls(mixed ...$args): self + { + $inst = $this; + foreach ($args as $called => $data) { + if(!is_array($data)) { + throw new InvalidArgumentException( + 'The argument must be a array that contains the expected method arguments.' + ); + } + foreach ($data as $key => $value) { + $inst = $inst->withArgumentAt($key, $value, $called); + } + } + return $inst; + } + + /** + * This will validate an argument at position + * + * @param int $called + * @param int $position + * @param mixed $value + * @return $this + */ + public function withArgumentAt(int $position, mixed $value, int $called = 0): self + { + $inst = $this; + $inst->arguments[] = [ + "validateInData" => ["$called.$position", "equal", [$value]], + ]; + return $inst; + } + /** * Check if a method has been called x times * diff --git a/src/TestCase.php b/src/TestCase.php index 5bbbdad..9cdcec7 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -399,6 +399,7 @@ private function validateRow(object $row, MethodRegistry $pool): array continue; } + if(!property_exists($row, $property)) { throw new ErrorException( "The mock method meta data property name '$property' is undefined in mock object. " . @@ -407,11 +408,12 @@ private function validateRow(object $row, MethodRegistry $pool): array ); } $currentValue = $row->{$property}; + if (is_array($value)) { $validPool = $this->validateArrayValue($value, $currentValue); $valid = $validPool->isValid(); - if (is_array($currentValue)) { + $this->compareFromValidCollection($validPool, $value, $currentValue); } } else { diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index a71f3e5..b5a3306 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -111,6 +111,19 @@ public function registerUser(string $email): bool { } $unit = new Unit(); +$unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("addFromEmail") + ->withArguments("john.doe@gmail.com", "John Doe") + ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) + ->called(2); + }); + + $mail->addFromEmail("john.doe@gmail.com", "John Doe"); + $mail->addFromEmail("jane.doe@gmail.com", "Jane Doe"); +}); + +/* $unit->group("Mocking a PSR-7 Stream", function(TestCase $case) { // Create a mock of a PSR-7 StreamInterface @@ -124,13 +137,7 @@ public function registerUser(string $email): bool { }); }); -$unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { - $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { - $method->method("send")->keepOriginal(); - }); - echo $mail->send(); -}); $unit->group("Example API Request", function(TestCase $case) { @@ -187,8 +194,6 @@ public function registerUser(string $email): bool { }); -/* - $unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { From 289f38ce3710bf9ec34daf0bec4241f98f4c44f3 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Tue, 27 May 2025 22:03:19 +0200 Subject: [PATCH 36/78] Add will throw method --- src/Mocker/MockBuilder.php | 33 +++++++++++++++++++++++++++++++++ src/Mocker/MockController.php | 1 + src/Mocker/MockedMethod.php | 35 ++++++++++++++++++++++++++++++++--- src/TestCase.php | 1 + tests/unitary-unitary.php | 9 ++++++++- 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 350b31f..7c460fd 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -75,6 +75,7 @@ protected function addMockMetadata(array $data, string $mockClassName, mixed $re $data['mocker'] = $mockClassName; $data['return'] = ($methodItem && $methodItem->hasReturn()) ? $methodItem->return : eval($returnValue); $data['keepOriginal'] = ($methodItem && $methodItem->keepOriginal) ? $methodItem->keepOriginal : false; + $data['throwOnce'] = ($methodItem && $methodItem->throwOnce) ? $methodItem->throwOnce : false; return $data; } @@ -281,12 +282,18 @@ protected function generateMockMethodOverrides(string $mockClassName): string } } + $exception = ($methodItem && $methodItem->getThrowable()) ? $this->handleTrownExceptions($methodItem->getThrowable()) : ""; + $safeJson = base64_encode($info); $overrides .= " $modifiers function $methodName($paramList){$returnType} { \$obj = \\MaplePHP\\Unitary\\Mocker\\MockController::getInstance()->buildMethodData('$safeJson', func_get_args(), true); \$data = \\MaplePHP\\Unitary\\Mocker\\MockController::getDataItem(\$obj->mocker, \$obj->name); + + if(\$data->throwOnce === false || \$data->called <= 1) { + {$exception} + } {$returnValue} } "; @@ -294,6 +301,32 @@ protected function generateMockMethodOverrides(string $mockClassName): string return $overrides; } + protected function handleTrownExceptions(\Throwable $exception) { + $class = get_class($exception); + $reflection = new \ReflectionClass($exception); + $constructor = $reflection->getConstructor(); + $args = []; + if ($constructor) { + foreach ($constructor->getParameters() as $param) { + $name = $param->getName(); + $value = $exception->{$name} ?? null; + switch ($name) { + case 'message': + $value = $exception->getMessage(); + break; + case 'code': + $value = $exception->getCode(); + break; + case 'previous': + $value = null; + break; + } + $args[] = var_export($value, true); + } + } + + return "throw new \\{$class}(" . implode(', ', $args) . ");"; + } /** * Will build the wrapper return diff --git a/src/Mocker/MockController.php b/src/Mocker/MockController.php index eb917ef..cb3f8d8 100644 --- a/src/Mocker/MockController.php +++ b/src/Mocker/MockController.php @@ -89,6 +89,7 @@ public function buildMethodData(string $method, array $args = [], bool $isBase64 // You can prepare values here with defaults $data->called = 0; $data->arguments = []; + $data->throw = null; self::$data[$mocker][$name] = $data; // Mocked method has trigger "once"! } else { diff --git a/src/Mocker/MockedMethod.php b/src/Mocker/MockedMethod.php index e20a965..d313da2 100644 --- a/src/Mocker/MockedMethod.php +++ b/src/Mocker/MockedMethod.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use Closure; use MaplePHP\Unitary\TestUtils\ExecutionWrapper; +use Throwable; /** * @psalm-suppress PossiblyUnusedProperty @@ -13,7 +14,10 @@ final class MockedMethod { private ?MockBuilder $mocker; + private ?Throwable $throwable = null; + public mixed $return = null; + public array $throw = []; public int|array|null $called = null; public ?string $class = null; @@ -36,9 +40,11 @@ final class MockedMethod public ?int $endLine = null; public ?string $fileName = null; public bool $keepOriginal = false; + public bool $throwOnce = false; protected bool $hasReturn = false; protected ?Closure $wrapper = null; + public function __construct(?MockBuilder $mocker = null) { $this->mocker = $mocker; @@ -83,6 +89,16 @@ public function getWrap(): ?Closure return $this->wrapper; } + /** + * Get the throwable if added as Throwable + * + * @return Throwable|null + */ + public function getThrowable(): ?Throwable + { + return $this->throwable; + } + /** * Check if a return value has been added * @@ -128,11 +144,10 @@ public function called(int $times): self */ public function withArguments(mixed ...$args): self { - $inst = $this; foreach ($args as $key => $value) { - $inst = $inst->withArgumentAt($key, $value); + $this->withArgumentAt($key, $value); } - return $inst; + return $this; } /** @@ -235,6 +250,20 @@ public function willReturn(mixed $value): self return $inst; } + public function willThrow(Throwable $throwable) + { + $this->throwable = $throwable; + $this->throw = []; + return $this; + } + + public function willThrowOnce(Throwable $throwable) + { + $this->throwOnce = true; + $this->willThrow($throwable); + return $this; + } + /** * Set the class name. * diff --git a/src/TestCase.php b/src/TestCase.php index 9cdcec7..72d3c1e 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -180,6 +180,7 @@ protected function expectAndValidate( $test->setTestValue($this->value); if ($validation instanceof Closure) { $listArr = $this->buildClosureTest($validation, $description); + foreach ($listArr as $list) { if(is_bool($list)) { $test->setUnit($list, "Validation"); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index b5a3306..0d325c6 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -116,11 +116,18 @@ public function registerUser(string $email): bool { $method->method("addFromEmail") ->withArguments("john.doe@gmail.com", "John Doe") ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) + ->willThrowOnce(new Exception("Lorem ipsum")) ->called(2); }); - $mail->addFromEmail("john.doe@gmail.com", "John Doe"); + try { + $mail->addFromEmail("john.doe@gmail.com", "John Doe"); + } catch (Exception $e) { + + } + $mail->addFromEmail("jane.doe@gmail.com", "Jane Doe"); + }); /* From 2a67a489ccf4574091501e675937d9af3cba14c8 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Thu, 29 May 2025 20:46:09 +0200 Subject: [PATCH 37/78] Add throwable validations to Expect Add test for library --- src/Expect.php | 169 +++++++++++++++++++++++++++++++++++++ src/Mocker/MockBuilder.php | 4 +- src/TestCase.php | 11 ++- src/TestUnit.php | 3 + src/Utils/Helpers.php | 25 ++++++ tests/unitary-unitary.php | 78 +++++++++++++++-- 6 files changed, 277 insertions(+), 13 deletions(-) create mode 100644 src/Utils/Helpers.php diff --git a/src/Expect.php b/src/Expect.php index ce4433a..e6a7016 100644 --- a/src/Expect.php +++ b/src/Expect.php @@ -2,9 +2,178 @@ namespace MaplePHP\Unitary; +use Exception; +use Throwable; use MaplePHP\Validate\ValidationChain; class Expect extends ValidationChain { + protected mixed $initValue = null; + protected Throwable|false|null $except = null; + + /** + * Validate exception instance + * + * @param string|object|callable $compare + * @return $this + * @throws Exception + */ + public function isThrowable(string|object|callable $compare): self + { + if($except = $this->getException()) { + $this->setValue($except); + } + $this->validateExcept(__METHOD__, $compare, fn() => $this->isClass($compare)); + return $this; + } + + /** + * Validate exception message + * + * @param string|callable $compare + * @return $this + * @throws Exception + */ + public function hasThrowableMessage(string|callable $compare): self + { + if($except = $this->getException()) { + $this->setValue($except->getMessage()); + } + $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + return $this; + } + + /** + * Validate exception code + * + * @param int|callable $compare + * @return $this + * @throws Exception + */ + public function hasThrowableCode(int|callable $compare): self + { + if($except = $this->getException()) { + $this->setValue($except->getCode()); + } + $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + return $this; + } + + /** + * Validate exception Severity + * + * @param int|callable $compare + * @return $this + * @throws Exception + */ + public function hasThrowableSeverity(int|callable $compare): self + { + if($except = $this->getException()) { + $value = method_exists($except, 'getSeverity') ? $except->getSeverity() : 0; + $this->setValue($value); + } + $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + return $this; + } + + /** + * Validate exception file + * + * @param string|callable $compare + * @return $this + * @throws Exception + */ + public function hasThrowableFile(string|callable $compare): self + { + if($except = $this->getException()) { + $this->setValue($except->getFile()); + } + $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + return $this; + } + + /** + * Validate exception line + * + * @param int|callable $compare + * @return $this + * @throws Exception + */ + public function hasThrowableLine(int|callable $compare): self + { + if($except = $this->getException()) { + $this->setValue($except->getLine()); + } + $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + return $this; + } + + /** + * Helper to validate the exception instance against the provided callable. + * + * @param string $name + * @param string|object|callable $compare + * @param callable $fall + * @return self + */ + protected function validateExcept(string $name, string|object|callable $compare, callable $fall): self + { + $pos = strrpos($name, '::'); + $name = ($pos !== false) ? substr($name, $pos + 2) : $name; + $this->mapErrorValidationName($name); + if(is_callable($compare)) { + $compare($this); + } else { + $fall($this); + } + + if(is_null($this->initValue)) { + $this->initValue = $this->getValue(); + } + + if($this->except === false) { + $this->setValue(null); + } + return $this; + } + + /** + * Used to get the first value before any validation is performed and + * any changes to the value are made. + * + * @return mixed + */ + public function getInitValue(): mixed + { + return $this->initValue; + } + + /** + * Retrieves the exception instance if one has been caught, + * otherwise attempts to invoke a callable value to detect any exception. + * + * @return Throwable|false Returns the caught exception if available, or false if no exception occurs. + * @throws Exception Throws an exception if the provided value is not callable. + */ + protected function getException(): Throwable|false + { + if (!is_null($this->except)) { + return $this->except; + } + + if(!is_callable($this->getValue())) { + throw new Exception("Except method only accepts callable"); + } + try { + $expect = $this->getValue(); + $expect(); + $this->except = false; + } catch (Throwable $exception) { + $this->except = $exception; + return $this->except; + } + return false; + + } } \ No newline at end of file diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 7c460fd..46112c6 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -1,4 +1,5 @@ setTestValue($this->value); if ($validation instanceof Closure) { - $listArr = $this->buildClosureTest($validation, $description); + $validPool = new Expect($this->value); + $listArr = $this->buildClosureTest($validation, $validPool, $description); foreach ($listArr as $list) { if(is_bool($list)) { @@ -190,6 +191,11 @@ protected function expectAndValidate( } } } + // In some rare cases the validation value might change along the rode + // tell the test to use the new value + $initValue = $validPool->getInitValue(); + $initValue = ($initValue !== null) ? $initValue : $this->getValue(); + $test->setTestValue($initValue); } else { foreach ($validation as $method => $args) { if (!($args instanceof Closure) && !is_array($args)) { @@ -636,10 +642,9 @@ public function getTest(): array * @param string|null $message * @return array */ - protected function buildClosureTest(Closure $validation, ?string $message = null): array + protected function buildClosureTest(Closure $validation, Expect $validPool, ?string $message = null): array { //$bool = false; - $validPool = new Expect($this->value); $validation = $validation->bindTo($validPool); $error = []; if ($validation !== null) { diff --git a/src/TestUnit.php b/src/TestUnit.php index fb11bba..abaf585 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -216,6 +216,9 @@ public function getReadValue(mixed $value = null, bool $minify = false): string| if (is_array($value)) { return '"' . $this->excerpt(json_encode($value)) . '"' . ($minify ? "" : " (type: array)"); } + if (is_callable($value)) { + return '"' . $this->excerpt(get_class((object)$value)) . '"' . ($minify ? "" : " (type: callable)"); + } if (is_object($value)) { return '"' . $this->excerpt(get_class($value)) . '"' . ($minify ? "" : " (type: object)"); } diff --git a/src/Utils/Helpers.php b/src/Utils/Helpers.php new file mode 100644 index 0000000..d43524d --- /dev/null +++ b/src/Utils/Helpers.php @@ -0,0 +1,25 @@ +group("Advanced Mailer Test", function (TestCase $case) use($unit) { + +$unit->group("Test mocker", function (TestCase $case) use($unit) { + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") ->withArguments("john.doe@gmail.com", "John Doe") ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) - ->willThrowOnce(new Exception("Lorem ipsum")) + ->willThrowOnce(new InvalidArgumentException("Lorem ipsum")) ->called(2); - }); - try { - $mail->addFromEmail("john.doe@gmail.com", "John Doe"); - } catch (Exception $e) { + $method->method("addBCC") + ->isPublic() + ->hasDocComment() + ->hasParams() + ->paramHasType(0) + ->paramIsType(0, "string") + ->paramHasDefault(1, "Daniel") + ->paramIsOptional(1) + ->paramIsReference(2) + ->called(0); + }); - } + $case->validate(fn() => $mail->addFromEmail("john.doe@gmail.com", "John Doe"), function(Expect $inst) { + $inst->isThrowable(InvalidArgumentException::class); + }); $mail->addFromEmail("jane.doe@gmail.com", "Jane Doe"); + $case->error("Test all exception validations") + ->validate(fn() => throw new ErrorException("Lorem ipsum", 1, 1, "example.php", 22), function(Expect $inst, Traverse $obj) { + $inst->isThrowable(ErrorException::class); + $inst->hasThrowableMessage("Lorem ipsum"); + $inst->hasThrowableSeverity(1); + $inst->hasThrowableCode(1); + $inst->hasThrowableFile("example.php"); + $inst->hasThrowableLine(22); + }); + + $case->validate(fn() => throw new TypeError("Lorem ipsum"), function(Expect $inst, Traverse $obj) { + $inst->isThrowable(TypeError::class); + }); }); -/* +$config = TestConfig::make("Mocking response")->withName("unitary"); + +$unit->group($config, function (TestCase $case) use($unit) { + + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('HelloWorld') + ->calledAtLeast(1); + }); + $response = new Response($stream); + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->hasResponse(); + $inst->isEqualTo('HelloWorld'); + $inst->notIsEqualTo('HelloWorldNot'); + }); +}); + +$unit->group($config->withSubject("Assert validations"), function ($case) { + $case->validate("HelloWorld", function(Expect $inst) { + assert($inst->isEqualTo("HelloWorld")->isValid(), "Assert has failed"); + }); + assert(1 === 1, "Assert has failed"); +}); + +$unit->case($config->withSubject("Old validation syntax"), function ($case) { + $case->add("HelloWorld", [ + "isString" => [], + "User validation" => function($value) { + return $value === "HelloWorld"; + } + ], "Is not a valid port number"); + + $this->add("HelloWorld", [ + "isEqualTo" => ["HelloWorld"], + ], "Failed to validate"); +}); + + +/* $unit->group("Mocking a PSR-7 Stream", function(TestCase $case) { // Create a mock of a PSR-7 StreamInterface $stream = $case->mock(StreamInterface::class); From 636d3b6987eaf25b13a29da3da0c7f02228ed9fb Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Thu, 29 May 2025 20:58:47 +0200 Subject: [PATCH 38/78] bugfix: mocker method argument inheritance --- src/Mocker/MockBuilder.php | 6 ++- tests/unitary-unitary.php | 84 +++++--------------------------------- 2 files changed, 14 insertions(+), 76 deletions(-) diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 46112c6..df94bdb 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -165,7 +165,9 @@ public static function __set_state(array \$an_array): self } "; - //Helpers::createFile() + //print_r($code); + //die; + //Helpers::createFile($this->mockClassName, $code); eval($code); /** @@ -276,7 +278,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string } if($methodItem && $methodItem->keepOriginal) { - $returnValue = "parent::$methodName($paramList);"; + $returnValue = "parent::$methodName(...func_get_args());"; if (!in_array('void', $types)) { $returnValue = "return $returnValue"; } diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 8bcfc29..622429e 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -112,6 +112,8 @@ public function registerUser(string $email): bool { $unit = new Unit(); + + $unit->group("Test mocker", function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { @@ -192,86 +194,20 @@ public function registerUser(string $email): bool { ], "Failed to validate"); }); - -/* -$unit->group("Mocking a PSR-7 Stream", function(TestCase $case) { - // Create a mock of a PSR-7 StreamInterface - $stream = $case->mock(StreamInterface::class); - - // Inject the mock into a Response object - $response = new Response($stream); - - $case->validate($response->getBody(), function(Expect $expect) { - $expect->isInstanceOf(StreamInterface::class); - }); -}); - - - -$unit->group("Example API Request", function(TestCase $case) { - - $request = new Request("GET", "https://example.com/?page=1&slug=hello-world"); - - $case->validate($request->getMethod(), function(Expect $expect) { - $expect->isRequestMethod(); - }); - - $case->validate($request->getUri()->getQuery(), function(Expect $expect) { - $expect->hasQueryParam("page", 1); - $expect->hasQueryParam("slug", "hello-world"); - }); -}); - -$config = TestConfig::make("Testing mocking library")->withName("unitary")->withSkip(); - -$unit->group($config, function (TestCase $case) use($unit) { - - $stream = $case->mock(Stream::class, function (MethodRegistry $method) { - $method->method("getContents") - ->willReturn('') - ->calledAtLeast(1); - }); - $response = new Response($stream); - - $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->hasResponse(); - }); -}); - -$unit->group($config->withSubject("Testing custom validations"), function ($case) { - - $case->validate("HelloWorld", function(Expect $inst) { - assert($inst->isEqualTo("HelloWorld")->isValid(), "Assert has failed"); - }); - - assert(1 === 1, "Assert has failed"); - -}); - -$unit->case($config->withSubject("Validate old Unitary case syntax"), function ($case) { - - $case->add("HelloWorld", [ - "isString" => [], - "User validation" => function($value) { - return $value === "HelloWorld"; - } - ], "Is not a valid port number"); - - $this->add("HelloWorld", [ - "isEqualTo" => ["HelloWorld"], - ], "Failed to validate");; -}); - - - -$unit->group("Advanced Mailer Test", function (TestCase $case) use($unit) { +$unit->group("Validate partial mock", function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("send")->keepOriginal(); + $method->method("isValidEmail")->keepOriginal(); $method->method("sendEmail")->keepOriginal(); }); - $mail->send(); + + $case->validate(fn() => $mail->send(), function(Expect $inst) { + $inst->hasThrowableMessage("Invalid email"); + }); }); + +/* $unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { From e8d5a94ba010513648108916c3b560e363f4f120 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Fri, 30 May 2025 16:57:01 +0200 Subject: [PATCH 39/78] Data type and code quality improvements --- src/Expect.php | 19 ++++-- src/Mocker/MethodRegistry.php | 17 ++++- src/Mocker/MockBuilder.php | 41 ++++++++---- src/Mocker/MockController.php | 1 + src/Mocker/MockedMethod.php | 6 +- src/Setup/assert-polyfill.php | 4 +- src/TestCase.php | 82 +++++++++++++---------- src/TestConfig.php | 2 +- src/TestUnit.php | 37 ++++++----- src/TestUtils/DataTypeMock.php | 15 +++-- src/TestUtils/ExecutionWrapper.php | 13 +++- src/Unit.php | 39 +++++------ src/Utils/FileIterator.php | 14 ++-- src/Utils/Helpers.php | 56 +++++++++++++++- src/Utils/Performance.php | 2 +- tests/unitary-unitary.php | 102 +++++++++++++++++++++++++++-- 16 files changed, 328 insertions(+), 122 deletions(-) diff --git a/src/Expect.php b/src/Expect.php index e6a7016..a3199ff 100644 --- a/src/Expect.php +++ b/src/Expect.php @@ -6,6 +6,9 @@ use Throwable; use MaplePHP\Validate\ValidationChain; +/** + * @api + */ class Expect extends ValidationChain { @@ -24,7 +27,8 @@ public function isThrowable(string|object|callable $compare): self if($except = $this->getException()) { $this->setValue($except); } - $this->validateExcept(__METHOD__, $compare, fn() => $this->isClass($compare)); + /** @psalm-suppress PossiblyInvalidCast */ + $this->validateExcept(__METHOD__, $compare, fn() => $this->isClass((string)$compare)); return $this; } @@ -40,6 +44,7 @@ public function hasThrowableMessage(string|callable $compare): self if($except = $this->getException()) { $this->setValue($except->getMessage()); } + /** @psalm-suppress PossiblyInvalidCast */ $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); return $this; } @@ -56,6 +61,7 @@ public function hasThrowableCode(int|callable $compare): self if($except = $this->getException()) { $this->setValue($except->getCode()); } + /** @psalm-suppress PossiblyInvalidCast */ $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); return $this; } @@ -73,6 +79,7 @@ public function hasThrowableSeverity(int|callable $compare): self $value = method_exists($except, 'getSeverity') ? $except->getSeverity() : 0; $this->setValue($value); } + /** @psalm-suppress PossiblyInvalidCast */ $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); return $this; } @@ -89,6 +96,7 @@ public function hasThrowableFile(string|callable $compare): self if($except = $this->getException()) { $this->setValue($except->getFile()); } + /** @psalm-suppress PossiblyInvalidCast */ $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); return $this; } @@ -105,6 +113,7 @@ public function hasThrowableLine(int|callable $compare): self if($except = $this->getException()) { $this->setValue($except->getLine()); } + /** @psalm-suppress PossiblyInvalidCast */ $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); return $this; } @@ -113,11 +122,11 @@ public function hasThrowableLine(int|callable $compare): self * Helper to validate the exception instance against the provided callable. * * @param string $name - * @param string|object|callable $compare + * @param string|int|object|callable $compare * @param callable $fall * @return self */ - protected function validateExcept(string $name, string|object|callable $compare, callable $fall): self + protected function validateExcept(string $name, int|string|object|callable $compare, callable $fall): self { $pos = strrpos($name, '::'); $name = ($pos !== false) ? substr($name, $pos + 2) : $name; @@ -162,11 +171,11 @@ protected function getException(): Throwable|false return $this->except; } - if(!is_callable($this->getValue())) { + $expect = $this->getValue(); + if(!is_callable($expect)) { throw new Exception("Except method only accepts callable"); } try { - $expect = $this->getValue(); $expect(); $this->except = false; } catch (Throwable $exception) { diff --git a/src/Mocker/MethodRegistry.php b/src/Mocker/MethodRegistry.php index 7080349..7990819 100644 --- a/src/Mocker/MethodRegistry.php +++ b/src/Mocker/MethodRegistry.php @@ -5,7 +5,7 @@ class MethodRegistry { private ?MockBuilder $mocker; - /** @var array */ + /** @var array> */ private static array $methods = []; public function __construct(?MockBuilder $mocker = null) @@ -30,7 +30,11 @@ public static function reset(string $class): void */ public static function getMethod(string $class, string $name): ?MockedMethod { - return self::$methods[$class][$name] ?? null; + $mockedMethod = self::$methods[$class][$name] ?? null; + if($mockedMethod instanceof MockedMethod) { + return $mockedMethod; + } + return null; } /** @@ -42,6 +46,9 @@ public static function getMethod(string $class, string $name): ?MockedMethod */ public function method(string $name): MockedMethod { + if(is_null($this->mocker)) { + throw new \BadMethodCallException("MockBuilder is not set yet."); + } self::$methods[$this->mocker->getMockedClassName()][$name] = new MockedMethod($this->mocker); return self::$methods[$this->mocker->getMockedClassName()][$name]; } @@ -54,6 +61,9 @@ public function method(string $name): MockedMethod */ public function get(string $key): MockedMethod|null { + if(is_null($this->mocker)) { + throw new \BadMethodCallException("MockBuilder is not set yet."); + } return self::$methods[$this->mocker->getMockedClassName()][$key] ?? null; } @@ -75,6 +85,9 @@ public function getAll(): array */ public function has(string $name): bool { + if(is_null($this->mocker)) { + throw new \BadMethodCallException("MockBuilder is not set yet."); + } return isset(self::$methods[$this->mocker->getMockedClassName()][$name]); } diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index df94bdb..bd3ce0a 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -61,6 +61,11 @@ public function __construct(string $className, array $args = []) */ } + protected function getMockClass(?MockedMethod $methodItem, callable $call, mixed $fallback = null): mixed + { + return ($methodItem instanceof MockedMethod) ? $call($methodItem) : $fallback; + } + /** * Adds metadata to the mock method, including the mock class name, return value. * This is possible custom-added data that "has to" validate against the MockedMethod instance @@ -71,12 +76,12 @@ public function __construct(string $className, array $args = []) * @param mixed $methodItem * @return array The data array with added metadata */ - protected function addMockMetadata(array $data, string $mockClassName, mixed $returnValue, mixed $methodItem): array + protected function addMockMetadata(array $data, string $mockClassName, mixed $returnValue, ?MockedMethod $methodItem): array { $data['mocker'] = $mockClassName; - $data['return'] = ($methodItem && $methodItem->hasReturn()) ? $methodItem->return : eval($returnValue); - $data['keepOriginal'] = ($methodItem && $methodItem->keepOriginal) ? $methodItem->keepOriginal : false; - $data['throwOnce'] = ($methodItem && $methodItem->throwOnce) ? $methodItem->throwOnce : false; + $data['return'] = ($methodItem instanceof MockedMethod && $methodItem->hasReturn()) ? $methodItem->return : eval($returnValue); + $data['keepOriginal'] = ($methodItem instanceof MockedMethod && $methodItem->keepOriginal) ? $methodItem->keepOriginal : false; + $data['throwOnce'] = ($methodItem instanceof MockedMethod && $methodItem->throwOnce) ? $methodItem->throwOnce : false; return $data; } @@ -118,7 +123,7 @@ public function getClassArgs(): array */ public function getMockedClassName(): string { - return $this->mockClassName; + return (string)$this->mockClassName; } /** @@ -132,7 +137,7 @@ public function getMockedClassName(): string */ public function mockDataType(string $dataType, mixed $value, ?string $bindToMethod = null): self { - if($bindToMethod) { + if($bindToMethod !== null && $bindToMethod) { $this->dataTypeMock = $this->dataTypeMock->withCustomBoundDefault($bindToMethod, $dataType, $value); } else { $this->dataTypeMock = $this->dataTypeMock->withCustomDefault($dataType, $value); @@ -149,7 +154,7 @@ public function mockDataType(string $dataType, mixed $value, ?string $bindToMeth public function execute(): mixed { $className = $this->reflection->getName(); - $overrides = $this->generateMockMethodOverrides($this->mockClassName); + $overrides = $this->generateMockMethodOverrides((string)$this->mockClassName); $unknownMethod = $this->errorHandleUnknownMethod($className, !$this->reflection->isInterface()); $extends = $this->reflection->isInterface() ? "implements $className" : "extends $className"; @@ -170,6 +175,10 @@ public static function __set_state(array \$an_array): self //Helpers::createFile($this->mockClassName, $code); eval($code); + if(!is_string($this->mockClassName)) { + throw new Exception("Mock class name is not a string"); + } + /** * @psalm-suppress MixedMethodCall * @psalm-suppress InvalidStringClass @@ -215,7 +224,10 @@ protected function getReturnValue(array $types, mixed $method, ?MockedMethod $me { // Will overwrite the auto generated value if ($methodItem && $methodItem->hasReturn()) { - return "return " . var_export($methodItem->return, true) . ";"; + return " + \$returnData = " . var_export($methodItem->return, true) . "; + return \$returnData[\$data->called-1] ?? \$returnData[0]; + "; } if ($types) { return (string)$this->getMockValueForType((string)$types[0], $method); @@ -284,7 +296,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string } } - $exception = ($methodItem && $methodItem->getThrowable()) ? $this->handleTrownExceptions($methodItem->getThrowable()) : ""; + $exception = ($methodItem && $methodItem->getThrowable()) ? $this->handleThrownExceptions($methodItem->getThrowable()) : ""; $safeJson = base64_encode($info); $overrides .= " @@ -303,7 +315,14 @@ protected function generateMockMethodOverrides(string $mockClassName): string return $overrides; } - protected function handleTrownExceptions(\Throwable $exception) { + /** + * Will mocked handle the thrown exception + * + * @param \Throwable $exception + * @return string + */ + protected function handleThrownExceptions(\Throwable $exception): string + { $class = get_class($exception); $reflection = new \ReflectionClass($exception); $constructor = $reflection->getConstructor(); @@ -403,7 +422,7 @@ protected function getReturnType(ReflectionMethod $method): array } elseif ($returnType instanceof ReflectionIntersectionType) { $intersect = array_map( - fn ($type) => $type instanceof ReflectionNamedType ? $type->getName() : (string) $type, + fn (ReflectionNamedType $type) => $type->getName(), $returnType->getTypes() ); $types[] = $intersect; diff --git a/src/Mocker/MockController.php b/src/Mocker/MockController.php index cb3f8d8..d24eb3f 100644 --- a/src/Mocker/MockController.php +++ b/src/Mocker/MockController.php @@ -96,6 +96,7 @@ public function buildMethodData(string $method, array $args = [], bool $isBase64 // This is the mocked method // You can overwrite the default with the expected mocked values here if (isset(self::$data[$mocker][$name])) { + /** @psalm-suppress MixedArrayAssignment */ self::$data[$mocker][$name]->arguments[] = $args; self::$data[$mocker][$name]->called = (int)self::$data[$mocker][$name]->called + 1; // Mocked method has trigger "More Than" once! diff --git a/src/Mocker/MockedMethod.php b/src/Mocker/MockedMethod.php index d313da2..72be003 100644 --- a/src/Mocker/MockedMethod.php +++ b/src/Mocker/MockedMethod.php @@ -242,7 +242,7 @@ public function calledAtMost(int $times): self * @param mixed $value * @return $this */ - public function willReturn(mixed $value): self + public function willReturn(mixed ...$value): self { $inst = $this; $inst->hasReturn = true; @@ -250,14 +250,14 @@ public function willReturn(mixed $value): self return $inst; } - public function willThrow(Throwable $throwable) + public function willThrow(Throwable $throwable): self { $this->throwable = $throwable; $this->throw = []; return $this; } - public function willThrowOnce(Throwable $throwable) + public function willThrowOnce(Throwable $throwable): self { $this->throwOnce = true; $this->willThrow($throwable); diff --git a/src/Setup/assert-polyfill.php b/src/Setup/assert-polyfill.php index 17db104..5753be4 100644 --- a/src/Setup/assert-polyfill.php +++ b/src/Setup/assert-polyfill.php @@ -12,11 +12,11 @@ */ if (PHP_VERSION_ID < 80400) { - if (!ini_get('assert.active')) { + if (ini_get('assert.active') === false) { ini_set('assert.active', 1); } - if (!ini_get('assert.exception')) { + if (ini_get('assert.exception') === false) { ini_set('assert.exception', 1); } } diff --git a/src/TestCase.php b/src/TestCase.php index c1da864..92d76e1 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -28,19 +28,23 @@ */ final class TestCase { + /** + * List of properties to exclude from validation + * (some properties are not valid for comparison) + * + * @var array + */ + private const EXCLUDE_VALIDATE = ["return"]; + private mixed $value; private TestConfig $config; - private ?string $message = null; private array $test = []; private int $count = 0; private ?Closure $bind = null; private ?string $errorMessage = null; private array $deferredValidation = []; - /** @var MockBuilder */ - private MockBuilder $mocker; - /** - * @var true - */ + + private ?MockBuilder $mocker = null; private bool $hasAssertError = false; /** @@ -51,7 +55,7 @@ final class TestCase public function __construct(TestConfig|string|null $config = null) { if (!($config instanceof TestConfig)) { - $this->config = new TestConfig($config); + $this->config = new TestConfig((string)$config); } else { $this->config = $config; } @@ -114,7 +118,7 @@ public function dispatchTest(self &$row): array ); } catch (Throwable $e) { if(str_contains($e->getFile(), "eval()")) { - throw new BlunderErrorException($e->getMessage(), $e->getCode()); + throw new BlunderErrorException($e->getMessage(), (int)$e->getCode()); } throw $e; } @@ -187,6 +191,7 @@ protected function expectAndValidate( $test->setUnit($list, "Validation"); } else { foreach ($list as $method => $valid) { + /** @var array|bool $valid */ $test->setUnit(false, (string)$method, $valid); } } @@ -205,7 +210,7 @@ protected function expectAndValidate( } } if (!$test->isValid()) { - if(!$trace) { + if($trace === null || $trace === []) { $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; } @@ -292,6 +297,9 @@ public function withMock(string $class, array $args = []): self */ public function buildMock(?Closure $validate = null): mixed { + if(!($this->mocker instanceof MockBuilder)) { + throw new BadMethodCallException("The mocker is not set yet!"); + } if (is_callable($validate)) { $this->prepareValidation($this->mocker, $validate); } @@ -299,7 +307,7 @@ public function buildMock(?Closure $validate = null): mixed /** @psalm-suppress MixedReturnStatement */ return $this->mocker->execute(); } catch (Throwable $e) { - throw new BlunderErrorException($e->getMessage(), $e->getCode()); + throw new BlunderErrorException($e->getMessage(), (int)$e->getCode()); } } @@ -310,7 +318,6 @@ public function buildMock(?Closure $validate = null): mixed * A validation closure can also be provided to define mock expectations. These * validations are deferred and will be executed later via runDeferredValidations(). * - * @template T of object * @param class-string $class * @param Closure|null $validate * @param array $args @@ -325,6 +332,9 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) public function getMocker(): MockBuilder { + if(!($this->mocker instanceof MockBuilder)) { + throw new BadMethodCallException("The mocker is not set yet!"); + } return $this->mocker; } @@ -373,8 +383,8 @@ private function runValidation(MockBuilder $mocker, MethodRegistry $pool): array throw new ErrorException("Could not get data from mocker!"); } foreach ($data as $row) { - if (is_object($row) && isset($row->name) && $pool->has($row->name)) { - $error[(string)$row->name] = $this->validateRow($row, $pool); + if (is_object($row) && isset($row->name) && is_string($row->name) && $pool->has($row->name)) { + $error[$row->name] = $this->validateRow($row, $pool); } } return $error; @@ -400,13 +410,11 @@ private function validateRow(object $row, MethodRegistry $pool): array } $errors = []; - foreach (get_object_vars($item) as $property => $value) { if ($value === null) { continue; } - if(!property_exists($row, $property)) { throw new ErrorException( "The mock method meta data property name '$property' is undefined in mock object. " . @@ -416,24 +424,24 @@ private function validateRow(object $row, MethodRegistry $pool): array } $currentValue = $row->{$property}; - if (is_array($value)) { - $validPool = $this->validateArrayValue($value, $currentValue); - $valid = $validPool->isValid(); - if (is_array($currentValue)) { - - $this->compareFromValidCollection($validPool, $value, $currentValue); + if(!in_array($property, self::EXCLUDE_VALIDATE)) { + if (is_array($value)) { + $validPool = $this->validateArrayValue($value, $currentValue); + $valid = $validPool->isValid(); + if (is_array($currentValue)) { + $this->compareFromValidCollection($validPool, $value, $currentValue); + } + } else { + /** @psalm-suppress MixedArgument */ + $valid = Validator::value($currentValue)->equal($value); } - } else { - /** @psalm-suppress MixedArgument */ - $valid = Validator::value($currentValue)->equal($value); + $errors[] = [ + "property" => $property, + "currentValue" => $currentValue, + "expectedValue" => $value, + "valid" => $valid + ]; } - - $errors[] = [ - "property" => $property, - "currentValue" => $currentValue, - "expectedValue" => $value, - "valid" => $valid - ]; } return $errors; @@ -529,6 +537,7 @@ public function runDeferredValidations(): array /** @var callable $row['call'] */ $error = $row['call'](); $hasValidated = []; + /** @var string $method */ foreach ($error as $method => $arr) { $test = new TestUnit("Mock method \"$method\" failed"); if (isset($row['trace']) && is_array($row['trace'])) { @@ -536,12 +545,13 @@ public function runDeferredValidations(): array } foreach ($arr as $data) { // We do not want to validate the return here automatically - if($data['property'] !== "return") { - /** @var array{expectedValue: mixed, currentValue: mixed} $data */ + /** @var array{property: string} $data */ + if(!in_array($data['property'], self::EXCLUDE_VALIDATE)) { + /** @var array{valid: bool|null, expectedValue: mixed, currentValue: mixed} $data */ $test->setUnit($data['valid'], $data['property'], [], [ $data['expectedValue'], $data['currentValue'] ]); - if (!isset($hasValidated[$method]) && !$data['valid']) { + if (!isset($hasValidated[$method]) && $data['valid'] === null || $data['valid'] === false) { $hasValidated[$method] = true; $this->count++; } @@ -747,8 +757,8 @@ public function listAllProxyMethods(string $class, ?string $prefixMethods = null } $params = array_map(function ($param) { - $type = $param->hasType() ? $param->getType() . ' ' : ''; - $value = $param->isDefaultValueAvailable() ? ' = ' . Str::value($param->getDefaultValue())->exportReadableValue()->get() : null; + $type = $param->hasType() ? (string)$param->getType() . ' ' : ''; + $value = $param->isDefaultValueAvailable() ? ' = ' . (string)Str::value($param->getDefaultValue())->exportReadableValue()->get() : ""; return $type . '$' . $param->getName() . $value; }, $method->getParameters()); diff --git a/src/TestConfig.php b/src/TestConfig.php index 9f7c702..82cbb63 100644 --- a/src/TestConfig.php +++ b/src/TestConfig.php @@ -2,7 +2,7 @@ namespace MaplePHP\Unitary; -class TestConfig +final class TestConfig { public ?string $message; diff --git a/src/TestUnit.php b/src/TestUnit.php index abaf585..5fb1b2e 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -6,8 +6,9 @@ use ErrorException; use MaplePHP\DTO\Format\Str; +use MaplePHP\Unitary\Utils\Helpers; -class TestUnit +final class TestUnit { private bool $valid; private mixed $value = null; @@ -56,7 +57,7 @@ public function setTestValue(mixed $value): void * * @param bool|null $valid can be null if validation should execute later * @param string|null|\Closure $validation - * @param array $args + * @param array|bool $args * @param array $compare * @return $this * @throws ErrorException @@ -73,8 +74,8 @@ public function setUnit( $this->count++; } - if (!is_callable($validation)) { - $addArgs = is_array($args) ? "(" . implode(", ", $args) . ")" : ""; + if (is_string($validation)) { + $addArgs = is_array($args) ? "(" . Helpers::stringifyArgs($args) . ")" : ""; $valLength = strlen($validation . $addArgs); if ($validation && $this->valLength < $valLength) { $this->valLength = $valLength; @@ -113,20 +114,22 @@ public function getValidationLength(): int public function setCodeLine(array $trace): self { $this->codeLine = []; - $file = $trace['file'] ?? ''; - $line = $trace['line'] ?? 0; - if ($file && $line) { - $lines = file($file); + $file = (string)($trace['file'] ?? ''); + $line = (int)($trace['line'] ?? 0); + $lines = file($file); + $code = ""; + if($lines !== false) { $code = trim($lines[$line - 1] ?? ''); if (str_starts_with($code, '->')) { $code = substr($code, 2); } $code = $this->excerpt($code); - - $this->codeLine['line'] = $line; - $this->codeLine['file'] = $file; - $this->codeLine['code'] = $code; } + + $this->codeLine['line'] = $line; + $this->codeLine['file'] = $file; + $this->codeLine['code'] = $code; + return $this; } @@ -195,10 +198,10 @@ public function getValue(): mixed * * @param mixed|null $value * @param bool $minify - * @return string|bool + * @return string * @throws ErrorException */ - public function getReadValue(mixed $value = null, bool $minify = false): string|bool + public function getReadValue(mixed $value = null, bool $minify = false): string { $value = $value === null ? $this->value : $value; if (is_bool($value)) { @@ -214,7 +217,11 @@ public function getReadValue(mixed $value = null, bool $minify = false): string| return '"' . $this->excerpt($value) . '"' . ($minify ? "" : " (type: string)"); } if (is_array($value)) { - return '"' . $this->excerpt(json_encode($value)) . '"' . ($minify ? "" : " (type: array)"); + $json = json_encode($value); + if($json === false) { + return "(unknown type)"; + } + return '"' . $this->excerpt($json) . '"' . ($minify ? "" : " (type: array)"); } if (is_callable($value)) { return '"' . $this->excerpt(get_class((object)$value)) . '"' . ($minify ? "" : " (type: callable)"); diff --git a/src/TestUtils/DataTypeMock.php b/src/TestUtils/DataTypeMock.php index 6e328b7..891ded6 100644 --- a/src/TestUtils/DataTypeMock.php +++ b/src/TestUtils/DataTypeMock.php @@ -12,7 +12,7 @@ * This class is particularly useful for testing type-specific functionality * and generating test data with specific data types. */ -class DataTypeMock +final class DataTypeMock { /** @@ -26,7 +26,7 @@ class DataTypeMock private ?array $types = null; /** - * @var array|null Stores bound arguments with their associated keys + * @var array>|null */ private ?array $bindArguments = null; @@ -121,6 +121,9 @@ public function withCustomBoundDefault(string $key, string $dataType, mixed $val { $inst = clone $this; $tempInst = $this->withCustomDefault($dataType, $value); + if($inst->bindArguments === null) { + $inst->bindArguments = []; + } $inst->bindArguments[$key][$dataType] = $tempInst->defaultArguments[$dataType]; return $inst; } @@ -141,23 +144,23 @@ public function getDataTypeListToString(): array * Initializes types' array if not already set * * @param string $dataType The data type to get the value for - * @return mixed The string representation of the value for the specified data type + * @return string The string representation of the value for the specified data type * @throws InvalidArgumentException If the specified data type is invalid */ - public function getDataTypeValue(string $dataType, ?string $bindKey = null): mixed + public function getDataTypeValue(string $dataType, ?string $bindKey = null): string { if(is_string($bindKey) && isset($this->bindArguments[$bindKey][$dataType])) { return self::exportValue($this->bindArguments[$bindKey][$dataType]); } if($this->types === null) { - $this->types = $this->getDataTypeListToString(); + $this->types = $this->getDataTypeListToString(); } if(!isset($this->types[$dataType])) { throw new InvalidArgumentException("Invalid data type: $dataType"); } - return $this->types[$dataType]; + return (string)$this->types[$dataType]; } diff --git a/src/TestUtils/ExecutionWrapper.php b/src/TestUtils/ExecutionWrapper.php index e1dab81..04c8291 100755 --- a/src/TestUtils/ExecutionWrapper.php +++ b/src/TestUtils/ExecutionWrapper.php @@ -19,6 +19,7 @@ abstract class ExecutionWrapper { protected Reflection $ref; protected object $instance; + /** @var array */ private array $methods = []; /** @@ -45,7 +46,11 @@ public function __construct(string $className, array $args = []) */ public function bind(Closure $call): Closure { - return $call->bindTo($this->instance); + $closure = $call->bindTo($this->instance); + if(!is_callable($closure)) { + throw new Exception("Closure is not callable."); + } + return $closure; } /** @@ -64,6 +69,9 @@ public function override(string $method, Closure $call): self ); } $call = $call->bindTo($this->instance); + if (!is_callable($call)) { + throw new Exception("Closure is not callable."); + } $this->methods[$method] = $call; return $this; } @@ -84,6 +92,9 @@ public function add(string $method, Closure $call): self ); } $call = $call->bindTo($this->instance); + if (!is_callable($call)) { + throw new Exception("Closure is not callable."); + } $this->methods[$method] = $call; return $this; } diff --git a/src/Unit.php b/src/Unit.php index 5dfaea3..535be37 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -12,10 +12,11 @@ use MaplePHP\Prompts\Command; use MaplePHP\Prompts\Themes\Blocks; use MaplePHP\Unitary\Handlers\HandlerInterface; +use MaplePHP\Unitary\Utils\Helpers; use MaplePHP\Unitary\Utils\Performance; use RuntimeException; -class Unit +final class Unit { private ?HandlerInterface $handler = null; private Command $command; @@ -178,11 +179,11 @@ public function performance(Closure $func, ?string $title = null): void $this->command->message($line); $this->command->message( $this->command->getAnsi()->style(["bold"], "Execution time: ") . - (round($start->getExecutionTime(), 3) . " seconds") + ((string)round($start->getExecutionTime(), 3) . " seconds") ); $this->command->message( $this->command->getAnsi()->style(["bold"], "Memory Usage: ") . - (round($start->getMemoryUsage(), 2) . " KB") + ((string)round($start->getMemoryUsage(), 2) . " KB") ); /* $this->command->message( @@ -211,7 +212,7 @@ public function execute(): bool // LOOP through each case ob_start(); - $countCases = count($this->cases); + //$countCases = count($this->cases); foreach ($this->cases as $index => $row) { if (!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); @@ -220,7 +221,7 @@ public function execute(): bool $errArg = self::getArgs("errors-only"); $row->dispatchTest($row); $tests = $row->runDeferredValidations(); - $checksum = (self::$headers['checksum'] ?? "") . $index; + $checksum = (string)(self::$headers['checksum'] ?? "") . $index; $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); if ($row->hasFailed()) { @@ -251,7 +252,7 @@ public function execute(): bool if($show && !$row->hasFailed()) { $this->command->message(""); $this->command->message( - $this->command->getAnsi()->style(["italic", $color], "Test file: " . self::$headers['file']) + $this->command->getAnsi()->style(["italic", $color], "Test file: " . (string)self::$headers['file']) ); } @@ -276,29 +277,25 @@ public function execute(): bool $this->command->message($this->command->getAnsi()->style(["grey"], " → {$trace['code']}")); } - /** @var array $unit */ foreach ($test->getUnits() as $unit) { - if (is_string($unit['validation']) && !$unit['valid']) { + + /** @var array{validation: string, valid: bool|null, args: array, compare: array} $unit */ + if ($unit['valid'] === false) { $lengthA = $test->getValidationLength(); - $addArgs = is_array($unit['args']) ? "(" . implode(", ", $unit['args']) . ")" : ""; + $addArgs = ($unit['args'] !== []) ? "(" . Helpers::stringifyArgs($unit['args']) . ")" : "()"; $validation = "{$unit['validation']}{$addArgs}"; $title = str_pad($validation, $lengthA); $compare = ""; - if ($unit['compare']) { + if ($unit['compare'] !== []) { $expectedValue = array_shift($unit['compare']); $compare = "Expected: $expectedValue | Actual: " . implode(":", $unit['compare']); } - $failedMsg = " " .$title . ((!$unit['valid']) ? " → failed" : ""); - $this->command->message( - $this->command->getAnsi()->style( - ((!$unit['valid']) ? $color : null), - $failedMsg - ) - ); + $failedMsg = " " .$title . " → failed"; + $this->command->message($this->command->getAnsi()->style($color, $failedMsg)); - if (!$unit['valid'] && $compare) { + if ($compare) { $lengthB = (strlen($compare) + strlen($failedMsg) - 8); $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); $this->command->message( @@ -340,7 +337,7 @@ public function execute(): bool $this->command->message($footer); $this->command->message(""); } - $this->output .= ob_get_clean(); + $this->output .= (string)ob_get_clean(); if ($this->output) { $this->buildNotice("Note:", $this->output, 80); @@ -476,7 +473,7 @@ public static function hasUnit(): bool */ public static function getUnit(): ?Unit { - if (self::hasUnit() === null) { + if (self::hasUnit() === false) { throw new Exception("Unit has not been set yet. It needs to be set first."); } return self::$current; @@ -496,7 +493,7 @@ public static function completed(): void self::$current->command->getAnsi()->style( ["italic", "grey"], "Total: " . self::$totalPassedTests . "/" . self::$totalTests . " $dot " . - "Peak memory usage: " . round(memory_get_peak_usage() / 1024, 2) . " KB" + "Peak memory usage: " . (string)round(memory_get_peak_usage() / 1024, 2) . " KB" ) ); self::$current->command->message(""); diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index c276264..d979f03 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -35,8 +35,8 @@ public function __construct(array $args = []) */ public function executeAll(string $path, string|bool $rootDir = false): void { - $rootDir = $rootDir !== false ? realpath($rootDir) : false; - $path = (!$path && $rootDir) ? $rootDir : $path; + $rootDir = is_string($rootDir) ? realpath($rootDir) : false; + $path = (!$path && $rootDir !== false) ? $rootDir : $path; if($rootDir !== false && !str_starts_with($path, "/") && !str_starts_with($path, $rootDir)) { $path = $rootDir . "/" . $path; } @@ -89,7 +89,6 @@ private function findFiles(string $path, string|bool $rootDir = false): array if(is_file($path)) { $path = dirname($path) . "/"; } - if(is_dir($path)) { $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); /** @var string $pattern */ @@ -104,12 +103,10 @@ private function findFiles(string $path, string|bool $rootDir = false): array } } } - - if($rootDir && count($files) <= 0 && str_starts_with($path, $rootDir) && isset($this->args['smart-search'])) { - $path = realpath($path . "/..") . "/"; + if($rootDir !== false && count($files) <= 0 && str_starts_with($path, $rootDir) && isset($this->args['smart-search'])) { + $path = (string)realpath($path . "/..") . "/"; return $this->findFiles($path, $rootDir); } - return $files; } @@ -146,8 +143,7 @@ public function findExcluded(array $exclArr, string $relativeDir, string $file): { $file = $this->getNaturalPath($file); foreach ($exclArr as $excl) { - /* @var string $excl */ - $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . $excl); + $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . (string)$excl); if (fnmatch($relativeExclPath, $file)) { return true; } diff --git a/src/Utils/Helpers.php b/src/Utils/Helpers.php index d43524d..d0c6ba1 100644 --- a/src/Utils/Helpers.php +++ b/src/Utils/Helpers.php @@ -2,17 +2,69 @@ namespace MaplePHP\Unitary\Utils; -class Helpers +final class Helpers { + /** + * Used to stringify arguments to show in test + * + * @param array $args + * @return string + */ + public static function stringifyArgs(array $args): string + { + $levels = 0; + $str = self::stringify($args, $levels); + if($levels > 1) { + return "[$str]"; + } + return $str; + } + + /** + * Stringify an array and objects + * + * @param mixed $arg + * @param int $levels + * @return string + */ + public static function stringify(mixed $arg, int &$levels = 0): string + { + if (is_array($arg)) { + $items = array_map(function($item) use(&$levels) { + $levels++; + return self::stringify($item, $levels); + }, $arg); + return implode(', ', $items); + } + + if (is_object($arg)) { + return get_class($arg); + } + + return (string)$arg; + } + + /** + * Create a file instead of eval for improved debug + * + * @param string $filename + * @param string $input + * @return void + */ public static function createFile(string $filename, string $input) { - $tempDir = getenv('UNITARY_TEMP_DIR') ?: sys_get_temp_dir(); + $temp = getenv('UNITARY_TEMP_DIR'); + $tempDir = $temp !== false ? $temp : sys_get_temp_dir(); if (!is_dir($tempDir)) { mkdir($tempDir, 0777, true); } $tempFile = rtrim($tempDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename; file_put_contents($tempFile, "mailer->getFromEmail(), FILTER_VALIDATE_EMAIL)) { throw new \Exception("Invalid email"); } - //echo $this->mailer->sendEmail($email, $name)."\n"; - //echo $this->mailer->sendEmail($email, $name); return true; } } @@ -114,9 +112,86 @@ public function registerUser(string $email): bool { +$unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { + + + // Quickly mock the Stream class + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('{"test":"test"}') + ->calledAtLeast(1); + + $method->method("fopen")->isPrivate(); + }); + // Mock with configuration + // + // Notice: this will handle TestCase as immutable, and because of this + // the new instance of TestCase must be return to the group callable below + // + // By passing the mocked Stream class to the Response constructor, we + // will actually also test that the argument has the right data type + $case = $case->withMock(Response::class, [$stream]); + + // We can override all "default" mocking values tide to TestCase Instance + // to use later on in out in the validations, you can also tie the mock + // value to a method + $case->getMocker() + ->mockDataType("string", "myCustomMockStringValue") + ->mockDataType("array", ["myCustomMockArrayItem"]) + ->mockDataType("int", 200, "getStatusCode"); + + // List all default mock values that will be automatically used in + // parameters and return values + //print_r(\MaplePHP\Unitary\TestUtils\DataTypeMock::inst()->getMockValues()); + + $response = $case->buildMock(function (MethodRegistry $method) use($stream) { + // Even tho Unitary mocker tries to automatically mock the return type of methods, + // it might fail if the return type is an expected Class instance, then you will + // need to manually set the return type to tell Unitary mocker what class to expect, + // which is in this example a class named "Stream". + // You can do this by either passing the expected class directly into the `return` method + // or even better by mocking the expected class and then passing the mocked class. + $method->method("getBody")->willReturn($stream); + }); + + $case->validate($response->getHeader("lorem"), function(Expect $inst) { + // Validate against the new default array item value + // If we weren't overriding the default the array would be ['item1', 'item2', 'item3'] + $inst->isInArray(["myCustomMockArrayItem"]); + }); + + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->isString(); + $inst->isJson(); + }); + + $case->validate($response->getStatusCode(), function(Expect $inst) { + // Will validate to the default int data type set above + // and bounded to "getStatusCode" method + $inst->isHttpSuccess(); + }); + + $case->validate($response->getProtocolVersion(), function(Expect $inst) { + // MockedValue is the default value that the mocked class will return + // if you do not specify otherwise, either by specify what the method should return + // or buy overrides the default mocking data type values. + $inst->isEqualTo("MockedValue"); + }); + + $case->validate($response->getBody(), function(Expect $inst) { + $inst->isInstanceOf(Stream::class); + }); + + // You need to return a new instance of TestCase for new mocking settings + return $case; +}); + + + $unit->group("Test mocker", function (TestCase $case) use($unit) { - $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") ->withArguments("john.doe@gmail.com", "John Doe") ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) @@ -139,6 +214,7 @@ public function registerUser(string $email): bool { $inst->isThrowable(InvalidArgumentException::class); }); + $mail->addFromEmail("jane.doe@gmail.com", "Jane Doe"); $case->error("Test all exception validations") @@ -154,6 +230,13 @@ public function registerUser(string $email): bool { $case->validate(fn() => throw new TypeError("Lorem ipsum"), function(Expect $inst, Traverse $obj) { $inst->isThrowable(TypeError::class); }); + + + $case->validate(fn() => throw new TypeError("Lorem ipsum"), function(Expect $inst, Traverse $obj) { + $inst->isThrowable(function(Expect $inst) { + $inst->isClass(TypeError::class); + }); + }); }); $config = TestConfig::make("Mocking response")->withName("unitary"); @@ -162,16 +245,20 @@ public function registerUser(string $email): bool { $stream = $case->mock(Stream::class, function (MethodRegistry $method) { $method->method("getContents") - ->willReturn('HelloWorld') + ->willReturn('HelloWorld', 'HelloWorld2') ->calledAtLeast(1); }); $response = new Response($stream); $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->hasResponse(); - $inst->isEqualTo('HelloWorld'); + $inst->isEqualTo('Hello1World'); $inst->notIsEqualTo('HelloWorldNot'); }); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->isEqualTo('Hello2World2'); + }); }); $unit->group($config->withSubject("Assert validations"), function ($case) { @@ -183,7 +270,8 @@ public function registerUser(string $email): bool { $unit->case($config->withSubject("Old validation syntax"), function ($case) { $case->add("HelloWorld", [ - "isString" => [], + "isInt" => [], + "isBool" => [], "User validation" => function($value) { return $value === "HelloWorld"; } @@ -202,7 +290,7 @@ public function registerUser(string $email): bool { }); $case->validate(fn() => $mail->send(), function(Expect $inst) { - $inst->hasThrowableMessage("Invalid email"); + $inst->hasThrowableMessage("Invalid email 2"); }); }); From 97b498320ce229db9c42a2d6e3880017b1a7f09c Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Fri, 30 May 2025 17:15:23 +0200 Subject: [PATCH 40/78] Code quality imporvements --- src/Expect.php | 1 + src/Handlers/FileHandler.php | 1 - src/Handlers/HtmlHandler.php | 1 - src/Mocker/MethodRegistry.php | 1 + src/Mocker/MockBuilder.php | 4 +- src/Mocker/MockController.php | 1 + src/Mocker/MockedMethod.php | 1 + src/TestCase.php | 1 - src/TestConfig.php | 1 + src/TestUnit.php | 1 - src/TestUtils/DataTypeMock.php | 1 + src/TestUtils/ExecutionWrapper.php | 12 +- src/Unit.php | 1 - src/Utils/FileIterator.php | 1 - src/Utils/Helpers.php | 1 + src/Utils/Performance.php | 1 - tests/TestLib/Mailer.php | 86 +++++++++ tests/TestLib/UserService.php | 20 ++ tests/unitary-unitary.php | 295 ++--------------------------- 19 files changed, 145 insertions(+), 286 deletions(-) create mode 100644 tests/TestLib/Mailer.php create mode 100644 tests/TestLib/UserService.php diff --git a/src/Expect.php b/src/Expect.php index a3199ff..628d153 100644 --- a/src/Expect.php +++ b/src/Expect.php @@ -1,4 +1,5 @@ instance, $method)) { - throw new \BadMethodCallException( + throw new BadMethodCallException( "Method '$method' does not exist in the class '" . get_class($this->instance) . "' and therefore cannot be overridden or called." ); @@ -82,11 +87,12 @@ public function override(string $method, Closure $call): self * @param string $method * @param Closure $call * @return $this + * @throws Exception */ public function add(string $method, Closure $call): self { if (method_exists($this->instance, $method)) { - throw new \BadMethodCallException( + throw new BadMethodCallException( "Method '$method' already exists in the class '" . get_class($this->instance) . "'. Use the 'override' method in TestWrapper instead." ); @@ -125,7 +131,7 @@ public function __call(string $name, array $arguments): mixed * @param Reflection $ref * @param array $args * @return mixed|object - * @throws \ReflectionException + * @throws ReflectionException */ final protected function createInstance(Reflection $ref, array $args): mixed { diff --git a/src/Unit.php b/src/Unit.php index 535be37..8441cdf 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -1,5 +1,4 @@ sendEmail($this->getFromEmail()); + + return $this->privateMethod(); + } + + public function sendEmail(string $email, string $name = "daniel"): string + { + if(!$this->isValidEmail($email)) { + throw new \Exception("Invalid email"); + } + return "Sent email"; + } + + public function isValidEmail(string $email): bool + { + return filter_var($email, FILTER_VALIDATE_EMAIL); + } + + public function setFromEmail(string $email): self + { + $this->from = $email; + return $this; + } + + public function getFromEmail(): string + { + return !empty($this->from) ? $this->from : "empty email"; + } + + private function privateMethod(): string + { + return "HEHEHE"; + } + + /** + * Add from email address + * + * @param string $email + * @return void + */ + public function addFromEmail(string $email, string $name = ""): void + { + $this->from = $email; + } + + /** + * Add a BCC (blind carbon copy) email address + * + * @param string $email The email address to be added as BCC + * @param string $name The name associated with the email address, default is "Daniel" + * @param mixed $testRef A reference variable, default is "Daniel" + * @return void + */ + public function addBCC(string $email, string $name = "Daniel", &$testRef = "Daniel"): void + { + $this->bcc = $email; + } + + public function test(...$params): void + { + $this->test2(); + } + + public function test2(): void + { + echo "Hello World\n"; + } + +} \ No newline at end of file diff --git a/tests/TestLib/UserService.php b/tests/TestLib/UserService.php new file mode 100644 index 0000000..4c3fb84 --- /dev/null +++ b/tests/TestLib/UserService.php @@ -0,0 +1,20 @@ +mailer->addFromEmail($email); + $this->mailer->addBCC("jane.doe@hotmail.com", "Jane Doe"); + $this->mailer->addBCC("lane.doe@hotmail.com", "Lane Doe"); + + if(!filter_var($this->mailer->getFromEmail(), FILTER_VALIDATE_EMAIL)) { + throw new \Exception("Invalid email: " . $this->mailer->getFromEmail()); + } + return true; + } +} \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 9cdf6f0..77961f1 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,194 +1,19 @@ - sendEmail($this->getFromEmail()); - - return $this->privateMethod(); - } - - public function sendEmail(string $email, string $name = "daniel"): string - { - if(!$this->isValidEmail($email)) { - throw new \Exception("Invalid email"); - } - return "Sent email"; - } - - public function isValidEmail(string $email): bool - { - return filter_var($email, FILTER_VALIDATE_EMAIL); - } - - public function setFromEmail(string $email): self - { - $this->from = $email; - return $this; - } - - public function getFromEmail(): string - { - return !empty($this->from) ? $this->from : "empty email"; - } - - private function privateMethod(): string - { - return "HEHEHE"; - } - - /** - * Add from email address - * - * @param string $email - * @return void - */ - public function addFromEmail(string $email, string $name = ""): void - { - $this->from = $email; - } - - /** - * Add a BCC (blind carbon copy) email address - * - * @param string $email The email address to be added as BCC - * @param string $name The name associated with the email address, default is "Daniel" - * @param mixed $testRef A reference variable, default is "Daniel" - * @return void - */ - public function addBCC(string $email, string $name = "Daniel", &$testRef = "Daniel"): void - { - $this->bcc = $email; - } - - public function test(...$params): void - { - $this->test2(); - } - - public function test2(): void - { - echo "Hello World\n"; - } - -} - -class UserService { - public function __construct(private Mailer $mailer) {} - - public function registerUser(string $email): bool { - // register user logic... - - $this->mailer->addFromEmail($email); - $this->mailer->addBCC("jane.doe@hotmail.com", "Jane Doe"); - $this->mailer->addBCC("lane.doe@hotmail.com", "Lane Doe"); - - if(!filter_var($this->mailer->getFromEmail(), FILTER_VALIDATE_EMAIL)) { - throw new \Exception("Invalid email"); - } - return true; - } -} $unit = new Unit(); - -$unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { - - - // Quickly mock the Stream class - $stream = $case->mock(Stream::class, function (MethodRegistry $method) { - $method->method("getContents") - ->willReturn('{"test":"test"}') - ->calledAtLeast(1); - - $method->method("fopen")->isPrivate(); - }); - // Mock with configuration - // - // Notice: this will handle TestCase as immutable, and because of this - // the new instance of TestCase must be return to the group callable below - // - // By passing the mocked Stream class to the Response constructor, we - // will actually also test that the argument has the right data type - $case = $case->withMock(Response::class, [$stream]); - - // We can override all "default" mocking values tide to TestCase Instance - // to use later on in out in the validations, you can also tie the mock - // value to a method - $case->getMocker() - ->mockDataType("string", "myCustomMockStringValue") - ->mockDataType("array", ["myCustomMockArrayItem"]) - ->mockDataType("int", 200, "getStatusCode"); - - // List all default mock values that will be automatically used in - // parameters and return values - //print_r(\MaplePHP\Unitary\TestUtils\DataTypeMock::inst()->getMockValues()); - - $response = $case->buildMock(function (MethodRegistry $method) use($stream) { - // Even tho Unitary mocker tries to automatically mock the return type of methods, - // it might fail if the return type is an expected Class instance, then you will - // need to manually set the return type to tell Unitary mocker what class to expect, - // which is in this example a class named "Stream". - // You can do this by either passing the expected class directly into the `return` method - // or even better by mocking the expected class and then passing the mocked class. - $method->method("getBody")->willReturn($stream); - }); - - $case->validate($response->getHeader("lorem"), function(Expect $inst) { - // Validate against the new default array item value - // If we weren't overriding the default the array would be ['item1', 'item2', 'item3'] - $inst->isInArray(["myCustomMockArrayItem"]); - }); - - - $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->isString(); - $inst->isJson(); - }); - - $case->validate($response->getStatusCode(), function(Expect $inst) { - // Will validate to the default int data type set above - // and bounded to "getStatusCode" method - $inst->isHttpSuccess(); - }); - - $case->validate($response->getProtocolVersion(), function(Expect $inst) { - // MockedValue is the default value that the mocked class will return - // if you do not specify otherwise, either by specify what the method should return - // or buy overrides the default mocking data type values. - $inst->isEqualTo("MockedValue"); - }); - - $case->validate($response->getBody(), function(Expect $inst) { - $inst->isInstanceOf(Stream::class); - }); - - // You need to return a new instance of TestCase for new mocking settings - return $case; -}); - - - $unit->group("Test mocker", function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { @@ -252,12 +77,12 @@ public function registerUser(string $email): bool { $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->hasResponse(); - $inst->isEqualTo('Hello1World'); + $inst->isEqualTo('HelloWorld'); $inst->notIsEqualTo('HelloWorldNot'); }); $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->isEqualTo('Hello2World2'); + $inst->isEqualTo('HelloWorld2'); }); }); @@ -270,8 +95,7 @@ public function registerUser(string $email): bool { $unit->case($config->withSubject("Old validation syntax"), function ($case) { $case->add("HelloWorld", [ - "isInt" => [], - "isBool" => [], + "isString" => [], "User validation" => function($value) { return $value === "HelloWorld"; } @@ -290,12 +114,12 @@ public function registerUser(string $email): bool { }); $case->validate(fn() => $mail->send(), function(Expect $inst) { - $inst->hasThrowableMessage("Invalid email 2"); + $inst->hasThrowableMessage("Invalid email"); }); }); -/* + $unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { @@ -317,49 +141,32 @@ public function registerUser(string $email): bool { $case = $case->withMock(Response::class, [$stream]); // We can override all "default" mocking values tide to TestCase Instance - // to use later on in out in the validations, you can also tie the mock - // value to a method $case->getMocker() ->mockDataType("string", "myCustomMockStringValue") ->mockDataType("array", ["myCustomMockArrayItem"]) ->mockDataType("int", 200, "getStatusCode"); - // List all default mock values that will be automatically used in - // parameters and return values - //print_r(\MaplePHP\Unitary\TestUtils\DataTypeMock::inst()->getMockValues()); - $response = $case->buildMock(function (MethodRegistry $method) use($stream) { - // Even tho Unitary mocker tries to automatically mock the return type of methods, - // it might fail if the return type is an expected Class instance, then you will - // need to manually set the return type to tell Unitary mocker what class to expect, - // which is in this example a class named "Stream". - // You can do this by either passing the expected class directly into the `return` method - // or even better by mocking the expected class and then passing the mocked class. $method->method("getBody")->willReturn($stream); }); - $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->isString(); $inst->isJson(); }); - $case->validate($response->getHeader("lorem"), function(Expect $inst) { - // Validate against the new default array item value - // If we weren't overriding the default the array would be ['item1', 'item2', 'item3'] - $inst->isInArray(["myCustomMockArrayItem"]); - }); - $case->validate($response->getStatusCode(), function(Expect $inst) { - // Will validate to the default int data type set above - // and bounded to "getStatusCode" method + // Overriding the default making it a 200 integer $inst->isHttpSuccess(); }); + $case->validate($response->getHeader("lorem"), function(Expect $inst) { + // Overriding the default the array would be ['item1', 'item2', 'item3'] + $inst->isInArray("myCustomMockArrayItem"); + }); + $case->validate($response->getProtocolVersion(), function(Expect $inst) { // MockedValue is the default value that the mocked class will return - // if you do not specify otherwise, either by specify what the method should return - // or buy overrides the default mocking data type values. $inst->isEqualTo("MockedValue"); }); @@ -372,79 +179,19 @@ public function registerUser(string $email): bool { }); -$unit->group("Mailer test", function (TestCase $inst) use($unit) { - $mock = $inst->mock(Mailer::class, function (MethodRegistry $method) use($inst) { - $method->method("addBCC") - ->paramIsType(0, "string") - ->paramHasDefault(1, "Daniel") - ->paramIsOptional(1) - ->paramIsReference(1) - ->called(1); - }); - $mock->addBCC("World"); - $mock->test(1); -}); - - -$unit->group("Testing User service", function (TestCase $inst) { - - $mailer = $inst->mock(Mailer::class, function (MethodRegistry $method) use($inst) { - $method->method("addFromEmail") - ->called(1); - - $method->method("addBCC") - ->isPublic() - ->hasDocComment() - ->hasParams() - ->paramHasType(0) - ->paramIsType(0, "string") - ->paramHasDefault(1, "Daniel") - ->paramIsOptional(1) - ->paramIsReference(1) - ->called(2); - - $method->method("getFromEmail") - ->willReturn("john.doe@gmail.com"); - - }, [true // <- Mailer class constructor argument, enable debug]); - - $service = new UserService($mailer); - - $case->validate($service->send(), function(Expect $inst) { - $inst->isTrue(); - }); - -}); $unit->group("Testing User service", function (TestCase $case) { $mailer = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") + ->keepOriginal() ->called(1); - - $method->method("addBCC") - ->isPublic() - ->hasDocComment() - ->hasParams() - ->paramHasType(0) - ->paramIsType(0, "string") - ->paramHasDefault(2, "Daniel") - ->paramIsOptional(2) - ->paramIsReference(2) - ->called(1); - $method->method("getFromEmail") - ->willReturn("john.doe@gmail.com"); - - }, [true]); // <-- true is passed as argument 1 to Mailer constructor + ->keepOriginal() + ->called(1); + }); $service = new UserService($mailer); $case->validate($service->registerUser("john.doe@gmail.com"), function(Expect $inst) { $inst->isTrue(); }); - }); - */ - - - - From 50e89c78b4e535fe1f141498ddf61e59acfd4628 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Fri, 30 May 2025 17:21:06 +0200 Subject: [PATCH 41/78] Add comment block to files --- src/Expect.php | 8 ++++++++ src/Handlers/FileHandler.php | 8 ++++++++ src/Handlers/HandlerInterface.php | 9 ++++++++- src/Handlers/HtmlHandler.php | 8 ++++++++ src/Mocker/MethodRegistry.php | 8 ++++++++ src/Mocker/MockBuilder.php | 9 +++++---- src/Mocker/MockController.php | 8 ++++++++ src/Mocker/MockedMethod.php | 8 ++++++++ src/TestCase.php | 8 ++++++++ src/TestConfig.php | 8 ++++++++ src/TestUnit.php | 8 ++++++++ src/TestUtils/DataTypeMock.php | 8 ++++++++ src/TestUtils/ExecutionWrapper.php | 13 ++++++------- src/Unit.php | 8 ++++++++ src/Utils/FileIterator.php | 8 ++++++++ src/Utils/Helpers.php | 8 ++++++++ src/Utils/Performance.php | 8 ++++++++ 17 files changed, 131 insertions(+), 12 deletions(-) diff --git a/src/Expect.php b/src/Expect.php index 628d153..216d618 100644 --- a/src/Expect.php +++ b/src/Expect.php @@ -1,4 +1,12 @@ Date: Sun, 1 Jun 2025 13:36:30 +0200 Subject: [PATCH 42/78] Creating TestItem class --- src/Mocker/MockBuilder.php | 12 +++- src/TestCase.php | 8 +-- src/TestItem.php | 104 ++++++++++++++++++++++++++++++++++ src/TestUnit.php | 30 ++++++++-- src/Unit.php | 5 +- tests/TestLib/UserService.php | 10 ++++ tests/unitary-unitary.php | 36 +++++++++++- 7 files changed, 190 insertions(+), 15 deletions(-) create mode 100755 src/TestItem.php diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index dd7ffc3..10a7beb 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -251,6 +251,17 @@ protected function generateMockMethodOverrides(string $mockClassName): string if (!($method instanceof ReflectionMethod)) { throw new Exception("Method is not a ReflectionMethod"); } + + /* + if ($method->isFinal() || $method->isPrivate()) { + trigger_error( + "Cannot mock " . ($method->isFinal() ? "final" : "private") . + " method '" . $method->getName() . "' in '{$this->className}' – the real method will be executed.", + E_USER_WARNING + ); + } + */ + if ($method->isFinal()) { continue; } @@ -298,7 +309,6 @@ protected function generateMockMethodOverrides(string $mockClassName): string } $exception = ($methodItem && $methodItem->getThrowable()) ? $this->handleThrownExceptions($methodItem->getThrowable()) : ""; - $safeJson = base64_encode($info); $overrides .= " $modifiers function $methodName($paramList){$returnType} diff --git a/src/TestCase.php b/src/TestCase.php index 0344d4e..ff043ef 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -310,12 +310,8 @@ public function buildMock(?Closure $validate = null): mixed if (is_callable($validate)) { $this->prepareValidation($this->mocker, $validate); } - try { - /** @psalm-suppress MixedReturnStatement */ - return $this->mocker->execute(); - } catch (Throwable $e) { - throw new BlunderErrorException($e->getMessage(), (int)$e->getCode()); - } + /** @psalm-suppress MixedReturnStatement */ + return $this->mocker->execute(); } /** diff --git a/src/TestItem.php b/src/TestItem.php new file mode 100755 index 0000000..5827360 --- /dev/null +++ b/src/TestItem.php @@ -0,0 +1,104 @@ +valid = $isValid; + return $inst; + } + + public function setValidation(string $validation): self + { + $inst = clone $this; + $inst->validation = $validation; + return $inst; + } + + public function setValidationArgs(array $args): self + { + $inst = clone $this; + $inst->args = $args; + return $inst; + } + + public function setCompare(mixed $value, mixed ...$compareValue): self + { + $inst = clone $this; + $inst->value = $value; + $inst->compareValues = $compareValue; + return $inst; + } + + public function isValid(): bool + { + return $this->valid; + } + + public function getValidation(): string + { + return $this->validation; + } + + public function getValidationArgs(): array + { + return $this->args; + } + + public function getValue(): mixed + { + return $this->value; + } + + public function hasComparison(): bool + { + return ($this->compareValues !== []); + } + + public function getCompareValues(): mixed + { + return $this->compareValues; + } + + public function getComparison(): string + { + return "Expected: " . $this->getValue() . " | Actual: " . implode(":", $this->getCompareValues()); + } + + public function getStringifyArgs(): string + { + return Helpers::stringifyArgs($this->args); + } + + public function getValidationLength(): int + { + return strlen($this->getValidation()); + } +} diff --git a/src/TestUnit.php b/src/TestUnit.php index c216238..f59a44f 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -70,8 +70,8 @@ public function setTestValue(mixed $value): void * @throws ErrorException */ public function setUnit( - bool|null $valid, - null|string|\Closure $validation = null, + bool $valid, + string $validation = "", array|bool $args = [], array $compare = [] ): self { @@ -89,14 +89,36 @@ public function setUnit( } } + + + $item = new TestItem(); + + $item = $item->setIsValid($valid) + ->setValidation($validation) + //->setValidationArgs($args) + ; if ($compare && count($compare) > 0) { + $compare = array_map(fn ($value) => $this->getReadValue($value, true), $compare); + + // FIX + $newCompare = $compare; + if (!is_array($newCompare[1])) { + $newCompare[1] = [$newCompare[1]]; + } + $item = $item->setCompare($newCompare[0], ...$newCompare[1]); + } + + + + $this->unit[] = [ 'valid' => $valid, 'validation' => $validation, 'args' => $args, - 'compare' => $compare + 'compare' => $compare, + 'item' => $item ]; return $this; } @@ -201,7 +223,7 @@ public function getValue(): mixed } /** - * Used to get a readable value + * Used to get a readable value (Move to utility) * * @param mixed|null $value * @param bool $minify diff --git a/src/Unit.php b/src/Unit.php index 1b39530..de2b1d6 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -287,17 +287,20 @@ public function execute(): bool foreach ($test->getUnits() as $unit) { /** @var array{validation: string, valid: bool|null, args: array, compare: array} $unit */ - if ($unit['valid'] === false) { + if (!$unit['item']->isValid()) { $lengthA = $test->getValidationLength(); $addArgs = ($unit['args'] !== []) ? "(" . Helpers::stringifyArgs($unit['args']) . ")" : "()"; $validation = "{$unit['validation']}{$addArgs}"; $title = str_pad($validation, $lengthA); + $compare = $unit['item']->hasComparison() ? $unit['item']->getComparison() : ""; + /* $compare = ""; if ($unit['compare'] !== []) { $expectedValue = array_shift($unit['compare']); $compare = "Expected: $expectedValue | Actual: " . implode(":", $unit['compare']); } + */ $failedMsg = " " .$title . " → failed"; $this->command->message($this->command->getAnsi()->style($color, $failedMsg)); diff --git a/tests/TestLib/UserService.php b/tests/TestLib/UserService.php index 4c3fb84..ef20cf2 100644 --- a/tests/TestLib/UserService.php +++ b/tests/TestLib/UserService.php @@ -17,4 +17,14 @@ public function registerUser(string $email): bool { } return true; } + + private function getUserRole(): string + { + return "guest"; + } + + final public function getUserType(): string + { + return $this->getUserRole(); + } } \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 77961f1..49c80e5 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -14,13 +14,27 @@ $unit = new Unit(); + +$unit->group("Mock final method in UserService", function(TestCase $case) { + + $user = $case->mock(UserService::class, function(MethodRegistry $method) { + $method->method("getUserRole")->willReturn("admin"); + $method->method("getUserType")->willReturn("admin"); + }); + + $case->validate($user->getUserType(), function(Expect $expect) { + $expect->isEqualTo("admin"); + }); +}); + + $unit->group("Test mocker", function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") - ->withArguments("john.doe@gmail.com", "John Doe") - ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) - ->willThrowOnce(new InvalidArgumentException("Lorem ipsum")) + ->withArguments("john.doe@gwmail.com", "John Doe") + ->withArgumentsForCalls(["john.doe@wgmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) + ->willThrowOnce(new InvalidArgumentException("Lowrem ipsum")) ->called(2); $method->method("addBCC") @@ -118,6 +132,22 @@ }); }); +$unit->group("Should faile", function (TestCase $case) use($unit) { + + $case->error("Is integer 1")->validate(1, function(Expect $inst) { + $inst->isEmail(); + $inst->isString(); + }); + + $case->error("Will return false")->validate(true, function(Expect $inst) { + return false; + }); + + $case->error("Will return false")->validate(true, function(Expect $inst) { + assert(1 == 2); + }); +}); + $unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { From f058d7dd445654c59c673de15a72a9d1ef7d3faf Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 1 Jun 2025 15:46:37 +0200 Subject: [PATCH 43/78] Structure improvements --- src/TestCase.php | 48 ++++++++---- src/TestItem.php | 152 ++++++++++++++++++++++++++++++++++-- src/TestUnit.php | 140 +++------------------------------ src/Unit.php | 22 ++---- src/Utils/Helpers.php | 103 ++++++++++++++++++++++-- tests/unitary-unitary.php | 18 ----- tests/unitary-will-fail.php | 72 +++++++++++++++++ 7 files changed, 366 insertions(+), 189 deletions(-) create mode 100755 tests/unitary-will-fail.php diff --git a/src/TestCase.php b/src/TestCase.php index ff043ef..582f502 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -194,12 +194,22 @@ protected function expectAndValidate( $listArr = $this->buildClosureTest($validation, $validPool, $description); foreach ($listArr as $list) { + if(is_bool($list)) { - $test->setUnit($list, "Validation"); + $item = new TestItem(); + $item = $item->setIsValid($list)->setValidation("Validation"); + $test->setTestItem($item); } else { foreach ($list as $method => $valid) { - /** @var array|bool $valid */ - $test->setUnit(false, (string)$method, $valid); + $item = new TestItem(); + /** @var array|bool $valid */ + $item = $item->setIsValid(false)->setValidation((string)$method); + if(is_array($valid)) { + $item = $item->setValidationArgs($valid); + } else { + $item = $item->setHasArgs(false); + } + $test->setTestItem($item); } } } @@ -213,7 +223,11 @@ protected function expectAndValidate( if (!($args instanceof Closure) && !is_array($args)) { $args = [$args]; } - $test->setUnit($this->buildArrayTest($method, $args), $method, (is_array($args) ? $args : [])); + $item = new TestItem(); + $item = $item->setIsValid($this->buildArrayTest($method, $args)) + ->setValidation($method) + ->setValidationArgs((is_array($args) ? $args : [])); + $test->setTestItem($item); } } if (!$test->isValid()) { @@ -438,12 +452,13 @@ private function validateRow(object $row, MethodRegistry $pool): array /** @psalm-suppress MixedArgument */ $valid = Validator::value($currentValue)->equal($value); } - $errors[] = [ - "property" => $property, - "currentValue" => $currentValue, - "expectedValue" => $value, - "valid" => $valid - ]; + + $item = new TestItem(); + $item = $item->setIsValid($valid) + ->setValidation($property) + ->setValue($value) + ->setCompareToValue($currentValue); + $errors[] = $item; } } @@ -548,18 +563,16 @@ public function runDeferredValidations(): array } foreach ($arr as $data) { // We do not want to validate the return here automatically - /** @var array{property: string} $data */ - if(!in_array($data['property'], self::EXCLUDE_VALIDATE)) { - /** @var array{valid: bool|null, expectedValue: mixed, currentValue: mixed} $data */ - $test->setUnit($data['valid'], $data['property'], [], [ - $data['expectedValue'], $data['currentValue'] - ]); - if (!isset($hasValidated[$method]) && $data['valid'] === null || $data['valid'] === false) { + /** @var TestItem $data */ + if(!in_array($data->getValidation(), self::EXCLUDE_VALIDATE)) { + $test->setTestItem($data); + if (!isset($hasValidated[$method]) && !$data->isValid()) { $hasValidated[$method] = true; $this->count++; } } } + $this->test[] = $test; } } @@ -652,6 +665,7 @@ public function getTest(): array * This will build the closure test * * @param Closure $validation + * @param Expect $validPool * @param string|null $message * @return array */ diff --git a/src/TestItem.php b/src/TestItem.php index 5827360..efe247b 100755 --- a/src/TestItem.php +++ b/src/TestItem.php @@ -11,7 +11,7 @@ namespace MaplePHP\Unitary; -use Closure; +use ErrorException; use MaplePHP\Unitary\Utils\Helpers; final class TestItem @@ -21,6 +21,7 @@ final class TestItem protected string $validation = ""; protected array $args = []; protected mixed $value = null; + protected bool $hasArgs = true; protected array $compareValues = []; @@ -28,6 +29,11 @@ public function __construct() { } + /** + * Set if the test item is valid + * @param bool $isValid + * @return $this + */ public function setIsValid(bool $isValid): self { $inst = clone $this; @@ -35,6 +41,12 @@ public function setIsValid(bool $isValid): self return $inst; } + /** + * Sets the validation type that has been used. + * + * @param string $validation + * @return $this + */ public function setValidation(string $validation): self { $inst = clone $this; @@ -42,6 +54,12 @@ public function setValidation(string $validation): self return $inst; } + /** + * Sets the validation arguments. + * + * @param array $args + * @return $this + */ public function setValidationArgs(array $args): self { $inst = clone $this; @@ -49,56 +67,180 @@ public function setValidationArgs(array $args): self return $inst; } - public function setCompare(mixed $value, mixed ...$compareValue): self + /** + * Sets if the validation has arguments. If not, it will not be enclosed in parentheses. + * + * @param bool $enable + * @return $this + */ + public function setHasArgs(bool $enable): self + { + $inst = clone $this; + $inst->hasArgs = $enable; + return $inst; + } + + /** + * Sets the value that has been used in validation. + * + * @param mixed $value + * @return $this + */ + public function setValue(mixed $value): self { $inst = clone $this; $inst->value = $value; + return $inst; + } + + /** + * Sets a compare value for the current value. + * + * @param mixed ...$compareValue + * @return $this + */ + public function setCompareToValue(mixed ...$compareValue): self + { + $inst = clone $this; $inst->compareValues = $compareValue; return $inst; } + /** + * Converts the value to its string representation using a helper function. + * + * @return string The stringify representation of the value. + * @throws ErrorException + */ + public function getStringifyValue(): string + { + return Helpers::stringifyDataTypes($this->value, true); + } + + /** + * Converts the comparison values to their string representations using a helper function. + * + * @return array The array of stringify comparison values. + * @throws ErrorException + */ + public function getCompareToValue(): array + { + $compare = array_map(fn ($value) => Helpers::stringifyDataTypes($value, true), $this->compareValues); + return $compare; + } + + /** + * Checks if the current state is valid. + * + * @return bool True if the state is valid, false otherwise. + */ public function isValid(): bool { return $this->valid; } + /** + * Retrieves the validation string associated with the object. + * + * @return string The validation string. + */ public function getValidation(): string { return $this->validation; } + /** + * Retrieves the validation arguments. + * + * @return array The validation arguments. + */ public function getValidationArgs(): array { return $this->args; } + /** + * Retrieves the stored raw value. + * + * @return mixed + */ public function getValue(): mixed { return $this->value; } + /** + * Determines if there are any comparison values present. + * + * @return bool + */ public function hasComparison(): bool { return ($this->compareValues !== []); } - public function getCompareValues(): mixed + /** + * Returns the RAW comparison collection. + * + * @return array + */ + public function getCompareValues(): array { return $this->compareValues; } + /** + * Return a string representation of the comparison between expected and actual values. + * + * @return string + * @throws ErrorException + */ public function getComparison(): string { - return "Expected: " . $this->getValue() . " | Actual: " . implode(":", $this->getCompareValues()); + return "Expected: " . $this->getStringifyValue() . " | Actual: " . implode(":", $this->getCompareToValue()); } + /** + * Retrieves the string representation of the arguments, enclosed in parentheses if present. + * + * @return string + */ public function getStringifyArgs(): string { - return Helpers::stringifyArgs($this->args); + if($this->hasArgs) { + $args = array_map(fn ($value) => Helpers::stringifyArgs($value), $this->args); + return "(" . implode(", ", $args) . ")"; + } + return ""; } + /** + * Retrieves the validation title by combining validation data and arguments. + * + * @return string + */ + public function getValidationTitle(): string + { + return $this->getValidation() . $this->getStringifyArgs(); + } + + /** + * Retrieves the length of the validation string. + * + * @return int + */ public function getValidationLength(): int { return strlen($this->getValidation()); } + + /** + * Retrieves the length of the validation title. + * + * @return int + */ + public function getValidationLengthWithArgs(): int + { + return strlen($this->getValidationTitle()); + } } diff --git a/src/TestUnit.php b/src/TestUnit.php index f59a44f..21a284c 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -12,7 +12,6 @@ namespace MaplePHP\Unitary; use ErrorException; -use MaplePHP\DTO\Format\Str; use MaplePHP\Unitary\Utils\Helpers; final class TestUnit @@ -59,67 +58,26 @@ public function setTestValue(mixed $value): void $this->hasValue = true; } + /** - * Set the test unit + * Create a test item * - * @param bool|null $valid can be null if validation should execute later - * @param string|null|\Closure $validation - * @param array|bool $args - * @param array $compare + * @param TestItem $item * @return $this - * @throws ErrorException */ - public function setUnit( - bool $valid, - string $validation = "", - array|bool $args = [], - array $compare = [] - ): self { - - if (!$valid) { + public function setTestItem(TestItem $item): self + { + if (!$item->isValid()) { $this->valid = false; $this->count++; } - if (is_string($validation)) { - $addArgs = is_array($args) ? "(" . Helpers::stringifyArgs($args) . ")" : ""; - $valLength = strlen($validation . $addArgs); - if ($validation && $this->valLength < $valLength) { - $this->valLength = $valLength; - } + $valLength = $item->getValidationLengthWithArgs(); + if ($this->valLength < $valLength) { + $this->valLength = $valLength; } - - - $item = new TestItem(); - - $item = $item->setIsValid($valid) - ->setValidation($validation) - //->setValidationArgs($args) - ; - if ($compare && count($compare) > 0) { - - $compare = array_map(fn ($value) => $this->getReadValue($value, true), $compare); - - // FIX - $newCompare = $compare; - if (!is_array($newCompare[1])) { - $newCompare[1] = [$newCompare[1]]; - } - $item = $item->setCompare($newCompare[0], ...$newCompare[1]); - - } - - - - - $this->unit[] = [ - 'valid' => $valid, - 'validation' => $validation, - 'args' => $args, - 'compare' => $compare, - 'item' => $item - ]; + $this->unit[] = $item; return $this; } @@ -142,23 +100,7 @@ public function getValidationLength(): int */ public function setCodeLine(array $trace): self { - $this->codeLine = []; - $file = (string)($trace['file'] ?? ''); - $line = (int)($trace['line'] ?? 0); - $lines = file($file); - $code = ""; - if($lines !== false) { - $code = trim($lines[$line - 1] ?? ''); - if (str_starts_with($code, '->')) { - $code = substr($code, 2); - } - $code = $this->excerpt($code); - } - - $this->codeLine['line'] = $line; - $this->codeLine['file'] = $file; - $this->codeLine['code'] = $code; - + $this->codeLine = Helpers::getTrace($trace); return $this; } @@ -221,64 +163,4 @@ public function getValue(): mixed { return $this->value; } - - /** - * Used to get a readable value (Move to utility) - * - * @param mixed|null $value - * @param bool $minify - * @return string - * @throws ErrorException - */ - public function getReadValue(mixed $value = null, bool $minify = false): string - { - $value = $value === null ? $this->value : $value; - if (is_bool($value)) { - return '"' . ($value ? "true" : "false") . '"' . ($minify ? "" : " (type: bool)"); - } - if (is_int($value)) { - return '"' . $this->excerpt((string)$value) . '"' . ($minify ? "" : " (type: int)"); - } - if (is_float($value)) { - return '"' . $this->excerpt((string)$value) . '"' . ($minify ? "" : " (type: float)"); - } - if (is_string($value)) { - return '"' . $this->excerpt($value) . '"' . ($minify ? "" : " (type: string)"); - } - if (is_array($value)) { - $json = json_encode($value); - if($json === false) { - return "(unknown type)"; - } - return '"' . $this->excerpt($json) . '"' . ($minify ? "" : " (type: array)"); - } - if (is_callable($value)) { - return '"' . $this->excerpt(get_class((object)$value)) . '"' . ($minify ? "" : " (type: callable)"); - } - if (is_object($value)) { - return '"' . $this->excerpt(get_class($value)) . '"' . ($minify ? "" : " (type: object)"); - } - if ($value === null) { - return '"null"'. ($minify ? '' : ' (type: null)'); - } - if (is_resource($value)) { - return '"' . $this->excerpt(get_resource_type($value)) . '"' . ($minify ? "" : " (type: resource)"); - } - - return "(unknown type)"; - } - - /** - * Used to get exception to the readable value - * - * @param string $value - * @param int $length - * @return string - * @throws ErrorException - */ - final protected function excerpt(string $value, int $length = 80): string - { - $format = new Str($value); - return (string)$format->excerpt($length)->get(); - } } diff --git a/src/Unit.php b/src/Unit.php index de2b1d6..e23c3a3 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -22,6 +22,7 @@ use MaplePHP\Unitary\Utils\Helpers; use MaplePHP\Unitary\Utils\Performance; use RuntimeException; +use Throwable; final class Unit { @@ -207,7 +208,7 @@ public function performance(Closure $func, ?string $title = null): void * @return bool * @throws ErrorException * @throws BlunderErrorException - * @throws \Throwable + * @throws Throwable */ public function execute(): bool { @@ -286,21 +287,12 @@ public function execute(): bool foreach ($test->getUnits() as $unit) { - /** @var array{validation: string, valid: bool|null, args: array, compare: array} $unit */ - if (!$unit['item']->isValid()) { + /** @var TestItem $unit */ + if (!$unit->isValid()) { $lengthA = $test->getValidationLength(); - $addArgs = ($unit['args'] !== []) ? "(" . Helpers::stringifyArgs($unit['args']) . ")" : "()"; - $validation = "{$unit['validation']}{$addArgs}"; + $validation = $unit->getValidationTitle(); $title = str_pad($validation, $lengthA); - - $compare = $unit['item']->hasComparison() ? $unit['item']->getComparison() : ""; - /* - $compare = ""; - if ($unit['compare'] !== []) { - $expectedValue = array_shift($unit['compare']); - $compare = "Expected: $expectedValue | Actual: " . implode(":", $unit['compare']); - } - */ + $compare = $unit->hasComparison() ? $unit->getComparison() : ""; $failedMsg = " " .$title . " → failed"; $this->command->message($this->command->getAnsi()->style($color, $failedMsg)); @@ -318,7 +310,7 @@ public function execute(): bool $this->command->message(""); $this->command->message( $this->command->getAnsi()->bold("Input value: ") . - $test->getReadValue() + Helpers::stringifyDataTypes($test->getValue()) ); } } diff --git a/src/Utils/Helpers.php b/src/Utils/Helpers.php index 081ce6b..fba5c42 100644 --- a/src/Utils/Helpers.php +++ b/src/Utils/Helpers.php @@ -11,16 +11,20 @@ namespace MaplePHP\Unitary\Utils; +use ErrorException; +use Exception; +use MaplePHP\DTO\Format\Str; + final class Helpers { /** - * Used to stringify arguments to show in test + * Used to stringify arguments to show in a test * - * @param array $args + * @param mixed $args * @return string */ - public static function stringifyArgs(array $args): string + public static function stringifyArgs(mixed $args): string { $levels = 0; $str = self::stringify($args, $levels); @@ -60,8 +64,9 @@ public static function stringify(mixed $arg, int &$levels = 0): string * @param string $filename * @param string $input * @return void + * @throws Exception */ - public static function createFile(string $filename, string $input) + public static function createFile(string $filename, string $input): void { $temp = getenv('UNITARY_TEMP_DIR'); $tempDir = $temp !== false ? $temp : sys_get_temp_dir(); @@ -72,7 +77,7 @@ public static function createFile(string $filename, string $input) file_put_contents($tempFile, "')) { + $code = substr($code, 2); + } + $code = self::excerpt($code); + } + + $codeLine['line'] = $line; + $codeLine['file'] = $file; + $codeLine['code'] = $code; + + return $codeLine; + } + + + /** + * Generates an excerpt from the given string with a specified maximum length. + * + * @param string $value The input string to be excerpted. + * @param int $length The maximum length of the excerpt. Defaults to 80. + * @return string The resulting excerpted string. + * @throws ErrorException + */ + final public static function excerpt(string $value, int $length = 80): string + { + $format = new Str($value); + return (string)$format->excerpt($length)->get(); + } + + /** + * Used to get a readable value (Move to utility) + * + * @param mixed|null $value + * @param bool $minify + * @return string + * @throws ErrorException + */ + public static function stringifyDataTypes(mixed $value = null, bool $minify = false): string + { + if (is_bool($value)) { + return '"' . ($value ? "true" : "false") . '"' . ($minify ? "" : " (type: bool)"); + } + if (is_int($value)) { + return '"' . self::excerpt((string)$value) . '"' . ($minify ? "" : " (type: int)"); + } + if (is_float($value)) { + return '"' . self::excerpt((string)$value) . '"' . ($minify ? "" : " (type: float)"); + } + if (is_string($value)) { + return '"' . self::excerpt($value) . '"' . ($minify ? "" : " (type: string)"); + } + if (is_array($value)) { + $json = json_encode($value); + if($json === false) { + return "(unknown type)"; + } + return '"' . self::excerpt($json) . '"' . ($minify ? "" : " (type: array)"); + } + if (is_callable($value)) { + return '"' . self::excerpt(get_class((object)$value)) . '"' . ($minify ? "" : " (type: callable)"); + } + if (is_object($value)) { + return '"' . self::excerpt(get_class($value)) . '"' . ($minify ? "" : " (type: object)"); + } + if ($value === null) { + return '"null"'. ($minify ? '' : ' (type: null)'); + } + if (is_resource($value)) { + return '"' . self::excerpt(get_resource_type($value)) . '"' . ($minify ? "" : " (type: resource)"); + } + + return "(unknown type)"; + } } \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 49c80e5..b46e70f 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -132,24 +132,6 @@ }); }); -$unit->group("Should faile", function (TestCase $case) use($unit) { - - $case->error("Is integer 1")->validate(1, function(Expect $inst) { - $inst->isEmail(); - $inst->isString(); - }); - - $case->error("Will return false")->validate(true, function(Expect $inst) { - return false; - }); - - $case->error("Will return false")->validate(true, function(Expect $inst) { - assert(1 == 2); - }); -}); - - - $unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { diff --git a/tests/unitary-will-fail.php b/tests/unitary-will-fail.php new file mode 100755 index 0000000..749c997 --- /dev/null +++ b/tests/unitary-will-fail.php @@ -0,0 +1,72 @@ +withName("fail")->withSkip(true); +$unit->group($config, function (TestCase $case) use($unit) { + + $case->error("Default validations")->validate(1, function(Expect $inst) { + $inst->isEmail(); + $inst->length(100, 1); + $inst->isString(); + }); + + $case->error("Return validation")->validate(true, function(Expect $inst) { + return false; + }); + + $case->error("Assert validation")->validate(true, function(Expect $inst) { + assert(1 == 2); + }); + + $case->error("Assert with message validation")->validate(true, function(Expect $inst) { + assert(1 == 2, "Is not equal to 2"); + }); + + $case->error("Assert with all validation")->validate(true, function(Expect $inst) { + assert($inst->isEmail()->isString()->isValid(), "Is not email"); + }); + + + + $case->add("HelloWorld", [ + "isInt" => [], + "User validation" => function($value) { + return $value === 2; + } + ], "Old validation syntax"); + + + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("send")->keepOriginal()->called(0); + $method->method("isValidEmail")->keepOriginal(); + $method->method("sendEmail")->keepOriginal()->called(0); + + $method->method("addBCC") + ->isProtected() + ->hasDocComment() + ->hasParams() + ->paramHasType(0) + ->paramIsType(0, "int") + ->paramHasDefault(1, 1) + ->paramIsOptional(0) + ->paramIsReference(1) + ->called(0); + }); + + $case->error("Mocking validation")->validate(fn() => $mail->send(), function(Expect $inst) { + $inst->hasThrowableMessage("dwdwqdwqwdq email"); + }); + assert(1 == 2, "Assert in group level"); +}); \ No newline at end of file From b44c84d9427eb1824cc1c8ca9ae32016da90715c Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 1 Jun 2025 15:49:56 +0200 Subject: [PATCH 44/78] Add test to test --- tests/unitary-unitary.php | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index b46e70f..8402729 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -13,27 +13,23 @@ $unit = new Unit(); - - -$unit->group("Mock final method in UserService", function(TestCase $case) { - +$unit->group("Can not mock final or private", function(TestCase $case) { $user = $case->mock(UserService::class, function(MethodRegistry $method) { $method->method("getUserRole")->willReturn("admin"); $method->method("getUserType")->willReturn("admin"); }); $case->validate($user->getUserType(), function(Expect $expect) { - $expect->isEqualTo("admin"); + $expect->isEqualTo("guest"); }); }); - $unit->group("Test mocker", function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") - ->withArguments("john.doe@gwmail.com", "John Doe") - ->withArgumentsForCalls(["john.doe@wgmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) + ->withArguments("john.doe@gmail.com", "John Doe") + ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) ->willThrowOnce(new InvalidArgumentException("Lowrem ipsum")) ->called(2); @@ -79,7 +75,6 @@ }); $config = TestConfig::make("Mocking response")->withName("unitary"); - $unit->group($config, function (TestCase $case) use($unit) { $stream = $case->mock(Stream::class, function (MethodRegistry $method) { From cb9e455395c32e3916496aa836e2fb651db563fa Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 1 Jun 2025 16:05:44 +0200 Subject: [PATCH 45/78] Fix skip count Minor code quality improvements --- src/TestConfig.php | 1 - src/TestItem.php | 2 +- src/Unit.php | 11 ++++++----- src/Utils/FileIterator.php | 2 +- tests/unitary-will-fail.php | 12 +----------- 5 files changed, 9 insertions(+), 19 deletions(-) diff --git a/src/TestConfig.php b/src/TestConfig.php index 400fa66..0919d40 100644 --- a/src/TestConfig.php +++ b/src/TestConfig.php @@ -78,5 +78,4 @@ public function withSkip(bool $bool = true): self $inst->skip = $bool; return $inst; } - } \ No newline at end of file diff --git a/src/TestItem.php b/src/TestItem.php index efe247b..e65412f 100755 --- a/src/TestItem.php +++ b/src/TestItem.php @@ -1,6 +1,6 @@ getCount(); + // Important to add test from skip as successfully count to make sure that + // the total passed tests are correct, and it will not exit with code 1 + self::$totalPassedTests += ($row->getConfig()->skip) ? $row->getTotal() : $row->getCount(); self::$totalTests += $row->getTotal(); if ($row->getConfig()->select) { $checksum .= " (" . $row->getConfig()->select . ")"; @@ -366,7 +368,6 @@ public function resetExecute(): bool return false; } - /** * Validate method that must be called within a group method * @@ -508,7 +509,7 @@ public static function completed(): void */ public static function isSuccessful(): bool { - return (self::$totalPassedTests !== self::$totalTests); + return (self::$totalPassedTests === self::$totalTests); } /** diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index 4ec1781..5252546 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -72,7 +72,7 @@ public function executeAll(string $path, string|bool $rootDir = false): void } } Unit::completed(); - exit((int)Unit::isSuccessful()); + exit((int)!Unit::isSuccessful()); } } diff --git a/tests/unitary-will-fail.php b/tests/unitary-will-fail.php index 749c997..4bcc2cc 100755 --- a/tests/unitary-will-fail.php +++ b/tests/unitary-will-fail.php @@ -1,19 +1,12 @@ withName("fail")->withSkip(true); +$config = TestConfig::make("All A should fail")->withName("fail")->withSkip(); $unit->group($config, function (TestCase $case) use($unit) { $case->error("Default validations")->validate(1, function(Expect $inst) { @@ -38,8 +31,6 @@ assert($inst->isEmail()->isString()->isValid(), "Is not email"); }); - - $case->add("HelloWorld", [ "isInt" => [], "User validation" => function($value) { @@ -47,7 +38,6 @@ } ], "Old validation syntax"); - $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("send")->keepOriginal()->called(0); $method->method("isValidEmail")->keepOriginal(); From c448385e21a939d6cb324e1145849008be6428e3 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sat, 7 Jun 2025 01:04:43 +0200 Subject: [PATCH 46/78] Testing mocking idea --- src/Expect.php | 1 + src/Mocker/ClassSourceNormalizer.php | 122 +++++++++++++++++++++++++++ src/Mocker/MockBuilder.php | 61 +++++++++++--- src/TestCase.php | 8 +- src/TestConfig.php | 2 +- src/TestItem.php | 3 +- src/Unit.php | 19 +++-- src/Utils/FileIterator.php | 3 +- tests/TestLib/UserService.php | 3 +- tests/unitary-test-item.php | 75 ++++++++++++++++ tests/unitary-unitary.php | 41 +++++++-- tests/unitary-will-fail.php | 2 +- 12 files changed, 304 insertions(+), 36 deletions(-) create mode 100644 src/Mocker/ClassSourceNormalizer.php create mode 100644 tests/unitary-test-item.php diff --git a/src/Expect.php b/src/Expect.php index 216d618..123d5b1 100644 --- a/src/Expect.php +++ b/src/Expect.php @@ -12,6 +12,7 @@ namespace MaplePHP\Unitary; use Exception; +use MaplePHP\Validate\Validator; use Throwable; use MaplePHP\Validate\ValidationChain; diff --git a/src/Mocker/ClassSourceNormalizer.php b/src/Mocker/ClassSourceNormalizer.php new file mode 100644 index 0000000..b2cd4cf --- /dev/null +++ b/src/Mocker/ClassSourceNormalizer.php @@ -0,0 +1,122 @@ +className = $className; + + $shortClassName = explode("\\", $className); + $this->shortClassName = (string)end($shortClassName); + } + + /** + * Add a namespace to class + * + * @param string $namespace + * @return void + */ + public function addNamespace(string $namespace): void + { + $this->namespace = ltrim($namespace, "\\"); + } + + public function getClassName(): string + { + return $this->namespace . "\\" . $this->shortClassName; + } + + /** + * Retrieves the raw source code of the class. + * + * @return string|null + */ + public function getSource(): ?string + { + try { + + $ref = new ReflectionClass($this->className); + + var_dump($ref->getInterfaceNames()); + die; + $file = $ref->getFileName(); + if (!$file || !is_file($file)) { + // Likely an eval'd or dynamically declared class. + return null; + } + + $stream = new Stream($file, 'r'); + $this->source = $stream->getLines($ref->getStartLine(), $ref->getEndLine()); + var_dump($this->source); + + die("ww"); + return $this->source; + + } catch (ReflectionException) { + return null; + } + } + + /** + * Normalize PHP visibility modifiers in source code. + * - Removing 'final' from class and method declarations + * - Replacing 'private' with 'protected' for visibility declarations (except promoted properties) + * + * @param string $code + * @return string + */ + public function normalizeVisibility(string $code): string + { + $code = preg_replace('/\bfinal\s+(?=class\b)/i', '', $code); + $code = preg_replace('/\bfinal\s+(?=(public|protected|private|static)?\s*function\b)/i', '', $code); + $code = preg_replace_callback('/(?<=^|\s)(private)(\s+(static\s+)?(?:function|\$))/mi', [$this, 'replacePrivateWithProtected'], $code); + $code = preg_replace_callback('/__construct\s*\((.*?)\)/s', [$this, 'convertConstructorVisibility'], $code); + return $code; + } + + /** + * Returns the normalized, mockable version of the class source. + * + * @return string|false + */ + public function getMockableSource(): string|false + { + $source = "namespace {$this->namespace};\n" . $this->getSource(); + return $source !== null ? $this->normalizeVisibility($source) : false; + } + + /** + * Replace `private` with `protected` in method or property declarations. + * + * @param array $matches + * @return string + */ + protected function replacePrivateWithProtected(array $matches): string + { + return 'protected' . $matches[2]; + } + + /** + * Convert `private` to `protected` in constructor-promoted properties. + * + * @param array $matches + * @return string + */ + protected function convertConstructorVisibility(array $matches): string + { + $params = preg_replace('/\bprivate\b/', 'protected', $matches[1]); + return '__construct(' . $params . ')'; + } + +} diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 10a7beb..41b2426 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -13,6 +13,7 @@ use Closure; use Exception; +use MaplePHP\Http\Stream; use MaplePHP\Unitary\TestUtils\DataTypeMock; use Reflection; use ReflectionClass; @@ -27,11 +28,13 @@ final class MockBuilder protected ReflectionClass $reflection; protected string $className; /** @var class-string|null */ - protected ?string $mockClassName = null; + protected string $mockClassName; + protected string $copyClassName; /** @var array */ protected array $constructorArgs = []; protected array $methods; protected array $methodList = []; + protected array $isFinal = []; private DataTypeMock $dataTypeMock; /** @@ -53,7 +56,8 @@ public function __construct(string $className, array $args = []) * @var class-string $shortClassName * @psalm-suppress PropertyTypeCoercion */ - $this->mockClassName = 'Unitary_' . uniqid() . "_Mock_" . $shortClassName; + $this->mockClassName = "Unitary_" . uniqid() . "_Mock_" . $shortClassName; + $this->copyClassName = "Unitary_Mock_" . $shortClassName; /* // Auto fill the Constructor args! $test = $this->reflection->getConstructor(); @@ -127,6 +131,17 @@ public function getMockedClassName(): string return (string)$this->mockClassName; } + /** + * Gets the list of methods that are mocked. + * + * @param string $methodName + * @return bool + */ + public function isFinal(string $methodName): bool + { + return isset($this->isFinal[$methodName]); + } + /** * Sets a custom mock value for a specific data type. The mock value can be bound to a specific method * or used as a global default for the data type. @@ -157,7 +172,19 @@ public function execute(): mixed $className = $this->reflection->getName(); $overrides = $this->generateMockMethodOverrides((string)$this->mockClassName); $unknownMethod = $this->errorHandleUnknownMethod($className, !$this->reflection->isInterface()); - $extends = $this->reflection->isInterface() ? "implements $className" : "extends $className"; + + if($this->reflection->isInterface()) { + $extends = "implements $className"; + } else { + + $m = new ClassSourceNormalizer($className); + $m->addNamespace("\MaplePHP\Unitary\Mocker\MockedClass"); + eval($m->getMockableSource()); + $extends = "extends \\" . $m->getClassName(); + //$extends = "extends $className"; + } + + $code = " class $this->mockClassName $extends { @@ -171,9 +198,7 @@ public static function __set_state(array \$an_array): self } "; - //print_r($code); - //die; - //Helpers::createFile($this->mockClassName, $code); + eval($code); if(!is_string($this->mockClassName)) { @@ -187,6 +212,7 @@ public static function __set_state(array \$an_array): self return new $this->mockClassName(...$this->constructorArgs); } + /** * Handles the situation where an unknown method is called on the mock class. * If the base class defines a __call method, it will delegate to it. @@ -260,11 +286,11 @@ protected function generateMockMethodOverrides(string $mockClassName): string E_USER_WARNING ); } - */ - if ($method->isFinal()) { + $this->isFinal[$methodName] = true; continue; } + */ $methodName = $method->getName(); $this->methodList[] = $methodName; @@ -285,8 +311,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string } $returnType = ($types) ? ': ' . implode('|', $types) : ''; $modifiersArr = Reflection::getModifierNames($method->getModifiers()); - $modifiersArr = array_filter($modifiersArr, fn($val) => $val !== "abstract"); - $modifiers = implode(" ", $modifiersArr); + $modifiers = $this->handleModifiers($modifiersArr); $arr = $this->getMethodInfoAsArray($method); $arr = $this->addMockMetadata($arr, $mockClassName, $returnValue, $methodItem); @@ -326,6 +351,22 @@ protected function generateMockMethodOverrides(string $mockClassName): string return $overrides; } + /** + * Will handle modifier correctly + * + * @param array $modifiersArr + * @return string + */ + protected function handleModifiers(array $modifiersArr): string + { + $modifiersArr = array_filter($modifiersArr, fn($val) => $val !== "abstract"); + $modifiersArr = array_map(function($val) { + return ($val === "private") ? "protected" : $val; + }, $modifiersArr); + + return implode(" ", $modifiersArr); + } + /** * Will mocked handle the thrown exception * diff --git a/src/TestCase.php b/src/TestCase.php index 582f502..54f26ad 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -42,7 +42,6 @@ final class TestCase * @var array */ private const EXCLUDE_VALIDATE = ["return"]; - private mixed $value; private TestConfig $config; private array $test = []; @@ -124,9 +123,11 @@ public function dispatchTest(self &$row): array true, fn() => false, $msg, $e->getMessage(), $e->getTrace()[0] ); } catch (Throwable $e) { - if(str_contains($e->getFile(), "eval()")) { + /* + if(str_contains($e->getFile(), "eval()")) { throw new BlunderErrorException($e->getMessage(), (int)$e->getCode()); } + */ throw $e; } if ($newInst instanceof self) { @@ -341,7 +342,7 @@ public function buildMock(?Closure $validate = null): mixed * @return T * @throws Exception */ - public function mock(string $class, ?Closure $validate = null, array $args = []) + public function mock(string $class, ?Closure $validate = null, array $args = []): mixed { $this->mocker = new MockBuilder($class, $args); return $this->buildMock($validate); @@ -808,5 +809,4 @@ public function getAllTraitMethods(ReflectionClass $reflection): array } return $traitMethods; } - } diff --git a/src/TestConfig.php b/src/TestConfig.php index 0919d40..1df5011 100644 --- a/src/TestConfig.php +++ b/src/TestConfig.php @@ -29,7 +29,7 @@ public function __construct(string $message) * @param string $message * @return self */ - public static function make(string $message): self + public static function make(string $message = "Validating"): self { return new self($message); } diff --git a/src/TestItem.php b/src/TestItem.php index e65412f..0551136 100755 --- a/src/TestItem.php +++ b/src/TestItem.php @@ -125,8 +125,7 @@ public function getStringifyValue(): string */ public function getCompareToValue(): array { - $compare = array_map(fn ($value) => Helpers::stringifyDataTypes($value, true), $this->compareValues); - return $compare; + return array_map(fn ($value) => Helpers::stringifyDataTypes($value, true), $this->compareValues); } /** diff --git a/src/Unit.php b/src/Unit.php index d460a98..d8ab297 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -31,7 +31,7 @@ final class Unit private string $output = ""; private int $index = 0; private array $cases = []; - private bool $skip = false; + private bool $disableAllTests = false; private bool $executed = false; private static array $headers = []; private static ?Unit $current; @@ -60,16 +60,15 @@ public function __construct(HandlerInterface|StreamInterface|null $handler = nul } /** - * This will skip "ALL" tests in the test file + * This will disable "ALL" tests in the test file * If you want to skip a specific test, use the TestConfig class instead * - * @param bool $skip - * @return $this + * @param bool $disable + * @return void */ - public function skip(bool $skip): self + public function disableAllTest(bool $disable): void { - $this->skip = $skip; - return $this; + $this->disableAllTests = $disable; } /** @@ -214,7 +213,7 @@ public function execute(): bool { $this->template(); $this->help(); - if ($this->executed || $this->skip) { + if ($this->executed || $this->disableAllTests) { return false; } @@ -476,9 +475,11 @@ public static function hasUnit(): bool */ public static function getUnit(): ?Unit { - if (self::hasUnit() === false) { + /* + if (self::hasUnit() === false) { throw new Exception("Unit has not been set yet. It needs to be set first."); } + */ return self::$current; } diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index 5252546..c95fb19 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -212,7 +212,8 @@ protected function getUnit(): Unit { $unit = Unit::getUnit(); if ($unit === null) { - throw new RuntimeException("The Unit instance has not been initiated."); + $unit = new Unit(); + //throw new RuntimeException("The Unit instance has not been initiated."); } return $unit; diff --git a/tests/TestLib/UserService.php b/tests/TestLib/UserService.php index ef20cf2..ae61e42 100644 --- a/tests/TestLib/UserService.php +++ b/tests/TestLib/UserService.php @@ -2,7 +2,8 @@ namespace TestLib; -class UserService { +final class UserService { + private $test = 1; public function __construct(private Mailer $mailer) {} public function registerUser(string $email): bool { diff --git a/tests/unitary-test-item.php b/tests/unitary-test-item.php new file mode 100644 index 0000000..f70cbe5 --- /dev/null +++ b/tests/unitary-test-item.php @@ -0,0 +1,75 @@ +group(TestConfig::make("Test item class") + ->withName("unitary"), function (TestCase $case) { + + $item = new TestItem(); + + $item = $item + ->setValidation("validation") + ->setValidationArgs(["arg1", "arg2"]) + ->setIsValid(true) + ->setValue("value") + ->setCompareToValue("compare") + ->setHasArgs(true); + + $case->validate($item->isValid(), function(Expect $valid) { + $valid->isTrue(); + }); + + $case->validate($item->getValidation(), function(Expect $valid) { + $valid->isEqualTo("validation"); + }); + + $case->validate($item->getValidationArgs(), function(Expect $valid) { + $valid->isInArray("arg1"); + $valid->isInArray("arg2"); + $valid->isCountEqualTo(2); + }); + + $case->validate($item->getValue(), function(Expect $valid) { + $valid->isEqualTo("value"); + }); + + $case->validate($item->hasComparison(), function(Expect $valid) { + $valid->isTrue(); + }); + + $case->validate($item->getCompareValues(), function(Expect $valid) { + $valid->isInArray("compare"); + }); + + $case->validate($item->getComparison(), function(Expect $valid) { + $valid->isEqualTo('Expected: "value" | Actual: "compare"'); + }); + + $case->validate($item->getStringifyArgs(), function(Expect $valid) { + $valid->isEqualTo('(arg1, arg2)'); + }); + + $case->validate($item->getValidationTitle(), function(Expect $valid) { + $valid->isEqualTo('validation(arg1, arg2)'); + }); + + $case->validate($item->getValidationLength(), function(Expect $valid) { + $valid->isEqualTo(10); + }); + + $case->validate($item->getValidationLengthWithArgs(), function(Expect $valid) { + $valid->isEqualTo(22); + }); + + $case->validate($item->getStringifyValue(), function(Expect $valid) { + $valid->isEqualTo('"value"'); + }); + + $case->validate($item->getCompareToValue(), function(Expect $valid) { + $valid->isInArray( '"compare"'); + }); + +}); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 8402729..99e5496 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -13,7 +13,33 @@ $unit = new Unit(); -$unit->group("Can not mock final or private", function(TestCase $case) { +//$unit->disableAllTest(false); + +$config = TestConfig::make()->withName("unitary"); +$unit->group($config->withSubject("Mocking response"), function (TestCase $case) use($unit) { + + + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('HelloWorld', 'HelloWorld2') + ->calledAtLeast(1); + }); + $response = new Response($stream); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->hasResponse(); + $inst->isEqualTo('HelloWorld'); + $inst->notIsEqualTo('HelloWorldNot'); + }); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->isEqualTo('HelloWorld2'); + }); +}); + +/* + +$unit->group($config->withSubject("Can not mock final or private"), function(TestCase $case) { $user = $case->mock(UserService::class, function(MethodRegistry $method) { $method->method("getUserRole")->willReturn("admin"); $method->method("getUserType")->willReturn("admin"); @@ -24,7 +50,7 @@ }); }); -$unit->group("Test mocker", function (TestCase $case) use($unit) { +$unit->group($config->withSubject("Test mocker"), function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") @@ -74,8 +100,7 @@ }); }); -$config = TestConfig::make("Mocking response")->withName("unitary"); -$unit->group($config, function (TestCase $case) use($unit) { +$unit->group($config->withSubject("Mocking response"), function (TestCase $case) use($unit) { $stream = $case->mock(Stream::class, function (MethodRegistry $method) { $method->method("getContents") @@ -115,7 +140,7 @@ ], "Failed to validate"); }); -$unit->group("Validate partial mock", function (TestCase $case) use($unit) { +$unit->group($config->withSubject("Validate partial mock"), function (TestCase $case) use($unit) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("send")->keepOriginal(); $method->method("isValidEmail")->keepOriginal(); @@ -127,7 +152,7 @@ }); }); -$unit->group("Advanced App Response Test", function (TestCase $case) use($unit) { +$unit->group($config->withSubject("Advanced App Response Test"), function (TestCase $case) use($unit) { // Quickly mock the Stream class @@ -186,7 +211,7 @@ }); -$unit->group("Testing User service", function (TestCase $case) { +$unit->group($config->withSubject("Testing User service"), function (TestCase $case) { $mailer = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") @@ -202,3 +227,5 @@ $inst->isTrue(); }); }); + + */ \ No newline at end of file diff --git a/tests/unitary-will-fail.php b/tests/unitary-will-fail.php index 4bcc2cc..92d8620 100755 --- a/tests/unitary-will-fail.php +++ b/tests/unitary-will-fail.php @@ -6,7 +6,7 @@ use TestLib\Mailer; $unit = new Unit(); -$config = TestConfig::make("All A should fail")->withName("fail")->withSkip(); +$config = TestConfig::make("All A should fail")->withName("unitary-fail")->withSkip(); $unit->group($config, function (TestCase $case) use($unit) { $case->error("Default validations")->validate(1, function(Expect $inst) { From 465df4ae7172f0642916a39e7fe56b1261026810 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 8 Jun 2025 12:05:43 +0200 Subject: [PATCH 47/78] Minor refactoring --- src/Mocker/ClassSourceNormalizer.php | 122 --------------------------- src/Mocker/MockBuilder.php | 27 ++---- src/TestCase.php | 4 +- src/Unit.php | 12 ++- 4 files changed, 17 insertions(+), 148 deletions(-) delete mode 100644 src/Mocker/ClassSourceNormalizer.php diff --git a/src/Mocker/ClassSourceNormalizer.php b/src/Mocker/ClassSourceNormalizer.php deleted file mode 100644 index b2cd4cf..0000000 --- a/src/Mocker/ClassSourceNormalizer.php +++ /dev/null @@ -1,122 +0,0 @@ -className = $className; - - $shortClassName = explode("\\", $className); - $this->shortClassName = (string)end($shortClassName); - } - - /** - * Add a namespace to class - * - * @param string $namespace - * @return void - */ - public function addNamespace(string $namespace): void - { - $this->namespace = ltrim($namespace, "\\"); - } - - public function getClassName(): string - { - return $this->namespace . "\\" . $this->shortClassName; - } - - /** - * Retrieves the raw source code of the class. - * - * @return string|null - */ - public function getSource(): ?string - { - try { - - $ref = new ReflectionClass($this->className); - - var_dump($ref->getInterfaceNames()); - die; - $file = $ref->getFileName(); - if (!$file || !is_file($file)) { - // Likely an eval'd or dynamically declared class. - return null; - } - - $stream = new Stream($file, 'r'); - $this->source = $stream->getLines($ref->getStartLine(), $ref->getEndLine()); - var_dump($this->source); - - die("ww"); - return $this->source; - - } catch (ReflectionException) { - return null; - } - } - - /** - * Normalize PHP visibility modifiers in source code. - * - Removing 'final' from class and method declarations - * - Replacing 'private' with 'protected' for visibility declarations (except promoted properties) - * - * @param string $code - * @return string - */ - public function normalizeVisibility(string $code): string - { - $code = preg_replace('/\bfinal\s+(?=class\b)/i', '', $code); - $code = preg_replace('/\bfinal\s+(?=(public|protected|private|static)?\s*function\b)/i', '', $code); - $code = preg_replace_callback('/(?<=^|\s)(private)(\s+(static\s+)?(?:function|\$))/mi', [$this, 'replacePrivateWithProtected'], $code); - $code = preg_replace_callback('/__construct\s*\((.*?)\)/s', [$this, 'convertConstructorVisibility'], $code); - return $code; - } - - /** - * Returns the normalized, mockable version of the class source. - * - * @return string|false - */ - public function getMockableSource(): string|false - { - $source = "namespace {$this->namespace};\n" . $this->getSource(); - return $source !== null ? $this->normalizeVisibility($source) : false; - } - - /** - * Replace `private` with `protected` in method or property declarations. - * - * @param array $matches - * @return string - */ - protected function replacePrivateWithProtected(array $matches): string - { - return 'protected' . $matches[2]; - } - - /** - * Convert `private` to `protected` in constructor-promoted properties. - * - * @param array $matches - * @return string - */ - protected function convertConstructorVisibility(array $matches): string - { - $params = preg_replace('/\bprivate\b/', 'protected', $matches[1]); - return '__construct(' . $params . ')'; - } - -} diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 41b2426..dac59a9 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -172,19 +172,7 @@ public function execute(): mixed $className = $this->reflection->getName(); $overrides = $this->generateMockMethodOverrides((string)$this->mockClassName); $unknownMethod = $this->errorHandleUnknownMethod($className, !$this->reflection->isInterface()); - - if($this->reflection->isInterface()) { - $extends = "implements $className"; - } else { - - $m = new ClassSourceNormalizer($className); - $m->addNamespace("\MaplePHP\Unitary\Mocker\MockedClass"); - eval($m->getMockableSource()); - $extends = "extends \\" . $m->getClassName(); - //$extends = "extends $className"; - } - - + $extends = $this->reflection->isInterface() ? "implements $className" : "extends $className"; $code = " class $this->mockClassName $extends { @@ -198,7 +186,6 @@ public static function __set_state(array \$an_array): self } "; - eval($code); if(!is_string($this->mockClassName)) { @@ -286,13 +273,13 @@ protected function generateMockMethodOverrides(string $mockClassName): string E_USER_WARNING ); } + */ + + $methodName = $method->getName(); if ($method->isFinal()) { $this->isFinal[$methodName] = true; continue; } - */ - - $methodName = $method->getName(); $this->methodList[] = $methodName; // The MethodItem contains all items that are validatable @@ -360,15 +347,11 @@ protected function generateMockMethodOverrides(string $mockClassName): string protected function handleModifiers(array $modifiersArr): string { $modifiersArr = array_filter($modifiersArr, fn($val) => $val !== "abstract"); - $modifiersArr = array_map(function($val) { - return ($val === "private") ? "protected" : $val; - }, $modifiersArr); - return implode(" ", $modifiersArr); } /** - * Will mocked handle the thrown exception + * Will mocked a handle the thrown exception * * @param \Throwable $exception * @return string diff --git a/src/TestCase.php b/src/TestCase.php index 54f26ad..5a90ebc 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -123,11 +123,9 @@ public function dispatchTest(self &$row): array true, fn() => false, $msg, $e->getMessage(), $e->getTrace()[0] ); } catch (Throwable $e) { - /* - if(str_contains($e->getFile(), "eval()")) { + if(str_contains($e->getFile(), "eval()")) { throw new BlunderErrorException($e->getMessage(), (int)$e->getCode()); } - */ throw $e; } if ($newInst instanceof self) { diff --git a/src/Unit.php b/src/Unit.php index d8ab297..4d06ffa 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -71,6 +71,13 @@ public function disableAllTest(bool $disable): void $this->disableAllTests = $disable; } + // Deprecated: Almost same as `disableAllTest`, for older versions + public function skip(bool $disable): self + { + $this->disableAllTests = $disable; + return $this; + } + /** * DEPRECATED: Use TestConfig::setSelect instead * See documentation for more information @@ -476,7 +483,10 @@ public static function hasUnit(): bool public static function getUnit(): ?Unit { /* - if (self::hasUnit() === false) { + // Testing to comment out Exception in Unit instance is missing + // because this will trigger as soon as it finds a file name with unitary-* + // and can become tedious that this makes the test script stop. + if (self::hasUnit() === false) { throw new Exception("Unit has not been set yet. It needs to be set first."); } */ From 66a20033ef6cbc0a810254798cb6512193ac2725 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 8 Jun 2025 12:47:39 +0200 Subject: [PATCH 48/78] Add warning if trying to mock final methods Refactor code --- src/Expect.php | 1 - src/Mocker/MethodRegistry.php | 9 +++++ src/Mocker/MockBuilder.php | 28 +++++++------- src/TestCase.php | 70 +++++++++++++++++++++++++---------- src/Unit.php | 7 ++++ tests/TestLib/UserService.php | 2 +- tests/unitary-unitary.php | 42 ++++++++++----------- 7 files changed, 100 insertions(+), 59 deletions(-) diff --git a/src/Expect.php b/src/Expect.php index 123d5b1..216d618 100644 --- a/src/Expect.php +++ b/src/Expect.php @@ -12,7 +12,6 @@ namespace MaplePHP\Unitary; use Exception; -use MaplePHP\Validate\Validator; use Throwable; use MaplePHP\Validate\ValidationChain; diff --git a/src/Mocker/MethodRegistry.php b/src/Mocker/MethodRegistry.php index 1c8ed3e..2f7be72 100644 --- a/src/Mocker/MethodRegistry.php +++ b/src/Mocker/MethodRegistry.php @@ -100,4 +100,13 @@ public function has(string $name): bool return isset(self::$methods[$this->mocker->getMockedClassName()][$name]); } + public function getSelected(array $names): array + { + if(is_null($this->mocker)) { + throw new \BadMethodCallException("MockBuilder is not set yet."); + } + + return array_filter($names, fn($name) => $this->has($name)); + } + } diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index dac59a9..aeca100 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -131,15 +131,24 @@ public function getMockedClassName(): string return (string)$this->mockClassName; } + /** + * Return all final methods + * + * @return array + */ + public function getFinalMethods(): array + { + return $this->isFinal; + } + /** * Gets the list of methods that are mocked. * - * @param string $methodName * @return bool */ - public function isFinal(string $methodName): bool + public function hasFinal(): bool { - return isset($this->isFinal[$methodName]); + return $this->isFinal !== []; } /** @@ -199,7 +208,6 @@ public static function __set_state(array \$an_array): self return new $this->mockClassName(...$this->constructorArgs); } - /** * Handles the situation where an unknown method is called on the mock class. * If the base class defines a __call method, it will delegate to it. @@ -265,19 +273,9 @@ protected function generateMockMethodOverrides(string $mockClassName): string throw new Exception("Method is not a ReflectionMethod"); } - /* - if ($method->isFinal() || $method->isPrivate()) { - trigger_error( - "Cannot mock " . ($method->isFinal() ? "final" : "private") . - " method '" . $method->getName() . "' in '{$this->className}' – the real method will be executed.", - E_USER_WARNING - ); - } - */ - $methodName = $method->getName(); if ($method->isFinal()) { - $this->isFinal[$methodName] = true; + $this->isFinal[] = $methodName; continue; } $this->methodList[] = $methodName; diff --git a/src/TestCase.php b/src/TestCase.php index 5a90ebc..4ab241f 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -47,7 +47,8 @@ final class TestCase private array $test = []; private int $count = 0; private ?Closure $bind = null; - private ?string $errorMessage = null; + private ?string $error = null; + private ?string $warning = null; private array $deferredValidation = []; private ?MockBuilder $mocker = null; @@ -100,6 +101,40 @@ function getHasAssertError(): bool return $this->hasAssertError; } + /** + * Get a possible warning message if exists + * + * @return string|null + */ + public function getWarning(): ?string + { + return $this->warning; + } + + /** + * Set a possible warning in the test group + * + * @param string $message + * @return $this + */ + public function warning(string $message): self + { + $this->warning = $message; + return $this; + } + + /** + * Add custom error message if validation fails + * + * @param string $message + * @return $this + */ + public function error(string $message): self + { + $this->error = $message; + return $this; + } + /** * Will dispatch the case tests and return them as an array * @@ -135,18 +170,6 @@ public function dispatchTest(self &$row): array return $this->test; } - /** - * Add custom error message if validation fails - * - * @param string $message - * @return $this - */ - public function error(string $message): self - { - $this->errorMessage = $message; - return $this; - } - /** * Add a test unit validation using the provided expectation and validation logic * @@ -159,7 +182,7 @@ public function validate(mixed $expect, Closure $validation): self { $this->expectAndValidate($expect, function (mixed $value, Expect $inst) use ($validation) { return $validation($inst, new Traverse($value)); - }, $this->errorMessage); + }, $this->error); return $this; } @@ -238,7 +261,7 @@ protected function expectAndValidate( $this->count++; } $this->test[] = $test; - $this->errorMessage = null; + $this->error = null; return $this; } @@ -321,10 +344,17 @@ public function buildMock(?Closure $validate = null): mixed throw new BadMethodCallException("The mocker is not set yet!"); } if (is_callable($validate)) { - $this->prepareValidation($this->mocker, $validate); + $pool = $this->prepareValidation($this->mocker, $validate); } /** @psalm-suppress MixedReturnStatement */ - return $this->mocker->execute(); + $class = $this->mocker->execute(); + if($this->mocker->hasFinal()) { + $finalMethods = $pool->getSelected($this->mocker->getFinalMethods()); + if($finalMethods !== []) { + $this->warning = "Warning: It is not possible to mock final methods: " . implode(", ", $finalMethods); + } + } + return $class; } /** @@ -363,10 +393,10 @@ public function getMocker(): MockBuilder * * @param MockBuilder $mocker The mocker instance containing the mock object * @param Closure $validate The closure containing validation rules - * @return void + * @return MethodRegistry * @throws ErrorException */ - private function prepareValidation(MockBuilder $mocker, Closure $validate): void + private function prepareValidation(MockBuilder $mocker, Closure $validate): MethodRegistry { $pool = new MethodRegistry($mocker); $fn = $validate->bindTo($pool); @@ -374,8 +404,8 @@ private function prepareValidation(MockBuilder $mocker, Closure $validate): void throw new ErrorException("A callable Closure could not be bound to the method pool!"); } $fn($pool); - $this->deferValidation(fn () => $this->runValidation($mocker, $pool)); + return $pool; } /** diff --git a/src/Unit.php b/src/Unit.php index 4d06ffa..8abe871 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -271,6 +271,13 @@ public function execute(): bool } if (($show || !$row->getConfig()->skip)) { + // Show possible warnings + if($row->getWarning()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["italic", "yellow"], $row->getWarning()) + ); + } foreach ($tests as $test) { if (!($test instanceof TestUnit)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); diff --git a/tests/TestLib/UserService.php b/tests/TestLib/UserService.php index ae61e42..78f8aef 100644 --- a/tests/TestLib/UserService.php +++ b/tests/TestLib/UserService.php @@ -2,7 +2,7 @@ namespace TestLib; -final class UserService { +class UserService { private $test = 1; public function __construct(private Mailer $mailer) {} diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 99e5496..0d30e13 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -16,28 +16,7 @@ //$unit->disableAllTest(false); $config = TestConfig::make()->withName("unitary"); -$unit->group($config->withSubject("Mocking response"), function (TestCase $case) use($unit) { - - - $stream = $case->mock(Stream::class, function (MethodRegistry $method) { - $method->method("getContents") - ->willReturn('HelloWorld', 'HelloWorld2') - ->calledAtLeast(1); - }); - $response = new Response($stream); - - $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->hasResponse(); - $inst->isEqualTo('HelloWorld'); - $inst->notIsEqualTo('HelloWorldNot'); - }); - - $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->isEqualTo('HelloWorld2'); - }); -}); -/* $unit->group($config->withSubject("Can not mock final or private"), function(TestCase $case) { $user = $case->mock(UserService::class, function(MethodRegistry $method) { @@ -228,4 +207,23 @@ }); }); - */ \ No newline at end of file +$unit->group($config->withSubject("Mocking response"), function (TestCase $case) use($unit) { + + + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('HelloWorld', 'HelloWorld2') + ->calledAtLeast(1); + }); + $response = new Response($stream); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->hasResponse(); + $inst->isEqualTo('HelloWorld'); + $inst->notIsEqualTo('HelloWorldNot'); + }); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->isEqualTo('HelloWorld2'); + }); +}); \ No newline at end of file From 364bc20e4c896f16e987d00dcee7e5bde839d4be Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 8 Jun 2025 12:52:25 +0200 Subject: [PATCH 49/78] Adjust test --- tests/TestLib/UserService.php | 9 +++++++-- tests/unitary-unitary.php | 7 ++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/TestLib/UserService.php b/tests/TestLib/UserService.php index 78f8aef..506057b 100644 --- a/tests/TestLib/UserService.php +++ b/tests/TestLib/UserService.php @@ -19,13 +19,18 @@ public function registerUser(string $email): bool { return true; } - private function getUserRole(): string + private function test(): string + { + return "guest"; + } + + public function getUserRole(): string { return "guest"; } final public function getUserType(): string { - return $this->getUserRole(); + return "guest"; } } \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 0d30e13..83ad723 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -17,16 +17,21 @@ $config = TestConfig::make()->withName("unitary"); - $unit->group($config->withSubject("Can not mock final or private"), function(TestCase $case) { $user = $case->mock(UserService::class, function(MethodRegistry $method) { $method->method("getUserRole")->willReturn("admin"); $method->method("getUserType")->willReturn("admin"); }); + // You cannot mock final with data (should return a warning) $case->validate($user->getUserType(), function(Expect $expect) { $expect->isEqualTo("guest"); }); + + // You can of course mock regular methods with data + $case->validate($user->getUserRole(), function(Expect $expect) { + $expect->isEqualTo("admin"); + }); }); $unit->group($config->withSubject("Test mocker"), function (TestCase $case) use($unit) { From 391e7788789145aa9c805152746485cbd7652f0e Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 15 Jun 2025 20:56:23 +0200 Subject: [PATCH 50/78] Add more descriptive code commments --- src/Mocker/MockedMethod.php | 152 ++++++++++++++++++---------------- src/TestCase.php | 2 +- tests/TestLib/UserService.php | 13 +-- tests/unitary-unitary.php | 28 +++++++ 4 files changed, 119 insertions(+), 76 deletions(-) diff --git a/src/Mocker/MockedMethod.php b/src/Mocker/MockedMethod.php index 3f038e5..af7bbae 100644 --- a/src/Mocker/MockedMethod.php +++ b/src/Mocker/MockedMethod.php @@ -109,38 +109,59 @@ public function getThrowable(): ?Throwable } /** - * Check if a return value has been added - * - * @return bool + * Check if a method has been called x times + * + * @param int $times + * @return $this */ - public function hasReturn(): bool + public function called(int $times): self { - return $this->hasReturn; + $inst = $this; + $inst->called = $times; + return $inst; } /** - * Preserve the original method functionality instead of mocking it. - * When this is set, the method will execute its original implementation instead of any mock behavior. + * Check if a method has been called x times + * + * @return $this + */ + public function hasBeenCalled(): self + { + $inst = $this; + $inst->called = [ + "isAtLeast" => [1], + ]; + return $inst; + } + + /** + * Check if a method has been called x times * - * @return $this Method chain + * @param int $times + * @return $this */ - public function keepOriginal(): self + public function calledAtLeast(int $times): self { $inst = $this; - $inst->keepOriginal = true; + $inst->called = [ + "isAtLeast" => [$times], + ]; return $inst; } /** * Check if a method has been called x times - * + * * @param int $times * @return $this */ - public function called(int $times): self + public function calledAtMost(int $times): self { $inst = $this; - $inst->called = $times; + $inst->called = [ + "isAtMost" => [$times], + ]; return $inst; } @@ -202,47 +223,26 @@ public function withArgumentAt(int $position, mixed $value, int $called = 0): se } /** - * Check if a method has been called x times - * - * @return $this - */ - public function hasBeenCalled(): self - { - $inst = $this; - $inst->called = [ - "isAtLeast" => [1], - ]; - return $inst; - } - - /** - * Check if a method has been called x times + * Preserve the original method functionality instead of mocking it. + * When this is set, the method will execute its original implementation instead of any mock behavior. * - * @param int $times - * @return $this + * @return $this Method chain */ - public function calledAtLeast(int $times): self + public function keepOriginal(): self { $inst = $this; - $inst->called = [ - "isAtLeast" => [$times], - ]; + $inst->keepOriginal = true; return $inst; } /** - * Check if a method has been called x times + * Check if a return value has been added * - * @param int $times - * @return $this + * @return bool */ - public function calledAtMost(int $times): self + public function hasReturn(): bool { - $inst = $this; - $inst->called = [ - "isAtMost" => [$times], - ]; - return $inst; + return $this->hasReturn; } /** @@ -259,6 +259,12 @@ public function willReturn(mixed ...$value): self return $inst; } + /** + * Configures the method to throw an exception every time it's called + * + * @param Throwable $throwable + * @return $this + */ public function willThrow(Throwable $throwable): self { $this->throwable = $throwable; @@ -266,6 +272,12 @@ public function willThrow(Throwable $throwable): self return $this; } + /** + * Configures the method to throw an exception only once + * + * @param Throwable $throwable + * @return $this + */ public function willThrowOnce(Throwable $throwable): self { $this->throwOnce = true; @@ -274,7 +286,7 @@ public function willThrowOnce(Throwable $throwable): self } /** - * Set the class name. + * Compare if method has expected class name. * * @param string $class * @return self @@ -287,7 +299,7 @@ public function hasClass(string $class): self } /** - * Set the method name. + * Compare if method has expected method name. * * @param string $name * @return self @@ -300,7 +312,7 @@ public function hasName(string $name): self } /** - * Mark the method as static. + * Check if the method is expected to be static * * @return self */ @@ -312,7 +324,7 @@ public function isStatic(): self } /** - * Mark the method as public. + * Check if the method is expected to be public * * @return self */ @@ -324,7 +336,7 @@ public function isPublic(): self } /** - * Mark the method as private. + * Check if the method is expected to be private * * @return self */ @@ -336,7 +348,7 @@ public function isPrivate(): self } /** - * Mark the method as protected. + * Check if the method is expected to be protected. * * @return self */ @@ -348,7 +360,7 @@ public function isProtected(): self } /** - * Mark the method as abstract. + * Check if the method is expected to be abstract. * * @return self */ @@ -360,7 +372,7 @@ public function isAbstract(): self } /** - * Mark the method as final. + * Check if the method is expected to be final. * * @return self */ @@ -372,7 +384,7 @@ public function isFinal(): self } /** - * Mark the method as returning by reference. + * Check if the method is expected to return a reference * * @return self */ @@ -384,7 +396,7 @@ public function returnsReference(): self } /** - * Mark the method as having a return type. + * Check if the method has a return type. * * @return self */ @@ -396,7 +408,7 @@ public function hasReturnType(): self } /** - * Set the return type of the method. + * Check if the method return type has expected type * * @param string $type * @return self @@ -409,7 +421,7 @@ public function isReturnType(string $type): self } /** - * Mark the method as a constructor. + * Check if the method is the constructor. * * @return self */ @@ -421,7 +433,7 @@ public function isConstructor(): self } /** - * Mark the method as a destructor. + * Check if the method is the destructor. * * @return self */ @@ -433,7 +445,7 @@ public function isDestructor(): self } /** - * Check if parameter exists + * Check if the method parameters exists * * @return $this */ @@ -447,7 +459,7 @@ public function hasParams(): self } /** - * Check if all parameters have a data type + * Check if the method has parameter types * * @return $this */ @@ -461,7 +473,7 @@ public function hasParamsTypes(): self } /** - * Check if parameter does not exist + * Check if the method is missing parameters * * @return $this */ @@ -475,7 +487,7 @@ public function hasNotParams(): self } /** - * Check a parameter type for method + * Check if the method has equal number of parameters as expected * * @param int $length * @return $this @@ -490,7 +502,7 @@ public function paramsHasCount(int $length): self } /** - * Check a parameter type for method + * Check if the method parameter at given index location has expected data type * * @param int $paramPosition * @param string $dataType @@ -506,7 +518,7 @@ public function paramIsType(int $paramPosition, string $dataType): self } /** - * Check parameter default value for method + * Check if the method parameter at given index location has a default value * * @param int $paramPosition * @param string $defaultArgValue @@ -522,7 +534,7 @@ public function paramHasDefault(int $paramPosition, string $defaultArgValue): se } /** - * Check a parameter type for method + * Check if the method parameter at given index location has a data type * * @param int $paramPosition * @return $this @@ -537,7 +549,7 @@ public function paramHasType(int $paramPosition): self } /** - * Check a parameter type for method + * Check if the method parameter at given index location is optional * * @param int $paramPosition * @return $this @@ -552,7 +564,7 @@ public function paramIsOptional(int $paramPosition): self } /** - * Check parameter is Reference for method + * Check if the method parameter at given index location is a reference * * @param int $paramPosition * @return $this @@ -567,7 +579,7 @@ public function paramIsReference(int $paramPosition): self } /** - * Check the parameter is variadic (spread) for a method + * Check if the method parameter at given index location is a variadic (spread) * * @param int $paramPosition * @return $this @@ -588,7 +600,7 @@ public function paramIsSpread(int $paramPosition): self } /** - * Set the doc comment for the method. + * Check if the method has comment block * * @return self */ @@ -603,7 +615,7 @@ public function hasDocComment(): self } /** - * Set the file name where the method is declared. + * Check if the method exist in file with name * * @param string $file * @return self @@ -616,7 +628,7 @@ public function hasFileName(string $file): self } /** - * Set the starting line number of the method. + * Check if the method starts at line number * * @param int $line * @return self @@ -629,7 +641,7 @@ public function startLine(int $line): self } /** - * Set the ending line number of the method. + * Check if the method return ends at line number * * @param int $line * @return self diff --git a/src/TestCase.php b/src/TestCase.php index 4ab241f..c9d8996 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -365,7 +365,7 @@ public function buildMock(?Closure $validate = null): mixed * validations are deferred and will be executed later via runDeferredValidations(). * * @param class-string $class - * @param Closure|null $validate + * @param (Closure(MethodRegistry): void)|null $callback * @param array $args * @return T * @throws Exception diff --git a/tests/TestLib/UserService.php b/tests/TestLib/UserService.php index 506057b..c6a0524 100644 --- a/tests/TestLib/UserService.php +++ b/tests/TestLib/UserService.php @@ -19,11 +19,6 @@ public function registerUser(string $email): bool { return true; } - private function test(): string - { - return "guest"; - } - public function getUserRole(): string { return "guest"; @@ -33,4 +28,12 @@ final public function getUserType(): string { return "guest"; } + + public function issueToken(): string { + return $this->generateToken(); // private + } + + private function generateToken(): string { + return bin2hex(random_bytes(16)); + } } \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 83ad723..e8c20cf 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -17,6 +17,23 @@ $config = TestConfig::make()->withName("unitary"); + +$unit->group($config->withSubject("Test mocker"), function (TestCase $case) use($unit) { + + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("addFromEmail") + ->withArguments("john.doe@gmail.com", "John Doe") + ->called(2); + }); + + + $mail->addFromEmail("john.doe@gmail.com", "John Doe"); +}); + +$unit->group("Example of assert in group", function(TestCase $case) { + assert(1 === 2, "This is a error message"); +}); + $unit->group($config->withSubject("Can not mock final or private"), function(TestCase $case) { $user = $case->mock(UserService::class, function(MethodRegistry $method) { $method->method("getUserRole")->willReturn("admin"); @@ -32,6 +49,7 @@ $case->validate($user->getUserRole(), function(Expect $expect) { $expect->isEqualTo("admin"); }); + }); $unit->group($config->withSubject("Test mocker"), function (TestCase $case) use($unit) { @@ -231,4 +249,14 @@ $case->validate($response->getBody()->getContents(), function(Expect $inst) { $inst->isEqualTo('HelloWorld2'); }); +}); + +$unit->group("Example API Response", function(TestCase $case) { + + $case->validate('{"response":{"status":200,"message":"ok"}}', function(Expect $expect) { + + $expect->isJson()->hasJsonValueAt("response.status", 404); + assert($expect->isValid(), "Expected JSON structure did not match."); + }); + }); \ No newline at end of file From bd0b6b15e980dfca018d50a916f24852b0a3251f Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sat, 21 Jun 2025 18:41:02 +0200 Subject: [PATCH 51/78] feat: Start building code coverage functionality Improve README.md file --- README.md | 314 +++++++++++---------------------- src/TestUtils/CodeCoverage.php | 171 ++++++++++++++++++ tests/unitary-unitary.php | 1 + 3 files changed, 276 insertions(+), 210 deletions(-) create mode 100644 src/TestUtils/CodeCoverage.php diff --git a/README.md b/README.md index 08e6bdb..b9a8715 100644 --- a/README.md +++ b/README.md @@ -1,280 +1,174 @@ -# MaplePHP - Unitary +# MaplePHP Unitary — Fast Testing, Full Control, Zero Friction -PHP Unitary is a **user-friendly** and robust unit testing framework designed to make writing and running tests for your PHP code easy. With an intuitive CLI interface that works on all platforms and robust validation options, Unitary makes it easy for you as a developer to ensure your code is reliable and functions as intended. - -![Prompt demo](http://wazabii.se/github-assets/maplephp-unitary.png) -_Do you like the CLI theme? [Download it here](https://github.com/MaplePHP/DarkBark)_ +Unitary is a modern PHP testing framework built for developers who want speed, precision, and complete freedom. No config. No noise. Just a clean, purpose-built system that runs 100,000+ tests in a second and scales effortlessly—from quick checks to full-suite validation. +Mocking, validation, and assertions are all built in—ready to use without setup or plugins. The CLI is intuitive, the experience is consistent, and getting started takes seconds. Whether you’re testing one function or an entire system, Unitary helps you move fast and test with confidence. -### Syntax You Will Love -```php -$unit->case("MaplePHP Request URI path test", function() { - $response = new Response(200); +![Prompt demo](http://wazabii.se/github-assets/unitary/unitary-cli-states.png) - $this->add($response->getStatusCode(), function() { - return $this->equal(200); - }, "Did not return HTTP status code 200"); -}); -``` +_Do you like the CLI theme? [Download it here](https://github.com/MaplePHP/DarkBark)_ -## Documentation -The documentation is divided into several sections: -- [Installation](#installation) -- [Guide](#guide) - - [1. Create a Test File](#1-create-a-test-file) - - [2. Create a Test Case](#2-create-a-test-case) - - [3. Run the Tests](#3-run-the-tests) -- [Configurations](#configurations) -- [Validation List](#validation-list) - - [Data Type Checks](#data-type-checks) - - [Equality and Length Checks](#equality-and-length-checks) - - [Numeric Range Checks](#numeric-range-checks) - - [String and Pattern Checks](#string-and-pattern-checks) - - [Required and Boolean-Like Checks](#required-and-boolean-like-checks) - - [Date and Time Checks](#date-and-time-checks) - - [Version Checks](#version-checks) - - [Logical Checks](#logical-checks) - - -## Installation - -To install MaplePHP Unitary, run the following command: -```bash -composer require --dev maplephp/unitary -``` +### Familiar Syntax. Fast Feedback. -## Guide +Unitary is designed to feel natural for developers. With clear syntax, built-in validation, and zero setup required, writing tests becomes a smooth part of your daily flow—not a separate chore. -### 1. Create a Test File +```php +$unit->group("Has a about page", function(TestCase $case) { -Unitary will, by default, find all files prefixed with "unitary-" recursively from your project's root directory (where your "composer.json" file exists). The vendor directory will be excluded by default. + $response = $this->get("/about"); + $statusCode = $response->getStatusCode(); + + $case->validate($statusCode, function(Expect $valid) { + $valid->isHttpSuccess(); + }); +}); +``` -Start by creating a test file with a name that starts with "unitary-", e.g., "unitary-request.php". You can place the file inside your library directory, for example like this: `tests/unitary-request.php`. +--- -**Note: All of your library classes will automatically be autoloaded through Composer's autoloader inside your test file!** +## Next-Gen PHP Testing Framework -### 2. Create a Test Case +**Unitary** is a blazing-fast, developer-first testing framework for PHP, built from scratch with zero dependencies on legacy tools like many others. It’s simple to get started, lightning-fast to run, and powerful enough to test everything from units to mocks. -Now that we have created a test file, e.g., `tests/unitary-request.php`, we will need to add our test cases and tests. I will create a test for one of my other libraries below, which is MaplePHP/HTTP, specifically the Request library that has full PSR-7 support. +> 🚀 *Test 100,000+ cases in \~1 second. No config. No bloat. Just results.* -I will show you three different ways to test your application below. +--- -```php -case("MaplePHP Request URI path test", function() use($request) { +## ⚡ Blazing Fast Performance - // Then add tests to your case: - // Test 1: Access the validation instance inside the add closure - $this->add($request->getMethod(), function($value) { - return $this->equal("GET"); +Unitary runs large test suites in a fraction of the time — even **100,000+** tests in just **1 second**. - }, "HTTP Request method type does not equal GET"); - // Adding an error message is not required, but it is highly recommended. +🚀 That’s up to 46× faster than the most widely used testing frameworks. - // Test 2: Built-in validation shortcuts - $this->add($request->getUri()->getPort(), [ - "isInt" => [], // Has no arguments = empty array - "min" => [1], // The strict way is to pass each argument as an array item - "max" => 65535, // If it's only one argument, then this is acceptable too - "length" => [1, 5] - ], "Is not a valid port number"); +> Benchmarks based on real-world test cases. +> 👉 [See full benchmark comparison →](https://your-docs-link.com/benchmarks) - // Test 3: It is also possible to combine them all in one. - $this->add($request->getUri()->getUserInfo(), [ - "isString" => [], - "User validation" => function($value) { - $arr = explode(":", $value); - return ($this->withValue($arr[0])->equal("admin") && $this->withValue($arr[1])->equal("mypass")); - } +--- - ], "Did not get the expected user info credentials"); -}); -``` -The example above uses both built-in validation and custom validation (see below for all built-in validation options). +## Getting Started (Under 1 Minute) -### 3. Run the Tests +Set up your first test in three easy steps: -Now you are ready to execute the tests. Open your command line of choice, navigate (cd) to your project's root directory (where your `composer.json` file exists), and execute the following command: +### 1. Install ```bash -php vendor/bin/unitary +composer require --dev maplephp/unitary ``` -#### The Output: -![Prompt demo](http://wazabii.se/github-assets/maplephp-unitary-result.png) -*And that is it! Your tests have been successfully executed!* +_You can run unitary globally if preferred with `composer global require maplephp/unitary`._ -With that, you are ready to create your own tests! +--- +### 2. Create a Test File -## Mocking -Unitary comes with a built-in mocker that makes it super simple for you to mock classes. +Create a file like `tests/unitary-request.php`. Unitary automatically scans all files prefixed with `unitary-` (excluding `vendor/`). - -### Auto mocking -What is super cool with Unitary Mocker will try to automatically mock the class that you pass and -it will do it will do it quite accurate as long as the class and its methods that you are mocking is -using data type in arguments and return type. +Paste this test boilerplate to get started: ```php -$unit->group("Testing user service", function (TestCase $inst) { - - // Just call the unitary mock and pass in class name - $mock = $inst->mock(Mailer::class); - // Mailer class is not mocked! - - // Pass argument to Mailer constructor e.g. new Mailer('john.doe@gmail.com', 'John Doe'); - //$mock = $inst->mock([Mailer::class, ['john.doe@gmail.com', 'John Doe']); - // Mailer class is not mocked again! - - // Then just pass the mocked library to what ever service or controller you wish - $service = new UserService($mock); -}); -``` -_Why? Sometimes you just want to quick mock so that a Mailer library will not send a mail_ - -### Custom mocking -As I said Unitary mocker will try to automatically mock every method but might not successes in some user-cases -then you can just tell Unitary how those failed methods should load. - -```php -use MaplePHP\Validate\ValidationChain; -use \MaplePHP\Unitary\Mocker\MethodRegistry; - -$unit->group("Testing user service", function (TestCase $inst) { - $mock = $inst->mock(Mailer::class, function (MethodRegistry $pool) use($inst) { - // Quick way to tell Unitary that this method should return 'john.doe' - $pool->method("getFromEmail")->willReturn('john.doe@gmail.com'); - - // Or we can acctually pass a callable to it and tell it what it should return - // But we can also validate the argumnets! - $pool->method("addFromEmail")->wrap(function($email) use($inst) { - $inst->validate($email, function(ValidationChain $valid) { - $valid->email(); - $valid->isString(); - }); - return true; - }); - }); - - // Then just pass the mocked library to what ever service or controller you wish - $service = new UserService($mock); -}); -``` - -### Mocking: Add Consistency validation -What is really cool is that you can also use Unitary mocker to make sure consistencies is followed and -validate that the method is built and loaded correctly. +use MaplePHP\Unitary\{Unit, TestCase, TestConfig, Expect}; -```php -use \MaplePHP\Unitary\Mocker\MethodRegistry; - -$unit->group("Unitary test", function (TestCase $inst) { - $mock = $inst->mock(Mailer::class, function (MethodRegistry $pool) use($inst) { - $pool->method("addFromEmail") - ->isPublic() - ->hasDocComment() - ->hasReturnType() - ->isTimes(1); - - $pool->method("addBCC") - ->isPublic() - ->isTimes(3); +$unit = new Unit(); +$unit->group("Your test subject", function (TestCase $case) { + $case->validate("Your test value", function(Expect $valid) { + $valid->isString(); }); - $service = new UserService($mock); }); ``` +> 💡 Tip: Run `php vendor/bin/unitary --template` to auto-generate this boilerplate code above. -### Integration tests: Test Wrapper -Test wrapper is great to make integration test easier. +--- -Most libraries or services has a method that executes the service and runs all the logic. The test wrapper we -can high-jack that execution method and overwrite it with our own logic. +### 3. Run Tests -```php -$dispatch = $this->wrap(PaymentProcessor::class)->bind(function ($orderID) use ($inst) { - // Simulate order retrieval - $order = $this->orderService->getOrder($orderID); - $response = $inst->mock('gatewayCapture')->capture($order->id); - if ($response['status'] !== 'success') { - // Log action within the PaymentProcessor instance - $this->logger->info("Mocked: Capturing payment for Order ID: " . $order->id ?? 0); - // Has successfully found order and logged message - return true; - } - // Failed to find order - return false; -}); +```bash +php vendor/bin/unitary ``` +Need help? -## Configurations - -### Show help ```bash php vendor/bin/unitary --help ``` -### Show only errors -```bash -php vendor/bin/unitary --errors-only -``` +#### The Output: +![Prompt demo](http://wazabii.se/github-assets/maplephp-unitary-result.png) +*And that is it! Your tests have been successfully executed!* -### Select a Test File to Run +With that, you are ready to create your own tests! -After each test, a hash key is shown, allowing you to run specific tests instead of all. +--- -```bash -php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983 -``` +## 📅 Latest Release -### Run Test Case Manually +**v1.3.0 – 2025-06-20** +This release marks Unitary’s transition from a testing utility to a full framework. With the core in place, expect rapid improvements in upcoming versions. -You can also mark a test case to run manually, excluding it from the main test batch. +--- -```php -$unit->hide('maplePHPRequest')->case("MaplePHP Request URI path test", function() { - ... -}); -``` +## 🧱 Built From the Ground Up -And this will only run the manual test: -```bash -php vendor/bin/unitary --show=maplePHPRequest -``` +Unitary stands on a solid foundation of years of groundwork. Before Unitary was possible, these independent components were developed: -### Change Test Path +* [`maplephp/http`](https://github.com/maplephp/http) – PSR-7 HTTP messaging +* [`maplephp/stream`](https://github.com/maplephp/stream) – PHP stream handling +* [`maplephp/cli`](https://github.com/maplephp/prompts) – Interactive prompt/command engine +* [`maplephp/blunder`](https://github.com/maplephp/blunder) – A pretty error handling framework +* [`maplephp/validate`](https://github.com/maplephp/validate) – Type-safe input validation +* [`maplephp/dto`](https://github.com/maplephp/dto) – Strong data transport +* [`maplephp/container`](https://github.com/maplephp/container) – PSR-11 Container, container and DI system -The path argument takes both absolute and relative paths. The command below will find all tests recursively from the "tests" directory. +This full control means everything works together, no patching, no adapters and no guesswork. -```bash -php vendor/bin/unitary --path="/tests/" -``` +--- -**Note: The `vendor` directory will be excluded from tests by default. However, if you change the `--path`, you will need to manually exclude the `vendor` directory.** +## Philosophy -### Exclude Files or Directories +> **Test everything. All the time. Without friction.** -The exclude argument will always be a relative path from the `--path` argument's path. +TDD becomes natural when your test suite runs in under a second, even with 100,000 cases. No more cherry-picking. No more skipping. -```bash -php vendor/bin/unitary --exclude="./tests/unitary-query-php, tests/otherTests/*, */extras/*" -``` +--- ## Like The CLI Theme? That’s DarkBark. Dark, quiet, confident, like a rainy-night synthwave playlist for your CLI. -[Download it here](https://github.com/MaplePHP/DarkBark) \ No newline at end of file +[Download it here](https://github.com/MaplePHP/DarkBark) + + +--- + +## 🤝 Contribute + +Unitary is still young — your bug reports, feedback, and suggestions are hugely appreciated. + +If you like what you see, consider: + +* Reporting issues +* Sharing feedback +* Submitting PRs +* Starring the repo ⭐ + +--- + +## 📬 Stay in Touch + +Follow the full suite of MaplePHP tools: + +* [https://github.com/maplephp](https://github.com/maplephp) diff --git a/src/TestUtils/CodeCoverage.php b/src/TestUtils/CodeCoverage.php new file mode 100644 index 0000000..1b087dd --- /dev/null +++ b/src/TestUtils/CodeCoverage.php @@ -0,0 +1,171 @@ + */ + const ERROR = [ + "No error", + "Xdebug is not available", + "Xdebug is enabled, but coverage mode is missing" + ]; + + private ?array $data = null; + private int $errorCode = 0; + + private array $allowedDirs = []; + private array $excludeDirs = [ + "vendor", + "tests", + "test", + "unit-tests", + "spec", + "bin", + "public", + "storage", + "bootstrap", + "resources", + "database", + "config", + "node_modules", + "coverage-report", + // Exclude below to protect against edge cases + // (like someone accidentally putting a .php file in .github/scripts/ and including it) + ".idea", + ".vscode", + ".git", + ".github" + ]; + + + /** + * Check if Xdebug is enabled + * + * @return bool + */ + public function hasXdebug(): bool + { + if($this->errorCode > 0) { + return false; + } + if (!function_exists('xdebug_info')) { + $this->errorCode = 1; + return false; + } + return true; + } + + /** + * Check if Xdebug has coverage mode enabled. + * + * @return bool + */ + public function hasXdebugCoverage(): bool + { + if(!$this->hasXdebug()) { + return false; + } + $mode = ini_get('xdebug.mode'); + if ($mode === false || !str_contains($mode, 'coverage')) { + $this->errorCode = 1; + return false; + } + return true; + } + + + public function exclude(string|array $path): void + { + + } + + public function whitelist(string|array $path): void + { + + } + + /** + * Start coverage listening + * + * @psalm-suppress UndefinedFunction + * @psalm-suppress UndefinedConstant + * @noinspection PhpUndefinedFunctionInspection + * @noinspection PhpUndefinedConstantInspection + * + * @return void + */ + public function start(): void + { + $this->data = []; + if($this->hasXdebugCoverage()) { + xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE); + } + } + + /** + * End coverage listening + * + * @psalm-suppress UndefinedFunction + * @noinspection PhpUndefinedFunctionInspection + * + * @return void + */ + public function end(): void + { + if($this->data === []) { + throw new BadMethodCallException("You must start code coverage before you can end it"); + } + if($this->hasXdebugCoverage()) { + + $this->data = xdebug_get_code_coverage(); + xdebug_stop_code_coverage(); + } + } + + /** + * Get a Coverage result, will return false if there is an error + * + * @return array|false + */ + public function getResponse(): array|false + { + if($this->errorCode > 0) { + return false; + } + return $this->data; + } + + /** + * Get an error message + * + * @return string + */ + public function getError(): string + { + return self::ERROR[$this->errorCode]; + } + + /** + * Get an error code + * + * @return int + */ + public function getCode(): int + { + return $this->errorCode; + } + + /** + * Check if error exists + * + * @return bool + */ + public function hasError(): bool + { + return ($this->errorCode > 0); + } +} \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index e8c20cf..e2fd38e 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -30,6 +30,7 @@ $mail->addFromEmail("john.doe@gmail.com", "John Doe"); }); + $unit->group("Example of assert in group", function(TestCase $case) { assert(1 === 2, "This is a error message"); }); From 9a871f5543595bee45baf88b4f3d2668d4cb0213 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sun, 22 Jun 2025 16:00:41 +0200 Subject: [PATCH 52/78] feat: Introduce initial MVC setup for CLI --- bin/unitary | 45 +++++----- src/Kernel/Controllers/DefaultController.php | 21 +++++ src/Kernel/Controllers/RunTestController.php | 94 ++++++++++++++++++++ src/Kernel/Kernel.php | 60 +++++++++++++ src/Kernel/routes.php | 6 ++ src/TestUtils/CodeCoverage.php | 10 +++ src/Unit.php | 53 +---------- src/Utils/Dispatcher.php | 28 ++++++ src/Utils/Router.php | 62 +++++++++++++ 9 files changed, 302 insertions(+), 77 deletions(-) create mode 100644 src/Kernel/Controllers/DefaultController.php create mode 100644 src/Kernel/Controllers/RunTestController.php create mode 100644 src/Kernel/Kernel.php create mode 100644 src/Kernel/routes.php create mode 100644 src/Utils/Dispatcher.php create mode 100644 src/Utils/Router.php diff --git a/bin/unitary b/bin/unitary index 0ca8671..af261f9 100755 --- a/bin/unitary +++ b/bin/unitary @@ -5,36 +5,31 @@ * @example php unitary --path=fullDirPath --exclude="dir1/dir2/ */ -require $GLOBALS['_composer_autoload_path']; - +use MaplePHP\Container\Container; use MaplePHP\Http\Environment; use MaplePHP\Http\ServerRequest; use MaplePHP\Http\Uri; -use MaplePHP\Prompts\Command; -use MaplePHP\Unitary\Utils\FileIterator; +use MaplePHP\Unitary\Kernel\Kernel; + +$autoload = __DIR__ . '/../vendor/autoload.php'; +if (!file_exists($autoload)) { + if (!empty($GLOBALS['_composer_autoload_path'])) { + $autoload = $GLOBALS['_composer_autoload_path']; + } else { + fwrite(STDERR, "Autoloader not found. Run `composer install`.\n"); + exit(1); + } +} + +require $autoload; -$command = new Command(); +$container = new Container(); $env = new Environment(); +// Pass argv and expected start directory path where to run tests $request = new ServerRequest(new Uri($env->getUriParts([ - "argv" => $argv + "argv" => $argv, + "dir" => (defined("UNITARY_PATH") ? UNITARY_PATH : "./") ])), $env); -$data = $request->getCliArgs(); -$defaultPath = (defined("UNITARY_PATH") ? UNITARY_PATH : "./"); - -try { - $path = ($data['path'] ?? $defaultPath); - if(!isset($path)) { - throw new Exception("Path not specified: --path=path/to/dir"); - } - - $testDir = realpath($path); - if(!is_dir($testDir)) { - throw new Exception("Test directory '$testDir' does not exist"); - } - $unit = new FileIterator($data); - $unit->executeAll($testDir, $defaultPath); - -} catch (Exception $e) { - $command->error($e->getMessage()); -} +$kernel = new Kernel($request, $container); +$kernel->dispatch(); diff --git a/src/Kernel/Controllers/DefaultController.php b/src/Kernel/Controllers/DefaultController.php new file mode 100644 index 0000000..999552e --- /dev/null +++ b/src/Kernel/Controllers/DefaultController.php @@ -0,0 +1,21 @@ +request = $request; + $this->container = $container; + } +} \ No newline at end of file diff --git a/src/Kernel/Controllers/RunTestController.php b/src/Kernel/Controllers/RunTestController.php new file mode 100644 index 0000000..87501ef --- /dev/null +++ b/src/Kernel/Controllers/RunTestController.php @@ -0,0 +1,94 @@ +container->get("request")->getUri()->getDir(); + try { + $path = ($args['path'] ?? $defaultPath); + if(!isset($path)) { + throw new RuntimeException("Path not specified: --path=path/to/dir"); + } + $testDir = realpath($path); + if(!is_dir($testDir)) { + throw new RuntimeException("Test directory '$testDir' does not exist"); + } + $unit = new FileIterator($args); + $unit->executeAll($testDir, $defaultPath); + + } catch (Exception $e) { + $command->error($e->getMessage()); + } + } + + /** + * Main help page + * + * @param array $args + * @param Command $command + * @return void + */ + public function help(array $args, Command $command): void + { + $blocks = new Blocks($command); + $blocks->addHeadline("\n--- Unitary Help ---"); + $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); + + $blocks->addSection("Options", function(Blocks $inst) { + return $inst + ->addOption("help", "Show this help message") + ->addOption("show=", "Run a specific test by hash or manual test name") + ->addOption("errors-only", "Show only failing tests and skip passed test output") + ->addOption("template", "Will give you a boilerplate test code") + ->addOption("path=", "Specify test path (absolute or relative)") + ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); + }); + + $blocks->addSection("Examples", function(Blocks $inst) { + return $inst + ->addExamples( + "php vendor/bin/unitary", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983", + "Run the test with a specific hash ID" + )->addExamples( + "php vendor/bin/unitary --errors-only", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=YourNameHere", + "Run a manually named test case" + )->addExamples( + "php vendor/bin/unitary --template", + "Run a and will give you template code for a new test" + )->addExamples( + 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', + 'Run all tests under "tests/" excluding specified directories' + ); + }); + // Make sure nothing else is executed when help is triggered + exit(0); + } + +} \ No newline at end of file diff --git a/src/Kernel/Kernel.php b/src/Kernel/Kernel.php new file mode 100644 index 0000000..317126d --- /dev/null +++ b/src/Kernel/Kernel.php @@ -0,0 +1,60 @@ +request = $request; + $this->container = $container; + $this->router = new Router($this->request->getCliKeyword(), $this->request->getCliArgs()); + } + + /** + * Dispatch routes and call controller + * + * @return void + */ + function dispatch() + { + $router = $this->router; + require_once __DIR__ . "/routes.php"; + + $this->container->set("request", $this->request); + + $router->dispatch(function($controller, $args) { + $command = new Command(); + [$class, $method] = $controller; + if(method_exists($class, $method)) { + $inst = new $class($this->request, $this->container); + $inst->{$method}($args, $command); + + } else { + $command->error("The controller {$class}::{$method}() not found"); + } + }); + } +} \ No newline at end of file diff --git a/src/Kernel/routes.php b/src/Kernel/routes.php new file mode 100644 index 0000000..b204ce1 --- /dev/null +++ b/src/Kernel/routes.php @@ -0,0 +1,6 @@ +map(["", "test", "run"], [RunTestController::class, "run"]); +$router->map(["__404", "help"], [RunTestController::class, "help"]); \ No newline at end of file diff --git a/src/TestUtils/CodeCoverage.php b/src/TestUtils/CodeCoverage.php index 1b087dd..681d436 100644 --- a/src/TestUtils/CodeCoverage.php +++ b/src/TestUtils/CodeCoverage.php @@ -1,4 +1,14 @@ template(); - $this->help(); if ($this->executed || $this->disableAllTests) { return false; } @@ -561,57 +561,6 @@ private function template(): void } } - /** - * Display help information for the Unitary testing tool - * Shows usage instructions, available options and examples - * Only displays if --help argument is provided - * - * @return void True if help was displayed, false otherwise - */ - private function help(): void - { - if (self::getArgs("help") !== false) { - - $blocks = new Blocks($this->command); - $blocks->addHeadline("\n--- Unitary Help ---"); - $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); - - $blocks->addSection("Options", function(Blocks $inst) { - return $inst - ->addOption("help", "Show this help message") - ->addOption("show=", "Run a specific test by hash or manual test name") - ->addOption("errors-only", "Show only failing tests and skip passed test output") - ->addOption("template", "Will give you a boilerplate test code") - ->addOption("path=", "Specify test path (absolute or relative)") - ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); - }); - - $blocks->addSection("Examples", function(Blocks $inst) { - return $inst - ->addExamples( - "php vendor/bin/unitary", - "Run all tests in the default path (./tests)" - )->addExamples( - "php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983", - "Run the test with a specific hash ID" - )->addExamples( - "php vendor/bin/unitary --errors-only", - "Run all tests in the default path (./tests)" - )->addExamples( - "php vendor/bin/unitary --show=YourNameHere", - "Run a manually named test case" - )->addExamples( - "php vendor/bin/unitary --template", - "Run a and will give you template code for a new test" - )->addExamples( - 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', - 'Run all tests under "tests/" excluding specified directories' - ); - }); - exit(0); - } - } - /** * Adds a test case to the collection. * diff --git a/src/Utils/Dispatcher.php b/src/Utils/Dispatcher.php new file mode 100644 index 0000000..ebf1da0 --- /dev/null +++ b/src/Utils/Dispatcher.php @@ -0,0 +1,28 @@ +$method($args); + } + +} \ No newline at end of file diff --git a/src/Utils/Router.php b/src/Utils/Router.php new file mode 100644 index 0000000..22fe1f6 --- /dev/null +++ b/src/Utils/Router.php @@ -0,0 +1,62 @@ +args = $argv; + $this->needle = $needle; + } + + /** + * Map one or more needles to controller + + * @param string|array $needles + * @param array $controller + * @return $this + */ + public function map(string|array $needles, array $controller): self + { + if(is_string($needles)) { + $needles = [$needles]; + } + foreach ($needles as $key) { + $this->controllers[$key] = $controller; + } + return $this; + } + + /** + * Dispatch matched router + * + * @param callable $call + * @return bool + */ + function dispatch(callable $call): bool + { + if(isset($this->controllers[$this->needle])) { + $call($this->controllers[$this->needle], $this->args, $this->needle); + return true; + } + if (isset($this->controllers["__404"])) { + $call($this->controllers["__404"], $this->args, $this->needle); + } + return false; + } +} \ No newline at end of file From c89fc39c984dd73e89e36769e729345be1b0fa6c Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Thu, 26 Jun 2025 21:04:55 +0200 Subject: [PATCH 53/78] Add coverage Start building stream handlers --- src/Contracts/AbstractHandler.php | 119 +++++++++++ src/Contracts/HandlerInterface.php | 83 ++++++++ src/Handlers/CliHandler.php | 191 ++++++++++++++++++ src/Kernel/Controllers/CoverageController.php | 101 +++++++++ src/Kernel/Controllers/RunTestController.php | 15 +- src/Kernel/Controllers/TemplateController.php | 98 +++++++++ src/Kernel/routes.php | 5 + src/TestUtils/CodeCoverage.php | 59 +++++- src/TestUtils/Configs.php | 35 ++++ src/Unit.php | 177 ++-------------- src/Utils/Dispatcher.php | 28 --- src/Utils/FileIterator.php | 41 ++-- 12 files changed, 747 insertions(+), 205 deletions(-) create mode 100644 src/Contracts/AbstractHandler.php create mode 100644 src/Contracts/HandlerInterface.php create mode 100644 src/Handlers/CliHandler.php create mode 100644 src/Kernel/Controllers/CoverageController.php create mode 100644 src/Kernel/Controllers/TemplateController.php create mode 100644 src/TestUtils/Configs.php delete mode 100644 src/Utils/Dispatcher.php diff --git a/src/Contracts/AbstractHandler.php b/src/Contracts/AbstractHandler.php new file mode 100644 index 0000000..b94cddb --- /dev/null +++ b/src/Contracts/AbstractHandler.php @@ -0,0 +1,119 @@ +case = $testCase; + } + + /** + * {@inheritDoc} + */ + public function setSuitName(string $title): void + { + $this->suitName = $title; + } + + /** + * {@inheritDoc} + */ + public function setChecksum(string $checksum): void + { + $this->checksum = $checksum; + } + + /** + * {@inheritDoc} + */ + public function setTests(array $tests): void + { + $this->tests = $tests; + } + + /** + * {@inheritDoc} + */ + public function setShow(bool $show): void + { + $this->show = $show; + } + + /** + * {@inheritDoc} + */ + public function outputBuffer(string $outputBuffer): void + { + $this->outputBuffer = $outputBuffer; + } + + /** + * {@inheritDoc} + */ + public function buildBody(): void + { + throw new \RuntimeException('Your handler is missing the execution method.'); + } + + /** + * {@inheritDoc} + */ + public function buildNotes(): void + { + + throw new \RuntimeException('Your handler is missing the execution method.'); + } + + /** + * {@inheritDoc} + */ + public function returnStream(): StreamInterface + { + return new Stream(); + } + + + /** + * Make a file path into a title + * @param string $file + * @param int $length + * @param bool $removeSuffix + * @return string + */ + protected function formatFileTitle(string $file, int $length = 3, bool $removeSuffix = true): string + { + $file = explode("/", $file); + if ($removeSuffix) { + $pop = array_pop($file); + $file[] = substr($pop, (int)strpos($pop, 'unitary') + 8); + } + $file = array_chunk(array_reverse($file), $length); + $file = implode("\\", array_reverse($file[0])); + //$exp = explode('.', $file); + //$file = reset($exp); + return ".." . $file; + } + +} \ No newline at end of file diff --git a/src/Contracts/HandlerInterface.php b/src/Contracts/HandlerInterface.php new file mode 100644 index 0000000..651e16f --- /dev/null +++ b/src/Contracts/HandlerInterface.php @@ -0,0 +1,83 @@ +command = $command; + } + + /** + * {@inheritDoc} + */ + public function buildBody(): void + { + $this->initDefault(); + + $this->command->message(""); + $this->command->message( + $this->flag . " " . + $this->command->getAnsi()->style(["bold"], $this->formatFileTitle($this->suitName)) . + " - " . + $this->command->getAnsi()->style(["bold", $this->color], (string)$this->case->getMessage()) + ); + + if($this->show && !$this->case->hasFailed()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["italic", $this->color], "Test file: " . $this->suitName) + ); + } + + if (($this->show || !$this->case->getConfig()->skip)) { + // Show possible warnings + if($this->case->getWarning()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["italic", "yellow"], $this->case->getWarning()) + ); + } + + $this->showFailedTests(); + } + + $this->showFooter(); + } + + /** + * {@inheritDoc} + */ + public function buildNotes(): void + { + if($this->outputBuffer) { + $lineLength = 80; + $output = wordwrap($this->outputBuffer, $lineLength); + $line = $this->command->getAnsi()->line($lineLength); + + $this->command->message(""); + $this->command->message($this->command->getAnsi()->style(["bold"], "Note:")); + $this->command->message($line); + $this->command->message($output); + $this->command->message($line); + } + } + + /** + * {@inheritDoc} + */ + public function returnStream(): StreamInterface + { + return $this->command->getStream(); + } + + protected function showFooter(): void + { + $select = $this->checksum; + if ($this->case->getConfig()->select) { + $select .= " (" . $this->case->getConfig()->select . ")"; + } + $this->command->message(""); + + $passed = $this->command->getAnsi()->bold("Passed: "); + if ($this->case->getHasAssertError()) { + $passed .= $this->command->getAnsi()->style(["grey"], "N/A"); + } else { + $passed .= $this->command->getAnsi()->style([$this->color], $this->case->getCount() . "/" . $this->case->getTotal()); + } + + $footer = $passed . + $this->command->getAnsi()->style(["italic", "grey"], " - ". $select); + if (!$this->show && $this->case->getConfig()->skip) { + $footer = $this->command->getAnsi()->style(["italic", "grey"], $select); + } + $this->command->message($footer); + $this->command->message(""); + + } + + protected function showFailedTests(): void + { + if (($this->show || !$this->case->getConfig()->skip)) { + // Show possible warnings + if($this->case->getWarning()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["italic", "yellow"], $this->case->getWarning()) + ); + } + foreach ($this->tests as $test) { + + if (!($test instanceof TestUnit)) { + throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); + } + + if (!$test->isValid()) { + $msg = (string)$test->getMessage(); + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["bold", $this->color], "Error: ") . + $this->command->getAnsi()->bold($msg) + ); + $this->command->message(""); + + $trace = $test->getCodeLine(); + if (!empty($trace['code'])) { + $this->command->message($this->command->getAnsi()->style(["bold", "grey"], "Failed on {$trace['file']}:{$trace['line']}")); + $this->command->message($this->command->getAnsi()->style(["grey"], " → {$trace['code']}")); + } + + foreach ($test->getUnits() as $unit) { + + /** @var TestItem $unit */ + if (!$unit->isValid()) { + $lengthA = $test->getValidationLength(); + $validation = $unit->getValidationTitle(); + $title = str_pad($validation, $lengthA); + $compare = $unit->hasComparison() ? $unit->getComparison() : ""; + + $failedMsg = " " .$title . " → failed"; + $this->command->message($this->command->getAnsi()->style($this->color, $failedMsg)); + + if ($compare) { + $lengthB = (strlen($compare) + strlen($failedMsg) - 8); + $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); + $this->command->message( + $this->command->getAnsi()->style($this->color, $comparePad) + ); + } + } + } + if ($test->hasValue()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->bold("Input value: ") . + Helpers::stringifyDataTypes($test->getValue()) + ); + } + } + } + } + } + + protected function initDefault(): void + { + $this->color = ($this->case->hasFailed() ? "brightRed" : "brightBlue"); + if ($this->case->hasFailed()) { + $this->flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); + } + if ($this->case->getConfig()->skip) { + $this->color = "yellow"; + $this->flag = $this->command->getAnsi()->style(['yellowBg', 'black'], " SKIP "); + } + $this->flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); + } +} \ No newline at end of file diff --git a/src/Kernel/Controllers/CoverageController.php b/src/Kernel/Controllers/CoverageController.php new file mode 100644 index 0000000..9b40183 --- /dev/null +++ b/src/Kernel/Controllers/CoverageController.php @@ -0,0 +1,101 @@ +enableExitScript(false); + + $coverage->start(); + $this->iterateTest($commandInMem, $iterator, $args); + $coverage->end(); + + $result = $coverage->getResponse(); + + $block = new Blocks($command); + + $block->addSection("Code coverage", function(Blocks $block) use ($result) { + return $block->addList("Total lines:", $result['totalLines']) + ->addList("Executed lines:", $result['executedLines']) + ->addList("Code coverage percent:", $result['percent']); + }); + + $command->message(""); + $iterator->exitScript(); + } + + /** + * Main help page + * + * @param array $args + * @param Command $command + * @return void + */ + public function help(array $args, Command $command): void + { + $blocks = new Blocks($command); + $blocks->addHeadline("\n--- Unitary Help ---"); + $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); + + $blocks->addSection("Options", function(Blocks $inst) { + return $inst + ->addOption("help", "Show this help message") + ->addOption("show=", "Run a specific test by hash or manual test name") + ->addOption("errors-only", "Show only failing tests and skip passed test output") + ->addOption("template", "Will give you a boilerplate test code") + ->addOption("path=", "Specify test path (absolute or relative)") + ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); + }); + + $blocks->addSection("Examples", function(Blocks $inst) { + return $inst + ->addExamples( + "php vendor/bin/unitary", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983", + "Run the test with a specific hash ID" + )->addExamples( + "php vendor/bin/unitary --errors-only", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=YourNameHere", + "Run a manually named test case" + )->addExamples( + "php vendor/bin/unitary --template", + "Run a and will give you template code for a new test" + )->addExamples( + 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', + 'Run all tests under "tests/" excluding specified directories' + ); + }); + // Make sure nothing else is executed when help is triggered + exit(0); + } + +} \ No newline at end of file diff --git a/src/Kernel/Controllers/RunTestController.php b/src/Kernel/Controllers/RunTestController.php index 87501ef..dd8b5a0 100644 --- a/src/Kernel/Controllers/RunTestController.php +++ b/src/Kernel/Controllers/RunTestController.php @@ -7,6 +7,7 @@ use MaplePHP\Container\Interfaces\NotFoundExceptionInterface; use MaplePHP\Prompts\Command; use MaplePHP\Prompts\Themes\Blocks; +use MaplePHP\Unitary\TestUtils\Configs; use MaplePHP\Unitary\Utils\FileIterator; use RuntimeException; @@ -24,6 +25,14 @@ class RunTestController extends DefaultController */ public function run(array $args, Command $command): void { + $iterator = new FileIterator($args); + $this->iterateTest($command, $iterator, $args); + } + + protected function iterateTest(Command $command, FileIterator $iterator, array $args): void + { + Configs::getInstance()->setCommand($command); + $defaultPath = $this->container->get("request")->getUri()->getDir(); try { $path = ($args['path'] ?? $defaultPath); @@ -31,11 +40,11 @@ public function run(array $args, Command $command): void throw new RuntimeException("Path not specified: --path=path/to/dir"); } $testDir = realpath($path); - if(!is_dir($testDir)) { + if(!file_exists($testDir)) { throw new RuntimeException("Test directory '$testDir' does not exist"); } - $unit = new FileIterator($args); - $unit->executeAll($testDir, $defaultPath); + + $iterator->executeAll($testDir, $defaultPath); } catch (Exception $e) { $command->error($e->getMessage()); diff --git a/src/Kernel/Controllers/TemplateController.php b/src/Kernel/Controllers/TemplateController.php new file mode 100644 index 0000000..c73cf66 --- /dev/null +++ b/src/Kernel/Controllers/TemplateController.php @@ -0,0 +1,98 @@ +addHeadline("\n--- Unitary template ---"); + $blocks->addCode( + <<<'PHP' + use MaplePHP\Unitary\{Unit, TestCase, TestConfig, Expect}; + + $unit = new Unit(); + $unit->group("Your test subject", function (TestCase $case) { + + $case->validate("Your test value", function(Expect $valid) { + $valid->isString(); + }); + + }); + PHP + ); + exit(0); + } + + /** + * Main help page + * + * @param array $args + * @param Command $command + * @return void + */ + public function help(array $args, Command $command): void + { + $blocks = new Blocks($command); + $blocks->addHeadline("\n--- Unitary Help ---"); + $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); + + $blocks->addSection("Options", function(Blocks $inst) { + return $inst + ->addOption("help", "Show this help message") + ->addOption("show=", "Run a specific test by hash or manual test name") + ->addOption("errors-only", "Show only failing tests and skip passed test output") + ->addOption("template", "Will give you a boilerplate test code") + ->addOption("path=", "Specify test path (absolute or relative)") + ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); + }); + + $blocks->addSection("Examples", function(Blocks $inst) { + return $inst + ->addExamples( + "php vendor/bin/unitary", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983", + "Run the test with a specific hash ID" + )->addExamples( + "php vendor/bin/unitary --errors-only", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=YourNameHere", + "Run a manually named test case" + )->addExamples( + "php vendor/bin/unitary --template", + "Run a and will give you template code for a new test" + )->addExamples( + 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', + 'Run all tests under "tests/" excluding specified directories' + ); + }); + // Make sure nothing else is executed when help is triggered + exit(0); + } + +} \ No newline at end of file diff --git a/src/Kernel/routes.php b/src/Kernel/routes.php index b204ce1..1aa5fe0 100644 --- a/src/Kernel/routes.php +++ b/src/Kernel/routes.php @@ -1,6 +1,11 @@ map("coverage", [CoverageController::class, "run"]); +$router->map("template", [TemplateController::class, "run"]); $router->map(["", "test", "run"], [RunTestController::class, "run"]); $router->map(["__404", "help"], [RunTestController::class, "help"]); \ No newline at end of file diff --git a/src/TestUtils/CodeCoverage.php b/src/TestUtils/CodeCoverage.php index 681d436..3d05c41 100644 --- a/src/TestUtils/CodeCoverage.php +++ b/src/TestUtils/CodeCoverage.php @@ -28,10 +28,11 @@ class CodeCoverage private int $errorCode = 0; private array $allowedDirs = []; - private array $excludeDirs = [ + private array $exclude = [ "vendor", "tests", "test", + "unitary-*", "unit-tests", "spec", "bin", @@ -51,7 +52,6 @@ class CodeCoverage ".github" ]; - /** * Check if Xdebug is enabled * @@ -88,9 +88,9 @@ public function hasXdebugCoverage(): bool } - public function exclude(string|array $path): void + public function exclude(array $exclude): void { - + $this->exclude = $exclude; } public function whitelist(string|array $path): void @@ -126,7 +126,7 @@ public function start(): void */ public function end(): void { - if($this->data === []) { + if($this->data === null) { throw new BadMethodCallException("You must start code coverage before you can end it"); } if($this->hasXdebugCoverage()) { @@ -136,6 +136,26 @@ public function end(): void } } + protected function excludePattern(string $file): bool + { + $filename = basename($file); + + foreach ($this->exclude as $pattern) { + if (preg_match('#/' . preg_quote($pattern, '#') . '(/|$)#', $file)) { + return true; + } + if (str_ends_with($pattern, '*')) { + $prefix = substr($pattern, 0, -1); + if (str_starts_with($filename, $prefix)) { + return true; + } + } + } + return false; + } + + + /** * Get a Coverage result, will return false if there is an error * @@ -146,7 +166,34 @@ public function getResponse(): array|false if($this->errorCode > 0) { return false; } - return $this->data; + + $totalLines = 0; + $executedLines = 0; + foreach ($this->data as $file => $lines) { + if ($this->excludePattern($file)) { + continue; + } + + foreach ($lines as $line => $status) { + if ($status === -2) continue; + $totalLines++; + if ($status === 1) { + $executedLines++; + } + } + } + + $percent = $totalLines > 0 ? round(($executedLines / $totalLines) * 100, 2) : 0; + return [ + 'totalLines' => $totalLines, + 'executedLines' => $executedLines, + 'percent' => $percent + ]; + } + + public function getRawData(): array + { + return $this->data ?? []; } /** diff --git a/src/TestUtils/Configs.php b/src/TestUtils/Configs.php new file mode 100644 index 0000000..58bd579 --- /dev/null +++ b/src/TestUtils/Configs.php @@ -0,0 +1,35 @@ +command = $command; + } + + public function getCommand(): Command + { + return self::getInstance()->command; + } + +} diff --git a/src/Unit.php b/src/Unit.php index aca6c61..168a1d0 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -15,6 +15,8 @@ use Closure; use ErrorException; use Exception; +use MaplePHP\Unitary\Handlers\CliHandler; +use MaplePHP\Unitary\TestUtils\Configs; use RuntimeException; use Throwable; use MaplePHP\Blunder\BlunderErrorException; @@ -39,8 +41,6 @@ final class Unit public static int $totalPassedTests = 0; public static int $totalTests = 0; - - /** * Initialize Unit test instance with optional handler * @@ -55,7 +55,7 @@ public function __construct(HandlerInterface|StreamInterface|null $handler = nul $this->handler = $handler; $this->command = $this->handler->getCommand(); } else { - $this->command = new Command($handler); + $this->command = ($handler === null) ? Configs::getInstance()->getCommand() : new Command($handler); } self::$current = $this; } @@ -149,7 +149,6 @@ public function confirm(string $message = "Do you wish to continue?"): bool */ public function add(string $message, Closure $callback): void { - //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); $this->case($message, $callback); } @@ -219,7 +218,6 @@ public function performance(Closure $func, ?string $title = null): void */ public function execute(): bool { - $this->template(); if ($this->executed || $this->disableAllTests) { return false; } @@ -227,6 +225,10 @@ public function execute(): bool // LOOP through each case ob_start(); //$countCases = count($this->cases); + + $handler = new CliHandler(); + $handler->setCommand($this->command); + foreach ($this->cases as $index => $row) { if (!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); @@ -236,15 +238,6 @@ public function execute(): bool $row->dispatchTest($row); $tests = $row->runDeferredValidations(); $checksum = (string)(self::$headers['checksum'] ?? "") . $index; - $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); - $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); - if ($row->hasFailed()) { - $flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); - } - if ($row->getConfig()->skip) { - $color = "yellow"; - $flag = $this->command->getAnsi()->style(['yellowBg', 'black'], " SKIP "); - } $show = ($row->getConfig()->select === self::getArgs('show') || self::getArgs('show') === $checksum); if((self::getArgs('show') !== false) && !$show) { @@ -256,110 +249,29 @@ public function execute(): bool continue; } - $this->command->message(""); - $this->command->message( - $flag . " " . - $this->command->getAnsi()->style(["bold"], $this->formatFileTitle((string)(self::$headers['file'] ?? ""))) . - " - " . - $this->command->getAnsi()->style(["bold", $color], (string)$row->getMessage()) - ); - if($show && !$row->hasFailed()) { - $this->command->message(""); - $this->command->message( - $this->command->getAnsi()->style(["italic", $color], "Test file: " . (string)self::$headers['file']) - ); - } - - if (($show || !$row->getConfig()->skip)) { - // Show possible warnings - if($row->getWarning()) { - $this->command->message(""); - $this->command->message( - $this->command->getAnsi()->style(["italic", "yellow"], $row->getWarning()) - ); - } - foreach ($tests as $test) { - if (!($test instanceof TestUnit)) { - throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); - } - - if (!$test->isValid()) { - $msg = (string)$test->getMessage(); - $this->command->message(""); - $this->command->message( - $this->command->getAnsi()->style(["bold", $color], "Error: ") . - $this->command->getAnsi()->bold($msg) - ); - $this->command->message(""); - - $trace = $test->getCodeLine(); - if (!empty($trace['code'])) { - $this->command->message($this->command->getAnsi()->style(["bold", "grey"], "Failed on {$trace['file']}:{$trace['line']}")); - $this->command->message($this->command->getAnsi()->style(["grey"], " → {$trace['code']}")); - } - - foreach ($test->getUnits() as $unit) { - - /** @var TestItem $unit */ - if (!$unit->isValid()) { - $lengthA = $test->getValidationLength(); - $validation = $unit->getValidationTitle(); - $title = str_pad($validation, $lengthA); - $compare = $unit->hasComparison() ? $unit->getComparison() : ""; - - $failedMsg = " " .$title . " → failed"; - $this->command->message($this->command->getAnsi()->style($color, $failedMsg)); - - if ($compare) { - $lengthB = (strlen($compare) + strlen($failedMsg) - 8); - $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); - $this->command->message( - $this->command->getAnsi()->style($color, $comparePad) - ); - } - } - } - if ($test->hasValue()) { - $this->command->message(""); - $this->command->message( - $this->command->getAnsi()->bold("Input value: ") . - Helpers::stringifyDataTypes($test->getValue()) - ); - } - } - } - } + $handler->setCase($row); + $handler->setSuitName(self::$headers['file'] ?? ""); + $handler->setChecksum($checksum); + $handler->setTests($tests); + $handler->setShow($show); + $handler->buildBody(); // Important to add test from skip as successfully count to make sure that // the total passed tests are correct, and it will not exit with code 1 self::$totalPassedTests += ($row->getConfig()->skip) ? $row->getTotal() : $row->getCount(); self::$totalTests += $row->getTotal(); - if ($row->getConfig()->select) { - $checksum .= " (" . $row->getConfig()->select . ")"; - } - $this->command->message(""); - - $passed = $this->command->getAnsi()->bold("Passed: "); - if ($row->getHasAssertError()) { - $passed .= $this->command->getAnsi()->style(["grey"], "N/A"); - } else { - $passed .= $this->command->getAnsi()->style([$color], $row->getCount() . "/" . $row->getTotal()); - } - - $footer = $passed . - $this->command->getAnsi()->style(["italic", "grey"], " - ". $checksum); - if (!$show && $row->getConfig()->skip) { - $footer = $this->command->getAnsi()->style(["italic", "grey"], $checksum); - } - $this->command->message($footer); - $this->command->message(""); } $this->output .= (string)ob_get_clean(); - + $handler->outputBuffer($this->output); if ($this->output) { - $this->buildNotice("Note:", $this->output, 80); + $handler->buildNotes(); + } + $stream = $handler->returnStream(); + if ($stream->isSeekable()) { + $this->getStream()->rewind(); + echo $this->getStream()->getContents(); } - $this->handler?->execute(); + $this->executed = true; return true; } @@ -393,24 +305,7 @@ public function validate(): self "Move this validate() call inside your group() callback function."); } - /** - * Build the notification stream - * @param string $title - * @param string $output - * @param int $lineLength - * @return void - */ - public function buildNotice(string $title, string $output, int $lineLength): void - { - $this->output = wordwrap($output, $lineLength); - $line = $this->command->getAnsi()->line($lineLength); - $this->command->message(""); - $this->command->message($this->command->getAnsi()->style(["bold"], $title)); - $this->command->message($line); - $this->command->message($this->output); - $this->command->message($line); - } /** * Make a file path into a title @@ -530,36 +425,6 @@ public static function isSuccessful(): bool return (self::$totalPassedTests === self::$totalTests); } - /** - * Display a template for the Unitary testing tool - * Shows a basic template for the Unitary testing tool - * Only displays if --template argument is provided - * - * @return void - */ - private function template(): void - { - if (self::getArgs("template") !== false) { - - $blocks = new Blocks($this->command); - $blocks->addHeadline("\n--- Unitary template ---"); - $blocks->addCode( - <<<'PHP' - use MaplePHP\Unitary\{Unit, TestCase, TestConfig, Expect}; - - $unit = new Unit(); - $unit->group("Your test subject", function (TestCase $case) { - - $case->validate("Your test value", function(Expect $valid) { - $valid->isString(); - }); - - }); - PHP - ); - exit(0); - } - } /** * Adds a test case to the collection. diff --git a/src/Utils/Dispatcher.php b/src/Utils/Dispatcher.php deleted file mode 100644 index ebf1da0..0000000 --- a/src/Utils/Dispatcher.php +++ /dev/null @@ -1,28 +0,0 @@ -$method($args); - } - -} \ No newline at end of file diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index c95fb19..31e1805 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -16,6 +16,7 @@ use MaplePHP\Blunder\Exceptions\BlunderSoftException; use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\Blunder\Run; +use MaplePHP\Prompts\Command; use MaplePHP\Unitary\Unit; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -27,6 +28,8 @@ final class FileIterator public const PATTERN = 'unitary-*.php'; private array $args; + private bool $exitScript = true; + private ?Command $command = null; public function __construct(array $args = []) { @@ -72,10 +75,35 @@ public function executeAll(string $path, string|bool $rootDir = false): void } } Unit::completed(); - exit((int)!Unit::isSuccessful()); + if ($this->exitScript) { + $this->exitScript(); + } + } } + + /** + * You can change the default exist script from enabled to disabled + * + * @param $exitScript + * @return void + */ + public function enableExitScript($exitScript): void + { + $this->exitScript = $exitScript; + } + + /** + * Exist the script with right expected number + * + * @return void + */ + public function exitScript(): void + { + exit((int)!Unit::isSuccessful()); + } + /** * Will Scan and find all unitary test files * @param string $path @@ -178,28 +206,17 @@ private function requireUnitFile(string $file): ?Closure $clone = clone $this; $call = function () use ($file, $clone): void { $cli = new CliHandler(); - if (Unit::getArgs('trace') !== false) { $cli->enableTraceLines(true); } $run = new Run($cli); $run->setExitCode(1); $run->load(); - - //ob_start(); if (!is_file($file)) { throw new RuntimeException("File \"$file\" do not exists."); } require_once($file); - $clone->getUnit()->execute(); - - /* - $outputBuffer = ob_get_clean(); - if (strlen($outputBuffer) && Unit::hasUnit()) { - $clone->getUnit()->buildNotice("Note:", $outputBuffer, 80); - } - */ }; return $call->bindTo(null); } From a8c44147f8b619913572fc4c59f1685f084d6e19 Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Mon, 14 Jul 2025 12:21:02 +0200 Subject: [PATCH 54/78] Adding MVC and dependency injection --- bin/unitary | 10 +- src/Handlers/CliHandler.php | 11 +- src/Kernel/Controllers/DefaultController.php | 12 +- src/Kernel/Controllers/RunTestController.php | 51 +++---- src/Kernel/Kernel.php | 127 ++++++++++++++++-- .../Middlewares/BuildResponseMiddleware.php | 54 ++++++++ src/Unit.php | 5 +- src/Utils/FileIterator.php | 1 - 8 files changed, 216 insertions(+), 55 deletions(-) create mode 100644 src/Kernel/Middlewares/BuildResponseMiddleware.php diff --git a/bin/unitary b/bin/unitary index af261f9..0492ffd 100755 --- a/bin/unitary +++ b/bin/unitary @@ -7,6 +7,7 @@ use MaplePHP\Container\Container; use MaplePHP\Http\Environment; +use MaplePHP\Http\ResponseFactory; use MaplePHP\Http\ServerRequest; use MaplePHP\Http\Uri; use MaplePHP\Unitary\Kernel\Kernel; @@ -23,7 +24,7 @@ if (!file_exists($autoload)) { require $autoload; -$container = new Container(); + $env = new Environment(); // Pass argv and expected start directory path where to run tests $request = new ServerRequest(new Uri($env->getUriParts([ @@ -31,5 +32,10 @@ $request = new ServerRequest(new Uri($env->getUriParts([ "dir" => (defined("UNITARY_PATH") ? UNITARY_PATH : "./") ])), $env); -$kernel = new Kernel($request, $container); + +$factory = new ResponseFactory(); +$kernel = new Kernel($factory); + +$kernel->run($request, new \MaplePHP\Emitron\Emitters\CliEmitter()); + $kernel->dispatch(); diff --git a/src/Handlers/CliHandler.php b/src/Handlers/CliHandler.php index 6be2371..3890157 100644 --- a/src/Handlers/CliHandler.php +++ b/src/Handlers/CliHandler.php @@ -47,6 +47,7 @@ public function buildBody(): void } if (($this->show || !$this->case->getConfig()->skip)) { + // Show possible warnings if($this->case->getWarning()) { $this->command->message(""); @@ -55,6 +56,7 @@ public function buildBody(): void ); } + // Show Failed tests $this->showFailedTests(); } @@ -115,13 +117,6 @@ protected function showFooter(): void protected function showFailedTests(): void { if (($this->show || !$this->case->getConfig()->skip)) { - // Show possible warnings - if($this->case->getWarning()) { - $this->command->message(""); - $this->command->message( - $this->command->getAnsi()->style(["italic", "yellow"], $this->case->getWarning()) - ); - } foreach ($this->tests as $test) { if (!($test instanceof TestUnit)) { @@ -179,6 +174,7 @@ protected function showFailedTests(): void protected function initDefault(): void { $this->color = ($this->case->hasFailed() ? "brightRed" : "brightBlue"); + $this->flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); if ($this->case->hasFailed()) { $this->flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); } @@ -186,6 +182,5 @@ protected function initDefault(): void $this->color = "yellow"; $this->flag = $this->command->getAnsi()->style(['yellowBg', 'black'], " SKIP "); } - $this->flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); } } \ No newline at end of file diff --git a/src/Kernel/Controllers/DefaultController.php b/src/Kernel/Controllers/DefaultController.php index 999552e..52c0314 100644 --- a/src/Kernel/Controllers/DefaultController.php +++ b/src/Kernel/Controllers/DefaultController.php @@ -5,17 +5,19 @@ use MaplePHP\Container\Interfaces\ContainerInterface; use MaplePHP\Http\Interfaces\RequestInterface; use MaplePHP\Http\Interfaces\ServerRequestInterface; +use MaplePHP\Prompts\Command; abstract class DefaultController { protected readonly ServerRequestInterface|RequestInterface $request; protected readonly ContainerInterface $container; + protected Command $command; + protected array $args; - public function __construct( - ServerRequestInterface|RequestInterface $request, - ContainerInterface $container - ) { - $this->request = $request; + public function __construct(ContainerInterface $container) { $this->container = $container; + $this->args = $this->container->get("args"); + $this->command = $this->container->get("command"); + $this->request = $this->container->get("request"); } } \ No newline at end of file diff --git a/src/Kernel/Controllers/RunTestController.php b/src/Kernel/Controllers/RunTestController.php index dd8b5a0..1e492ed 100644 --- a/src/Kernel/Controllers/RunTestController.php +++ b/src/Kernel/Controllers/RunTestController.php @@ -5,6 +5,7 @@ use Exception; use MaplePHP\Container\Interfaces\ContainerExceptionInterface; use MaplePHP\Container\Interfaces\NotFoundExceptionInterface; +use MaplePHP\Http\Interfaces\ResponseInterface; use MaplePHP\Prompts\Command; use MaplePHP\Prompts\Themes\Blocks; use MaplePHP\Unitary\TestUtils\Configs; @@ -17,38 +18,42 @@ class RunTestController extends DefaultController /** * Main test runner * - * @param array $args - * @param Command $command - * @return void - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface + * @return FileIterator */ - public function run(array $args, Command $command): void + public function run(): FileIterator { - $iterator = new FileIterator($args); - $this->iterateTest($command, $iterator, $args); + $iterator = new FileIterator($this->args); + return $this->iterateTest($this->command, $iterator, $this->args); } - protected function iterateTest(Command $command, FileIterator $iterator, array $args): void + /** + * @param Command $command + * @param FileIterator $iterator + * @param array $args + * @return FileIterator + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws \MaplePHP\Blunder\Exceptions\BlunderSoftException + */ + protected function iterateTest(Command $command, FileIterator $iterator, array $args): FileIterator { Configs::getInstance()->setCommand($command); $defaultPath = $this->container->get("request")->getUri()->getDir(); - try { - $path = ($args['path'] ?? $defaultPath); - if(!isset($path)) { - throw new RuntimeException("Path not specified: --path=path/to/dir"); - } - $testDir = realpath($path); - if(!file_exists($testDir)) { - throw new RuntimeException("Test directory '$testDir' does not exist"); - } + $path = ($args['path'] ?? $defaultPath); + if(!isset($path)) { + throw new RuntimeException("Path not specified: --path=path/to/dir"); + } + $testDir = realpath($path); - $iterator->executeAll($testDir, $defaultPath); - } catch (Exception $e) { - $command->error($e->getMessage()); + if(!file_exists($testDir)) { + throw new RuntimeException("Test directory '$testDir' does not exist"); } + + $iterator->enableExitScript(false); + $iterator->executeAll($testDir, $defaultPath); + return $iterator; } /** @@ -58,9 +63,9 @@ protected function iterateTest(Command $command, FileIterator $iterator, array $ * @param Command $command * @return void */ - public function help(array $args, Command $command): void + public function help(): void { - $blocks = new Blocks($command); + $blocks = new Blocks($this->command); $blocks->addHeadline("\n--- Unitary Help ---"); $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); diff --git a/src/Kernel/Kernel.php b/src/Kernel/Kernel.php index 317126d..c6f62ac 100644 --- a/src/Kernel/Kernel.php +++ b/src/Kernel/Kernel.php @@ -14,47 +14,146 @@ namespace MaplePHP\Unitary\Kernel; use MaplePHP\Container\Interfaces\ContainerInterface; +use MaplePHP\Container\Reflection; +use MaplePHP\Emitron\Contracts\EmitterInterface; +use MaplePHP\Emitron\Emitters\CliEmitter; +use MaplePHP\Emitron\Emitters\HttpEmitter; +use MaplePHP\Emitron\RequestHandler; use MaplePHP\Http\Interfaces\RequestInterface; +use MaplePHP\Http\Interfaces\ResponseFactoryInterface; +use MaplePHP\Http\Interfaces\ResponseInterface; use MaplePHP\Http\Interfaces\ServerRequestInterface; +use MaplePHP\Http\Response; +use MaplePHP\Http\ResponseFactory; +use MaplePHP\Http\Stream; use MaplePHP\Prompts\Command; +use MaplePHP\Unitary\Utils\FileIterator; use MaplePHP\Unitary\Utils\Router; class Kernel { - private ServerRequestInterface $request; + private ResponseFactoryInterface $responseFactory; private ContainerInterface $container; - private Router $router; + private array $userMiddlewares; - function __construct(ServerRequestInterface|RequestInterface $request, ContainerInterface $container) + function __construct(ResponseFactoryInterface $responseFactory, ContainerInterface $container, array $userMiddlewares = []) { - $this->request = $request; + $this->responseFactory = $responseFactory; + $this->userMiddlewares = $userMiddlewares; $this->container = $container; - $this->router = new Router($this->request->getCliKeyword(), $this->request->getCliArgs()); } /** - * Dispatch routes and call controller + * You can bind an instance (singleton) to an interface class that then is loaded through + * the dependency injector preferably that in implementable of that class * + * @param callable $call * @return void */ - function dispatch() + public function bindInstToInterfaces(callable $call): void { - $router = $this->router; + Reflection::interfaceFactory($call); + } + + /** + * Run the emitter and init all routes, middlewares and configs + * + * @param ServerRequestInterface|RequestInterface $request + * @return void + * @throws \ReflectionException + */ + public function run(ServerRequestInterface|RequestInterface $request): void + { + + $router = new Router($request->getCliKeyword(), $request->getCliArgs()); require_once __DIR__ . "/routes.php"; - $this->container->set("request", $this->request); + $router->dispatch(function($controller, $args) use ($request) { + + $fileIterator = null; + $response = $this->createResponse(); + $handler = new RequestHandler($this->userMiddlewares, $response); + $command = new Command($response); + + $this->container->set("command", $command); + $this->container->set("request", $request); + $this->container->set("args", $args); + + $response = $handler->handle($request); + $this->bindCoreInstToInterfaces($request, $response); - $router->dispatch(function($controller, $args) { - $command = new Command(); [$class, $method] = $controller; if(method_exists($class, $method)) { - $inst = new $class($this->request, $this->container); - $inst->{$method}($args, $command); + $reflect = new Reflection($class); + $classInst = $reflect->dependencyInjector(); + // Can replace the active Response instance through Command instance + $hasNewResponse = $reflect->dependencyInjector($classInst, $method); + + $response = ($hasNewResponse instanceof ResponseInterface) ? $hasNewResponse : $response; + $fileIterator = ($hasNewResponse instanceof FileIterator) ? $hasNewResponse : null; } else { - $command->error("The controller {$class}::{$method}() not found"); + $response->getBody()->write("\nERROR: Could not load Controller class {$class} and method {$method}()\n"); + } + + $this->getEmitter()->emit($response, $request); + + if ($fileIterator !== null && $this->isCli()) { + $fileIterator->exitScript(); } }); } + + /** + * Will bind core instances (singletons) to interface classes that then is loaded through + * the dependency injector preferably that in implementable of that class + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @return void + */ + private function bindCoreInstToInterfaces(RequestInterface $request, ResponseInterface $response): void + { + Reflection::interfaceFactory(function ($className) use ($request, $response) { + return match ($className) { + "ContainerInterface" => $this->container, + "RequestInterface" => $request, + "ResponseInterface" => $response, + default => null, + }; + }); + } + + /** + * Check if is inside a command line interface (CLI) + * + * @return bool + */ + private function isCli(): bool + { + return PHP_SAPI === 'cli'; + } + + /** + * Will Create preferred Stream and Response instance depending on a platform + * + * @return ResponseInterface + */ + private function createResponse(): ResponseInterface + { + $stream = new Stream($this->isCli() ? Stream::STDOUT : Stream::TEMP); + $factory = new ResponseFactory(); + return $factory->createResponse(body: $stream); + } + + /** + * Get emitter based on a platform + * + * @return EmitterInterface + */ + private function getEmitter(): EmitterInterface + { + return $this->isCli() ? new CliEmitter() : new HttpEmitter(); + } } \ No newline at end of file diff --git a/src/Kernel/Middlewares/BuildResponseMiddleware.php b/src/Kernel/Middlewares/BuildResponseMiddleware.php new file mode 100644 index 0000000..341f9e9 --- /dev/null +++ b/src/Kernel/Middlewares/BuildResponseMiddleware.php @@ -0,0 +1,54 @@ +controllerDispatch = $controllerDispatch; + } + + /** + * Get the body content length reliably with PSR Stream. + * + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + //$this->container->set("request", $this->request); + //$this->container->set("args", $args); + + $command = new Command(); + [$class, $method] = $this->controllerDispatch; + if(method_exists($class, $method)) { + + $inst = new $class($this->request, $this->container); + $response = $inst->{$method}($args, $command); + + + if ($response instanceof ResponseInterface) { + $stream = $response->getBody(); + $stream->rewind(); + echo $stream->getContents(); + } + + } else { + $command->error("The controller {$class}::{$method}() not found"); + } + + return $response; + } +} \ No newline at end of file diff --git a/src/Unit.php b/src/Unit.php index 168a1d0..2789bed 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -22,9 +22,7 @@ use MaplePHP\Blunder\BlunderErrorException; use MaplePHP\Http\Interfaces\StreamInterface; use MaplePHP\Prompts\Command; -use MaplePHP\Prompts\Themes\Blocks; use MaplePHP\Unitary\Handlers\HandlerInterface; -use MaplePHP\Unitary\Utils\Helpers; use MaplePHP\Unitary\Utils\Performance; final class Unit @@ -266,11 +264,14 @@ public function execute(): bool if ($this->output) { $handler->buildNotes(); } + + /* $stream = $handler->returnStream(); if ($stream->isSeekable()) { $this->getStream()->rewind(); echo $this->getStream()->getContents(); } + */ $this->executed = true; return true; diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index 31e1805..774a005 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -82,7 +82,6 @@ public function executeAll(string $path, string|bool $rootDir = false): void } } - /** * You can change the default exist script from enabled to disabled * From 9ccc208ed9a8d6cc7c9051308d576a1b8707668a Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Wed, 16 Jul 2025 12:40:41 +0200 Subject: [PATCH 55/78] Improving the MVC, DI and Middleware setup --- src/Contracts/RouterInterface.php | 29 ++++++ src/Kernel/Controllers/DefaultController.php | 2 +- src/Kernel/Controllers/RunTestController.php | 8 +- src/Kernel/DispatchConfig.php | 34 +++++++ src/Kernel/Kernel.php | 89 +++++++++++++------ .../Middlewares/AddCommandMiddleware.php | 30 +++++++ .../Middlewares/BuildResponseMiddleware.php | 54 ----------- src/Utils/FileIterator.php | 12 ++- src/Utils/Router.php | 20 ++++- 9 files changed, 192 insertions(+), 86 deletions(-) create mode 100644 src/Contracts/RouterInterface.php create mode 100644 src/Kernel/DispatchConfig.php create mode 100644 src/Kernel/Middlewares/AddCommandMiddleware.php delete mode 100644 src/Kernel/Middlewares/BuildResponseMiddleware.php diff --git a/src/Contracts/RouterInterface.php b/src/Contracts/RouterInterface.php new file mode 100644 index 0000000..7b8fbd3 --- /dev/null +++ b/src/Contracts/RouterInterface.php @@ -0,0 +1,29 @@ +container->get("dispatchConfig"); $iterator = new FileIterator($this->args); - return $this->iterateTest($this->command, $iterator, $this->args); + $iterator = $this->iterateTest($this->command, $iterator, $this->args); + + //$config->setExitCode($iterator->getExitCode()); + return $iterator; } /** diff --git a/src/Kernel/DispatchConfig.php b/src/Kernel/DispatchConfig.php new file mode 100644 index 0000000..6707dda --- /dev/null +++ b/src/Kernel/DispatchConfig.php @@ -0,0 +1,34 @@ +exitCode; + } + + /** + * Add exit after execution of the app has been completed + * + * @param int|null $exitCode + * @return $this + */ + public function setExitCode(int|null $exitCode): self + { + $this->exitCode = $exitCode; + return $this; + } +} \ No newline at end of file diff --git a/src/Kernel/Kernel.php b/src/Kernel/Kernel.php index c6f62ac..257f55b 100644 --- a/src/Kernel/Kernel.php +++ b/src/Kernel/Kernel.php @@ -13,37 +13,45 @@ namespace MaplePHP\Unitary\Kernel; -use MaplePHP\Container\Interfaces\ContainerInterface; +use Psr\Container\ContainerInterface; use MaplePHP\Container\Reflection; use MaplePHP\Emitron\Contracts\EmitterInterface; use MaplePHP\Emitron\Emitters\CliEmitter; use MaplePHP\Emitron\Emitters\HttpEmitter; use MaplePHP\Emitron\RequestHandler; -use MaplePHP\Http\Interfaces\RequestInterface; -use MaplePHP\Http\Interfaces\ResponseFactoryInterface; use MaplePHP\Http\Interfaces\ResponseInterface; use MaplePHP\Http\Interfaces\ServerRequestInterface; -use MaplePHP\Http\Response; use MaplePHP\Http\ResponseFactory; use MaplePHP\Http\Stream; +use MaplePHP\Log\InvalidArgumentException; use MaplePHP\Prompts\Command; +use MaplePHP\Unitary\Contracts\RouterInterface; use MaplePHP\Unitary\Utils\FileIterator; use MaplePHP\Unitary\Utils\Router; class Kernel { - private ResponseFactoryInterface $responseFactory; private ContainerInterface $container; private array $userMiddlewares; + private ?RouterInterface $router = null; + private ?int $exitCode = null; + private ?DispatchConfig $dispatchConfig = null; - function __construct(ResponseFactoryInterface $responseFactory, ContainerInterface $container, array $userMiddlewares = []) + function __construct(ContainerInterface $container, array $userMiddlewares = []) { - $this->responseFactory = $responseFactory; $this->userMiddlewares = $userMiddlewares; $this->container = $container; } + public function getDispatchConfig(): DispatchConfig + { + if ($this->dispatchConfig === null) { + $this->dispatchConfig = new DispatchConfig(); + } + return $this->dispatchConfig; + } + /** * You can bind an instance (singleton) to an interface class that then is loaded through * the dependency injector preferably that in implementable of that class @@ -56,29 +64,44 @@ public function bindInstToInterfaces(callable $call): void Reflection::interfaceFactory($call); } + public function addExitCode(int $exitCode): self + { + $inst = clone $this; + $inst->exitCode = $exitCode; + return $inst; + } + + public function addRouter(RouterInterface $router): self + { + $inst = clone $this; + $inst->router = $router; + return $inst; + } + /** * Run the emitter and init all routes, middlewares and configs * - * @param ServerRequestInterface|RequestInterface $request + * @param ServerRequestInterface $request * @return void * @throws \ReflectionException */ - public function run(ServerRequestInterface|RequestInterface $request): void + public function run(ServerRequestInterface $request): void { + $router = $this->createRouter($request->getCliKeyword(), $request->getCliArgs()); - $router = new Router($request->getCliKeyword(), $request->getCliArgs()); - require_once __DIR__ . "/routes.php"; - - $router->dispatch(function($controller, $args) use ($request) { + $router->dispatch(function($data, $args) use ($request) { + if (!isset($data['handler'])) { + throw new InvalidArgumentException("The router dispatch method arg 1 is missing the 'handler' key."); + } - $fileIterator = null; + $controller = $data['handler']; $response = $this->createResponse(); + $handler = new RequestHandler($this->userMiddlewares, $response); - $command = new Command($response); - $this->container->set("command", $command); $this->container->set("request", $request); $this->container->set("args", $args); + $this->container->set("dispatchConfig", $this->getDispatchConfig()); $response = $handler->handle($request); $this->bindCoreInstToInterfaces($request, $response); @@ -90,17 +113,16 @@ public function run(ServerRequestInterface|RequestInterface $request): void // Can replace the active Response instance through Command instance $hasNewResponse = $reflect->dependencyInjector($classInst, $method); - $response = ($hasNewResponse instanceof ResponseInterface) ? $hasNewResponse : $response; - $fileIterator = ($hasNewResponse instanceof FileIterator) ? $hasNewResponse : null; + } else { $response->getBody()->write("\nERROR: Could not load Controller class {$class} and method {$method}()\n"); } - $this->getEmitter()->emit($response, $request); + $this->createEmitter()->emit($response, $request); - if ($fileIterator !== null && $this->isCli()) { - $fileIterator->exitScript(); + if ($this->getDispatchConfig()->getExitCode() !== null) { + exit($this->exitCode); } }); } @@ -109,16 +131,16 @@ public function run(ServerRequestInterface|RequestInterface $request): void * Will bind core instances (singletons) to interface classes that then is loaded through * the dependency injector preferably that in implementable of that class * - * @param RequestInterface $request + * @param ServerRequestInterface $request * @param ResponseInterface $response * @return void */ - private function bindCoreInstToInterfaces(RequestInterface $request, ResponseInterface $response): void + private function bindCoreInstToInterfaces(ServerRequestInterface $request, ResponseInterface $response): void { Reflection::interfaceFactory(function ($className) use ($request, $response) { return match ($className) { "ContainerInterface" => $this->container, - "RequestInterface" => $request, + "RequestInterface", "ServerRequestInterface" => $request, "ResponseInterface" => $response, default => null, }; @@ -135,6 +157,23 @@ private function isCli(): bool return PHP_SAPI === 'cli'; } + /** + * Will create the router + * + * @param string $needle + * @param array $argv + * @return RouterInterface + */ + private function createRouter(string $needle, array $argv): RouterInterface + { + if($this->router !== null) { + return $this->router; + } + $router = new Router($needle, $argv); + require_once __DIR__ . "/routes.php"; + return $router; + } + /** * Will Create preferred Stream and Response instance depending on a platform * @@ -152,7 +191,7 @@ private function createResponse(): ResponseInterface * * @return EmitterInterface */ - private function getEmitter(): EmitterInterface + private function createEmitter(): EmitterInterface { return $this->isCli() ? new CliEmitter() : new HttpEmitter(); } diff --git a/src/Kernel/Middlewares/AddCommandMiddleware.php b/src/Kernel/Middlewares/AddCommandMiddleware.php new file mode 100644 index 0000000..f6c5721 --- /dev/null +++ b/src/Kernel/Middlewares/AddCommandMiddleware.php @@ -0,0 +1,30 @@ +container = $container; + } + + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $response = $handler->handle($request); + $this->container->set("command", new Command($response)); + return $response; + } + +} \ No newline at end of file diff --git a/src/Kernel/Middlewares/BuildResponseMiddleware.php b/src/Kernel/Middlewares/BuildResponseMiddleware.php deleted file mode 100644 index 341f9e9..0000000 --- a/src/Kernel/Middlewares/BuildResponseMiddleware.php +++ /dev/null @@ -1,54 +0,0 @@ -controllerDispatch = $controllerDispatch; - } - - /** - * Get the body content length reliably with PSR Stream. - * - * @param ServerRequestInterface $request - * @param RequestHandlerInterface $handler - * @return ResponseInterface - */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - //$this->container->set("request", $this->request); - //$this->container->set("args", $args); - - $command = new Command(); - [$class, $method] = $this->controllerDispatch; - if(method_exists($class, $method)) { - - $inst = new $class($this->request, $this->container); - $response = $inst->{$method}($args, $command); - - - if ($response instanceof ResponseInterface) { - $stream = $response->getBody(); - $stream->rewind(); - echo $stream->getContents(); - } - - } else { - $command->error("The controller {$class}::{$method}() not found"); - } - - return $response; - } -} \ No newline at end of file diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index 774a005..1bc6a70 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -100,7 +100,17 @@ public function enableExitScript($exitScript): void */ public function exitScript(): void { - exit((int)!Unit::isSuccessful()); + exit($this->getExitCode()); + } + + /** + * Get expected exit code + * + * @return int + */ + public function getExitCode(): int + { + return (int)!Unit::isSuccessful(); } /** diff --git a/src/Utils/Router.php b/src/Utils/Router.php index 22fe1f6..834ccb4 100644 --- a/src/Utils/Router.php +++ b/src/Utils/Router.php @@ -12,7 +12,10 @@ namespace MaplePHP\Unitary\Utils; -class Router +use InvalidArgumentException; +use MaplePHP\Unitary\Contracts\RouterInterface; + +class Router implements RouterInterface { private array $controllers = []; private string $needle = ""; @@ -26,18 +29,27 @@ public function __construct(string $needle, array $argv) /** * Map one or more needles to controller - + * * @param string|array $needles * @param array $controller + * @param array $args Pass custom data to router * @return $this */ - public function map(string|array $needles, array $controller): self + public function map(string|array $needles, array $controller, array $args = []): self { + if(isset($args['handler'])) { + throw new InvalidArgumentException('The handler argument is reserved, you can not use that key.'); + } + if(is_string($needles)) { $needles = [$needles]; } + foreach ($needles as $key) { - $this->controllers[$key] = $controller; + $this->controllers[$key] = [ + "handler" => $controller, + ...$args + ]; } return $this; } From e5f01d65bb47b65311d78c829f6c37b9a7374818 Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Sun, 20 Jul 2025 22:06:21 +0200 Subject: [PATCH 56/78] Refactor and structure improvements Add no content if no matching controller --- src/Contracts/RouterDispatchInterface.php | 14 ++ src/Contracts/RouterInterface.php | 11 +- src/Kernel/AbstractKernel.php | 159 ++++++++++++++ src/Kernel/Controllers/CoverageController.php | 51 +++-- src/Kernel/Controllers/RunTestController.php | 18 +- src/Kernel/Controllers/TemplateController.php | 62 +----- src/Kernel/Enum/CoverageIssue.php | 29 +++ src/Kernel/Kernel.php | 202 +++++------------- .../Middlewares/AddCommandMiddleware.php | 16 +- src/Kernel/routes.php | 2 +- src/TestUtils/CodeCoverage.php | 81 ++++--- src/Utils/Router.php | 4 +- unitary.config.php | 4 + 13 files changed, 355 insertions(+), 298 deletions(-) create mode 100644 src/Contracts/RouterDispatchInterface.php create mode 100644 src/Kernel/AbstractKernel.php create mode 100644 src/Kernel/Enum/CoverageIssue.php create mode 100644 unitary.config.php diff --git a/src/Contracts/RouterDispatchInterface.php b/src/Contracts/RouterDispatchInterface.php new file mode 100644 index 0000000..2830af6 --- /dev/null +++ b/src/Contracts/RouterDispatchInterface.php @@ -0,0 +1,14 @@ +userMiddlewares = $userMiddlewares; + $this->container = $container; + $this->dispatchConfig = ($dispatchConfig === null) ? + new DispatchConfig(static::getConfigFilePath()) : $dispatchConfig; + } + + /** + * Makes it easy to specify a config file inside a custom kernel file + * + * @param string $path + * @return void + */ + public static function setConfigFilePath(string $path): void + { + static::$configFilePath = $path; + } + + /** + * Get expected config file + * + * @return string + */ + public static function getConfigFilePath(): string + { + if(static::$configFilePath === null) { + return static::CONFIG_FILE_PATH; + } + return static::$configFilePath; + } + + /** + * Get config instance for configure dispatch result + * + * @return DispatchConfig + */ + public function getDispatchConfig(): DispatchConfig + { + return $this->dispatchConfig; + } + + /** + * You can bind an instance (singleton) to an interface class that then is loaded through + * the dependency injector preferably that in implementable of that class + * + * @param callable $call + * @return void + */ + public function bindInstToInterfaces(callable $call): void + { + Reflection::interfaceFactory($call); + } + + /** + * Will bind core instances (singletons) to interface classes that then is loaded through + * the dependency injector preferably that in implementable of that class + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return void + */ + protected function bindCoreInstToInterfaces(ServerRequestInterface $request, ResponseInterface $response): void + { + Reflection::interfaceFactory(function ($className) use ($request, $response) { + return match ($className) { + "ContainerInterface" => $this->container, + "RequestInterface", "ServerRequestInterface" => $request, + "ResponseInterface" => $response, + default => null, + }; + }); + } + + /** + * Check if is inside a command line interface (CLI) + * + * @return bool + */ + protected function isCli(): bool + { + return PHP_SAPI === 'cli'; + } + + /** + * Will Create preferred Stream and Response instance depending on a platform + * + * @return ResponseInterface + */ + protected function createResponse(): ResponseInterface + { + $stream = new Stream($this->isCli() ? Stream::STDOUT : Stream::TEMP); + $factory = new ResponseFactory(); + $response = $factory->createResponse(body: $stream); + if ($this->isCli()) { + // In CLI, the status code is used as the exit code rather than an HTTP status code. + // By default, a successful execution should return 0 as the exit code. + $response = $response->withStatus(0); + } + return $response; + } + + /** + * Get emitter based on a platform + * + * @return EmitterInterface + */ + protected function createEmitter(): EmitterInterface + { + return $this->isCli() ? new CliEmitter() : new HttpEmitter(); + } + +} \ No newline at end of file diff --git a/src/Kernel/Controllers/CoverageController.php b/src/Kernel/Controllers/CoverageController.php index 9b40183..915ea5c 100644 --- a/src/Kernel/Controllers/CoverageController.php +++ b/src/Kernel/Controllers/CoverageController.php @@ -2,15 +2,15 @@ namespace MaplePHP\Unitary\Kernel\Controllers; -use Exception; +use MaplePHP\Blunder\Exceptions\BlunderSoftException; use MaplePHP\Container\Interfaces\ContainerExceptionInterface; -use MaplePHP\Container\Interfaces\NotFoundExceptionInterface; use MaplePHP\Http\Stream; use MaplePHP\Prompts\Command; use MaplePHP\Prompts\Themes\Blocks; +use MaplePHP\Unitary\Kernel\DispatchConfig; use MaplePHP\Unitary\TestUtils\CodeCoverage; use MaplePHP\Unitary\Utils\FileIterator; -use RuntimeException; +use Psr\Container\NotFoundExceptionInterface; class CoverageController extends RunTestController { @@ -18,47 +18,54 @@ class CoverageController extends RunTestController /** * Main test runner * - * @param array $args - * @param Command $command * @return void + * @throws BlunderSoftException + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface */ - public function run(array $args, Command $command): void + public function run(): void { - $coverage = new CodeCoverage(); + /** @var DispatchConfig $config */ + $config = $this->container->get("dispatchConfig"); + // Create a silent handler + $coverage = new CodeCoverage(); $commandInMem = new Command(new Stream(Stream::TEMP)); - $iterator = new FileIterator($args); - $iterator->enableExitScript(false); + $iterator = new FileIterator($this->args); + $config->setExitCode($iterator->getExitCode()); $coverage->start(); - $this->iterateTest($commandInMem, $iterator, $args); + $this->iterateTest($commandInMem, $iterator, $this->args); $coverage->end(); $result = $coverage->getResponse(); - $block = new Blocks($command); + if($result !== false) { + $block = new Blocks($this->command); + $block->addSection("Code coverage", function(Blocks $block) use ($result) { + return $block->addList("Total lines:", $result['totalLines']) + ->addList("Executed lines:", $result['executedLines']) + ->addList("Code coverage percent:", $result['percent']); + }); - $block->addSection("Code coverage", function(Blocks $block) use ($result) { - return $block->addList("Total lines:", $result['totalLines']) - ->addList("Executed lines:", $result['executedLines']) - ->addList("Code coverage percent:", $result['percent']); - }); + } else { + $this->command->error("Error: Code coverage is not reachable"); + $this->command->error("Reason: " . $coverage->getIssue()->message()); + } - $command->message(""); - $iterator->exitScript(); + $this->command->message(""); } /** * Main help page * - * @param array $args - * @param Command $command * @return void */ - public function help(array $args, Command $command): void + public function help(): void { - $blocks = new Blocks($command); + die(); + $blocks = new Blocks($this->command); $blocks->addHeadline("\n--- Unitary Help ---"); $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); diff --git a/src/Kernel/Controllers/RunTestController.php b/src/Kernel/Controllers/RunTestController.php index b8a2275..7d4d446 100644 --- a/src/Kernel/Controllers/RunTestController.php +++ b/src/Kernel/Controllers/RunTestController.php @@ -4,7 +4,7 @@ use Exception; use MaplePHP\Container\Interfaces\ContainerExceptionInterface; -use MaplePHP\Container\Interfaces\NotFoundExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; use MaplePHP\Http\Interfaces\ResponseInterface; use MaplePHP\Prompts\Command; use MaplePHP\Prompts\Themes\Blocks; @@ -18,18 +18,20 @@ class RunTestController extends DefaultController /** * Main test runner - * - * @return FileIterator */ - public function run(): FileIterator + public function run(ResponseInterface $response): ResponseInterface { - /** @var DispatchConfig $config */ - $config = $this->container->get("dispatchConfig"); + // /** @var DispatchConfig $config */ + // $config = $this->container->get("dispatchConfig"); $iterator = new FileIterator($this->args); $iterator = $this->iterateTest($this->command, $iterator, $this->args); - //$config->setExitCode($iterator->getExitCode()); - return $iterator; + // CLI Response + if(PHP_SAPI === 'cli') { + return $response->withStatus($iterator->getExitCode()); + } + // Text/Browser Response + return $response; } /** diff --git a/src/Kernel/Controllers/TemplateController.php b/src/Kernel/Controllers/TemplateController.php index c73cf66..98874c8 100644 --- a/src/Kernel/Controllers/TemplateController.php +++ b/src/Kernel/Controllers/TemplateController.php @@ -4,7 +4,7 @@ use Exception; use MaplePHP\Container\Interfaces\ContainerExceptionInterface; -use MaplePHP\Container\Interfaces\NotFoundExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; use MaplePHP\Http\Stream; use MaplePHP\Prompts\Command; use MaplePHP\Prompts\Themes\Blocks; @@ -12,7 +12,7 @@ use MaplePHP\Unitary\Utils\FileIterator; use RuntimeException; -class TemplateController extends RunTestController +class TemplateController extends DefaultController { /** @@ -20,15 +20,12 @@ class TemplateController extends RunTestController * Shows a basic template for the Unitary testing tool * Only displays if --template argument is provided * - * @param array $args - * @param Command $command * @return void */ - public function run(array $args, Command $command): void + public function run(): void { - - $blocks = new Blocks($command); - $blocks->addHeadline("\n--- Unitary template ---"); + $blocks = new Blocks($this->command); + $blocks->addHeadline("\n--- Copy and paste code --->"); $blocks->addCode( <<<'PHP' use MaplePHP\Unitary\{Unit, TestCase, TestConfig, Expect}; @@ -43,56 +40,9 @@ public function run(array $args, Command $command): void }); PHP ); + $blocks->addHeadline("---------------------------\n"); exit(0); } - /** - * Main help page - * - * @param array $args - * @param Command $command - * @return void - */ - public function help(array $args, Command $command): void - { - $blocks = new Blocks($command); - $blocks->addHeadline("\n--- Unitary Help ---"); - $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); - - $blocks->addSection("Options", function(Blocks $inst) { - return $inst - ->addOption("help", "Show this help message") - ->addOption("show=", "Run a specific test by hash or manual test name") - ->addOption("errors-only", "Show only failing tests and skip passed test output") - ->addOption("template", "Will give you a boilerplate test code") - ->addOption("path=", "Specify test path (absolute or relative)") - ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); - }); - - $blocks->addSection("Examples", function(Blocks $inst) { - return $inst - ->addExamples( - "php vendor/bin/unitary", - "Run all tests in the default path (./tests)" - )->addExamples( - "php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983", - "Run the test with a specific hash ID" - )->addExamples( - "php vendor/bin/unitary --errors-only", - "Run all tests in the default path (./tests)" - )->addExamples( - "php vendor/bin/unitary --show=YourNameHere", - "Run a manually named test case" - )->addExamples( - "php vendor/bin/unitary --template", - "Run a and will give you template code for a new test" - )->addExamples( - 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', - 'Run all tests under "tests/" excluding specified directories' - ); - }); - // Make sure nothing else is executed when help is triggered - exit(0); - } } \ No newline at end of file diff --git a/src/Kernel/Enum/CoverageIssue.php b/src/Kernel/Enum/CoverageIssue.php new file mode 100644 index 0000000..c2d48e6 --- /dev/null +++ b/src/Kernel/Enum/CoverageIssue.php @@ -0,0 +1,29 @@ + 'No error occurred.', + self::MissingXdebug => 'Xdebug is not installed or enabled.', + self::MissingCoverage => 'Xdebug is enabled, but coverage mode is missing.', + }; + } +} \ No newline at end of file diff --git a/src/Kernel/Kernel.php b/src/Kernel/Kernel.php index 257f55b..b2851a2 100644 --- a/src/Kernel/Kernel.php +++ b/src/Kernel/Kernel.php @@ -13,186 +13,82 @@ namespace MaplePHP\Unitary\Kernel; -use Psr\Container\ContainerInterface; -use MaplePHP\Container\Reflection; -use MaplePHP\Emitron\Contracts\EmitterInterface; -use MaplePHP\Emitron\Emitters\CliEmitter; -use MaplePHP\Emitron\Emitters\HttpEmitter; -use MaplePHP\Emitron\RequestHandler; -use MaplePHP\Http\Interfaces\ResponseInterface; +use Exception; +use MaplePHP\Emitron\DispatchConfig; +use MaplePHP\Emitron\Middlewares\NoContentMiddleware; use MaplePHP\Http\Interfaces\ServerRequestInterface; -use MaplePHP\Http\ResponseFactory; -use MaplePHP\Http\Stream; -use MaplePHP\Log\InvalidArgumentException; -use MaplePHP\Prompts\Command; -use MaplePHP\Unitary\Contracts\RouterInterface; -use MaplePHP\Unitary\Utils\FileIterator; +use MaplePHP\Unitary\Kernel\Middlewares\AddCommandMiddleware; use MaplePHP\Unitary\Utils\Router; +use Psr\Container\ContainerInterface; +use MaplePHP\Emitron\Kernel as EmitronKernel; class Kernel { - + public const CONFIG_FILE_PATH = __DIR__ . '/../../unitary.config'; private ContainerInterface $container; private array $userMiddlewares; - private ?RouterInterface $router = null; - private ?int $exitCode = null; - private ?DispatchConfig $dispatchConfig = null; - - function __construct(ContainerInterface $container, array $userMiddlewares = []) - { - $this->userMiddlewares = $userMiddlewares; - $this->container = $container; - } - - public function getDispatchConfig(): DispatchConfig - { - if ($this->dispatchConfig === null) { - $this->dispatchConfig = new DispatchConfig(); - } - return $this->dispatchConfig; - } + private ?DispatchConfig $dispatchConfig; /** - * You can bind an instance (singleton) to an interface class that then is loaded through - * the dependency injector preferably that in implementable of that class + * Unitary kernel file * - * @param callable $call - * @return void + * @param ContainerInterface $container + * @param array $userMiddlewares + * @param DispatchConfig|null $dispatchConfig */ - public function bindInstToInterfaces(callable $call): void - { - Reflection::interfaceFactory($call); - } - - public function addExitCode(int $exitCode): self - { - $inst = clone $this; - $inst->exitCode = $exitCode; - return $inst; - } + public function __construct( + ContainerInterface $container, + array $userMiddlewares = [], + ?DispatchConfig $dispatchConfig = null, + ) { + $this->container = $container; + $this->userMiddlewares = $userMiddlewares; + $this->dispatchConfig = $dispatchConfig; - public function addRouter(RouterInterface $router): self - { - $inst = clone $this; - $inst->router = $router; - return $inst; + // This middleware is used in the DefaultController, which is why I always load it, + // It will not change any response but will load a CLI helper Command library + if(!in_array(AddCommandMiddleware::class, $this->userMiddlewares)) { + $this->userMiddlewares[] = AddCommandMiddleware::class; + } + EmitronKernel::setConfigFilePath(self::CONFIG_FILE_PATH); } /** - * Run the emitter and init all routes, middlewares and configs + * This will run Emitron kernel with Unitary configuration * * @param ServerRequestInterface $request * @return void - * @throws \ReflectionException + * @throws Exception */ public function run(ServerRequestInterface $request): void { - $router = $this->createRouter($request->getCliKeyword(), $request->getCliArgs()); - - $router->dispatch(function($data, $args) use ($request) { - if (!isset($data['handler'])) { - throw new InvalidArgumentException("The router dispatch method arg 1 is missing the 'handler' key."); - } - - $controller = $data['handler']; - $response = $this->createResponse(); - - $handler = new RequestHandler($this->userMiddlewares, $response); - - $this->container->set("request", $request); - $this->container->set("args", $args); - $this->container->set("dispatchConfig", $this->getDispatchConfig()); - - $response = $handler->handle($request); - $this->bindCoreInstToInterfaces($request, $response); - - [$class, $method] = $controller; - if(method_exists($class, $method)) { - $reflect = new Reflection($class); - $classInst = $reflect->dependencyInjector(); - - // Can replace the active Response instance through Command instance - $hasNewResponse = $reflect->dependencyInjector($classInst, $method); - $response = ($hasNewResponse instanceof ResponseInterface) ? $hasNewResponse : $response; - - } else { - $response->getBody()->write("\nERROR: Could not load Controller class {$class} and method {$method}()\n"); - } - - $this->createEmitter()->emit($response, $request); - - if ($this->getDispatchConfig()->getExitCode() !== null) { - exit($this->exitCode); - } - }); - } - - /** - * Will bind core instances (singletons) to interface classes that then is loaded through - * the dependency injector preferably that in implementable of that class - * - * @param ServerRequestInterface $request - * @param ResponseInterface $response - * @return void - */ - private function bindCoreInstToInterfaces(ServerRequestInterface $request, ResponseInterface $response): void - { - Reflection::interfaceFactory(function ($className) use ($request, $response) { - return match ($className) { - "ContainerInterface" => $this->container, - "RequestInterface", "ServerRequestInterface" => $request, - "ResponseInterface" => $response, - default => null, - }; - }); - } - - /** - * Check if is inside a command line interface (CLI) - * - * @return bool - */ - private function isCli(): bool - { - return PHP_SAPI === 'cli'; - } - - /** - * Will create the router - * - * @param string $needle - * @param array $argv - * @return RouterInterface - */ - private function createRouter(string $needle, array $argv): RouterInterface - { - if($this->router !== null) { - return $this->router; + if($this->dispatchConfig === null) { + $this->dispatchConfig = $this->configuration($request); } - $router = new Router($needle, $argv); - require_once __DIR__ . "/routes.php"; - return $router; + $kernel = new EmitronKernel($this->container, $this->userMiddlewares, $this->dispatchConfig); + $kernel->run($request); } /** - * Will Create preferred Stream and Response instance depending on a platform + * This is the default unitary configuration * - * @return ResponseInterface - */ - private function createResponse(): ResponseInterface - { - $stream = new Stream($this->isCli() ? Stream::STDOUT : Stream::TEMP); - $factory = new ResponseFactory(); - return $factory->createResponse(body: $stream); - } - - /** - * Get emitter based on a platform - * - * @return EmitterInterface + * @param ServerRequestInterface $request + * @return DispatchConfig + * @throws Exception */ - private function createEmitter(): EmitterInterface + private function configuration(ServerRequestInterface $request): DispatchConfig { - return $this->isCli() ? new CliEmitter() : new HttpEmitter(); + $config = new DispatchConfig(EmitronKernel::getConfigFilePath()); + return $config + ->setRouter(function($path) use ($request) { + $routerFile = $path . "/src/Kernel/routes.php"; + $router = new Router($request->getCliKeyword(), $request->getCliArgs()); + if(!is_file($routerFile)) { + throw new Exception('The routes file (' . $routerFile . ') is missing.'); + } + require_once $routerFile; + return $router; + }) + ->setExitCode(0); } } \ No newline at end of file diff --git a/src/Kernel/Middlewares/AddCommandMiddleware.php b/src/Kernel/Middlewares/AddCommandMiddleware.php index f6c5721..f5cb4ce 100644 --- a/src/Kernel/Middlewares/AddCommandMiddleware.php +++ b/src/Kernel/Middlewares/AddCommandMiddleware.php @@ -11,20 +11,30 @@ class AddCommandMiddleware implements MiddlewareInterface { - private ContainerInterface $container; + /** + * Get the active Container instance with the Dependency injector + * + * @param ContainerInterface $container + */ public function __construct(ContainerInterface $container) { $this->container = $container; } - + /** + * Will bind current Response and Stream to the Command CLI library class + * this is initialized and passed to the Container + * + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $response = $handler->handle($request); $this->container->set("command", new Command($response)); return $response; } - } \ No newline at end of file diff --git a/src/Kernel/routes.php b/src/Kernel/routes.php index 1aa5fe0..e1e0a13 100644 --- a/src/Kernel/routes.php +++ b/src/Kernel/routes.php @@ -6,6 +6,6 @@ $router->map("coverage", [CoverageController::class, "run"]); -$router->map("template", [TemplateController::class, "run"]); +$router->map("template", []); $router->map(["", "test", "run"], [RunTestController::class, "run"]); $router->map(["__404", "help"], [RunTestController::class, "help"]); \ No newline at end of file diff --git a/src/TestUtils/CodeCoverage.php b/src/TestUtils/CodeCoverage.php index 3d05c41..4398782 100644 --- a/src/TestUtils/CodeCoverage.php +++ b/src/TestUtils/CodeCoverage.php @@ -13,22 +13,15 @@ namespace MaplePHP\Unitary\TestUtils; use BadMethodCallException; +use MaplePHP\Unitary\Kernel\Enum\CoverageIssue; class CodeCoverage { - - /** @var array */ - const ERROR = [ - "No error", - "Xdebug is not available", - "Xdebug is enabled, but coverage mode is missing" - ]; - + private CoverageIssue $coverageIssue = CoverageIssue::None; private ?array $data = null; - private int $errorCode = 0; - - private array $allowedDirs = []; - private array $exclude = [ + private array $exclude = []; + /** @var array */ + private const DEFAULT_EXCLUDED_FILES = [ "vendor", "tests", "test", @@ -59,11 +52,11 @@ class CodeCoverage */ public function hasXdebug(): bool { - if($this->errorCode > 0) { + if($this->hasIssue()) { return false; } if (!function_exists('xdebug_info')) { - $this->errorCode = 1; + $this->coverageIssue = CoverageIssue::MissingXdebug; return false; } return true; @@ -81,21 +74,26 @@ public function hasXdebugCoverage(): bool } $mode = ini_get('xdebug.mode'); if ($mode === false || !str_contains($mode, 'coverage')) { - $this->errorCode = 1; + $this->coverageIssue = CoverageIssue::MissingCoverage; return false; } return true; } - - public function exclude(array $exclude): void - { - $this->exclude = $exclude; - } - - public function whitelist(string|array $path): void + /** + * Add files and directories to be excluded from coverage. + * + * By default, this method includes a set of common files and directories + * that are typically excluded. To override and reset the list completely, + * pass `true` as the second argument. + * + * @param array $exclude Additional files or directories to exclude. + * @param bool $reset If true, replaces the default excluded list instead of merging with it. + * @return void + */ + public function exclude(array $exclude, bool $reset = false): void { - + $this->exclude = (!$reset) ? array_merge(self::DEFAULT_EXCLUDED_FILES, $exclude) : $exclude; } /** @@ -136,6 +134,13 @@ public function end(): void } } + /** + * This is a simple exclude checker used to exclude a file, directories or files in a pattern + * with the help of wildcard, for example, "unitary-*" will exclude all files with prefix unitary. + * + * @param string $file + * @return bool + */ protected function excludePattern(string $file): bool { $filename = basename($file); @@ -154,8 +159,6 @@ protected function excludePattern(string $file): bool return false; } - - /** * Get a Coverage result, will return false if there is an error * @@ -163,7 +166,7 @@ protected function excludePattern(string $file): bool */ public function getResponse(): array|false { - if($this->errorCode > 0) { + if($this->hasIssue()) { return false; } @@ -182,7 +185,6 @@ public function getResponse(): array|false } } } - $percent = $totalLines > 0 ? round(($executedLines / $totalLines) * 100, 2) : 0; return [ 'totalLines' => $totalLines, @@ -191,29 +193,22 @@ public function getResponse(): array|false ]; } - public function getRawData(): array - { - return $this->data ?? []; - } - /** - * Get an error message + * Get raw data * - * @return string + * @return array */ - public function getError(): string + public function getRawData(): array { - return self::ERROR[$this->errorCode]; + return $this->data ?? []; } /** - * Get an error code - * - * @return int + * @return CoverageIssue */ - public function getCode(): int + public function getIssue(): CoverageIssue { - return $this->errorCode; + return $this->coverageIssue; } /** @@ -221,8 +216,8 @@ public function getCode(): int * * @return bool */ - public function hasError(): bool + public function hasIssue(): bool { - return ($this->errorCode > 0); + return $this->coverageIssue !== CoverageIssue::None; } } \ No newline at end of file diff --git a/src/Utils/Router.php b/src/Utils/Router.php index 834ccb4..158df57 100644 --- a/src/Utils/Router.php +++ b/src/Utils/Router.php @@ -21,9 +21,9 @@ class Router implements RouterInterface private string $needle = ""; private array $args = []; - public function __construct(string $needle, array $argv) + public function __construct(string $needle, array $args) { - $this->args = $argv; + $this->args = $args; $this->needle = $needle; } diff --git a/unitary.config.php b/unitary.config.php new file mode 100644 index 0000000..c3802e8 --- /dev/null +++ b/unitary.config.php @@ -0,0 +1,4 @@ + 'unitary', +]; \ No newline at end of file From 1b59632d3ea97cda66cb8ebe4383413459b1be54 Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Mon, 21 Jul 2025 12:34:08 +0200 Subject: [PATCH 57/78] refactor: Code structure improvements --- src/Handlers/CliHandler.php | 30 ++- src/Handlers/HandlerInterface.php | 30 --- .../AbstractHandler.php | 14 +- .../HandlerInterface.php | 10 +- .../RouterDispatchInterface.php | 2 +- .../RouterInterface.php | 2 +- src/Kernel/Controllers/RunTestController.php | 9 +- src/Unit.php | 231 ++++++++---------- src/Utils/Router.php | 2 +- 9 files changed, 137 insertions(+), 193 deletions(-) delete mode 100755 src/Handlers/HandlerInterface.php rename src/{Contracts => Interfaces}/AbstractHandler.php (89%) rename src/{Contracts => Interfaces}/HandlerInterface.php (85%) rename src/{Contracts => Interfaces}/RouterDispatchInterface.php (83%) rename src/{Contracts => Interfaces}/RouterInterface.php (92%) diff --git a/src/Handlers/CliHandler.php b/src/Handlers/CliHandler.php index 3890157..4813f8a 100644 --- a/src/Handlers/CliHandler.php +++ b/src/Handlers/CliHandler.php @@ -5,7 +5,8 @@ use MaplePHP\Http\Interfaces\StreamInterface; use MaplePHP\Http\Stream; use MaplePHP\Prompts\Command; -use MaplePHP\Unitary\Contracts\AbstractHandler; +use MaplePHP\Unitary\Interfaces\AbstractHandler; +use MaplePHP\Unitary\TestCase; use MaplePHP\Unitary\TestItem; use MaplePHP\Unitary\TestUnit; use MaplePHP\Unitary\Utils\Helpers; @@ -19,7 +20,12 @@ class CliHandler extends AbstractHandler private string $color; private string $flag; - public function setCommand(Command $command) + /** + * Pass the main command and stream to handler + * + * @param Command $command + */ + public function __construct(Command $command) { $this->command = $command; } @@ -82,13 +88,10 @@ public function buildNotes(): void } /** - * {@inheritDoc} + * Footer template part + * + * @return void */ - public function returnStream(): StreamInterface - { - return $this->command->getStream(); - } - protected function showFooter(): void { $select = $this->checksum; @@ -114,6 +117,12 @@ protected function showFooter(): void } + /** + * Failed tests template part + * + * @return void + * @throws \ErrorException + */ protected function showFailedTests(): void { if (($this->show || !$this->case->getConfig()->skip)) { @@ -171,6 +180,11 @@ protected function showFailedTests(): void } } + /** + * Init some default styled object + * + * @return void + */ protected function initDefault(): void { $this->color = ($this->case->hasFailed() ? "brightRed" : "brightBlue"); diff --git a/src/Handlers/HandlerInterface.php b/src/Handlers/HandlerInterface.php deleted file mode 100755 index 4bca91c..0000000 --- a/src/Handlers/HandlerInterface.php +++ /dev/null @@ -1,30 +0,0 @@ -outputBuffer = $outputBuffer; + $out = (ob_get_level() > 0) ? ob_get_clean() : ''; + $this->outputBuffer = $out . $addToOutput; + return $this->outputBuffer; } /** diff --git a/src/Contracts/HandlerInterface.php b/src/Interfaces/HandlerInterface.php similarity index 85% rename from src/Contracts/HandlerInterface.php rename to src/Interfaces/HandlerInterface.php index 651e16f..a629141 100644 --- a/src/Contracts/HandlerInterface.php +++ b/src/Interfaces/HandlerInterface.php @@ -1,6 +1,6 @@ disableAllTests = $disable; } - // Deprecated: Almost same as `disableAllTest`, for older versions - public function skip(bool $disable): self - { - $this->disableAllTests = $disable; - return $this; - } - - /** - * DEPRECATED: Use TestConfig::setSelect instead - * See documentation for more information - * - * @return void - */ - public function manual(): void - { - throw new RuntimeException("Manual method has been deprecated, use TestConfig::setSelect instead. " . - "See documentation for more information."); - } - /** * Access command instance * @return Command @@ -129,7 +109,8 @@ public function message(string $message): false|string } /** - * confirm for execute + * Confirm for execute + * * @param string $message * @return bool */ @@ -140,7 +121,9 @@ public function confirm(string $message = "Do you wish to continue?"): bool /** * Name has been changed to case - * WILL BECOME DEPRECATED VERY SOON + * + * Note: This will become DEPRECATED in the future with exception + * * @param string $message * @param Closure $callback * @return void @@ -177,35 +160,6 @@ public function case(string|TestConfig $message, Closure $callback): void $this->addCase($message, $callback, true); } - public function performance(Closure $func, ?string $title = null): void - { - $start = new Performance(); - $func = $func->bindTo($this); - if ($func !== null) { - $func($this); - } - $line = $this->command->getAnsi()->line(80); - $this->command->message(""); - $this->command->message($this->command->getAnsi()->style(["bold", "yellow"], "Performance" . ($title !== null ? " - $title:" : ":"))); - - $this->command->message($line); - $this->command->message( - $this->command->getAnsi()->style(["bold"], "Execution time: ") . - ((string)round($start->getExecutionTime(), 3) . " seconds") - ); - $this->command->message( - $this->command->getAnsi()->style(["bold"], "Memory Usage: ") . - ((string)round($start->getMemoryUsage(), 2) . " KB") - ); - /* - $this->command->message( - $this->command->getAnsi()->style(["bold", "grey"], "Peak Memory Usage: ") . - $this->command->getAnsi()->blue(round($start->getMemoryPeakUsage(), 2) . " KB") - ); - */ - $this->command->message($line); - } - /** * Execute tests suite * @@ -224,8 +178,7 @@ public function execute(): bool ob_start(); //$countCases = count($this->cases); - $handler = new CliHandler(); - $handler->setCommand($this->command); + $handler = new CliHandler($this->command); foreach ($this->cases as $index => $row) { if (!($row instanceof TestCase)) { @@ -259,41 +212,14 @@ public function execute(): bool self::$totalPassedTests += ($row->getConfig()->skip) ? $row->getTotal() : $row->getCount(); self::$totalTests += $row->getTotal(); } - $this->output .= (string)ob_get_clean(); - $handler->outputBuffer($this->output); - if ($this->output) { + $out = $handler->outputBuffer(); + if ($out) { $handler->buildNotes(); } - - /* - $stream = $handler->returnStream(); - if ($stream->isSeekable()) { - $this->getStream()->rewind(); - echo $this->getStream()->getContents(); - } - */ - $this->executed = true; return true; } - /** - * Will reset the executing and stream if is a seekable stream - * - * @return bool - */ - public function resetExecute(): bool - { - if ($this->executed) { - if ($this->getStream()->isSeekable()) { - $this->getStream()->rewind(); - } - $this->executed = false; - return true; - } - return false; - } - /** * Validate method that must be called within a group method * @@ -306,31 +232,9 @@ public function validate(): self "Move this validate() call inside your group() callback function."); } - - /** - * Make a file path into a title - * @param string $file - * @param int $length - * @param bool $removeSuffix - * @return string - */ - private function formatFileTitle(string $file, int $length = 3, bool $removeSuffix = true): string - { - $file = explode("/", $file); - if ($removeSuffix) { - $pop = array_pop($file); - $file[] = substr($pop, (int)strpos($pop, 'unitary') + 8); - } - $file = array_chunk(array_reverse($file), $length); - $file = implode("\\", array_reverse($file[0])); - //$exp = explode('.', $file); - //$file = reset($exp); - return ".." . $file; - } - - /** - * Global header information + * This is custom header information that is passed, that work with both CLI and Browsers + * * @param array $headers * @return void */ @@ -340,7 +244,8 @@ public static function setHeaders(array $headers): void } /** - * Get global header + * Get passed CLI arguments + * * @param string $key * @return mixed */ @@ -350,18 +255,9 @@ public static function getArgs(string $key): mixed } /** - * Append to global header - * @param string $key - * @param mixed $value - * @return void - */ - public static function appendHeader(string $key, mixed $value): void - { - self::$headers[$key] = $value; - } - - /** - * Used to reset the current instance + * The test is liner it also has a current test instance that needs + * to be rested when working with loop + * * @return void */ public static function resetUnit(): void @@ -370,7 +266,8 @@ public static function resetUnit(): void } /** - * Used to check if an instance is set + * Check if a current instance exists + * * @return bool */ public static function hasUnit(): bool @@ -379,20 +276,26 @@ public static function hasUnit(): bool } /** - * Used to get instance + * Get the current instance + * * @return ?Unit - * @throws Exception */ public static function getUnit(): ?Unit { - /* - // Testing to comment out Exception in Unit instance is missing - // because this will trigger as soon as it finds a file name with unitary-* - // and can become tedious that this makes the test script stop. - if (self::hasUnit() === false) { - throw new Exception("Unit has not been set yet. It needs to be set first."); + $verbose = self::getArgs('verbose'); + if ($verbose !== false && self::hasUnit() === false) { + $file = self::$headers['file'] ?? ""; + + $command = new Command(); + $command->message( + $command->getAnsi()->style(['redBg', 'brightWhite'], " ERROR ") . ' ' . + $command->getAnsi()->style(['red', 'bold'], "The Unit instance is missing in the file") + ); + $command->message(''); + $command->message($command->getAnsi()->bold("In File:")); + $command->message($file); + $command->message(''); } - */ return self::$current; } @@ -418,7 +321,8 @@ public static function completed(): void } /** - * Check if successful + * Check if all tests is successful + * * @return bool */ public static function isSuccessful(): bool @@ -426,7 +330,6 @@ public static function isSuccessful(): bool return (self::$totalPassedTests === self::$totalTests); } - /** * Adds a test case to the collection. * @@ -443,8 +346,71 @@ protected function addCase(string|TestConfig $message, Closure $callback, bool $ $this->index++; } + + // NOTE: Just a test will be added in a new library and controller. + public function performance(Closure $func, ?string $title = null): void + { + $start = new Performance(); + $func = $func->bindTo($this); + if ($func !== null) { + $func($this); + } + $line = $this->command->getAnsi()->line(80); + $this->command->message(""); + $this->command->message($this->command->getAnsi()->style(["bold", "yellow"], "Performance" . ($title !== null ? " - $title:" : ":"))); + + $this->command->message($line); + $this->command->message( + $this->command->getAnsi()->style(["bold"], "Execution time: ") . + ((string)round($start->getExecutionTime(), 3) . " seconds") + ); + $this->command->message( + $this->command->getAnsi()->style(["bold"], "Memory Usage: ") . + ((string)round($start->getMemoryUsage(), 2) . " KB") + ); + /* + $this->command->message( + $this->command->getAnsi()->style(["bold", "grey"], "Peak Memory Usage: ") . + $this->command->getAnsi()->blue(round($start->getMemoryPeakUsage(), 2) . " KB") + ); + */ + $this->command->message($line); + } + + // Deprecated: Almost same as `disableAllTest`, for older versions + public function skip(bool $disable): self + { + $this->disableAllTests = $disable; + return $this; + } + + /** + * DEPRECATED: Use TestConfig::setSelect instead + * See documentation for more information + * + * @return void + */ + public function manual(): void + { + throw new RuntimeException("Manual method has been deprecated, use TestConfig::setSelect instead. " . + "See documentation for more information."); + } + + /** + * DEPRECATED: Append to global header + * + * @param string $key + * @param mixed $value + * @return void + */ + public static function appendHeader(string $key, mixed $value): void + { + self::$headers[$key] = $value; + } + /** * DEPRECATED: Not used anymore + * * @return $this */ public function addTitle(): self @@ -452,3 +418,4 @@ public function addTitle(): self return $this; } } + diff --git a/src/Utils/Router.php b/src/Utils/Router.php index 158df57..be63472 100644 --- a/src/Utils/Router.php +++ b/src/Utils/Router.php @@ -13,7 +13,7 @@ namespace MaplePHP\Unitary\Utils; use InvalidArgumentException; -use MaplePHP\Unitary\Contracts\RouterInterface; +use MaplePHP\Unitary\Interfaces\RouterInterface; class Router implements RouterInterface { From 282043eb932fc02191eddcec545d0ca72e7cebba Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Sun, 10 Aug 2025 22:24:22 +0200 Subject: [PATCH 58/78] refactor: Organize and restructure code fix: start adding in config file support fix: MVC structure --- bin/unitary | 27 ++- composer.json | 3 +- .../AbstractBodyHandler.php} | 15 +- .../{CliHandler.php => CliEmitter.php} | 8 +- src/Handlers/SilentBodyHandler.php | 11 ++ ...HandlerInterface.php => BodyInterface.php} | 4 +- src/Interfaces/TestEmitterInterface.php | 9 + src/Kernel/AbstractKernel.php | 15 +- src/Kernel/Controllers/CoverageController.php | 59 +----- src/Kernel/Controllers/DefaultController.php | 5 +- src/Kernel/Controllers/RunTestController.php | 54 +----- src/Kernel/Controllers/TemplateController.php | 8 +- src/Kernel/DispatchConfig.php | 60 +++++- src/Kernel/Kernel.php | 9 +- .../Middlewares/AddCommandMiddleware.php | 2 +- src/Kernel/Services/AbstractTestService.php | 34 ++++ src/Kernel/Services/RunTestService.php | 55 ++++++ src/Kernel/routes.php | 2 +- src/Setup/assert-polyfill.php | 6 - src/Setup/unitary-helpers.php | 22 +++ src/TestCase.php | 34 +++- src/TestConfig.php | 12 ++ src/TestEmitter.php | 52 ++++++ src/TestUtils/Configs.php | 35 ---- src/Unit.php | 93 +++------- src/Utils/FileIterator.php | 175 +++++++++++++----- tests/unitary-test.php | 23 +++ unitary.config.php | 8 +- 28 files changed, 521 insertions(+), 319 deletions(-) rename src/{Interfaces/AbstractHandler.php => Handlers/AbstractBodyHandler.php} (88%) rename src/Handlers/{CliHandler.php => CliEmitter.php} (97%) create mode 100644 src/Handlers/SilentBodyHandler.php rename src/Interfaces/{HandlerInterface.php => BodyInterface.php} (95%) create mode 100644 src/Interfaces/TestEmitterInterface.php create mode 100644 src/Kernel/Services/AbstractTestService.php create mode 100644 src/Kernel/Services/RunTestService.php create mode 100644 src/Setup/unitary-helpers.php create mode 100644 src/TestEmitter.php delete mode 100644 src/TestUtils/Configs.php create mode 100755 tests/unitary-test.php diff --git a/bin/unitary b/bin/unitary index 0492ffd..c3b2c2f 100755 --- a/bin/unitary +++ b/bin/unitary @@ -1,19 +1,21 @@ #!/usr/bin/env php getUriParts([ "argv" => $argv, - "dir" => (defined("UNITARY_PATH") ? UNITARY_PATH : "./") + "dir" => getcwd() ])), $env); - -$factory = new ResponseFactory(); -$kernel = new Kernel($factory); - -$kernel->run($request, new \MaplePHP\Emitron\Emitters\CliEmitter()); - -$kernel->dispatch(); +$kernel = new Kernel(new Container(), [ + AddCommandMiddleware::class, +]); +$kernel->run($request); diff --git a/composer.json b/composer.json index c003376..211dfe3 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,8 @@ }, "autoload": { "files": [ - "src/Setup/assert-polyfill.php" + "src/Setup/assert-polyfill.php", + "src/Setup/unitary-helpers.php" ], "psr-4": { "MaplePHP\\Unitary\\": "src" diff --git a/src/Interfaces/AbstractHandler.php b/src/Handlers/AbstractBodyHandler.php similarity index 88% rename from src/Interfaces/AbstractHandler.php rename to src/Handlers/AbstractBodyHandler.php index 4e4d640..63cd22d 100644 --- a/src/Interfaces/AbstractHandler.php +++ b/src/Handlers/AbstractBodyHandler.php @@ -1,12 +1,13 @@ userMiddlewares = $userMiddlewares; $this->container = $container; @@ -79,9 +80,9 @@ public static function getConfigFilePath(): string /** * Get config instance for configure dispatch result * - * @return DispatchConfig + * @return DispatchConfigInterface */ - public function getDispatchConfig(): DispatchConfig + public function getDispatchConfig(): DispatchConfigInterface { return $this->dispatchConfig; } diff --git a/src/Kernel/Controllers/CoverageController.php b/src/Kernel/Controllers/CoverageController.php index 915ea5c..372ac45 100644 --- a/src/Kernel/Controllers/CoverageController.php +++ b/src/Kernel/Controllers/CoverageController.php @@ -4,13 +4,14 @@ use MaplePHP\Blunder\Exceptions\BlunderSoftException; use MaplePHP\Container\Interfaces\ContainerExceptionInterface; +use MaplePHP\Http\Interfaces\ResponseInterface; use MaplePHP\Http\Stream; use MaplePHP\Prompts\Command; use MaplePHP\Prompts\Themes\Blocks; use MaplePHP\Unitary\Kernel\DispatchConfig; use MaplePHP\Unitary\TestUtils\CodeCoverage; use MaplePHP\Unitary\Utils\FileIterator; -use Psr\Container\NotFoundExceptionInterface; +use MaplePHP\Container\Interfaces\NotFoundExceptionInterface; class CoverageController extends RunTestController { @@ -18,12 +19,8 @@ class CoverageController extends RunTestController /** * Main test runner * - * @return void - * @throws BlunderSoftException - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface */ - public function run(): void + public function run(ResponseInterface $response): ResponseInterface { /** @var DispatchConfig $config */ @@ -55,54 +52,6 @@ public function run(): void } $this->command->message(""); + return $response; } - - /** - * Main help page - * - * @return void - */ - public function help(): void - { - die(); - $blocks = new Blocks($this->command); - $blocks->addHeadline("\n--- Unitary Help ---"); - $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); - - $blocks->addSection("Options", function(Blocks $inst) { - return $inst - ->addOption("help", "Show this help message") - ->addOption("show=", "Run a specific test by hash or manual test name") - ->addOption("errors-only", "Show only failing tests and skip passed test output") - ->addOption("template", "Will give you a boilerplate test code") - ->addOption("path=", "Specify test path (absolute or relative)") - ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); - }); - - $blocks->addSection("Examples", function(Blocks $inst) { - return $inst - ->addExamples( - "php vendor/bin/unitary", - "Run all tests in the default path (./tests)" - )->addExamples( - "php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983", - "Run the test with a specific hash ID" - )->addExamples( - "php vendor/bin/unitary --errors-only", - "Run all tests in the default path (./tests)" - )->addExamples( - "php vendor/bin/unitary --show=YourNameHere", - "Run a manually named test case" - )->addExamples( - "php vendor/bin/unitary --template", - "Run a and will give you template code for a new test" - )->addExamples( - 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', - 'Run all tests under "tests/" excluding specified directories' - ); - }); - // Make sure nothing else is executed when help is triggered - exit(0); - } - } \ No newline at end of file diff --git a/src/Kernel/Controllers/DefaultController.php b/src/Kernel/Controllers/DefaultController.php index 250f3b0..8fc59f0 100644 --- a/src/Kernel/Controllers/DefaultController.php +++ b/src/Kernel/Controllers/DefaultController.php @@ -2,7 +2,8 @@ namespace MaplePHP\Unitary\Kernel\Controllers; -use Psr\Container\ContainerInterface; +use MaplePHP\Unitary\Kernel\DispatchConfig; +use MaplePHP\Container\Interfaces\ContainerInterface; use MaplePHP\Http\Interfaces\RequestInterface; use MaplePHP\Http\Interfaces\ServerRequestInterface; use MaplePHP\Prompts\Command; @@ -12,6 +13,7 @@ abstract class DefaultController protected readonly ServerRequestInterface|RequestInterface $request; protected readonly ContainerInterface $container; protected Command $command; + protected DispatchConfig $configs; protected array $args; public function __construct(ContainerInterface $container) { @@ -19,5 +21,6 @@ public function __construct(ContainerInterface $container) { $this->args = $this->container->get("args"); $this->command = $this->container->get("command"); $this->request = $this->container->get("request"); + $this->configs = $this->container->get("dispatchConfig"); } } \ No newline at end of file diff --git a/src/Kernel/Controllers/RunTestController.php b/src/Kernel/Controllers/RunTestController.php index 69f6c61..4bad44b 100644 --- a/src/Kernel/Controllers/RunTestController.php +++ b/src/Kernel/Controllers/RunTestController.php @@ -2,14 +2,10 @@ namespace MaplePHP\Unitary\Kernel\Controllers; -use MaplePHP\Blunder\Exceptions\BlunderSoftException; -use Psr\Container\NotFoundExceptionInterface; +use MaplePHP\Unitary\Handlers\CliEmitter; +use MaplePHP\Unitary\Kernel\Services\RunTestService; use MaplePHP\Http\Interfaces\ResponseInterface; -use MaplePHP\Prompts\Command; use MaplePHP\Prompts\Themes\Blocks; -use MaplePHP\Unitary\TestUtils\Configs; -use MaplePHP\Unitary\Utils\FileIterator; -use RuntimeException; class RunTestController extends DefaultController { @@ -17,55 +13,15 @@ class RunTestController extends DefaultController /** * Main test runner */ - public function run(ResponseInterface $response): ResponseInterface + public function run(RunTestService $service): ResponseInterface { - // /** @var DispatchConfig $config */ - // $config = $this->container->get("dispatchConfig"); - $iterator = new FileIterator($this->args); - $iterator = $this->iterateTest($this->command, $iterator, $this->args); - - // CLI Response - if(PHP_SAPI === 'cli') { - return $response->withStatus($iterator->getExitCode()); - } - // Text/Browser Response - return $response; - } - - /** - * @param Command $command - * @param FileIterator $iterator - * @param array $args - * @return FileIterator - * @throws BlunderSoftException - * @throws NotFoundExceptionInterface - * @throws \Psr\Container\ContainerExceptionInterface - */ - protected function iterateTest(Command $command, FileIterator $iterator, array $args): FileIterator - { - Configs::getInstance()->setCommand($command); - - $defaultPath = $this->container->get("request")->getUri()->getDir(); - $path = ($args['path'] ?? $defaultPath); - if(!isset($path)) { - throw new RuntimeException("Path not specified: --path=path/to/dir"); - } - $testDir = realpath($path); - - if(!file_exists($testDir)) { - throw new RuntimeException("Test directory '$testDir' does not exist"); - } - - $iterator->enableExitScript(false); - $iterator->executeAll($testDir, $defaultPath); - return $iterator; + $handler = new CliEmitter($this->command); + return $service->run($handler); } /** * Main help page * - * @param array $args - * @param Command $command * @return void */ public function help(): void diff --git a/src/Kernel/Controllers/TemplateController.php b/src/Kernel/Controllers/TemplateController.php index 98874c8..4a54e84 100644 --- a/src/Kernel/Controllers/TemplateController.php +++ b/src/Kernel/Controllers/TemplateController.php @@ -4,7 +4,7 @@ use Exception; use MaplePHP\Container\Interfaces\ContainerExceptionInterface; -use Psr\Container\NotFoundExceptionInterface; +use MaplePHP\Container\Interfaces\NotFoundExceptionInterface; use MaplePHP\Http\Stream; use MaplePHP\Prompts\Command; use MaplePHP\Prompts\Themes\Blocks; @@ -28,10 +28,9 @@ public function run(): void $blocks->addHeadline("\n--- Copy and paste code --->"); $blocks->addCode( <<<'PHP' - use MaplePHP\Unitary\{Unit, TestCase, TestConfig, Expect}; + use MaplePHP\Unitary\{TestCase, TestConfig, Expect}; - $unit = new Unit(); - $unit->group("Your test subject", function (TestCase $case) { + group("Your test subject", function (TestCase $case) { $case->validate("Your test value", function(Expect $valid) { $valid->isString(); @@ -41,7 +40,6 @@ public function run(): void PHP ); $blocks->addHeadline("---------------------------\n"); - exit(0); } diff --git a/src/Kernel/DispatchConfig.php b/src/Kernel/DispatchConfig.php index 6707dda..b157dec 100644 --- a/src/Kernel/DispatchConfig.php +++ b/src/Kernel/DispatchConfig.php @@ -5,10 +5,68 @@ /** * Configure the kernels dispatched behavior */ -class DispatchConfig +class DispatchConfig extends \MaplePHP\Emitron\DispatchConfig { + protected string|bool $path = false; private ?int $exitCode = null; + private bool $verbose = false; + private bool $smartSearch = false; + + /** + * Check if verbose is active + * + * @return bool + */ + public function isVerbose(): bool + { + return $this->verbose; + } + + /** + * Set if you want to show more possible warnings and errors that might be hidden + * + * @param bool $enableVerbose + * @return $this + */ + public function setVerbose(bool $enableVerbose): self + { + $this->verbose = $enableVerbose; + return $this; + } + + /** + * Check if smart search is active + * + * @return bool + */ + public function isSmartSearch(): bool + { + return $this->smartSearch; + } + + /** + * Enable smart search that will try to find test files automatically + * even if missing from an expected path. Will add some overhead. + * + * @param bool $enableSmartSearch + * @return $this + */ + public function setSmartSearch(bool $enableSmartSearch): self + { + $this->smartSearch = $enableSmartSearch; + return $this; + } + + /** + * Will return the expected test path + * + * @return string|bool + */ + public function getPath(): string|bool + { + return $this->path; + } /** * Get current exit code as int or null if not set diff --git a/src/Kernel/Kernel.php b/src/Kernel/Kernel.php index b2851a2..732fb7d 100644 --- a/src/Kernel/Kernel.php +++ b/src/Kernel/Kernel.php @@ -14,12 +14,11 @@ namespace MaplePHP\Unitary\Kernel; use Exception; -use MaplePHP\Emitron\DispatchConfig; -use MaplePHP\Emitron\Middlewares\NoContentMiddleware; +use MaplePHP\Emitron\Contracts\DispatchConfigInterface; use MaplePHP\Http\Interfaces\ServerRequestInterface; use MaplePHP\Unitary\Kernel\Middlewares\AddCommandMiddleware; use MaplePHP\Unitary\Utils\Router; -use Psr\Container\ContainerInterface; +use MaplePHP\Container\Interfaces\ContainerInterface; use MaplePHP\Emitron\Kernel as EmitronKernel; class Kernel @@ -73,10 +72,10 @@ public function run(ServerRequestInterface $request): void * This is the default unitary configuration * * @param ServerRequestInterface $request - * @return DispatchConfig + * @return DispatchConfigInterface * @throws Exception */ - private function configuration(ServerRequestInterface $request): DispatchConfig + private function configuration(ServerRequestInterface $request): DispatchConfigInterface { $config = new DispatchConfig(EmitronKernel::getConfigFilePath()); return $config diff --git a/src/Kernel/Middlewares/AddCommandMiddleware.php b/src/Kernel/Middlewares/AddCommandMiddleware.php index f5cb4ce..f255375 100644 --- a/src/Kernel/Middlewares/AddCommandMiddleware.php +++ b/src/Kernel/Middlewares/AddCommandMiddleware.php @@ -2,7 +2,7 @@ namespace MaplePHP\Unitary\Kernel\Middlewares; -use Psr\Container\ContainerInterface; +use MaplePHP\Container\Interfaces\ContainerInterface; use MaplePHP\Emitron\Contracts\MiddlewareInterface; use MaplePHP\Emitron\Contracts\RequestHandlerInterface; use MaplePHP\Http\Interfaces\ResponseInterface; diff --git a/src/Kernel/Services/AbstractTestService.php b/src/Kernel/Services/AbstractTestService.php new file mode 100644 index 0000000..d8cd6a6 --- /dev/null +++ b/src/Kernel/Services/AbstractTestService.php @@ -0,0 +1,34 @@ +response = $response; + $this->container = $container; + $this->args = $this->container->get("args"); + $this->request = $this->container->get("request"); + $this->configs = $this->container->get("dispatchConfig"); + } + + protected function getArg($key): mixed + { + return ($this->args[$key] ?? null); + } + +} \ No newline at end of file diff --git a/src/Kernel/Services/RunTestService.php b/src/Kernel/Services/RunTestService.php new file mode 100644 index 0000000..16ed7b1 --- /dev/null +++ b/src/Kernel/Services/RunTestService.php @@ -0,0 +1,55 @@ +container->get("dispatchConfig"); + //$this->configs->isSmartSearch(); + + $iterator = new FileIterator($handler, $this->args); + $iterator = $this->iterateTest($iterator); + + // CLI Response + if(PHP_SAPI === 'cli') { + return $this->response->withStatus($iterator->getExitCode()); + } + // Text/Browser Response + return $this->response; + } + + /** + * @param FileIterator $iterator + * @return FileIterator + * @throws BlunderSoftException + * @throws \MaplePHP\Container\Interfaces\ContainerExceptionInterface + * @throws \MaplePHP\Container\Interfaces\NotFoundExceptionInterface + */ + private function iterateTest(FileIterator $iterator): FileIterator + { + $defaultPath = $this->container->get("request")->getUri()->getDir(); + $defaultPath = ($this->configs->getPath() !== null) ? $this->configs->getPath() : $defaultPath; + $path = ($this->args['path'] ?? $defaultPath); + + if(!isset($path)) { + throw new RuntimeException("Path not specified: --path=path/to/dir"); + } + $testDir = realpath($path); + if(!file_exists($testDir)) { + throw new RuntimeException("Test directory '$testDir' does not exist"); + } + $iterator->enableExitScript(false); + $iterator->executeAll($testDir, $defaultPath); + return $iterator; + } +} \ No newline at end of file diff --git a/src/Kernel/routes.php b/src/Kernel/routes.php index e1e0a13..1aa5fe0 100644 --- a/src/Kernel/routes.php +++ b/src/Kernel/routes.php @@ -6,6 +6,6 @@ $router->map("coverage", [CoverageController::class, "run"]); -$router->map("template", []); +$router->map("template", [TemplateController::class, "run"]); $router->map(["", "test", "run"], [RunTestController::class, "run"]); $router->map(["__404", "help"], [RunTestController::class, "help"]); \ No newline at end of file diff --git a/src/Setup/assert-polyfill.php b/src/Setup/assert-polyfill.php index 5753be4..4ba3656 100644 --- a/src/Setup/assert-polyfill.php +++ b/src/Setup/assert-polyfill.php @@ -3,12 +3,6 @@ * assert-polyfill.php * * Ensures consistent assert() behavior across PHP versions. - * - * In PHP < 8.4, assert() can be disabled via ini settings and may not throw exceptions. - * This file forces `assert.active` and `assert.exception` to be enabled to simulate - * the stricter behavior introduced in PHP 8.4, where assert() is always active and throws. - * - * This file is automatically loaded via Composer's autoload.files to apply this setup early. */ if (PHP_VERSION_ID < 80400) { diff --git a/src/Setup/unitary-helpers.php b/src/Setup/unitary-helpers.php new file mode 100644 index 0000000..be678df --- /dev/null +++ b/src/Setup/unitary-helpers.php @@ -0,0 +1,22 @@ +group($message, $expect, $config); +} + +if (!function_exists('group')) { + function group(string|TestConfig $message, Closure $expect, ?TestConfig $config = null): void + { + unitary_group(...func_get_args()); + } +} diff --git a/src/TestCase.php b/src/TestCase.php index c9d8996..4a89e66 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -16,7 +16,7 @@ use Closure; use ErrorException; use Exception; -use MaplePHP\Blunder\BlunderErrorException; +use MaplePHP\Blunder\Exceptions\BlunderErrorException; use MaplePHP\DTO\Format\Str; use MaplePHP\DTO\Traverse; use MaplePHP\Unitary\Mocker\MethodRegistry; @@ -126,15 +126,23 @@ public function warning(string $message): self /** * Add custom error message if validation fails * - * @param string $message + * @param ?string $message * @return $this */ - public function error(string $message): self + public function error(?string $message): self { - $this->error = $message; + if($message !== null) { + $this->error = $message; + } return $this; } + // Alias to error + public function message(?string $message): self + { + return $this->error($message); + } + /** * Will dispatch the case tests and return them as an array * @@ -187,6 +195,22 @@ public function validate(mixed $expect, Closure $validation): self return $this; } + /** + * Quickly validate with asserting + * + * @param bool $expect Assert value should be bool + * @param string|null $message + * @return $this + * @throws ErrorException + */ + public function assert(bool $expect, ?string $message = null): self + { + $this->expectAndValidate($expect, function () use ($expect, $message) { + assert($expect, $assertMsg ?? $message); + }, $this->error); + return $this; + } + /** * Executes a test case at runtime by validating the expected value. * @@ -351,7 +375,7 @@ public function buildMock(?Closure $validate = null): mixed if($this->mocker->hasFinal()) { $finalMethods = $pool->getSelected($this->mocker->getFinalMethods()); if($finalMethods !== []) { - $this->warning = "Warning: It is not possible to mock final methods: " . implode(", ", $finalMethods); + $this->warning = "Warning: Final methods cannot be mocked or have their behavior modified: " . implode(", ", $finalMethods); } } return $class; diff --git a/src/TestConfig.php b/src/TestConfig.php index 1df5011..a5d440a 100644 --- a/src/TestConfig.php +++ b/src/TestConfig.php @@ -17,6 +17,7 @@ final class TestConfig public ?string $message; public bool $skip = false; public string $select = ""; + private bool $updatedSubject = false; public function __construct(string $message) { @@ -62,10 +63,21 @@ public function setSelect(string $key): self public function withSubject(string $subject): self { $inst = clone $this; + $inst->updatedSubject = true; $inst->message = $subject; return $inst; } + /** + * Check if a subject has been added in `withSubject` + * + * @return bool + */ + public function hasSubject(): bool + { + return $this->updatedSubject; + } + /** * Sets the skip state for the current instance. * diff --git a/src/TestEmitter.php b/src/TestEmitter.php new file mode 100644 index 0000000..2966c6c --- /dev/null +++ b/src/TestEmitter.php @@ -0,0 +1,52 @@ +unit = new Unit(); + $this->file = $file; + } + + public function emit(): void + { + $this->runBlunder(); + require_once($this->file); + $this->unit->execute(); + } + + /** + * Initialize Blunder error handler + * + * @return void + */ + protected function runBlunder(): void + { + $run = new Run(new CliHandler()); + $run->setExitCode(1); + $run->load(); + } +} \ No newline at end of file diff --git a/src/TestUtils/Configs.php b/src/TestUtils/Configs.php deleted file mode 100644 index 58bd579..0000000 --- a/src/TestUtils/Configs.php +++ /dev/null @@ -1,35 +0,0 @@ -command = $command; - } - - public function getCommand(): Command - { - return self::getInstance()->command; - } - -} diff --git a/src/Unit.php b/src/Unit.php index 83a497c..5f297c2 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -14,19 +14,19 @@ use Closure; use ErrorException; -use Throwable; -use MaplePHP\Unitary\Handlers\CliHandler; -use MaplePHP\Unitary\TestUtils\Configs; +use MaplePHP\Unitary\Interfaces\BodyInterface; use RuntimeException; -use MaplePHP\Blunder\BlunderErrorException; +use Throwable; +use MaplePHP\Blunder\Exceptions\BlunderErrorException; use MaplePHP\Http\Interfaces\StreamInterface; use MaplePHP\Prompts\Command; +use MaplePHP\Unitary\Handlers\CliEmitter; use MaplePHP\Unitary\Handlers\HandlerInterface; use MaplePHP\Unitary\Utils\Performance; final class Unit { - private ?HandlerInterface $handler = null; + private ?BodyInterface $handler = null; private Command $command; private string $output = ""; private int $index = 0; @@ -46,14 +46,10 @@ final class Unit * If StreamInterface is provided, creates a new Command with it * If null, creates a new Command without a stream */ - public function __construct(HandlerInterface|StreamInterface|null $handler = null) + public function __construct(BodyInterface|null $handler = null) { - if ($handler instanceof HandlerInterface) { - $this->handler = $handler; - $this->command = $this->handler->getCommand(); - } else { - $this->command = ($handler === null) ? Configs::getInstance()->getCommand() : new Command($handler); - } + + $this->handler = ($handler === null) ? new CliEmitter(new Command()) : $handler; self::$current = $this; } @@ -69,56 +65,6 @@ public function disableAllTest(bool $disable): void $this->disableAllTests = $disable; } - /** - * Access command instance - * @return Command - */ - public function getCommand(): Command - { - return $this->command; - } - - /** - * Access command instance - * @return StreamInterface - */ - public function getStream(): StreamInterface - { - return $this->command->getStream(); - } - - /** - * Disable ANSI - * @param bool $disableAnsi - * @return self - */ - public function disableAnsi(bool $disableAnsi): self - { - $this->command->getAnsi()->disableAnsi($disableAnsi); - return $this; - } - - /** - * Print message - * @param string $message - * @return false|string - */ - public function message(string $message): false|string - { - return $this->command->message($message); - } - - /** - * Confirm for execute - * - * @param string $message - * @return bool - */ - public function confirm(string $message = "Do you wish to continue?"): bool - { - return $this->command->confirm($message); - } - /** * Name has been changed to case * @@ -138,12 +84,17 @@ public function add(string $message, Closure $callback): void * The key difference from group() is that this TestCase will NOT be bound the Closure * * @param string|TestConfig $message The message or configuration for the test case. - * @param Closure $callback The closure containing the test case logic. + * @param Closure $expect The closure containing the test case logic. + * @param TestConfig|null $config * @return void */ - public function group(string|TestConfig $message, Closure $callback): void + public function group(string|TestConfig $message, Closure $expect, ?TestConfig $config = null): void { - $this->addCase($message, $callback); + if($config !== null && !$config->hasSubject()) { + $addMessage = ($message instanceof TestConfig && $message->hasSubject()) ? $message->message : $message; + $message = $config->withSubject($addMessage); + } + $this->addCase($message, $expect); } /** @@ -178,7 +129,10 @@ public function execute(): bool ob_start(); //$countCases = count($this->cases); - $handler = new CliHandler($this->command); + $handler = $this->handler; + if(count($this->cases) === 0) { + return false; + } foreach ($this->cases as $index => $row) { if (!($row instanceof TestCase)) { @@ -334,19 +288,18 @@ public static function isSuccessful(): bool * Adds a test case to the collection. * * @param string|TestConfig $message The description or configuration of the test case. - * @param Closure $callback The closure that defines the test case logic. + * @param Closure $expect The closure that defines the test case logic. * @param bool $bindToClosure Indicates whether the closure should be bound to TestCase. * @return void */ - protected function addCase(string|TestConfig $message, Closure $callback, bool $bindToClosure = false): void + protected function addCase(string|TestConfig $message, Closure $expect, bool $bindToClosure = false): void { $testCase = new TestCase($message); - $testCase->bind($callback, $bindToClosure); + $testCase->bind($expect, $bindToClosure); $this->cases[$this->index] = $testCase; $this->index++; } - // NOTE: Just a test will be added in a new library and controller. public function performance(Closure $func, ?string $title = null): void { diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index 1bc6a70..c069afc 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -17,6 +17,7 @@ use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\Blunder\Run; use MaplePHP\Prompts\Command; +use MaplePHP\Unitary\Interfaces\BodyInterface; use MaplePHP\Unitary\Unit; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -28,22 +29,40 @@ final class FileIterator public const PATTERN = 'unitary-*.php'; private array $args; + private bool $verbose = false; + private bool $smartSearch = false; private bool $exitScript = true; private ?Command $command = null; + private BodyInterface $handler; + private static ?Unit $unitary = null; - public function __construct(array $args = []) + public function __construct(BodyInterface $handler, array $args = []) { $this->args = $args; + $this->handler = $handler; + } + + + function enableSmartSearch(bool $isVerbose): void + { + $this->verbose = $isVerbose; + } + + function enableVerbose(bool $isVerbose): void + { + $this->verbose = $isVerbose; } /** - * Will Execute all unitary test files. + * Will Execute all unitary test files + * * @param string $path * @param string|bool $rootDir + * @param callable|null $callback * @return void * @throws BlunderSoftException */ - public function executeAll(string $path, string|bool $rootDir = false): void + public function executeAll(string $path, string|bool $rootDir = false, ?callable $callback = null): void { $rootDir = is_string($rootDir) ? realpath($rootDir) : false; $path = (!$path && $rootDir !== false) ? $rootDir : $path; @@ -59,19 +78,28 @@ public function executeAll(string $path, string|bool $rootDir = false): void } else { foreach ($files as $file) { extract($this->args, EXTR_PREFIX_SAME, "wddx"); + + // DELETE Unit::resetUnit(); + + // DELETE (BUT PASSS) Unit::setHeaders([ "args" => $this->args, "file" => $file, "checksum" => md5((string)$file) ]); + // Error Handler library + $this->runBlunder(); + $call = $this->requireUnitFile((string)$file); + if ($call !== null) { $call(); } - if (!Unit::hasUnit()) { - throw new RuntimeException("The Unitary Unit class has not been initiated inside \"$file\"."); + + if($callback !== null) { + $callback(); } } Unit::completed(); @@ -83,7 +111,50 @@ public function executeAll(string $path, string|bool $rootDir = false): void } /** - * You can change the default exist script from enabled to disabled + * Prepares a callable that will include and execute a unit test file in isolation. + * + * Wrapping with Closure achieves: + * Scope isolation, $this unbinding, State separation, Deferred execution + * + * @param string $file The full path to the test file to require. + * @return Closure|null A callable that, when invoked, runs the test file. + */ + private function requireUnitFile(string $file): ?Closure + { + + $handler = $this->handler; + $verbose = $this->verbose; + + $call = function () use ($file, $handler, $verbose): void { + if (!is_file($file)) { + throw new RuntimeException("File \"$file\" do not exists."); + } + self::$unitary = new Unit($handler); + $unitInst = require_once($file); + if ($unitInst instanceof Unit) { + self::$unitary = $unitInst; + } + $bool = self::$unitary->execute(); + + if(!$bool && $verbose) { + throw new BlunderSoftException( + "Could not find any tests inside the test file:\n" . + $file . "\n\n" . + "Possible causes:\n" . + " • There are not test in test group/case.\n" . + " • Unitary could not locate the Unit instance.\n" . + " • You did not use the `group()` function.\n" . + " • You created a new Unit in the test file but did not return it at the end. \n" + ); + + } + }; + + return $call->bindTo(null); + } + + /** + * You can change the default exist script from enabled to disable * * @param $exitScript * @return void @@ -94,7 +165,7 @@ public function enableExitScript($exitScript): void } /** - * Exist the script with right expected number + * Exist the script with the right expected number * * @return void */ @@ -115,6 +186,7 @@ public function getExitCode(): int /** * Will Scan and find all unitary test files + * * @param string $path * @param string|false $rootDir * @return array @@ -134,19 +206,10 @@ private function findFiles(string $path, string|bool $rootDir = false): array $path = dirname($path) . "/"; } if(is_dir($path)) { - $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); - /** @var string $pattern */ - $pattern = FileIterator::PATTERN; - foreach ($iterator as $file) { - if (($file instanceof SplFileInfo) && fnmatch($pattern, $file->getFilename()) && - (!empty($this->args['exclude']) || !str_contains($file->getPathname(), DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR))) { - if (!$this->findExcluded($this->exclude(), $path, $file->getPathname())) { - $files[] = $file->getPathname(); - } - } - } + $files += $this->getFileIterateReclusive($path); } } + // If smart search flag then step back if no test files have been found and try again if($rootDir !== false && count($files) <= 0 && str_starts_with($path, $rootDir) && isset($this->args['smart-search'])) { $path = (string)realpath($path . "/..") . "/"; return $this->findFiles($path, $rootDir); @@ -156,9 +219,10 @@ private function findFiles(string $path, string|bool $rootDir = false): array /** * Get exclude parameter + * * @return array */ - public function exclude(): array + private function exclude(): array { $excl = []; if (isset($this->args['exclude']) && is_string($this->args['exclude'])) { @@ -178,12 +242,13 @@ public function exclude(): array /** * Validate an exclude path + * * @param array $exclArr * @param string $relativeDir * @param string $file * @return bool */ - public function findExcluded(array $exclArr, string $relativeDir, string $file): bool + private function findExcluded(array $exclArr, string $relativeDir, string $file): bool { $file = $this->getNaturalPath($file); foreach ($exclArr as $excl) { @@ -195,53 +260,65 @@ public function findExcluded(array $exclArr, string $relativeDir, string $file): return false; } + + /** + * Iterate files that match the expected patterns + * + * @param string $path + * @return array + */ + private function getFileIterateReclusive(string $path): array + { + $files = []; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); + /** @var string $pattern */ + $pattern = FileIterator::PATTERN; + foreach ($iterator as $file) { + if (($file instanceof SplFileInfo) && fnmatch($pattern, $file->getFilename()) && + (!empty($this->args['exclude']) || !str_contains($file->getPathname(), DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR))) { + if (!$this->findExcluded($this->exclude(), $path, $file->getPathname())) { + $files[] = $file->getPathname(); + } + } + } + return $files; + } + /** * Get a path as a natural path + * * @param string $path * @return string */ - public function getNaturalPath(string $path): string + private function getNaturalPath(string $path): string { return str_replace("\\", "/", $path); } /** - * Require a file without inheriting any class information - * @param string $file - * @return Closure|null + * Initialize Blunder error handler + * + * @return void */ - private function requireUnitFile(string $file): ?Closure + protected function runBlunder(): void { - $clone = clone $this; - $call = function () use ($file, $clone): void { - $cli = new CliHandler(); - if (Unit::getArgs('trace') !== false) { - $cli->enableTraceLines(true); - } - $run = new Run($cli); - $run->setExitCode(1); - $run->load(); - if (!is_file($file)) { - throw new RuntimeException("File \"$file\" do not exists."); - } - require_once($file); - $clone->getUnit()->execute(); - }; - return $call->bindTo(null); + $run = new Run(new CliHandler()); + $run->setExitCode(1); + $run->load(); } /** + * Get instance of Unit class + * * @return Unit - * @throws RuntimeException|Exception */ - protected function getUnit(): Unit + public static function getUnitaryInst(): Unit { - $unit = Unit::getUnit(); - if ($unit === null) { - $unit = new Unit(); - //throw new RuntimeException("The Unit instance has not been initiated."); + if(self::$unitary === null) { + throw new \BadMethodCallException('Unit has not been initiated.'); } - return $unit; - + return self::$unitary; } + + } diff --git a/tests/unitary-test.php b/tests/unitary-test.php new file mode 100755 index 0000000..d62d820 --- /dev/null +++ b/tests/unitary-test.php @@ -0,0 +1,23 @@ +withName("unitary-test"); + +group("Hello world 0", function(TestCase $case) { + + $case->assert(1 === 2, "wdwdq 2"); + +}, $config); + +group("Hello world 1", function(TestCase $case) { + + $case->validate(1 === 2, function(Expect $expect) { + $expect->isEqualTo(true); + }); + +}, $config); + +group($config->withSubject("Hello world 2"), function(TestCase $case) { + $case->validate(2 === 2, fn() => true); +}); \ No newline at end of file diff --git a/unitary.config.php b/unitary.config.php index c3802e8..e78529a 100644 --- a/unitary.config.php +++ b/unitary.config.php @@ -1,4 +1,10 @@ 'unitary', + //'path' => 'app/Libraries/Unitary/tests/unitary-test.php', + 'path' => false, // false|string|array[string] + //'smart_search' => false, + //'errors_only' => false, + //'verbose' => false, + //'exclude' => false, // false|string|array[string] + //'exit_error_code' => 1, ]; \ No newline at end of file From e5be6bda4e6eb93455a46e7b3dc45b27811867ac Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Tue, 12 Aug 2025 14:44:34 +0200 Subject: [PATCH 59/78] update: Add ConfigProps, defines the set of allowed configuration properties and CLI arguments --- src/ConfigProps.php | 24 +++++ src/Interfaces/TestEmitterInterface.php | 4 +- src/Kernel/AbstractKernel.php | 1 + src/Kernel/Controllers/CoverageController.php | 7 +- src/Kernel/Controllers/DefaultController.php | 76 ++++++++++++++- src/Kernel/DispatchConfig.php | 92 ------------------- src/Kernel/Kernel.php | 3 +- src/Kernel/Services/AbstractTestService.php | 5 +- src/Kernel/Services/RunTestService.php | 2 +- src/TestEmitter.php | 48 +++++++--- src/Utils/FileIterator.php | 16 ++-- unitary.config.php | 2 +- 12 files changed, 156 insertions(+), 124 deletions(-) create mode 100644 src/ConfigProps.php delete mode 100644 src/Kernel/DispatchConfig.php diff --git a/src/ConfigProps.php b/src/ConfigProps.php new file mode 100644 index 0000000..bf5a755 --- /dev/null +++ b/src/ConfigProps.php @@ -0,0 +1,24 @@ +container->get("dispatchConfig"); + /* + $config = $this->container->get("dispatchConfig"); // Create a silent handler $coverage = new CodeCoverage(); @@ -52,6 +52,7 @@ public function run(ResponseInterface $response): ResponseInterface } $this->command->message(""); + */ return $response; } } \ No newline at end of file diff --git a/src/Kernel/Controllers/DefaultController.php b/src/Kernel/Controllers/DefaultController.php index 8fc59f0..c0e649a 100644 --- a/src/Kernel/Controllers/DefaultController.php +++ b/src/Kernel/Controllers/DefaultController.php @@ -2,25 +2,97 @@ namespace MaplePHP\Unitary\Kernel\Controllers; -use MaplePHP\Unitary\Kernel\DispatchConfig; +use MaplePHP\Emitron\Contracts\DispatchConfigInterface; use MaplePHP\Container\Interfaces\ContainerInterface; use MaplePHP\Http\Interfaces\RequestInterface; use MaplePHP\Http\Interfaces\ServerRequestInterface; use MaplePHP\Prompts\Command; +use MaplePHP\Unitary\ConfigProps; abstract class DefaultController { protected readonly ServerRequestInterface|RequestInterface $request; protected readonly ContainerInterface $container; protected Command $command; - protected DispatchConfig $configs; + protected DispatchConfigInterface $configs; protected array $args; + private ?ConfigProps $props = null; + /** + * Set some data type safe object that comes from container and the dispatcher + * + * @param ContainerInterface $container + * @throws \MaplePHP\Container\Interfaces\ContainerExceptionInterface + * @throws \MaplePHP\Container\Interfaces\NotFoundExceptionInterface + */ public function __construct(ContainerInterface $container) { $this->container = $container; $this->args = $this->container->get("args"); $this->command = $this->container->get("command"); $this->request = $this->container->get("request"); $this->configs = $this->container->get("dispatchConfig"); + + $this->buildAllowedProps(); } + + /** + * Builds the list of allowed CLI arguments from ConfigProps. + * + * These properties can be defined either in the configuration file or as CLI arguments. + * If invalid arguments are passed, and verbose mode is enabled, an error will be displayed + * along with a warning about the unknown properties. + * + * @return void + */ + private function buildAllowedProps(): void + { + if($this->props === null) { + try { + $props = array_merge($this->configs->getProps()->toArray(), $this->autoCastArgsToType()); + $this->props = new ConfigProps($props); + + } catch (\RuntimeException $e) { + if($e->getCode() === 2 && isset($this->args['verbose'])) { + $this->command->error($e->getMessage()); + $this->command->message( + "One or more arguments you passed are not recognized as valid options.\n" . + "Check your command syntax or configuration." + ); + exit(1); + } + } catch (\Throwable $e) { + if(isset($this->args['verbose'])) { + $this->command->error($e->getMessage()); + exit(1); + } + } + } + } + + + /** + * Will try to auto cast argument data type from CLI argument + * + * @return array + */ + private function autoCastArgsToType(): array + { + $args = []; + foreach($this->args as $key => $value) { + $lower = strtolower($value); + if ($lower === "true") { + $value = true; + } + if ($lower === "false") { + $value = false; + } + if (is_numeric($value)) { + $value = (strpos($value, '.') !== false) ? (float)$value : (int)$value; + } + $args[$key] = ($value === "") ? null : $value; + } + return $args; + } + + } \ No newline at end of file diff --git a/src/Kernel/DispatchConfig.php b/src/Kernel/DispatchConfig.php deleted file mode 100644 index b157dec..0000000 --- a/src/Kernel/DispatchConfig.php +++ /dev/null @@ -1,92 +0,0 @@ -verbose; - } - - /** - * Set if you want to show more possible warnings and errors that might be hidden - * - * @param bool $enableVerbose - * @return $this - */ - public function setVerbose(bool $enableVerbose): self - { - $this->verbose = $enableVerbose; - return $this; - } - - /** - * Check if smart search is active - * - * @return bool - */ - public function isSmartSearch(): bool - { - return $this->smartSearch; - } - - /** - * Enable smart search that will try to find test files automatically - * even if missing from an expected path. Will add some overhead. - * - * @param bool $enableSmartSearch - * @return $this - */ - public function setSmartSearch(bool $enableSmartSearch): self - { - $this->smartSearch = $enableSmartSearch; - return $this; - } - - /** - * Will return the expected test path - * - * @return string|bool - */ - public function getPath(): string|bool - { - return $this->path; - } - - /** - * Get current exit code as int or null if not set - * - * @return int|null - */ - public function getExitCode(): ?int - { - return $this->exitCode; - } - - /** - * Add exit after execution of the app has been completed - * - * @param int|null $exitCode - * @return $this - */ - public function setExitCode(int|null $exitCode): self - { - $this->exitCode = $exitCode; - return $this; - } -} \ No newline at end of file diff --git a/src/Kernel/Kernel.php b/src/Kernel/Kernel.php index 732fb7d..585482c 100644 --- a/src/Kernel/Kernel.php +++ b/src/Kernel/Kernel.php @@ -15,6 +15,7 @@ use Exception; use MaplePHP\Emitron\Contracts\DispatchConfigInterface; +use MaplePHP\Emitron\DispatchConfig; use MaplePHP\Http\Interfaces\ServerRequestInterface; use MaplePHP\Unitary\Kernel\Middlewares\AddCommandMiddleware; use MaplePHP\Unitary\Utils\Router; @@ -88,6 +89,6 @@ private function configuration(ServerRequestInterface $request): DispatchConfigI require_once $routerFile; return $router; }) - ->setExitCode(0); + ->setProp('exitCode', 0); } } \ No newline at end of file diff --git a/src/Kernel/Services/AbstractTestService.php b/src/Kernel/Services/AbstractTestService.php index d8cd6a6..dc2ec0f 100644 --- a/src/Kernel/Services/AbstractTestService.php +++ b/src/Kernel/Services/AbstractTestService.php @@ -3,18 +3,17 @@ namespace MaplePHP\Unitary\Kernel\Services; use MaplePHP\Container\Interfaces\ContainerInterface; +use MaplePHP\Emitron\Contracts\DispatchConfigInterface; use MaplePHP\Http\Interfaces\RequestInterface; use MaplePHP\Http\Interfaces\ResponseInterface; use MaplePHP\Http\Interfaces\ServerRequestInterface; -use MaplePHP\Unitary\Kernel\DispatchConfig; abstract class AbstractTestService { - protected ResponseInterface $response; protected ContainerInterface $container; protected array $args; - protected DispatchConfig $configs; + protected DispatchConfigInterface $configs; protected ServerRequestInterface|RequestInterface $request; public function __construct(ResponseInterface $response, ContainerInterface $container) diff --git a/src/Kernel/Services/RunTestService.php b/src/Kernel/Services/RunTestService.php index 16ed7b1..3db4871 100644 --- a/src/Kernel/Services/RunTestService.php +++ b/src/Kernel/Services/RunTestService.php @@ -38,7 +38,7 @@ public function run(BodyInterface $handler): ResponseInterface private function iterateTest(FileIterator $iterator): FileIterator { $defaultPath = $this->container->get("request")->getUri()->getDir(); - $defaultPath = ($this->configs->getPath() !== null) ? $this->configs->getPath() : $defaultPath; + $defaultPath = ($this->configs->getProps()->path !== null) ? $this->configs->getProps()->path : $defaultPath; $path = ($this->args['path'] ?? $defaultPath); if(!isset($path)) { diff --git a/src/TestEmitter.php b/src/TestEmitter.php index 2966c6c..0cd76e4 100644 --- a/src/TestEmitter.php +++ b/src/TestEmitter.php @@ -1,5 +1,4 @@ unit = new Unit($handler); + $this->args = $args; + $this->runBlunder($errorHandler); + } - public function __construct(string $file) + public function emit(string $file): void { + + $verbose = (bool)($this->args['verbose'] ?? false); + if(!is_file($file)) { throw new \RuntimeException("The test file \"$file\" do not exists."); } - $this->unit = new Unit(); - $this->file = $file; + + + require_once($file); + + $hasExecutedTest = $this->unit->execute(); + + if(!$hasExecutedTest && $verbose) { + throw new BlunderSoftException( + "Could not find any tests inside the test file:\n" . + $file . "\n\n" . + "Possible causes:\n" . + " • There are not test in test group/case.\n" . + " • Unitary could not locate the Unit instance.\n" . + " • You did not use the `group()` function.\n" . + " • You created a new Unit in the test file but did not return it at the end. \n" + ); + } + } - public function emit(): void + public function getUnit(): Unit { - $this->runBlunder(); - require_once($this->file); - $this->unit->execute(); + return $this->unit; } /** * Initialize Blunder error handler * + * @param AbstractHandlerInterface $errorHandler * @return void */ - protected function runBlunder(): void + protected function runBlunder(AbstractHandlerInterface $errorHandler): void { - $run = new Run(new CliHandler()); + $run = new Run($errorHandler); $run->setExitCode(1); $run->load(); } diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php index c069afc..19f337c 100755 --- a/src/Utils/FileIterator.php +++ b/src/Utils/FileIterator.php @@ -12,7 +12,6 @@ namespace MaplePHP\Unitary\Utils; use Closure; -use Exception; use MaplePHP\Blunder\Exceptions\BlunderSoftException; use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\Blunder\Run; @@ -42,7 +41,6 @@ public function __construct(BodyInterface $handler, array $args = []) $this->handler = $handler; } - function enableSmartSearch(bool $isVerbose): void { $this->verbose = $isVerbose; @@ -76,6 +74,10 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable (FileIterator::PATTERN ?? "") . "\" in directory \"" . dirname($path) . "\" and its subdirectories."); } else { + + // Error Handler library + $this->runBlunder(); + foreach ($files as $file) { extract($this->args, EXTR_PREFIX_SAME, "wddx"); @@ -89,9 +91,6 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable "checksum" => md5((string)$file) ]); - // Error Handler library - $this->runBlunder(); - $call = $this->requireUnitFile((string)$file); if ($call !== null) { @@ -121,7 +120,6 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable */ private function requireUnitFile(string $file): ?Closure { - $handler = $this->handler; $verbose = $this->verbose; @@ -146,7 +144,6 @@ private function requireUnitFile(string $file): ?Closure " • You did not use the `group()` function.\n" . " • You created a new Unit in the test file but did not return it at the end. \n" ); - } }; @@ -310,6 +307,9 @@ protected function runBlunder(): void /** * Get instance of Unit class * + * This is primary used to access the main test Unit instance that is + * pre-initialized for each test file. Is used by shortcut function like `group()` + * * @return Unit */ public static function getUnitaryInst(): Unit @@ -319,6 +319,4 @@ public static function getUnitaryInst(): Unit } return self::$unitary; } - - } diff --git a/unitary.config.php b/unitary.config.php index e78529a..6e7d7a2 100644 --- a/unitary.config.php +++ b/unitary.config.php @@ -1,7 +1,7 @@ 'app/Libraries/Unitary/tests/unitary-test.php', - 'path' => false, // false|string|array[string] + //'path' => false, // false|string|array[string] //'smart_search' => false, //'errors_only' => false, //'verbose' => false, From a49e4447fc4b0189f55670f13cf411608f8a9807 Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Wed, 13 Aug 2025 20:06:55 +0200 Subject: [PATCH 60/78] refactor: Organization and re-naming of files and directories --- README.md | 2 +- bin/unitary | 4 +- composer.json | 2 +- src/{ => Config}/ConfigProps.php | 2 +- src/{ => Config}/TestConfig.php | 2 +- .../routes.php => Console/ConsoleRouter.php} | 6 +- .../Controllers/CoverageController.php | 11 +- .../Controllers/DefaultController.php | 6 +- .../Controllers/RunTestController.php | 8 +- .../Controllers/TemplateController.php | 12 +- .../Enum/CoverageIssue.php | 2 +- src/{Kernel => Console}/Kernel.php | 22 +-- .../Middlewares/AddCommandMiddleware.php | 2 +- .../Services/AbstractMainService.php} | 4 +- .../Services/RunTestService.php | 14 +- .../TestDiscovery.php} | 8 +- src/Handlers/FileHandler.php | 59 ------- src/Handlers/HtmlHandler.php | 55 ------ src/Handlers/SilentBodyHandler.php | 11 -- src/Kernel/AbstractKernel.php | 161 ------------------ src/Mocker/MockBuilder.php | 3 +- src/Mocker/MockedMethod.php | 4 +- .../AbstractRenderHandler.php} | 4 +- .../CliRenderer.php} | 6 +- src/Renders/SilentRender.php | 11 ++ src/{Utils => Support}/Helpers.php | 2 +- src/{Utils => Support}/Performance.php | 2 +- src/{Utils => Support}/Router.php | 2 +- src/{ => Support}/TestUtils/CodeCoverage.php | 7 +- src/{ => Support}/TestUtils/DataTypeMock.php | 15 +- .../TestUtils/ExecutionWrapper.php | 2 +- .../functions.php} | 6 +- src/TestCase.php | 3 +- src/TestItem.php | 2 +- src/TestUnit.php | 2 +- src/Unit.php | 27 ++- tests/unitary-test-item.php | 2 +- tests/unitary-test.php | 2 +- tests/unitary-unitary.php | 2 +- tests/unitary-will-fail.php | 2 +- 40 files changed, 113 insertions(+), 386 deletions(-) rename src/{ => Config}/ConfigProps.php (95%) rename src/{ => Config}/TestConfig.php (98%) rename src/{Kernel/routes.php => Console/ConsoleRouter.php} (59%) rename src/{Kernel => Console}/Controllers/CoverageController.php (78%) rename src/{Kernel => Console}/Controllers/DefaultController.php (97%) rename src/{Kernel => Console}/Controllers/RunTestController.php (92%) rename src/{Kernel => Console}/Controllers/TemplateController.php (68%) rename src/{Kernel => Console}/Enum/CoverageIssue.php (94%) rename src/{Kernel => Console}/Kernel.php (84%) rename src/{Kernel => Console}/Middlewares/AddCommandMiddleware.php (96%) rename src/{Kernel/Services/AbstractTestService.php => Console/Services/AbstractMainService.php} (92%) rename src/{Kernel => Console}/Services/RunTestService.php (81%) rename src/{Utils/FileIterator.php => Discovery/TestDiscovery.php} (97%) delete mode 100755 src/Handlers/FileHandler.php delete mode 100755 src/Handlers/HtmlHandler.php delete mode 100644 src/Handlers/SilentBodyHandler.php delete mode 100644 src/Kernel/AbstractKernel.php rename src/{Handlers/AbstractBodyHandler.php => Renders/AbstractRenderHandler.php} (96%) rename src/{Handlers/CliEmitter.php => Renders/CliRenderer.php} (98%) create mode 100644 src/Renders/SilentRender.php rename src/{Utils => Support}/Helpers.php (99%) rename src/{Utils => Support}/Performance.php (97%) rename src/{Utils => Support}/Router.php (98%) rename src/{ => Support}/TestUtils/CodeCoverage.php (95%) rename src/{ => Support}/TestUtils/DataTypeMock.php (99%) rename src/{ => Support}/TestUtils/ExecutionWrapper.php (98%) rename src/{Setup/unitary-helpers.php => Support/functions.php} (77%) diff --git a/README.md b/README.md index b9a8715..5d7275c 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Create a file like `tests/unitary-request.php`. Unitary automatically scans all Paste this test boilerplate to get started: ```php -use MaplePHP\Unitary\{Unit, TestCase, TestConfig, Expect}; +use MaplePHP\Unitary\{Expect,TestCase,Unit}; $unit = new Unit(); $unit->group("Your test subject", function (TestCase $case) { diff --git a/bin/unitary b/bin/unitary index c3b2c2f..6295d62 100755 --- a/bin/unitary +++ b/bin/unitary @@ -8,8 +8,8 @@ use MaplePHP\Container\Container; use MaplePHP\Http\Environment; use MaplePHP\Http\ServerRequest; use MaplePHP\Http\Uri; -use MaplePHP\Unitary\Kernel\Kernel; -use MaplePHP\Unitary\Kernel\Middlewares\AddCommandMiddleware; +use MaplePHP\Unitary\Console\Kernel; +use MaplePHP\Unitary\Console\Middlewares\AddCommandMiddleware; $autoload = __DIR__ . '/../../../../vendor/autoload.php'; $autoload = is_file($autoload) ? $autoload : __DIR__ . '/../vendor/autoload.php'; diff --git a/composer.json b/composer.json index 211dfe3..54b949a 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "autoload": { "files": [ "src/Setup/assert-polyfill.php", - "src/Setup/unitary-helpers.php" + "src/Support/functions.php" ], "psr-4": { "MaplePHP\\Unitary\\": "src" diff --git a/src/ConfigProps.php b/src/Config/ConfigProps.php similarity index 95% rename from src/ConfigProps.php rename to src/Config/ConfigProps.php index bf5a755..efe3558 100644 --- a/src/ConfigProps.php +++ b/src/Config/ConfigProps.php @@ -1,7 +1,7 @@ map("coverage", [CoverageController::class, "run"]); diff --git a/src/Kernel/Controllers/CoverageController.php b/src/Console/Controllers/CoverageController.php similarity index 78% rename from src/Kernel/Controllers/CoverageController.php rename to src/Console/Controllers/CoverageController.php index 548f22c..8e936db 100644 --- a/src/Kernel/Controllers/CoverageController.php +++ b/src/Console/Controllers/CoverageController.php @@ -1,16 +1,9 @@ command); + $handler = new CliRenderer($this->command); return $service->run($handler); } diff --git a/src/Kernel/Controllers/TemplateController.php b/src/Console/Controllers/TemplateController.php similarity index 68% rename from src/Kernel/Controllers/TemplateController.php rename to src/Console/Controllers/TemplateController.php index 4a54e84..40dbb3a 100644 --- a/src/Kernel/Controllers/TemplateController.php +++ b/src/Console/Controllers/TemplateController.php @@ -1,16 +1,8 @@ addHeadline("\n--- Copy and paste code --->"); $blocks->addCode( <<<'PHP' - use MaplePHP\Unitary\{TestCase, TestConfig, Expect}; + use MaplePHP\Unitary\{Expect,TestCase}; group("Your test subject", function (TestCase $case) { diff --git a/src/Kernel/Enum/CoverageIssue.php b/src/Console/Enum/CoverageIssue.php similarity index 94% rename from src/Kernel/Enum/CoverageIssue.php rename to src/Console/Enum/CoverageIssue.php index c2d48e6..77623e0 100644 --- a/src/Kernel/Enum/CoverageIssue.php +++ b/src/Console/Enum/CoverageIssue.php @@ -10,7 +10,7 @@ declare(strict_types=1); -namespace MaplePHP\Unitary\Kernel\Enum; +namespace MaplePHP\Unitary\Console\Enum; enum CoverageIssue { diff --git a/src/Kernel/Kernel.php b/src/Console/Kernel.php similarity index 84% rename from src/Kernel/Kernel.php rename to src/Console/Kernel.php index 585482c..5f2711b 100644 --- a/src/Kernel/Kernel.php +++ b/src/Console/Kernel.php @@ -11,23 +11,25 @@ declare(strict_types=1); -namespace MaplePHP\Unitary\Kernel; +namespace MaplePHP\Unitary\Console; use Exception; use MaplePHP\Emitron\Contracts\DispatchConfigInterface; use MaplePHP\Emitron\DispatchConfig; use MaplePHP\Http\Interfaces\ServerRequestInterface; -use MaplePHP\Unitary\Kernel\Middlewares\AddCommandMiddleware; -use MaplePHP\Unitary\Utils\Router; +use MaplePHP\Unitary\Console\Middlewares\AddCommandMiddleware; +use MaplePHP\Unitary\Support\Router; use MaplePHP\Container\Interfaces\ContainerInterface; -use MaplePHP\Emitron\Kernel as EmitronKernel; +use MaplePHP\Emitron\EmitronKernel; class Kernel { + private const DEFAULT_ROUTER_FILE = '/src/Console/ConsoleRouter.php'; public const CONFIG_FILE_PATH = __DIR__ . '/../../unitary.config'; + private ContainerInterface $container; private array $userMiddlewares; - private ?DispatchConfig $dispatchConfig; + private ?DispatchConfig $config; /** * Unitary kernel file @@ -43,7 +45,7 @@ public function __construct( ) { $this->container = $container; $this->userMiddlewares = $userMiddlewares; - $this->dispatchConfig = $dispatchConfig; + $this->config = $dispatchConfig; // This middleware is used in the DefaultController, which is why I always load it, // It will not change any response but will load a CLI helper Command library @@ -62,10 +64,10 @@ public function __construct( */ public function run(ServerRequestInterface $request): void { - if($this->dispatchConfig === null) { - $this->dispatchConfig = $this->configuration($request); + if($this->config === null) { + $this->config = $this->configuration($request); } - $kernel = new EmitronKernel($this->container, $this->userMiddlewares, $this->dispatchConfig); + $kernel = new EmitronKernel($this->container, $this->userMiddlewares, $this->config); $kernel->run($request); } @@ -81,7 +83,7 @@ private function configuration(ServerRequestInterface $request): DispatchConfigI $config = new DispatchConfig(EmitronKernel::getConfigFilePath()); return $config ->setRouter(function($path) use ($request) { - $routerFile = $path . "/src/Kernel/routes.php"; + $routerFile = $path . self::DEFAULT_ROUTER_FILE; $router = new Router($request->getCliKeyword(), $request->getCliArgs()); if(!is_file($routerFile)) { throw new Exception('The routes file (' . $routerFile . ') is missing.'); diff --git a/src/Kernel/Middlewares/AddCommandMiddleware.php b/src/Console/Middlewares/AddCommandMiddleware.php similarity index 96% rename from src/Kernel/Middlewares/AddCommandMiddleware.php rename to src/Console/Middlewares/AddCommandMiddleware.php index f255375..13663dc 100644 --- a/src/Kernel/Middlewares/AddCommandMiddleware.php +++ b/src/Console/Middlewares/AddCommandMiddleware.php @@ -1,6 +1,6 @@ container->get("dispatchConfig"); //$this->configs->isSmartSearch(); - $iterator = new FileIterator($handler, $this->args); + $iterator = new TestDiscovery($handler, $this->args); $iterator = $this->iterateTest($iterator); // CLI Response @@ -29,13 +29,13 @@ public function run(BodyInterface $handler): ResponseInterface } /** - * @param FileIterator $iterator - * @return FileIterator + * @param TestDiscovery $iterator + * @return TestDiscovery * @throws BlunderSoftException * @throws \MaplePHP\Container\Interfaces\ContainerExceptionInterface * @throws \MaplePHP\Container\Interfaces\NotFoundExceptionInterface */ - private function iterateTest(FileIterator $iterator): FileIterator + private function iterateTest(TestDiscovery $iterator): TestDiscovery { $defaultPath = $this->container->get("request")->getUri()->getDir(); $defaultPath = ($this->configs->getProps()->path !== null) ? $this->configs->getProps()->path : $defaultPath; diff --git a/src/Utils/FileIterator.php b/src/Discovery/TestDiscovery.php similarity index 97% rename from src/Utils/FileIterator.php rename to src/Discovery/TestDiscovery.php index 19f337c..e11fb6d 100755 --- a/src/Utils/FileIterator.php +++ b/src/Discovery/TestDiscovery.php @@ -9,7 +9,7 @@ */ declare(strict_types=1); -namespace MaplePHP\Unitary\Utils; +namespace MaplePHP\Unitary\Discovery; use Closure; use MaplePHP\Blunder\Exceptions\BlunderSoftException; @@ -23,7 +23,7 @@ use RuntimeException; use SplFileInfo; -final class FileIterator +final class TestDiscovery { public const PATTERN = 'unitary-*.php'; @@ -71,7 +71,7 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable if (empty($files)) { /* @var string static::PATTERN */ throw new BlunderSoftException("Unitary could not find any test files matching the pattern \"" . - (FileIterator::PATTERN ?? "") . "\" in directory \"" . dirname($path) . + (TestDiscovery::PATTERN ?? "") . "\" in directory \"" . dirname($path) . "\" and its subdirectories."); } else { @@ -269,7 +269,7 @@ private function getFileIterateReclusive(string $path): array $files = []; $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); /** @var string $pattern */ - $pattern = FileIterator::PATTERN; + $pattern = TestDiscovery::PATTERN; foreach ($iterator as $file) { if (($file instanceof SplFileInfo) && fnmatch($pattern, $file->getFilename()) && (!empty($this->args['exclude']) || !str_contains($file->getPathname(), DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR))) { diff --git a/src/Handlers/FileHandler.php b/src/Handlers/FileHandler.php deleted file mode 100755 index a3b6c7c..0000000 --- a/src/Handlers/FileHandler.php +++ /dev/null @@ -1,59 +0,0 @@ -stream = new Stream(Stream::TEMP); - $this->command = new Command($this->stream); - $this->command->getAnsi()->disableAnsi(true); - $this->file = $file; - } - - /** - * Access the command stream - * - * @return Command - */ - public function getCommand(): Command - { - return $this->command; - } - - /** - * Execute the handler - * This will automatically be called inside the Unit execution - * - * @return void - */ - public function execute(): void - { - $upload = new UploadedFile($this->stream); - $upload->moveTo($this->file); - } -} diff --git a/src/Handlers/HtmlHandler.php b/src/Handlers/HtmlHandler.php deleted file mode 100755 index d85320e..0000000 --- a/src/Handlers/HtmlHandler.php +++ /dev/null @@ -1,55 +0,0 @@ -stream = new Stream(Stream::TEMP); - $this->command = new Command($this->stream); - $this->command->getAnsi()->disableAnsi(true); - } - - /** - * Access the command stream - * @return Command - */ - public function getCommand(): Command - { - return $this->command; - } - - /** - * Execute the handler - * This will automatically be called inside the Unit execution - * @return void - */ - public function execute(): void - { - $this->stream->rewind(); - $out = $this->stream->getContents(); - $style = 'background-color: #F1F1F1; color: #000; font-size: 2rem; font-weight: normal; font-family: "Lucida Console", Monaco, monospace;'; - $out = str_replace(["[", "]"], ['', ''], $out); - echo '
' . $out . '
'; - } -} diff --git a/src/Handlers/SilentBodyHandler.php b/src/Handlers/SilentBodyHandler.php deleted file mode 100644 index 96c1a0f..0000000 --- a/src/Handlers/SilentBodyHandler.php +++ /dev/null @@ -1,11 +0,0 @@ -userMiddlewares = $userMiddlewares; - $this->container = $container; - $this->dispatchConfig = ($dispatchConfig === null) ? - new DispatchConfig(static::getConfigFilePath()) : $dispatchConfig; - } - - /** - * Makes it easy to specify a config file inside a custom kernel file - * - * @param string $path - * @return void - */ - public static function setConfigFilePath(string $path): void - { - static::$configFilePath = $path; - } - - /** - * Get expected config file - * - * @return string - */ - public static function getConfigFilePath(): string - { - if(static::$configFilePath === null) { - return static::CONFIG_FILE_PATH; - } - return static::$configFilePath; - } - - /** - * Get config instance for configure dispatch result - * - * @return DispatchConfigInterface - */ - public function getDispatchConfig(): DispatchConfigInterface - { - return $this->dispatchConfig; - } - - /** - * You can bind an instance (singleton) to an interface class that then is loaded through - * the dependency injector preferably that in implementable of that class - * - * @param callable $call - * @return void - */ - public function bindInstToInterfaces(callable $call): void - { - Reflection::interfaceFactory($call); - } - - /** - * Will bind core instances (singletons) to interface classes that then is loaded through - * the dependency injector preferably that in implementable of that class - * - * @param ServerRequestInterface $request - * @param ResponseInterface $response - * @return void - */ - protected function bindCoreInstToInterfaces(ServerRequestInterface $request, ResponseInterface $response): void - { - Reflection::interfaceFactory(function ($className) use ($request, $response) { - return match ($className) { - "ContainerInterface" => $this->container, - "RequestInterface", "ServerRequestInterface" => $request, - "ResponseInterface" => $response, - default => null, - }; - }); - } - - /** - * Check if is inside a command line interface (CLI) - * - * @return bool - */ - protected function isCli(): bool - { - return PHP_SAPI === 'cli'; - } - - /** - * Will Create preferred Stream and Response instance depending on a platform - * - * @return ResponseInterface - */ - protected function createResponse(): ResponseInterface - { - $stream = new Stream($this->isCli() ? Stream::STDOUT : Stream::TEMP); - $factory = new ResponseFactory(); - $response = $factory->createResponse(body: $stream); - if ($this->isCli()) { - // In CLI, the status code is used as the exit code rather than an HTTP status code. - // By default, a successful execution should return 0 as the exit code. - $response = $response->withStatus(0); - } - return $response; - } - - /** - * Get emitter based on a platform - * - * @return EmitterInterface - */ - protected function createEmitter(): EmitterInterface - { - return $this->isCli() ? new CliEmitter() : new HttpEmitter(); - } - -} \ No newline at end of file diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index aeca100..c0fc4c6 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -13,8 +13,7 @@ use Closure; use Exception; -use MaplePHP\Http\Stream; -use MaplePHP\Unitary\TestUtils\DataTypeMock; +use MaplePHP\Unitary\Support\TestUtils\DataTypeMock; use Reflection; use ReflectionClass; use ReflectionIntersectionType; diff --git a/src/Mocker/MockedMethod.php b/src/Mocker/MockedMethod.php index af7bbae..5bd4679 100644 --- a/src/Mocker/MockedMethod.php +++ b/src/Mocker/MockedMethod.php @@ -12,9 +12,9 @@ namespace MaplePHP\Unitary\Mocker; use BadMethodCallException; -use InvalidArgumentException; use Closure; -use MaplePHP\Unitary\TestUtils\ExecutionWrapper; +use InvalidArgumentException; +use MaplePHP\Unitary\Support\TestUtils\ExecutionWrapper; use Throwable; /** diff --git a/src/Handlers/AbstractBodyHandler.php b/src/Renders/AbstractRenderHandler.php similarity index 96% rename from src/Handlers/AbstractBodyHandler.php rename to src/Renders/AbstractRenderHandler.php index 63cd22d..eecd915 100644 --- a/src/Handlers/AbstractBodyHandler.php +++ b/src/Renders/AbstractRenderHandler.php @@ -1,13 +1,13 @@ group($message, $expect, $config); + TestDiscovery::getUnitaryInst()->group($message, $expect, $config); } if (!function_exists('group')) { diff --git a/src/TestCase.php b/src/TestCase.php index 4a89e66..ed35105 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -19,10 +19,11 @@ use MaplePHP\Blunder\Exceptions\BlunderErrorException; use MaplePHP\DTO\Format\Str; use MaplePHP\DTO\Traverse; +use MaplePHP\Unitary\Config\TestConfig; use MaplePHP\Unitary\Mocker\MethodRegistry; use MaplePHP\Unitary\Mocker\MockBuilder; use MaplePHP\Unitary\Mocker\MockController; -use MaplePHP\Unitary\TestUtils\ExecutionWrapper; +use MaplePHP\Unitary\Support\TestUtils\ExecutionWrapper; use MaplePHP\Validate\Validator; use ReflectionClass; use ReflectionException; diff --git a/src/TestItem.php b/src/TestItem.php index 0551136..230620f 100755 --- a/src/TestItem.php +++ b/src/TestItem.php @@ -12,7 +12,7 @@ namespace MaplePHP\Unitary; use ErrorException; -use MaplePHP\Unitary\Utils\Helpers; +use MaplePHP\Unitary\Support\Helpers; final class TestItem { diff --git a/src/TestUnit.php b/src/TestUnit.php index 21a284c..d1518b7 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -12,7 +12,7 @@ namespace MaplePHP\Unitary; use ErrorException; -use MaplePHP\Unitary\Utils\Helpers; +use MaplePHP\Unitary\Support\Helpers; final class TestUnit { diff --git a/src/Unit.php b/src/Unit.php index 5f297c2..d5764bb 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -14,15 +14,16 @@ use Closure; use ErrorException; -use MaplePHP\Unitary\Interfaces\BodyInterface; -use RuntimeException; -use Throwable; use MaplePHP\Blunder\Exceptions\BlunderErrorException; use MaplePHP\Http\Interfaces\StreamInterface; use MaplePHP\Prompts\Command; -use MaplePHP\Unitary\Handlers\CliEmitter; -use MaplePHP\Unitary\Handlers\HandlerInterface; -use MaplePHP\Unitary\Utils\Performance; +use MaplePHP\Unitary\Config\TestConfig; +use MaplePHP\Unitary\Renders\CliRenderer; +use MaplePHP\Unitary\Renders\HandlerInterface; +use MaplePHP\Unitary\Interfaces\BodyInterface; +use MaplePHP\Unitary\Support\Performance; +use RuntimeException; +use Throwable; final class Unit { @@ -49,7 +50,7 @@ final class Unit public function __construct(BodyInterface|null $handler = null) { - $this->handler = ($handler === null) ? new CliEmitter(new Command()) : $handler; + $this->handler = ($handler === null) ? new CliRenderer(new Command()) : $handler; self::$current = $this; } @@ -186,6 +187,18 @@ public function validate(): self "Move this validate() call inside your group() callback function."); } + /** + * Validate method that must be called within a group method + * + * @return self + * @throws RuntimeException When called outside a group method + */ + public function assert(): self + { + throw new RuntimeException("The assert() method must be called inside a group() method! " . + "Move this assert() call inside your group() callback function."); + } + /** * This is custom header information that is passed, that work with both CLI and Browsers * diff --git a/tests/unitary-test-item.php b/tests/unitary-test-item.php index f70cbe5..c66e26d 100644 --- a/tests/unitary-test-item.php +++ b/tests/unitary-test-item.php @@ -1,6 +1,6 @@ withName("unitary-test"); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index e2fd38e..3fa7292 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -6,7 +6,7 @@ use MaplePHP\DTO\Traverse; use MaplePHP\Http\Response; use MaplePHP\Http\Stream; -use MaplePHP\Unitary\{Mocker\MethodRegistry, TestCase, TestConfig, Expect, Unit}; +use MaplePHP\Unitary\{Config\TestConfig, Expect, Mocker\MethodRegistry, TestCase, Unit}; use TestLib\Mailer; use TestLib\UserService; diff --git a/tests/unitary-will-fail.php b/tests/unitary-will-fail.php index 92d8620..0f486ec 100755 --- a/tests/unitary-will-fail.php +++ b/tests/unitary-will-fail.php @@ -2,7 +2,7 @@ require_once(__DIR__ . "/TestLib/Mailer.php"); require_once(__DIR__ . "/TestLib/UserService.php"); -use MaplePHP\Unitary\{Mocker\MethodRegistry, TestCase, TestConfig, Expect, Unit}; +use MaplePHP\Unitary\{Config\TestConfig, Expect, Mocker\MethodRegistry, TestCase, Unit}; use TestLib\Mailer; $unit = new Unit(); From 2c297de9b2a8e50dd40ae9d3763158b936c6f93a Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Thu, 14 Aug 2025 11:25:44 +0200 Subject: [PATCH 61/78] refactor: Organize code after solid principles --- README.md | 4 +- src/Console/Controllers/RunTestController.php | 29 ++- src/Console/Services/AbstractMainService.php | 8 +- src/Console/Services/RunTestService.php | 32 ++- src/Discovery/TestDiscovery.php | 107 ++++----- src/Unit.php | 204 +++++++----------- 6 files changed, 175 insertions(+), 209 deletions(-) diff --git a/README.md b/README.md index 5d7275c..730cbe3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ _Do you like the CLI theme? [Download it here](https://github.com/MaplePHP/DarkB Unitary is designed to feel natural for developers. With clear syntax, built-in validation, and zero setup required, writing tests becomes a smooth part of your daily flow—not a separate chore. ```php -$unit->group("Has a about page", function(TestCase $case) { +group("Has a about page", function(TestCase $case) { $response = $this->get("/about"); $statusCode = $response->getStatusCode(); @@ -84,7 +84,7 @@ Paste this test boilerplate to get started: use MaplePHP\Unitary\{Expect,TestCase,Unit}; $unit = new Unit(); -$unit->group("Your test subject", function (TestCase $case) { +group("Your test subject", function (TestCase $case) { $case->validate("Your test value", function(Expect $valid) { $valid->isString(); }); diff --git a/src/Console/Controllers/RunTestController.php b/src/Console/Controllers/RunTestController.php index 3e929f1..bea70f4 100644 --- a/src/Console/Controllers/RunTestController.php +++ b/src/Console/Controllers/RunTestController.php @@ -2,6 +2,7 @@ namespace MaplePHP\Unitary\Console\Controllers; +use MaplePHP\Unitary\Discovery\TestDiscovery; use MaplePHP\Unitary\Renders\CliRenderer; use MaplePHP\Unitary\Console\Services\RunTestService; use MaplePHP\Http\Interfaces\ResponseInterface; @@ -16,7 +17,9 @@ class RunTestController extends DefaultController public function run(RunTestService $service): ResponseInterface { $handler = new CliRenderer($this->command); - return $service->run($handler); + $response = $service->run($handler); + $this->buildFooter(); + return $response; } /** @@ -66,4 +69,28 @@ public function help(): void exit(0); } + /** + * Create a footer showing and end of script command + * + * This is not really part of the Unit test library, as other stuff might be present here + * + * @return void + */ + protected function buildFooter() + { + $inst = TestDiscovery::getUnitaryInst(); + $dot = $this->command->getAnsi()->middot(); + $peakMemory = (string)round(memory_get_peak_usage() / 1024, 2); + + $this->command->message( + $this->command->getAnsi()->style( + ["italic", "grey"], + "Total: " . $inst->getPassedTests() . "/" . $inst->getTotalTests() . " $dot " . + "Peak memory usage: " . $peakMemory . " KB" + ) + ); + $this->command->message(""); + + } + } \ No newline at end of file diff --git a/src/Console/Services/AbstractMainService.php b/src/Console/Services/AbstractMainService.php index a271635..7d330b8 100644 --- a/src/Console/Services/AbstractMainService.php +++ b/src/Console/Services/AbstractMainService.php @@ -7,12 +7,14 @@ use MaplePHP\Http\Interfaces\RequestInterface; use MaplePHP\Http\Interfaces\ResponseInterface; use MaplePHP\Http\Interfaces\ServerRequestInterface; +use MaplePHP\Prompts\Command; abstract class AbstractMainService { protected ResponseInterface $response; protected ContainerInterface $container; protected array $args; + protected Command $command; protected DispatchConfigInterface $configs; protected ServerRequestInterface|RequestInterface $request; @@ -23,11 +25,7 @@ public function __construct(ResponseInterface $response, ContainerInterface $con $this->args = $this->container->get("args"); $this->request = $this->container->get("request"); $this->configs = $this->container->get("dispatchConfig"); - } - - protected function getArg($key): mixed - { - return ($this->args[$key] ?? null); + $this->command = $this->container->get("command"); } } \ No newline at end of file diff --git a/src/Console/Services/RunTestService.php b/src/Console/Services/RunTestService.php index d4b3796..b7d1eda 100644 --- a/src/Console/Services/RunTestService.php +++ b/src/Console/Services/RunTestService.php @@ -3,9 +3,12 @@ namespace MaplePHP\Unitary\Console\Services; use MaplePHP\Blunder\Exceptions\BlunderSoftException; +use MaplePHP\Container\Interfaces\ContainerExceptionInterface; +use MaplePHP\Container\Interfaces\NotFoundExceptionInterface; use MaplePHP\Http\Interfaces\ResponseInterface; use MaplePHP\Unitary\Discovery\TestDiscovery; use MaplePHP\Unitary\Interfaces\BodyInterface; +use MaplePHP\Unitary\Unit; use RuntimeException; class RunTestService extends AbstractMainService @@ -17,8 +20,14 @@ public function run(BodyInterface $handler): ResponseInterface // $config = $this->container->get("dispatchConfig"); //$this->configs->isSmartSearch(); - $iterator = new TestDiscovery($handler, $this->args); - $iterator = $this->iterateTest($iterator); + // args should be able to be overwritten by configs... + + $iterator = new TestDiscovery(); + $iterator->enableVerbose(isset($this->args['verbose'])); + $iterator->enableSmartSearch(isset($this->args['smart-search'])); + $iterator->addExcludePaths($this->args['exclude'] ?? null); + + $iterator = $this->iterateTest($iterator, $handler); // CLI Response if(PHP_SAPI === 'cli') { @@ -30,12 +39,13 @@ public function run(BodyInterface $handler): ResponseInterface /** * @param TestDiscovery $iterator + * @param BodyInterface $handler * @return TestDiscovery * @throws BlunderSoftException - * @throws \MaplePHP\Container\Interfaces\ContainerExceptionInterface - * @throws \MaplePHP\Container\Interfaces\NotFoundExceptionInterface + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface */ - private function iterateTest(TestDiscovery $iterator): TestDiscovery + private function iterateTest(TestDiscovery $iterator, BodyInterface $handler): TestDiscovery { $defaultPath = $this->container->get("request")->getUri()->getDir(); $defaultPath = ($this->configs->getProps()->path !== null) ? $this->configs->getProps()->path : $defaultPath; @@ -46,10 +56,16 @@ private function iterateTest(TestDiscovery $iterator): TestDiscovery } $testDir = realpath($path); if(!file_exists($testDir)) { - throw new RuntimeException("Test directory '$testDir' does not exist"); + throw new RuntimeException("Test directory '$path' does not exist"); } - $iterator->enableExitScript(false); - $iterator->executeAll($testDir, $defaultPath); + $iterator->executeAll($testDir, $defaultPath, function($file) use ($handler) { + $unit = new Unit($handler); + $unit->setShowErrorsOnly(isset($this->args['errors-only'])); + $unit->setShow($this->args['show'] ?? null); + $unit->setFile($file); + return $unit; + }); return $iterator; } + } \ No newline at end of file diff --git a/src/Discovery/TestDiscovery.php b/src/Discovery/TestDiscovery.php index e11fb6d..3e7d4d3 100755 --- a/src/Discovery/TestDiscovery.php +++ b/src/Discovery/TestDiscovery.php @@ -30,25 +30,39 @@ final class TestDiscovery private array $args; private bool $verbose = false; private bool $smartSearch = false; - private bool $exitScript = true; + private ?string $exclude = null; + private ?Command $command = null; - private BodyInterface $handler; private static ?Unit $unitary = null; - public function __construct(BodyInterface $handler, array $args = []) + + public function __construct() { - $this->args = $args; - $this->handler = $handler; } - function enableSmartSearch(bool $isVerbose): void + function enableVerbose(bool $isVerbose): void { $this->verbose = $isVerbose; } - function enableVerbose(bool $isVerbose): void + function enableSmartSearch(bool $smartSearch): void { - $this->verbose = $isVerbose; + $this->smartSearch = $smartSearch; + } + + function addExcludePaths(?string $exclude): void + { + $this->exclude = $exclude; + } + + /** + * Get expected exit code + * + * @return int + */ + public function getExitCode(): int + { + return (int)!Unit::isSuccessful(); } /** @@ -77,35 +91,12 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable // Error Handler library $this->runBlunder(); - foreach ($files as $file) { - extract($this->args, EXTR_PREFIX_SAME, "wddx"); - - // DELETE - Unit::resetUnit(); - - // DELETE (BUT PASSS) - Unit::setHeaders([ - "args" => $this->args, - "file" => $file, - "checksum" => md5((string)$file) - ]); - - $call = $this->requireUnitFile((string)$file); - + $call = $this->requireUnitFile((string)$file, $callback); if ($call !== null) { $call(); } - - if($callback !== null) { - $callback(); - } } - Unit::completed(); - if ($this->exitScript) { - $this->exitScript(); - } - } } @@ -118,16 +109,21 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable * @param string $file The full path to the test file to require. * @return Closure|null A callable that, when invoked, runs the test file. */ - private function requireUnitFile(string $file): ?Closure + private function requireUnitFile(string $file, ?callable $callback = null): ?Closure { - $handler = $this->handler; $verbose = $this->verbose; - $call = function () use ($file, $handler, $verbose): void { + $call = function () use ($file, $verbose, $callback): void { if (!is_file($file)) { throw new RuntimeException("File \"$file\" do not exists."); } - self::$unitary = new Unit($handler); + + self::$unitary = $callback($file); + + if(!(self::$unitary instanceof Unit)) { + throw new \Exception("An instance of Unit must be return from callable in executeAll."); + } + $unitInst = require_once($file); if ($unitInst instanceof Unit) { self::$unitary = $unitInst; @@ -150,37 +146,6 @@ private function requireUnitFile(string $file): ?Closure return $call->bindTo(null); } - /** - * You can change the default exist script from enabled to disable - * - * @param $exitScript - * @return void - */ - public function enableExitScript($exitScript): void - { - $this->exitScript = $exitScript; - } - - /** - * Exist the script with the right expected number - * - * @return void - */ - public function exitScript(): void - { - exit($this->getExitCode()); - } - - /** - * Get expected exit code - * - * @return int - */ - public function getExitCode(): int - { - return (int)!Unit::isSuccessful(); - } - /** * Will Scan and find all unitary test files * @@ -207,7 +172,7 @@ private function findFiles(string $path, string|bool $rootDir = false): array } } // If smart search flag then step back if no test files have been found and try again - if($rootDir !== false && count($files) <= 0 && str_starts_with($path, $rootDir) && isset($this->args['smart-search'])) { + if($rootDir !== false && count($files) <= 0 && str_starts_with($path, $rootDir) && $this->smartSearch) { $path = (string)realpath($path . "/..") . "/"; return $this->findFiles($path, $rootDir); } @@ -222,8 +187,8 @@ private function findFiles(string $path, string|bool $rootDir = false): array private function exclude(): array { $excl = []; - if (isset($this->args['exclude']) && is_string($this->args['exclude'])) { - $exclude = explode(',', $this->args['exclude']); + if ($this->exclude !== null) { + $exclude = explode(',', $this->exclude); foreach ($exclude as $file) { $file = str_replace(['"', "'"], "", $file); $new = trim($file); @@ -272,7 +237,7 @@ private function getFileIterateReclusive(string $path): array $pattern = TestDiscovery::PATTERN; foreach ($iterator as $file) { if (($file instanceof SplFileInfo) && fnmatch($pattern, $file->getFilename()) && - (!empty($this->args['exclude']) || !str_contains($file->getPathname(), DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR))) { + ($this->exclude !== null || !str_contains($file->getPathname(), DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR))) { if (!$this->findExcluded($this->exclude(), $path, $file->getPathname())) { $files[] = $file->getPathname(); } diff --git a/src/Unit.php b/src/Unit.php index d5764bb..545dab6 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -34,7 +34,11 @@ final class Unit private array $cases = []; private bool $disableAllTests = false; private bool $executed = false; - private static array $headers = []; + + private string $file = ""; + private bool $showErrorsOnly = false; + private ?string $show = null; + private static ?Unit $current; public static int $totalPassedTests = 0; public static int $totalTests = 0; @@ -42,16 +46,83 @@ final class Unit /** * Initialize Unit test instance with optional handler * - * @param HandlerInterface|StreamInterface|null $handler Optional handler for test execution + * @param BodyInterface|null $handler Optional handler for test execution * If HandlerInterface is provided, uses its command * If StreamInterface is provided, creates a new Command with it * If null, creates a new Command without a stream */ public function __construct(BodyInterface|null $handler = null) { - $this->handler = ($handler === null) ? new CliRenderer(new Command()) : $handler; - self::$current = $this; + } + + /** + * Will pass a test file name to script used to: + * - Allocate tests + * - Show where tests is executed + * + * @param string $file + * @return $this + */ + public function setFile(string $file): Unit + { + $this->file = $file; + return $this; + } + + /** + * Will only display error and hide passed tests + * + * @param bool $showErrorsOnly + * @return $this + */ + public function setShowErrorsOnly(bool $showErrorsOnly): Unit + { + $this->showErrorsOnly = $showErrorsOnly; + return $this; + } + + /** + * Display only one test - + * Will accept either file checksum or name form named tests + * + * @param string|null $show + * @return $this + */ + public function setShow(?string $show = null): Unit + { + $this->show = $show; + return $this; + } + + /** + * Check if all executed tests is successful + * + * @return bool + */ + public static function isSuccessful(): bool + { + return (self::$totalPassedTests === self::$totalTests); + } + + /** + * Get number of executed passed tests + * + * @return int + */ + public static function getPassedTests(): int + { + return self::$totalPassedTests; + } + + /** + * Get number of executed tests + * + * @return int + */ + public static function getTotalTests(): int + { + return self::$totalTests; } /** @@ -135,28 +206,27 @@ public function execute(): bool return false; } + $fileChecksum = md5($this->file); + foreach ($this->cases as $index => $row) { if (!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); } - $errArg = self::getArgs("errors-only"); $row->dispatchTest($row); $tests = $row->runDeferredValidations(); - $checksum = (string)(self::$headers['checksum'] ?? "") . $index; + $checksum = $fileChecksum . $index; - $show = ($row->getConfig()->select === self::getArgs('show') || self::getArgs('show') === $checksum); - if((self::getArgs('show') !== false) && !$show) { + $show = ($row->getConfig()->select === $this->show || $this->show === $checksum); + if(($this->show !== null) && !$show) { continue; } - // Success, no need to try to show errors, continue with the next test - if ($errArg !== false && !$row->hasFailed()) { + if ($this->showErrorsOnly !== false && !$row->hasFailed()) { continue; } - $handler->setCase($row); - $handler->setSuitName(self::$headers['file'] ?? ""); + $handler->setSuitName($this->file); $handler->setChecksum($checksum); $handler->setTests($tests); $handler->setShow($show); @@ -199,104 +269,6 @@ public function assert(): self "Move this assert() call inside your group() callback function."); } - /** - * This is custom header information that is passed, that work with both CLI and Browsers - * - * @param array $headers - * @return void - */ - public static function setHeaders(array $headers): void - { - self::$headers = $headers; - } - - /** - * Get passed CLI arguments - * - * @param string $key - * @return mixed - */ - public static function getArgs(string $key): mixed - { - return (self::$headers['args'][$key] ?? false); - } - - /** - * The test is liner it also has a current test instance that needs - * to be rested when working with loop - * - * @return void - */ - public static function resetUnit(): void - { - self::$current = null; - } - - /** - * Check if a current instance exists - * - * @return bool - */ - public static function hasUnit(): bool - { - return self::$current !== null; - } - - /** - * Get the current instance - * - * @return ?Unit - */ - public static function getUnit(): ?Unit - { - $verbose = self::getArgs('verbose'); - if ($verbose !== false && self::hasUnit() === false) { - $file = self::$headers['file'] ?? ""; - - $command = new Command(); - $command->message( - $command->getAnsi()->style(['redBg', 'brightWhite'], " ERROR ") . ' ' . - $command->getAnsi()->style(['red', 'bold'], "The Unit instance is missing in the file") - ); - $command->message(''); - $command->message($command->getAnsi()->bold("In File:")); - $command->message($file); - $command->message(''); - } - return self::$current; - } - - /** - * This will be called when every test has been run by the FileIterator - * @return void - */ - public static function completed(): void - { - if (self::$current !== null && self::$current->handler === null) { - $dot = self::$current->command->getAnsi()->middot(); - - //self::$current->command->message(""); - self::$current->command->message( - self::$current->command->getAnsi()->style( - ["italic", "grey"], - "Total: " . self::$totalPassedTests . "/" . self::$totalTests . " $dot " . - "Peak memory usage: " . (string)round(memory_get_peak_usage() / 1024, 2) . " KB" - ) - ); - self::$current->command->message(""); - } - } - - /** - * Check if all tests is successful - * - * @return bool - */ - public static function isSuccessful(): bool - { - return (self::$totalPassedTests === self::$totalTests); - } - /** * Adds a test case to the collection. * @@ -362,18 +334,6 @@ public function manual(): void "See documentation for more information."); } - /** - * DEPRECATED: Append to global header - * - * @param string $key - * @param mixed $value - * @return void - */ - public static function appendHeader(string $key, mixed $value): void - { - self::$headers[$key] = $value; - } - /** * DEPRECATED: Not used anymore * From 711f21487c252fffdc5f55e7e235cce2e76390ab Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Thu, 14 Aug 2025 22:16:50 +0200 Subject: [PATCH 62/78] update: Config props hydration and inheritance --- src/Config/ConfigProps.php | 34 ++++++++++ src/Console/Controllers/DefaultController.php | 46 ++++--------- src/Console/Controllers/RunTestController.php | 4 +- src/Console/Services/AbstractMainService.php | 3 + src/Console/Services/RunTestService.php | 8 +-- src/Unit.php | 64 +++++++++---------- unitary.config.php | 8 +-- 7 files changed, 90 insertions(+), 77 deletions(-) diff --git a/src/Config/ConfigProps.php b/src/Config/ConfigProps.php index efe3558..5a13b68 100644 --- a/src/Config/ConfigProps.php +++ b/src/Config/ConfigProps.php @@ -18,7 +18,41 @@ class ConfigProps extends AbstractConfigProps { public ?string $path = null; + public ?string $exclude = null; public ?int $exitCode = null; public ?bool $verbose = null; + public ?bool $errorsOnly = null; public ?bool $smartSearch = null; + + /** + * Hydrate the properties/object with expected data, and handle unexpected data + * + * @param string $key + * @param mixed $value + * @return void + */ + protected function propsHydration(string $key, mixed $value): void + { + switch ($key) { + case 'path': + $this->path = (!is_string($value) || $value === '') ? null : $value; + break; + case 'exclude': + $this->exclude = (!is_string($value) || $value === '') ? null : $value; + break; + case 'exitCode': + $this->exitCode = ($value === null) ? null : (int)$value; + break; + case 'verbose': + $this->verbose = isset($value) && $value !== false; + break; + case 'smartSearch': + $this->smartSearch = isset($value) && $value !== false; + break; + case 'errorsOnly': + $this->errorsOnly = isset($value) && $value !== false; + break; + } + } + } \ No newline at end of file diff --git a/src/Console/Controllers/DefaultController.php b/src/Console/Controllers/DefaultController.php index e9e5eb0..2d8191e 100644 --- a/src/Console/Controllers/DefaultController.php +++ b/src/Console/Controllers/DefaultController.php @@ -16,7 +16,7 @@ abstract class DefaultController protected Command $command; protected DispatchConfigInterface $configs; protected array $args; - private ?ConfigProps $props = null; + protected ?ConfigProps $props = null; /** * Set some data type safe object that comes from container and the dispatcher @@ -32,7 +32,8 @@ public function __construct(ContainerInterface $container) { $this->request = $this->container->get("request"); $this->configs = $this->container->get("dispatchConfig"); - $this->buildAllowedProps(); + // $this->props is set in getInitProps + $this->container->set("props", $this->getInitProps()); } /** @@ -42,24 +43,24 @@ public function __construct(ContainerInterface $container) { * If invalid arguments are passed, and verbose mode is enabled, an error will be displayed * along with a warning about the unknown properties. * - * @return void + * @return ConfigProps */ - private function buildAllowedProps(): void + private function getInitProps(): ConfigProps { if($this->props === null) { try { - $props = array_merge($this->configs->getProps()->toArray(), $this->autoCastArgsToType()); + $props = array_merge($this->configs->getProps()->toArray(), $this->args); $this->props = new ConfigProps($props); - } catch (\RuntimeException $e) { - if($e->getCode() === 2 && isset($this->args['verbose'])) { - $this->command->error($e->getMessage()); + if($this->props->hasMissingProps() !== [] && isset($this->args['verbose'])) { + $this->command->error('The properties (' . + implode(", ", $this->props->hasMissingProps()) . ') is not exist in config props'); $this->command->message( "One or more arguments you passed are not recognized as valid options.\n" . "Check your command syntax or configuration." ); - exit(1); } + } catch (\Throwable $e) { if(isset($this->args['verbose'])) { $this->command->error($e->getMessage()); @@ -67,32 +68,7 @@ private function buildAllowedProps(): void } } } + return $this->props; } - - /** - * Will try to auto cast argument data type from CLI argument - * - * @return array - */ - private function autoCastArgsToType(): array - { - $args = []; - foreach($this->args as $key => $value) { - $lower = strtolower($value); - if ($lower === "true") { - $value = true; - } - if ($lower === "false") { - $value = false; - } - if (is_numeric($value)) { - $value = (strpos($value, '.') !== false) ? (float)$value : (int)$value; - } - $args[$key] = ($value === "") ? null : $value; - } - return $args; - } - - } \ No newline at end of file diff --git a/src/Console/Controllers/RunTestController.php b/src/Console/Controllers/RunTestController.php index bea70f4..5ab1c5e 100644 --- a/src/Console/Controllers/RunTestController.php +++ b/src/Console/Controllers/RunTestController.php @@ -37,7 +37,7 @@ public function help(): void return $inst ->addOption("help", "Show this help message") ->addOption("show=", "Run a specific test by hash or manual test name") - ->addOption("errors-only", "Show only failing tests and skip passed test output") + ->addOption("errorsOnly", "Show only failing tests and skip passed test output") ->addOption("template", "Will give you a boilerplate test code") ->addOption("path=", "Specify test path (absolute or relative)") ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); @@ -52,7 +52,7 @@ public function help(): void "php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983", "Run the test with a specific hash ID" )->addExamples( - "php vendor/bin/unitary --errors-only", + "php vendor/bin/unitary --errorsOnly", "Run all tests in the default path (./tests)" )->addExamples( "php vendor/bin/unitary --show=YourNameHere", diff --git a/src/Console/Services/AbstractMainService.php b/src/Console/Services/AbstractMainService.php index 7d330b8..a7cfad5 100644 --- a/src/Console/Services/AbstractMainService.php +++ b/src/Console/Services/AbstractMainService.php @@ -8,6 +8,7 @@ use MaplePHP\Http\Interfaces\ResponseInterface; use MaplePHP\Http\Interfaces\ServerRequestInterface; use MaplePHP\Prompts\Command; +use MaplePHP\Unitary\Config\ConfigProps; abstract class AbstractMainService { @@ -17,6 +18,7 @@ abstract class AbstractMainService protected Command $command; protected DispatchConfigInterface $configs; protected ServerRequestInterface|RequestInterface $request; + protected ?ConfigProps $props = null; public function __construct(ResponseInterface $response, ContainerInterface $container) { @@ -26,6 +28,7 @@ public function __construct(ResponseInterface $response, ContainerInterface $con $this->request = $this->container->get("request"); $this->configs = $this->container->get("dispatchConfig"); $this->command = $this->container->get("command"); + $this->props = $this->container->get("props"); } } \ No newline at end of file diff --git a/src/Console/Services/RunTestService.php b/src/Console/Services/RunTestService.php index b7d1eda..1ef2f8f 100644 --- a/src/Console/Services/RunTestService.php +++ b/src/Console/Services/RunTestService.php @@ -23,9 +23,9 @@ public function run(BodyInterface $handler): ResponseInterface // args should be able to be overwritten by configs... $iterator = new TestDiscovery(); - $iterator->enableVerbose(isset($this->args['verbose'])); - $iterator->enableSmartSearch(isset($this->args['smart-search'])); - $iterator->addExcludePaths($this->args['exclude'] ?? null); + $iterator->enableVerbose($this->props->verbose); + $iterator->enableSmartSearch($this->props->smartSearch); + $iterator->addExcludePaths($this->props->exclude); $iterator = $this->iterateTest($iterator, $handler); @@ -60,7 +60,7 @@ private function iterateTest(TestDiscovery $iterator, BodyInterface $handler): T } $iterator->executeAll($testDir, $defaultPath, function($file) use ($handler) { $unit = new Unit($handler); - $unit->setShowErrorsOnly(isset($this->args['errors-only'])); + $unit->setShowErrorsOnly(isset($this->args['errorsOnly'])); $unit->setShow($this->args['show'] ?? null); $unit->setFile($file); return $unit; diff --git a/src/Unit.php b/src/Unit.php index 545dab6..553bb71 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -40,8 +40,8 @@ final class Unit private ?string $show = null; private static ?Unit $current; - public static int $totalPassedTests = 0; - public static int $totalTests = 0; + private static int $totalPassedTests = 0; + private static int $totalTests = 0; /** * Initialize Unit test instance with optional handler @@ -285,36 +285,6 @@ protected function addCase(string|TestConfig $message, Closure $expect, bool $bi $this->index++; } - // NOTE: Just a test will be added in a new library and controller. - public function performance(Closure $func, ?string $title = null): void - { - $start = new Performance(); - $func = $func->bindTo($this); - if ($func !== null) { - $func($this); - } - $line = $this->command->getAnsi()->line(80); - $this->command->message(""); - $this->command->message($this->command->getAnsi()->style(["bold", "yellow"], "Performance" . ($title !== null ? " - $title:" : ":"))); - - $this->command->message($line); - $this->command->message( - $this->command->getAnsi()->style(["bold"], "Execution time: ") . - ((string)round($start->getExecutionTime(), 3) . " seconds") - ); - $this->command->message( - $this->command->getAnsi()->style(["bold"], "Memory Usage: ") . - ((string)round($start->getMemoryUsage(), 2) . " KB") - ); - /* - $this->command->message( - $this->command->getAnsi()->style(["bold", "grey"], "Peak Memory Usage: ") . - $this->command->getAnsi()->blue(round($start->getMemoryPeakUsage(), 2) . " KB") - ); - */ - $this->command->message($line); - } - // Deprecated: Almost same as `disableAllTest`, for older versions public function skip(bool $disable): self { @@ -343,5 +313,35 @@ public function addTitle(): self { return $this; } + + // NOTE: Just a test is is not used, and will NOT exist in this class + public function performance(Closure $func, ?string $title = null): void + { + $start = new Performance(); + $func = $func->bindTo($this); + if ($func !== null) { + $func($this); + } + $line = $this->command->getAnsi()->line(80); + $this->command->message(""); + $this->command->message($this->command->getAnsi()->style(["bold", "yellow"], "Performance" . ($title !== null ? " - $title:" : ":"))); + + $this->command->message($line); + $this->command->message( + $this->command->getAnsi()->style(["bold"], "Execution time: ") . + ((string)round($start->getExecutionTime(), 3) . " seconds") + ); + $this->command->message( + $this->command->getAnsi()->style(["bold"], "Memory Usage: ") . + ((string)round($start->getMemoryUsage(), 2) . " KB") + ); + /* + $this->command->message( + $this->command->getAnsi()->style(["bold", "grey"], "Peak Memory Usage: ") . + $this->command->getAnsi()->blue(round($start->getMemoryPeakUsage(), 2) . " KB") + ); + */ + $this->command->message($line); + } } diff --git a/unitary.config.php b/unitary.config.php index 6e7d7a2..a4bf881 100644 --- a/unitary.config.php +++ b/unitary.config.php @@ -2,9 +2,9 @@ return [ //'path' => 'app/Libraries/Unitary/tests/unitary-test.php', //'path' => false, // false|string|array[string] - //'smart_search' => false, - //'errors_only' => false, - //'verbose' => false, - //'exclude' => false, // false|string|array[string] + 'smartSearch' => false, + 'errorsOnly' => false, + 'verbose' => false, + 'exclude' => false, // false|string|array[string] //'exit_error_code' => 1, ]; \ No newline at end of file From 20f92bd6c1f4097c42fe33266cea254de82ec3fb Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Fri, 15 Aug 2025 13:08:35 +0200 Subject: [PATCH 63/78] fix: redirect E_USER_WARNING to PHP internal error handling fix: add verbose E_USER_WARNING if test file found but no tests exists --- src/Discovery/TestDiscovery.php | 14 +++++++++++--- src/TestEmitter.php | 8 ++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/Discovery/TestDiscovery.php b/src/Discovery/TestDiscovery.php index 3e7d4d3..a19e8c4 100755 --- a/src/Discovery/TestDiscovery.php +++ b/src/Discovery/TestDiscovery.php @@ -22,6 +22,7 @@ use RecursiveIteratorIterator; use RuntimeException; use SplFileInfo; +use function Amp\ByteStream\getStdin; final class TestDiscovery { @@ -131,14 +132,15 @@ private function requireUnitFile(string $file, ?callable $callback = null): ?Clo $bool = self::$unitary->execute(); if(!$bool && $verbose) { - throw new BlunderSoftException( + trigger_error( "Could not find any tests inside the test file:\n" . $file . "\n\n" . "Possible causes:\n" . - " • There are not test in test group/case.\n" . + " • There are no test in test group/case.\n" . " • Unitary could not locate the Unit instance.\n" . " • You did not use the `group()` function.\n" . - " • You created a new Unit in the test file but did not return it at the end. \n" + " • You created a new Unit in the test file but did not return it at the end.", + E_USER_WARNING ); } }; @@ -265,6 +267,12 @@ private function getNaturalPath(string $path): string protected function runBlunder(): void { $run = new Run(new CliHandler()); + $run->severity() + ->excludeSeverityLevels([E_USER_WARNING]) + ->redirectTo(function () { + // Let PHP’s default error handler process excluded severities + return false; + }); $run->setExitCode(1); $run->load(); } diff --git a/src/TestEmitter.php b/src/TestEmitter.php index 0cd76e4..86cb6cf 100644 --- a/src/TestEmitter.php +++ b/src/TestEmitter.php @@ -39,20 +39,20 @@ public function emit(string $file): void throw new \RuntimeException("The test file \"$file\" do not exists."); } - require_once($file); $hasExecutedTest = $this->unit->execute(); if(!$hasExecutedTest && $verbose) { - throw new BlunderSoftException( + trigger_error( "Could not find any tests inside the test file:\n" . $file . "\n\n" . "Possible causes:\n" . - " • There are not test in test group/case.\n" . + " • There are no test in test group/case.\n" . " • Unitary could not locate the Unit instance.\n" . " • You did not use the `group()` function.\n" . - " • You created a new Unit in the test file but did not return it at the end. \n" + " • You created a new Unit in the test file but did not return it at the end.", + E_USER_WARNING ); } From c6f35cecea5ebb1170f7343267fe5ae1c4dd0d26 Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Sat, 16 Aug 2025 16:18:14 +0200 Subject: [PATCH 64/78] update: Add test discovery pattern to config file update: Add full path wildcard to test discovery pattern --- src/Config/ConfigProps.php | 4 + src/Console/Controllers/RunTestController.php | 22 ++--- src/Console/Services/RunTestService.php | 1 + src/Discovery/TestDiscovery.php | 90 ++++++++++++------- src/Support/Helpers.php | 1 - src/Support/functions.php | 5 +- unitary.config.php | 16 ++-- 7 files changed, 90 insertions(+), 49 deletions(-) diff --git a/src/Config/ConfigProps.php b/src/Config/ConfigProps.php index 5a13b68..91b1c9b 100644 --- a/src/Config/ConfigProps.php +++ b/src/Config/ConfigProps.php @@ -18,6 +18,7 @@ class ConfigProps extends AbstractConfigProps { public ?string $path = null; + public ?string $discoverPattern = null; public ?string $exclude = null; public ?int $exitCode = null; public ?bool $verbose = null; @@ -37,6 +38,9 @@ protected function propsHydration(string $key, mixed $value): void case 'path': $this->path = (!is_string($value) || $value === '') ? null : $value; break; + case 'discoverPattern': + $this->discoverPattern = (!is_string($value) || $value === '') ? null : $value; + break; case 'exclude': $this->exclude = (!is_string($value) || $value === '') ? null : $value; break; diff --git a/src/Console/Controllers/RunTestController.php b/src/Console/Controllers/RunTestController.php index 5ab1c5e..c4169d0 100644 --- a/src/Console/Controllers/RunTestController.php +++ b/src/Console/Controllers/RunTestController.php @@ -79,17 +79,19 @@ public function help(): void protected function buildFooter() { $inst = TestDiscovery::getUnitaryInst(); - $dot = $this->command->getAnsi()->middot(); - $peakMemory = (string)round(memory_get_peak_usage() / 1024, 2); + if($inst !== null) { + $dot = $this->command->getAnsi()->middot(); + $peakMemory = (string)round(memory_get_peak_usage() / 1024, 2); - $this->command->message( - $this->command->getAnsi()->style( - ["italic", "grey"], - "Total: " . $inst->getPassedTests() . "/" . $inst->getTotalTests() . " $dot " . - "Peak memory usage: " . $peakMemory . " KB" - ) - ); - $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style( + ["italic", "grey"], + "Total: " . $inst->getPassedTests() . "/" . $inst->getTotalTests() . " $dot " . + "Peak memory usage: " . $peakMemory . " KB" + ) + ); + $this->command->message(""); + } } diff --git a/src/Console/Services/RunTestService.php b/src/Console/Services/RunTestService.php index 1ef2f8f..ec7630b 100644 --- a/src/Console/Services/RunTestService.php +++ b/src/Console/Services/RunTestService.php @@ -26,6 +26,7 @@ public function run(BodyInterface $handler): ResponseInterface $iterator->enableVerbose($this->props->verbose); $iterator->enableSmartSearch($this->props->smartSearch); $iterator->addExcludePaths($this->props->exclude); + $iterator->setDiscoverPattern($this->props->discoverPattern); $iterator = $this->iterateTest($iterator, $handler); diff --git a/src/Discovery/TestDiscovery.php b/src/Discovery/TestDiscovery.php index a19e8c4..25ac728 100755 --- a/src/Discovery/TestDiscovery.php +++ b/src/Discovery/TestDiscovery.php @@ -15,45 +15,79 @@ use MaplePHP\Blunder\Exceptions\BlunderSoftException; use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\Blunder\Run; -use MaplePHP\Prompts\Command; -use MaplePHP\Unitary\Interfaces\BodyInterface; use MaplePHP\Unitary\Unit; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use RuntimeException; use SplFileInfo; -use function Amp\ByteStream\getStdin; final class TestDiscovery { - public const PATTERN = 'unitary-*.php'; - - private array $args; + private string $pattern = '*/unitary-*.php'; private bool $verbose = false; private bool $smartSearch = false; - private ?string $exclude = null; - - private ?Command $command = null; + private ?array $exclude = null; private static ?Unit $unitary = null; - - public function __construct() + /** + * Enable verbose flag which will show errors that should not always be visible + * + * @param bool $isVerbose + * @return $this + */ + function enableVerbose(bool $isVerbose): self { + $this->verbose = $isVerbose; + return $this; } - function enableVerbose(bool $isVerbose): void + /** + * Enabling smart search; If no tests i found Unitary will try to traverse + * backwards until a test is found + * + * @param bool $smartSearch + * @return $this + */ + function enableSmartSearch(bool $smartSearch): self { - $this->verbose = $isVerbose; + $this->smartSearch = $smartSearch; + return $this; } - function enableSmartSearch(bool $smartSearch): void + /** + * Exclude paths from file iteration + * + * @param string|array|null $exclude + * @return $this + */ + function addExcludePaths(string|array|null $exclude): self { - $this->smartSearch = $smartSearch; + if($exclude !== null) { + $this->exclude = is_string($exclude) ? explode(', ', $exclude) : $exclude; + } + return $this; } - function addExcludePaths(?string $exclude): void + /** + * Change the default test discovery pattern from `unitary-*.php` to a custom pattern. + * + * Notes: + * - Wildcards can be used for paths (`tests/`) and files (`unitary-*.php`). + * - If no file extension is specified, `.php` is assumed. + * - Only PHP files are supported as test files. + * + * @param ?string $pattern null value will fall back to the default value + * @return $this + */ + function setDiscoverPattern(?string $pattern): self { - $this->exclude = $exclude; + if($pattern !== null) { + $pattern = rtrim($pattern, '*'); + $pattern = ltrim($pattern, '*'); + $pattern = ltrim($pattern, '/'); + $this->pattern = "*/" . (!str_ends_with($pattern, '.php') ? rtrim($pattern, '/') . "/*.php" : $pattern); + } + return $this; } /** @@ -83,10 +117,9 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable $path = $rootDir . "/" . $path; } $files = $this->findFiles($path, $rootDir); - if (empty($files)) { - /* @var string static::PATTERN */ + if (empty($files) && $this->verbose) { throw new BlunderSoftException("Unitary could not find any test files matching the pattern \"" . - (TestDiscovery::PATTERN ?? "") . "\" in directory \"" . dirname($path) . + $this->pattern . "\" in directory \"" . dirname($path) . "\" and its subdirectories."); } else { @@ -110,7 +143,7 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable * @param string $file The full path to the test file to require. * @return Closure|null A callable that, when invoked, runs the test file. */ - private function requireUnitFile(string $file, ?callable $callback = null): ?Closure + private function requireUnitFile(string $file, callable $callback): ?Closure { $verbose = $this->verbose; @@ -189,9 +222,8 @@ private function findFiles(string $path, string|bool $rootDir = false): array private function exclude(): array { $excl = []; - if ($this->exclude !== null) { - $exclude = explode(',', $this->exclude); - foreach ($exclude as $file) { + if ($this->exclude !== null && $this->exclude !== []) { + foreach ($this->exclude as $file) { $file = str_replace(['"', "'"], "", $file); $new = trim($file); $lastChar = substr($new, -1); @@ -236,9 +268,8 @@ private function getFileIterateReclusive(string $path): array $files = []; $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); /** @var string $pattern */ - $pattern = TestDiscovery::PATTERN; foreach ($iterator as $file) { - if (($file instanceof SplFileInfo) && fnmatch($pattern, $file->getFilename()) && + if (($file instanceof SplFileInfo) && fnmatch($this->pattern, $file->getPathname()) && ($this->exclude !== null || !str_contains($file->getPathname(), DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR))) { if (!$this->findExcluded($this->exclude(), $path, $file->getPathname())) { $files[] = $file->getPathname(); @@ -283,13 +314,10 @@ protected function runBlunder(): void * This is primary used to access the main test Unit instance that is * pre-initialized for each test file. Is used by shortcut function like `group()` * - * @return Unit + * @return Unit|null */ - public static function getUnitaryInst(): Unit + public static function getUnitaryInst(): ?Unit { - if(self::$unitary === null) { - throw new \BadMethodCallException('Unit has not been initiated.'); - } return self::$unitary; } } diff --git a/src/Support/Helpers.php b/src/Support/Helpers.php index ce508a9..f7dbb7d 100644 --- a/src/Support/Helpers.php +++ b/src/Support/Helpers.php @@ -17,7 +17,6 @@ final class Helpers { - /** * Used to stringify arguments to show in a test * diff --git a/src/Support/functions.php b/src/Support/functions.php index b2e4431..1a0a0d1 100644 --- a/src/Support/functions.php +++ b/src/Support/functions.php @@ -11,7 +11,10 @@ function unitary_group(string|TestConfig $message, Closure $expect, ?TestConfig $config = null): void { - TestDiscovery::getUnitaryInst()->group($message, $expect, $config); + $inst = TestDiscovery::getUnitaryInst(); + if($inst !== null) { + $inst->group($message, $expect, $config); + } } if (!function_exists('group')) { diff --git a/unitary.config.php b/unitary.config.php index a4bf881..adedf4e 100644 --- a/unitary.config.php +++ b/unitary.config.php @@ -1,10 +1,14 @@ 'app/Libraries/Unitary/tests/unitary-test.php', - //'path' => false, // false|string|array[string] - 'smartSearch' => false, - 'errorsOnly' => false, - 'verbose' => false, - 'exclude' => false, // false|string|array[string] - //'exit_error_code' => 1, + 'path' => false, // false|string|array + 'smartSearch' => false, // bool + 'errorsOnly' => false, // bool + 'verbose' => false, // bool + 'exclude' => false, // false|string|array + 'discoverPattern' => false // string|false (paths (`tests/`) and files (`unitary-*.php`).) + //'exit_error_code' => 1, ?? ]; \ No newline at end of file From 2abc4cb38368c23bbad3eba9940f3286e93ee661 Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Sun, 17 Aug 2025 11:17:39 +0200 Subject: [PATCH 65/78] fix: Add more config options --- README.md | 1 - src/Config/ConfigProps.php | 14 ++++++-- src/Console/Services/RunTestService.php | 6 ++-- src/Discovery/TestDiscovery.php | 1 + src/Interfaces/BodyInterface.php | 16 +++++++++ src/Renders/AbstractRenderHandler.php | 18 ++++++++++ src/Renders/CliRenderer.php | 3 +- src/Unit.php | 46 ++++++++++++++++++++++--- tests/unitary-test-item.php | 6 ++-- tests/unitary-unitary.php | 31 +++++++---------- tests/unitary-will-fail.php | 3 +- unitary.config.php | 4 ++- 12 files changed, 112 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 730cbe3..ba7c8ba 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,6 @@ Paste this test boilerplate to get started: ```php use MaplePHP\Unitary\{Expect,TestCase,Unit}; -$unit = new Unit(); group("Your test subject", function (TestCase $case) { $case->validate("Your test value", function(Expect $valid) { $valid->isString(); diff --git a/src/Config/ConfigProps.php b/src/Config/ConfigProps.php index 91b1c9b..311609b 100644 --- a/src/Config/ConfigProps.php +++ b/src/Config/ConfigProps.php @@ -20,8 +20,10 @@ class ConfigProps extends AbstractConfigProps public ?string $path = null; public ?string $discoverPattern = null; public ?string $exclude = null; + public ?string $show = null; public ?int $exitCode = null; public ?bool $verbose = null; + public ?bool $alwaysShowFiles = null; public ?bool $errorsOnly = null; public ?bool $smartSearch = null; @@ -44,17 +46,23 @@ protected function propsHydration(string $key, mixed $value): void case 'exclude': $this->exclude = (!is_string($value) || $value === '') ? null : $value; break; + case 'show': + $this->show = (!is_string($value) || $value === '') ? null : $value; + break; case 'exitCode': $this->exitCode = ($value === null) ? null : (int)$value; break; case 'verbose': - $this->verbose = isset($value) && $value !== false; + $this->verbose = $this->dataToBool($value); + break; + case 'alwaysShowFiles': + $this->alwaysShowFiles = $this->dataToBool($value); break; case 'smartSearch': - $this->smartSearch = isset($value) && $value !== false; + $this->smartSearch = $this->dataToBool($value); break; case 'errorsOnly': - $this->errorsOnly = isset($value) && $value !== false; + $this->errorsOnly = $this->dataToBool($value); break; } } diff --git a/src/Console/Services/RunTestService.php b/src/Console/Services/RunTestService.php index ec7630b..011a300 100644 --- a/src/Console/Services/RunTestService.php +++ b/src/Console/Services/RunTestService.php @@ -61,9 +61,11 @@ private function iterateTest(TestDiscovery $iterator, BodyInterface $handler): T } $iterator->executeAll($testDir, $defaultPath, function($file) use ($handler) { $unit = new Unit($handler); - $unit->setShowErrorsOnly(isset($this->args['errorsOnly'])); - $unit->setShow($this->args['show'] ?? null); + $unit->setShowErrorsOnly($this->props->errorsOnly); + $unit->setShow($this->props->show); $unit->setFile($file); + $unit->setVerbose($this->props->verbose); + $unit->setAlwaysShowFiles($this->props->alwaysShowFiles); return $unit; }); return $iterator; diff --git a/src/Discovery/TestDiscovery.php b/src/Discovery/TestDiscovery.php index 25ac728..4fc1eb6 100755 --- a/src/Discovery/TestDiscovery.php +++ b/src/Discovery/TestDiscovery.php @@ -160,6 +160,7 @@ private function requireUnitFile(string $file, callable $callback): ?Closure $unitInst = require_once($file); if ($unitInst instanceof Unit) { + $unitInst->inheritConfigs(self::$unitary); self::$unitary = $unitInst; } $bool = self::$unitary->execute(); diff --git a/src/Interfaces/BodyInterface.php b/src/Interfaces/BodyInterface.php index 7a2adfd..2c51eb3 100644 --- a/src/Interfaces/BodyInterface.php +++ b/src/Interfaces/BodyInterface.php @@ -8,6 +8,22 @@ interface BodyInterface { + /** + * Show hidden messages + * + * @param bool $verbose + * @return void + */ + public function setVerbose(bool $verbose): void; + + /** + * Show file paths even on passed tests + * + * @param bool $alwaysShowFiles + * @return void + */ + public function setAlwaysShowFiles(bool $alwaysShowFiles): void; + /** * Pass the test case * diff --git a/src/Renders/AbstractRenderHandler.php b/src/Renders/AbstractRenderHandler.php index eecd915..5e537ff 100644 --- a/src/Renders/AbstractRenderHandler.php +++ b/src/Renders/AbstractRenderHandler.php @@ -13,9 +13,27 @@ class AbstractRenderHandler implements BodyInterface protected string $suitName = ""; protected string $checksum = ""; protected bool $show = false; + protected bool $verbose = false; + protected bool $alwaysShowFiles = false; protected array $tests; protected string $outputBuffer = ""; + /** + * {@inheritDoc} + */ + public function setVerbose(bool $verbose): void + { + $this->verbose = $verbose; + } + + /** + * {@inheritDoc} + */ + public function setAlwaysShowFiles(bool $alwaysShowFiles): void + { + $this->alwaysShowFiles = $alwaysShowFiles; + } + /** * {@inheritDoc} */ diff --git a/src/Renders/CliRenderer.php b/src/Renders/CliRenderer.php index c925563..5ca61df 100644 --- a/src/Renders/CliRenderer.php +++ b/src/Renders/CliRenderer.php @@ -39,7 +39,7 @@ public function buildBody(): void $this->command->getAnsi()->style(["bold", $this->color], (string)$this->case->getMessage()) ); - if($this->show && !$this->case->hasFailed()) { + if(($this->show || $this->alwaysShowFiles || $this->verbose) && !$this->case->hasFailed()) { $this->command->message(""); $this->command->message( $this->command->getAnsi()->style(["italic", $this->color], "Test file: " . $this->suitName) @@ -47,7 +47,6 @@ public function buildBody(): void } if (($this->show || !$this->case->getConfig()->skip)) { - // Show possible warnings if($this->case->getWarning()) { $this->command->message(""); diff --git a/src/Unit.php b/src/Unit.php index 553bb71..414486b 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -38,6 +38,8 @@ final class Unit private string $file = ""; private bool $showErrorsOnly = false; private ?string $show = null; + private bool $verbose = false; + private bool $alwaysShowFiles = false; private static ?Unit $current; private static int $totalPassedTests = 0; @@ -47,9 +49,6 @@ final class Unit * Initialize Unit test instance with optional handler * * @param BodyInterface|null $handler Optional handler for test execution - * If HandlerInterface is provided, uses its command - * If StreamInterface is provided, creates a new Command with it - * If null, creates a new Command without a stream */ public function __construct(BodyInterface|null $handler = null) { @@ -95,6 +94,44 @@ public function setShow(?string $show = null): Unit return $this; } + /** + * Show hidden messages + * + * @param bool $verbose + * @return void + */ + public function setVerbose(bool $verbose): void + { + $this->verbose = $verbose; + } + + /** + * Show file paths even on passed tests + * + * @param bool $alwaysShowFiles + * @return void + */ + public function setAlwaysShowFiles(bool $alwaysShowFiles): void + { + $this->alwaysShowFiles = $alwaysShowFiles; + } + + /** + * This will help pass over some default for custom Unit instances + * + * @param Unit $inst + * @return $this + */ + public function inheritConfigs(Unit $inst): Unit + { + $this->setFile($inst->file); + $this->setShow($inst->show); + $this->setShowErrorsOnly($inst->showErrorsOnly); + $this->setVerbose($inst->verbose); + $this->setAlwaysShowFiles($inst->alwaysShowFiles); + return $this; + } + /** * Check if all executed tests is successful * @@ -207,7 +244,6 @@ public function execute(): bool } $fileChecksum = md5($this->file); - foreach ($this->cases as $index => $row) { if (!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); @@ -230,6 +266,8 @@ public function execute(): bool $handler->setChecksum($checksum); $handler->setTests($tests); $handler->setShow($show); + $handler->setVerbose($this->verbose); + $handler->setAlwaysShowFiles($this->alwaysShowFiles); $handler->buildBody(); // Important to add test from skip as successfully count to make sure that diff --git a/tests/unitary-test-item.php b/tests/unitary-test-item.php index c66e26d..1332c36 100644 --- a/tests/unitary-test-item.php +++ b/tests/unitary-test-item.php @@ -1,11 +1,9 @@ group(TestConfig::make("Test item class") +group(TestConfig::make("Test item class") ->withName("unitary"), function (TestCase $case) { $item = new TestItem(); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 3fa7292..70e17f7 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -11,14 +11,9 @@ use TestLib\UserService; -$unit = new Unit(); - -//$unit->disableAllTest(false); - $config = TestConfig::make()->withName("unitary"); - -$unit->group($config->withSubject("Test mocker"), function (TestCase $case) use($unit) { +group($config->withSubject("Test mocker"), function (TestCase $case) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") @@ -31,11 +26,11 @@ }); -$unit->group("Example of assert in group", function(TestCase $case) { +group("Example of assert in group", function(TestCase $case) { assert(1 === 2, "This is a error message"); }); -$unit->group($config->withSubject("Can not mock final or private"), function(TestCase $case) { +group($config->withSubject("Can not mock final or private"), function(TestCase $case) { $user = $case->mock(UserService::class, function(MethodRegistry $method) { $method->method("getUserRole")->willReturn("admin"); $method->method("getUserType")->willReturn("admin"); @@ -53,7 +48,7 @@ }); -$unit->group($config->withSubject("Test mocker"), function (TestCase $case) use($unit) { +group($config->withSubject("Test mocker"), function (TestCase $case) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") @@ -103,7 +98,7 @@ }); }); -$unit->group($config->withSubject("Mocking response"), function (TestCase $case) use($unit) { +group($config->withSubject("Mocking response"), function (TestCase $case) { $stream = $case->mock(Stream::class, function (MethodRegistry $method) { $method->method("getContents") @@ -123,14 +118,14 @@ }); }); -$unit->group($config->withSubject("Assert validations"), function ($case) { +group($config->withSubject("Assert validations"), function ($case) { $case->validate("HelloWorld", function(Expect $inst) { assert($inst->isEqualTo("HelloWorld")->isValid(), "Assert has failed"); }); assert(1 === 1, "Assert has failed"); }); -$unit->case($config->withSubject("Old validation syntax"), function ($case) { +group($config->withSubject("Old validation syntax"), function ($case) { $case->add("HelloWorld", [ "isString" => [], "User validation" => function($value) { @@ -138,12 +133,12 @@ } ], "Is not a valid port number"); - $this->add("HelloWorld", [ + $case->add("HelloWorld", [ "isEqualTo" => ["HelloWorld"], ], "Failed to validate"); }); -$unit->group($config->withSubject("Validate partial mock"), function (TestCase $case) use($unit) { +group($config->withSubject("Validate partial mock"), function (TestCase $case) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("send")->keepOriginal(); $method->method("isValidEmail")->keepOriginal(); @@ -155,7 +150,7 @@ }); }); -$unit->group($config->withSubject("Advanced App Response Test"), function (TestCase $case) use($unit) { +group($config->withSubject("Advanced App Response Test"), function (TestCase $case) { // Quickly mock the Stream class @@ -214,7 +209,7 @@ }); -$unit->group($config->withSubject("Testing User service"), function (TestCase $case) { +group($config->withSubject("Testing User service"), function (TestCase $case) { $mailer = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") @@ -231,7 +226,7 @@ }); }); -$unit->group($config->withSubject("Mocking response"), function (TestCase $case) use($unit) { +group($config->withSubject("Mocking response"), function (TestCase $case) { $stream = $case->mock(Stream::class, function (MethodRegistry $method) { @@ -252,7 +247,7 @@ }); }); -$unit->group("Example API Response", function(TestCase $case) { +group("Example API Response", function(TestCase $case) { $case->validate('{"response":{"status":200,"message":"ok"}}', function(Expect $expect) { diff --git a/tests/unitary-will-fail.php b/tests/unitary-will-fail.php index 0f486ec..2a82758 100755 --- a/tests/unitary-will-fail.php +++ b/tests/unitary-will-fail.php @@ -5,9 +5,8 @@ use MaplePHP\Unitary\{Config\TestConfig, Expect, Mocker\MethodRegistry, TestCase, Unit}; use TestLib\Mailer; -$unit = new Unit(); $config = TestConfig::make("All A should fail")->withName("unitary-fail")->withSkip(); -$unit->group($config, function (TestCase $case) use($unit) { +group($config, function (TestCase $case) { $case->error("Default validations")->validate(1, function(Expect $inst) { $inst->isEmail(); diff --git a/unitary.config.php b/unitary.config.php index adedf4e..82548c7 100644 --- a/unitary.config.php +++ b/unitary.config.php @@ -9,6 +9,8 @@ 'errorsOnly' => false, // bool 'verbose' => false, // bool 'exclude' => false, // false|string|array - 'discoverPattern' => false // string|false (paths (`tests/`) and files (`unitary-*.php`).) + 'discoverPattern' => false, // string|false (paths (`tests/`) and files (`unitary-*.php`).) + 'show' => false, + 'alwaysShowFiles' => false, //'exit_error_code' => 1, ?? ]; \ No newline at end of file From 6e93be0f96dddbcefc81ea832be3ee35746f682f Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Wed, 20 Aug 2025 15:58:59 +0200 Subject: [PATCH 66/78] fix: Add code coverage functionality to the CLI MVC --- .../Controllers/CoverageController.php | 53 ++++++++++--------- src/Support/TestUtils/CodeCoverage.php | 9 ++-- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/src/Console/Controllers/CoverageController.php b/src/Console/Controllers/CoverageController.php index 8e936db..962baf8 100644 --- a/src/Console/Controllers/CoverageController.php +++ b/src/Console/Controllers/CoverageController.php @@ -3,49 +3,50 @@ namespace MaplePHP\Unitary\Console\Controllers; use MaplePHP\Http\Interfaces\ResponseInterface; -use MaplePHP\Unitary\Console\DispatchConfig; +use MaplePHP\Prompts\Themes\Blocks; +use MaplePHP\Unitary\Console\Services\RunTestService; +use MaplePHP\Unitary\Renders\SilentRender; +use MaplePHP\Unitary\Support\TestUtils\CodeCoverage; -class CoverageController extends RunTestController +class CoverageController extends DefaultController { /** - * Main test runner - * + * Code Coverage Controller */ - public function ruwn(ResponseInterface $response): ResponseInterface + public function run(RunTestService $service): ResponseInterface { - - /** @var DispatchConfig $config */ - /* - $config = $this->container->get("dispatchConfig"); - - // Create a silent handler $coverage = new CodeCoverage(); - $commandInMem = new Command(new Stream(Stream::TEMP)); - $iterator = new FileIterator($this->args); - $config->setExitCode($iterator->getExitCode()); - $coverage->start(); - $this->iterateTest($commandInMem, $iterator, $this->args); + $handler = new SilentRender(); + $response = $service->run($handler); $coverage->end(); $result = $coverage->getResponse(); - if($result !== false) { - $block = new Blocks($this->command); - $block->addSection("Code coverage", function(Blocks $block) use ($result) { - return $block->addList("Total lines:", $result['totalLines']) - ->addList("Executed lines:", $result['executedLines']) - ->addList("Code coverage percent:", $result['percent']); - }); - + $this->outputBody($result); } else { $this->command->error("Error: Code coverage is not reachable"); $this->command->error("Reason: " . $coverage->getIssue()->message()); } - $this->command->message(""); - */ + return $response; } + + /** + * Will output the main body response in CLI + * + * @param array $result + * @return void + */ + private function outputBody(array $result): void + { + $block = new Blocks($this->command); + $block->addSection("Code coverage", function(Blocks $block) use ($result) { + return $block->addList("Total lines:", $result['totalLines']) + ->addList("Executed lines:", $result['executedLines']) + ->addList("Code coverage percent:", $result['percent'] . "%"); + }); + } } \ No newline at end of file diff --git a/src/Support/TestUtils/CodeCoverage.php b/src/Support/TestUtils/CodeCoverage.php index e672e3b..bc5bf79 100644 --- a/src/Support/TestUtils/CodeCoverage.php +++ b/src/Support/TestUtils/CodeCoverage.php @@ -14,9 +14,6 @@ use BadMethodCallException; use MaplePHP\Unitary\Console\Enum\CoverageIssue; -use function MaplePHP\Unitary\TestUtils\xdebug_get_code_coverage; -use function MaplePHP\Unitary\TestUtils\xdebug_start_code_coverage; -use function MaplePHP\Unitary\TestUtils\xdebug_stop_code_coverage; class CodeCoverage { @@ -113,7 +110,7 @@ public function start(): void { $this->data = []; if($this->hasXdebugCoverage()) { - xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE); + \xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE); } } @@ -132,8 +129,8 @@ public function end(): void } if($this->hasXdebugCoverage()) { - $this->data = xdebug_get_code_coverage(); - xdebug_stop_code_coverage(); + $this->data = \xdebug_get_code_coverage(); + \xdebug_stop_code_coverage(); } } From 4c42c9f3268afe4936847c50f707ee6f6132980f Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Wed, 20 Aug 2025 16:53:40 +0200 Subject: [PATCH 67/78] chore: code quality improvements --- src/Console/ConsoleRouter.php | 2 - src/Console/Controllers/DefaultController.php | 9 +- src/Console/Controllers/RunTestController.php | 4 +- src/Console/Services/AbstractMainService.php | 6 ++ src/Console/Services/RunTestService.php | 7 -- src/Discovery/TestDiscovery.php | 101 ++++++++++-------- src/Interfaces/BodyInterface.php | 2 +- src/Interfaces/TestEmitterInterface.php | 2 - src/Mocker/MethodRegistry.php | 10 +- src/Mocker/MockBuilder.php | 22 ++-- src/Mocker/MockController.php | 2 +- src/Mocker/MockedMethod.php | 2 + src/Renders/AbstractRenderHandler.php | 14 +-- src/Renders/CliRenderer.php | 4 +- src/Support/Router.php | 4 +- src/Support/functions.php | 4 +- src/TestCase.php | 6 +- src/TestEmitter.php | 13 ++- src/Unit.php | 46 +------- tests/unitary-test.php | 1 + 20 files changed, 114 insertions(+), 147 deletions(-) diff --git a/src/Console/ConsoleRouter.php b/src/Console/ConsoleRouter.php index ae42e70..af27d02 100644 --- a/src/Console/ConsoleRouter.php +++ b/src/Console/ConsoleRouter.php @@ -1,10 +1,8 @@ map("coverage", [CoverageController::class, "run"]); $router->map("template", [TemplateController::class, "run"]); $router->map(["", "test", "run"], [RunTestController::class, "run"]); diff --git a/src/Console/Controllers/DefaultController.php b/src/Console/Controllers/DefaultController.php index 2d8191e..7885938 100644 --- a/src/Console/Controllers/DefaultController.php +++ b/src/Console/Controllers/DefaultController.php @@ -2,12 +2,15 @@ namespace MaplePHP\Unitary\Console\Controllers; +use MaplePHP\Container\Interfaces\ContainerExceptionInterface; use MaplePHP\Container\Interfaces\ContainerInterface; +use MaplePHP\Container\Interfaces\NotFoundExceptionInterface; use MaplePHP\Emitron\Contracts\DispatchConfigInterface; use MaplePHP\Http\Interfaces\RequestInterface; use MaplePHP\Http\Interfaces\ServerRequestInterface; use MaplePHP\Prompts\Command; use MaplePHP\Unitary\Config\ConfigProps; +use Throwable; abstract class DefaultController { @@ -22,8 +25,8 @@ abstract class DefaultController * Set some data type safe object that comes from container and the dispatcher * * @param ContainerInterface $container - * @throws \MaplePHP\Container\Interfaces\ContainerExceptionInterface - * @throws \MaplePHP\Container\Interfaces\NotFoundExceptionInterface + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface */ public function __construct(ContainerInterface $container) { $this->container = $container; @@ -61,7 +64,7 @@ private function getInitProps(): ConfigProps ); } - } catch (\Throwable $e) { + } catch (Throwable $e) { if(isset($this->args['verbose'])) { $this->command->error($e->getMessage()); exit(1); diff --git a/src/Console/Controllers/RunTestController.php b/src/Console/Controllers/RunTestController.php index c4169d0..836a97e 100644 --- a/src/Console/Controllers/RunTestController.php +++ b/src/Console/Controllers/RunTestController.php @@ -49,7 +49,7 @@ public function help(): void "php vendor/bin/unitary", "Run all tests in the default path (./tests)" )->addExamples( - "php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983", + "php vendor/bin/unitary --show=b0620ca8ef6ea7598e5ed56a530b1983", "Run the test with a specific hash ID" )->addExamples( "php vendor/bin/unitary --errorsOnly", @@ -76,7 +76,7 @@ public function help(): void * * @return void */ - protected function buildFooter() + protected function buildFooter(): void { $inst = TestDiscovery::getUnitaryInst(); if($inst !== null) { diff --git a/src/Console/Services/AbstractMainService.php b/src/Console/Services/AbstractMainService.php index a7cfad5..5c5ff68 100644 --- a/src/Console/Services/AbstractMainService.php +++ b/src/Console/Services/AbstractMainService.php @@ -2,7 +2,9 @@ namespace MaplePHP\Unitary\Console\Services; +use MaplePHP\Container\Interfaces\ContainerExceptionInterface; use MaplePHP\Container\Interfaces\ContainerInterface; +use MaplePHP\Container\Interfaces\NotFoundExceptionInterface; use MaplePHP\Emitron\Contracts\DispatchConfigInterface; use MaplePHP\Http\Interfaces\RequestInterface; use MaplePHP\Http\Interfaces\ResponseInterface; @@ -20,6 +22,10 @@ abstract class AbstractMainService protected ServerRequestInterface|RequestInterface $request; protected ?ConfigProps $props = null; + /** + * @throws NotFoundExceptionInterface + * @throws ContainerExceptionInterface + */ public function __construct(ResponseInterface $response, ContainerInterface $container) { $this->response = $response; diff --git a/src/Console/Services/RunTestService.php b/src/Console/Services/RunTestService.php index 011a300..0bea39c 100644 --- a/src/Console/Services/RunTestService.php +++ b/src/Console/Services/RunTestService.php @@ -15,13 +15,6 @@ class RunTestService extends AbstractMainService { public function run(BodyInterface $handler): ResponseInterface { - - // /** @var DispatchConfig $config */ - // $config = $this->container->get("dispatchConfig"); - //$this->configs->isSmartSearch(); - - // args should be able to be overwritten by configs... - $iterator = new TestDiscovery(); $iterator->enableVerbose($this->props->verbose); $iterator->enableSmartSearch($this->props->smartSearch); diff --git a/src/Discovery/TestDiscovery.php b/src/Discovery/TestDiscovery.php index 4fc1eb6..95cf3ef 100755 --- a/src/Discovery/TestDiscovery.php +++ b/src/Discovery/TestDiscovery.php @@ -12,14 +12,18 @@ namespace MaplePHP\Unitary\Discovery; use Closure; -use MaplePHP\Blunder\Exceptions\BlunderSoftException; -use MaplePHP\Blunder\Handlers\CliHandler; -use MaplePHP\Blunder\Run; -use MaplePHP\Unitary\Unit; +use ErrorException; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use RuntimeException; use SplFileInfo; +use Throwable; +use UnexpectedValueException; +use MaplePHP\Blunder\Exceptions\BlunderErrorException; +use MaplePHP\Blunder\Exceptions\BlunderSoftException; +use MaplePHP\Blunder\Handlers\CliHandler; +use MaplePHP\Blunder\Run; +use MaplePHP\Unitary\Unit; final class TestDiscovery { @@ -42,7 +46,7 @@ function enableVerbose(bool $isVerbose): self } /** - * Enabling smart search; If no tests i found Unitary will try to traverse + * Enabling smart search; If no tests I found Unitary will try to traverse * backwards until a test is found * * @param bool $smartSearch @@ -107,7 +111,10 @@ public function getExitCode(): int * @param string|bool $rootDir * @param callable|null $callback * @return void + * @throws BlunderErrorException * @throws BlunderSoftException + * @throws ErrorException + * @throws Throwable */ public function executeAll(string $path, string|bool $rootDir = false, ?callable $callback = null): void { @@ -117,19 +124,17 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable $path = $rootDir . "/" . $path; } $files = $this->findFiles($path, $rootDir); + + // Init Blunder error handling framework + $this->runBlunder(); + if (empty($files) && $this->verbose) { throw new BlunderSoftException("Unitary could not find any test files matching the pattern \"" . $this->pattern . "\" in directory \"" . dirname($path) . "\" and its subdirectories."); } else { - - // Error Handler library - $this->runBlunder(); foreach ($files as $file) { - $call = $this->requireUnitFile((string)$file, $callback); - if ($call !== null) { - $call(); - } + $this->executeUnitFile((string)$file, $callback); } } } @@ -141,45 +146,57 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable * Scope isolation, $this unbinding, State separation, Deferred execution * * @param string $file The full path to the test file to require. - * @return Closure|null A callable that, when invoked, runs the test file. + * @param Closure $callback + * @return void + * @throws ErrorException + * @throws BlunderErrorException + * @throws Throwable */ - private function requireUnitFile(string $file, callable $callback): ?Closure + private function executeUnitFile(string $file, Closure $callback): void { $verbose = $this->verbose; + if (!is_file($file)) { + throw new RuntimeException("File \"$file\" do not exists."); + } - $call = function () use ($file, $verbose, $callback): void { - if (!is_file($file)) { - throw new RuntimeException("File \"$file\" do not exists."); - } + $instance = $callback($file); + if (!$instance instanceof Unit) { + throw new UnexpectedValueException('Callable must return ' . Unit::class); + } + self::$unitary = $instance; - self::$unitary = $callback($file); + $unitInst = $this->isolateRequire($file); - if(!(self::$unitary instanceof Unit)) { - throw new \Exception("An instance of Unit must be return from callable in executeAll."); - } + if ($unitInst instanceof Unit) { + $unitInst->inheritConfigs(self::$unitary); + self::$unitary = $unitInst; + } + $ok = self::$unitary->execute(); - $unitInst = require_once($file); - if ($unitInst instanceof Unit) { - $unitInst->inheritConfigs(self::$unitary); - self::$unitary = $unitInst; - } - $bool = self::$unitary->execute(); + if (!$ok && $verbose) { + trigger_error( + "Could not find any tests inside the test file:\n$file\n\nPossible causes:\n" . + " • There are no test in test group/case.\n" . + " • Unitary could not locate the Unit instance.\n" . + " • You did not use the `group()` function.\n" . + " • You created a new Unit in the test file but did not return it at the end.", + E_USER_WARNING + ); + } - if(!$bool && $verbose) { - trigger_error( - "Could not find any tests inside the test file:\n" . - $file . "\n\n" . - "Possible causes:\n" . - " • There are no test in test group/case.\n" . - " • Unitary could not locate the Unit instance.\n" . - " • You did not use the `group()` function.\n" . - " • You created a new Unit in the test file but did not return it at the end.", - E_USER_WARNING - ); - } - }; + } - return $call->bindTo(null); + /** + * Isolate the required file and keep $this out of scope + * + * @param $file + * @return mixed + */ + private function isolateRequire($file): mixed + { + return (static function (string $f) { + return require $f; + })($file); } /** diff --git a/src/Interfaces/BodyInterface.php b/src/Interfaces/BodyInterface.php index 2c51eb3..a08ad89 100644 --- a/src/Interfaces/BodyInterface.php +++ b/src/Interfaces/BodyInterface.php @@ -51,7 +51,7 @@ public function setChecksum(string $checksum): void; /** - * Should contain a array with tests + * Should contain an array with tests * * @param array $tests * @return void diff --git a/src/Interfaces/TestEmitterInterface.php b/src/Interfaces/TestEmitterInterface.php index a678aa2..6574de2 100644 --- a/src/Interfaces/TestEmitterInterface.php +++ b/src/Interfaces/TestEmitterInterface.php @@ -2,8 +2,6 @@ namespace MaplePHP\Unitary\Interfaces; -use MaplePHP\Blunder\Interfaces\AbstractHandlerInterface; - interface TestEmitterInterface { diff --git a/src/Mocker/MethodRegistry.php b/src/Mocker/MethodRegistry.php index 2f7be72..438864a 100644 --- a/src/Mocker/MethodRegistry.php +++ b/src/Mocker/MethodRegistry.php @@ -11,6 +11,8 @@ namespace MaplePHP\Unitary\Mocker; +use BadMethodCallException; + class MethodRegistry { private ?MockBuilder $mocker; @@ -56,7 +58,7 @@ public static function getMethod(string $class, string $name): ?MockedMethod public function method(string $name): MockedMethod { if(is_null($this->mocker)) { - throw new \BadMethodCallException("MockBuilder is not set yet."); + throw new BadMethodCallException("MockBuilder is not set yet."); } self::$methods[$this->mocker->getMockedClassName()][$name] = new MockedMethod($this->mocker); return self::$methods[$this->mocker->getMockedClassName()][$name]; @@ -71,7 +73,7 @@ public function method(string $name): MockedMethod public function get(string $key): MockedMethod|null { if(is_null($this->mocker)) { - throw new \BadMethodCallException("MockBuilder is not set yet."); + throw new BadMethodCallException("MockBuilder is not set yet."); } return self::$methods[$this->mocker->getMockedClassName()][$key] ?? null; } @@ -95,7 +97,7 @@ public function getAll(): array public function has(string $name): bool { if(is_null($this->mocker)) { - throw new \BadMethodCallException("MockBuilder is not set yet."); + throw new BadMethodCallException("MockBuilder is not set yet."); } return isset(self::$methods[$this->mocker->getMockedClassName()][$name]); } @@ -103,7 +105,7 @@ public function has(string $name): bool public function getSelected(array $names): array { if(is_null($this->mocker)) { - throw new \BadMethodCallException("MockBuilder is not set yet."); + throw new BadMethodCallException("MockBuilder is not set yet."); } return array_filter($names, fn($name) => $this->has($name)); diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index c0fc4c6..451b368 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -21,6 +21,7 @@ use ReflectionNamedType; use ReflectionUnionType; use RuntimeException; +use Throwable; final class MockBuilder { @@ -48,7 +49,6 @@ public function __construct(string $className, array $args = []) $this->dataTypeMock = new DataTypeMock(); $this->methods = $this->reflection->getMethods(); $this->constructorArgs = $args; - $shortClassName = explode("\\", $className); $shortClassName = end($shortClassName); /** @@ -57,12 +57,6 @@ public function __construct(string $className, array $args = []) */ $this->mockClassName = "Unitary_" . uniqid() . "_Mock_" . $shortClassName; $this->copyClassName = "Unitary_Mock_" . $shortClassName; - /* - // Auto fill the Constructor args! - $test = $this->reflection->getConstructor(); - $test = $this->generateMethodSignature($test); - $param = $test->getParameters(); - */ } protected function getMockClass(?MockedMethod $methodItem, callable $call, mixed $fallback = null): mixed @@ -161,7 +155,7 @@ public function hasFinal(): bool */ public function mockDataType(string $dataType, mixed $value, ?string $bindToMethod = null): self { - if($bindToMethod !== null && $bindToMethod) { + if($bindToMethod) { $this->dataTypeMock = $this->dataTypeMock->withCustomBoundDefault($bindToMethod, $dataType, $value); } else { $this->dataTypeMock = $this->dataTypeMock->withCustomDefault($dataType, $value); @@ -196,10 +190,6 @@ public static function __set_state(array \$an_array): self eval($code); - if(!is_string($this->mockClassName)) { - throw new Exception("Mock class name is not a string"); - } - /** * @psalm-suppress MixedMethodCall * @psalm-suppress InvalidStringClass @@ -350,13 +340,13 @@ protected function handleModifiers(array $modifiersArr): string /** * Will mocked a handle the thrown exception * - * @param \Throwable $exception + * @param Throwable $exception * @return string */ - protected function handleThrownExceptions(\Throwable $exception): string + protected function handleThrownExceptions(Throwable $exception): string { $class = get_class($exception); - $reflection = new \ReflectionClass($exception); + $reflection = new ReflectionClass($exception); $constructor = $reflection->getConstructor(); $args = []; if ($constructor) { @@ -378,7 +368,7 @@ protected function handleThrownExceptions(\Throwable $exception): string } } - return "throw new \\{$class}(" . implode(', ', $args) . ");"; + return "throw new \\$class(" . implode(', ', $args) . ");"; } /** diff --git a/src/Mocker/MockController.php b/src/Mocker/MockController.php index 48ac8c3..288c660 100644 --- a/src/Mocker/MockController.php +++ b/src/Mocker/MockController.php @@ -43,7 +43,7 @@ public static function getInstance(): self */ public static function getData(string $mockIdentifier): array|bool { - $data = isset(self::$data[$mockIdentifier]) ? self::$data[$mockIdentifier] : false; + $data = self::$data[$mockIdentifier] ?? false; if (!is_array($data)) { return false; } diff --git a/src/Mocker/MockedMethod.php b/src/Mocker/MockedMethod.php index 5bd4679..577a4c5 100644 --- a/src/Mocker/MockedMethod.php +++ b/src/Mocker/MockedMethod.php @@ -13,6 +13,7 @@ use BadMethodCallException; use Closure; +use Exception; use InvalidArgumentException; use MaplePHP\Unitary\Support\TestUtils\ExecutionWrapper; use Throwable; @@ -66,6 +67,7 @@ public function __construct(?MockBuilder $mocker = null) * @param Closure $call The closure to be executed as the wrapper function * @return $this Method chain * @throws BadMethodCallException When mocker is not set + * @throws Exception */ public function wrap(Closure $call): self { diff --git a/src/Renders/AbstractRenderHandler.php b/src/Renders/AbstractRenderHandler.php index 5e537ff..0815ec8 100644 --- a/src/Renders/AbstractRenderHandler.php +++ b/src/Renders/AbstractRenderHandler.php @@ -6,6 +6,7 @@ use MaplePHP\Http\Stream; use MaplePHP\Unitary\Interfaces\BodyInterface; use MaplePHP\Unitary\TestCase; +use RuntimeException; class AbstractRenderHandler implements BodyInterface { @@ -89,7 +90,7 @@ public function outputBuffer(string $addToOutput = ''): string */ public function buildBody(): void { - throw new \RuntimeException('Your handler is missing the execution method.'); + throw new RuntimeException('Your handler is missing the execution method.'); } /** @@ -98,7 +99,7 @@ public function buildBody(): void public function buildNotes(): void { - throw new \RuntimeException('Your handler is missing the execution method.'); + throw new RuntimeException('Your handler is missing the execution method.'); } /** @@ -109,15 +110,6 @@ public function getBody(): StreamInterface return new Stream(); } - /** - * {@inheritDoc} - */ - public function getCommand(): StreamInterface - { - return new Stream(); - } - - /** * Make a file path into a title * @param string $file diff --git a/src/Renders/CliRenderer.php b/src/Renders/CliRenderer.php index 5ca61df..b0081b4 100644 --- a/src/Renders/CliRenderer.php +++ b/src/Renders/CliRenderer.php @@ -2,6 +2,7 @@ namespace MaplePHP\Unitary\Renders; +use ErrorException; use MaplePHP\Prompts\Command; use MaplePHP\Unitary\TestItem; use MaplePHP\Unitary\TestUnit; @@ -26,6 +27,7 @@ public function __construct(Command $command) /** * {@inheritDoc} + * @throws ErrorException */ public function buildBody(): void { @@ -114,7 +116,7 @@ protected function showFooter(): void * Failed tests template part * * @return void - * @throws \ErrorException + * @throws ErrorException */ protected function showFailedTests(): void { diff --git a/src/Support/Router.php b/src/Support/Router.php index f7ecf26..57fb020 100644 --- a/src/Support/Router.php +++ b/src/Support/Router.php @@ -18,8 +18,8 @@ class Router implements RouterInterface { private array $controllers = []; - private string $needle = ""; - private array $args = []; + private string $needle; + private array $args; public function __construct(string $needle, array $args) { diff --git a/src/Support/functions.php b/src/Support/functions.php index 1a0a0d1..11d60e8 100644 --- a/src/Support/functions.php +++ b/src/Support/functions.php @@ -12,9 +12,7 @@ function unitary_group(string|TestConfig $message, Closure $expect, ?TestConfig $config = null): void { $inst = TestDiscovery::getUnitaryInst(); - if($inst !== null) { - $inst->group($message, $expect, $config); - } + $inst?->group($message, $expect, $config); } if (!function_exists('group')) { diff --git a/src/TestCase.php b/src/TestCase.php index ed35105..0abaffb 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -328,7 +328,7 @@ public function add(mixed $expect, array|Closure $validation, ?string $message = * Initialize a test wrapper * * NOTICE: When mocking a class with required constructor arguments, those arguments must be - * specified in the mock initialization method or it will fail. This is because the mock + * specified in the mock initialization method, or it will fail. This is because the mock * creates and simulates an actual instance of the original class with its real constructor. * * @param string $class @@ -368,12 +368,12 @@ public function buildMock(?Closure $validate = null): mixed if(!($this->mocker instanceof MockBuilder)) { throw new BadMethodCallException("The mocker is not set yet!"); } - if (is_callable($validate)) { + if ($validate instanceof Closure) { $pool = $this->prepareValidation($this->mocker, $validate); } /** @psalm-suppress MixedReturnStatement */ $class = $this->mocker->execute(); - if($this->mocker->hasFinal()) { + if($this->mocker->hasFinal() && isset($pool)) { $finalMethods = $pool->getSelected($this->mocker->getFinalMethods()); if($finalMethods !== []) { $this->warning = "Warning: Final methods cannot be mocked or have their behavior modified: " . implode(", ", $finalMethods); diff --git a/src/TestEmitter.php b/src/TestEmitter.php index 86cb6cf..c73c78b 100644 --- a/src/TestEmitter.php +++ b/src/TestEmitter.php @@ -11,12 +11,14 @@ namespace MaplePHP\Unitary; -use MaplePHP\Blunder\Exceptions\BlunderSoftException; -use MaplePHP\Blunder\Handlers\CliHandler; +use ErrorException; +use MaplePHP\Blunder\Exceptions\BlunderErrorException; use MaplePHP\Blunder\Interfaces\AbstractHandlerInterface; use MaplePHP\Blunder\Run; use MaplePHP\Unitary\Interfaces\BodyInterface; use MaplePHP\Unitary\Interfaces\TestEmitterInterface; +use RuntimeException; +use Throwable; class TestEmitter implements TestEmitterInterface { @@ -30,13 +32,18 @@ public function __construct(BodyInterface $handler, AbstractHandlerInterface $er $this->runBlunder($errorHandler); } + /** + * @throws Throwable + * @throws BlunderErrorException + * @throws ErrorException + */ public function emit(string $file): void { $verbose = (bool)($this->args['verbose'] ?? false); if(!is_file($file)) { - throw new \RuntimeException("The test file \"$file\" do not exists."); + throw new RuntimeException("The test file \"$file\" do not exists."); } require_once($file); diff --git a/src/Unit.php b/src/Unit.php index 414486b..57a4a17 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -15,33 +15,25 @@ use Closure; use ErrorException; use MaplePHP\Blunder\Exceptions\BlunderErrorException; -use MaplePHP\Http\Interfaces\StreamInterface; use MaplePHP\Prompts\Command; use MaplePHP\Unitary\Config\TestConfig; use MaplePHP\Unitary\Renders\CliRenderer; -use MaplePHP\Unitary\Renders\HandlerInterface; use MaplePHP\Unitary\Interfaces\BodyInterface; -use MaplePHP\Unitary\Support\Performance; use RuntimeException; use Throwable; final class Unit { - private ?BodyInterface $handler = null; - private Command $command; - private string $output = ""; + private ?BodyInterface $handler; private int $index = 0; private array $cases = []; private bool $disableAllTests = false; private bool $executed = false; - private string $file = ""; private bool $showErrorsOnly = false; private ?string $show = null; private bool $verbose = false; private bool $alwaysShowFiles = false; - - private static ?Unit $current; private static int $totalPassedTests = 0; private static int $totalTests = 0; @@ -233,11 +225,8 @@ public function execute(): bool if ($this->executed || $this->disableAllTests) { return false; } - - // LOOP through each case ob_start(); //$countCases = count($this->cases); - $handler = $this->handler; if(count($this->cases) === 0) { return false; @@ -248,12 +237,11 @@ public function execute(): bool if (!($row instanceof TestCase)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); } - $row->dispatchTest($row); $tests = $row->runDeferredValidations(); $checksum = $fileChecksum . $index; - $show = ($row->getConfig()->select === $this->show || $this->show === $checksum); + if(($this->show !== null) && !$show) { continue; } @@ -351,35 +339,5 @@ public function addTitle(): self { return $this; } - - // NOTE: Just a test is is not used, and will NOT exist in this class - public function performance(Closure $func, ?string $title = null): void - { - $start = new Performance(); - $func = $func->bindTo($this); - if ($func !== null) { - $func($this); - } - $line = $this->command->getAnsi()->line(80); - $this->command->message(""); - $this->command->message($this->command->getAnsi()->style(["bold", "yellow"], "Performance" . ($title !== null ? " - $title:" : ":"))); - - $this->command->message($line); - $this->command->message( - $this->command->getAnsi()->style(["bold"], "Execution time: ") . - ((string)round($start->getExecutionTime(), 3) . " seconds") - ); - $this->command->message( - $this->command->getAnsi()->style(["bold"], "Memory Usage: ") . - ((string)round($start->getMemoryUsage(), 2) . " KB") - ); - /* - $this->command->message( - $this->command->getAnsi()->style(["bold", "grey"], "Peak Memory Usage: ") . - $this->command->getAnsi()->blue(round($start->getMemoryPeakUsage(), 2) . " KB") - ); - */ - $this->command->message($line); - } } diff --git a/tests/unitary-test.php b/tests/unitary-test.php index e3432c4..716af1c 100755 --- a/tests/unitary-test.php +++ b/tests/unitary-test.php @@ -2,6 +2,7 @@ use MaplePHP\Unitary\{Config\TestConfig, Expect, TestCase}; + $config = TestConfig::make()->withName("unitary-test"); group("Hello world 0", function(TestCase $case) { From 2e011d79dc82f9f66cd34ea0e409ef168e613ca0 Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Wed, 20 Aug 2025 16:55:46 +0200 Subject: [PATCH 68/78] style: apply coding standards with php-cs-fixer --- src/Config/ConfigProps.php | 3 +- src/Config/TestConfig.php | 4 +- src/Console/ConsoleRouter.php | 3 +- .../Controllers/CoverageController.php | 7 ++-- src/Console/Controllers/DefaultController.php | 11 +++--- src/Console/Controllers/RunTestController.php | 9 ++--- .../Controllers/TemplateController.php | 3 +- src/Console/Enum/CoverageIssue.php | 3 +- src/Console/Kernel.php | 11 +++--- .../Middlewares/AddCommandMiddleware.php | 2 +- src/Console/Services/AbstractMainService.php | 2 +- src/Console/Services/RunTestService.php | 10 ++--- src/Discovery/TestDiscovery.php | 21 +++++----- src/Expect.php | 36 ++++++++--------- src/Interfaces/BodyInterface.php | 3 +- src/Interfaces/RouterDispatchInterface.php | 4 +- src/Interfaces/RouterInterface.php | 3 +- src/Interfaces/TestEmitterInterface.php | 3 +- src/Mocker/MethodRegistry.php | 13 ++++--- src/Mocker/MockBuilder.php | 17 ++++---- src/Mocker/MockController.php | 3 +- src/Mocker/MockedMethod.php | 9 +++-- src/Renders/AbstractRenderHandler.php | 2 +- src/Renders/CliRenderer.php | 8 ++-- src/Renders/SilentRender.php | 3 +- src/Setup/assert-polyfill.php | 1 + src/Support/Helpers.php | 13 ++++--- src/Support/Performance.php | 1 + src/Support/Router.php | 11 +++--- src/Support/TestUtils/CodeCoverage.php | 19 +++++---- src/Support/TestUtils/DataTypeMock.php | 32 +++++++-------- src/Support/TestUtils/ExecutionWrapper.php | 3 +- src/Support/functions.php | 1 + src/TestCase.php | 39 +++++++++++-------- src/TestEmitter.php | 11 +++--- src/TestItem.php | 4 +- src/TestUnit.php | 1 + src/Unit.php | 8 ++-- unitary.config.php | 3 +- 39 files changed, 180 insertions(+), 160 deletions(-) diff --git a/src/Config/ConfigProps.php b/src/Config/ConfigProps.php index 311609b..b052010 100644 --- a/src/Config/ConfigProps.php +++ b/src/Config/ConfigProps.php @@ -1,4 +1,5 @@ skip = $bool; return $inst; } -} \ No newline at end of file +} diff --git a/src/Console/ConsoleRouter.php b/src/Console/ConsoleRouter.php index af27d02..556918f 100644 --- a/src/Console/ConsoleRouter.php +++ b/src/Console/ConsoleRouter.php @@ -1,4 +1,5 @@ map("coverage", [CoverageController::class, "run"]); $router->map("template", [TemplateController::class, "run"]); $router->map(["", "test", "run"], [RunTestController::class, "run"]); -$router->map(["__404", "help"], [RunTestController::class, "help"]); \ No newline at end of file +$router->map(["__404", "help"], [RunTestController::class, "help"]); diff --git a/src/Console/Controllers/CoverageController.php b/src/Console/Controllers/CoverageController.php index 962baf8..8abd7bf 100644 --- a/src/Console/Controllers/CoverageController.php +++ b/src/Console/Controllers/CoverageController.php @@ -10,7 +10,6 @@ class CoverageController extends DefaultController { - /** * Code Coverage Controller */ @@ -23,7 +22,7 @@ public function run(RunTestService $service): ResponseInterface $coverage->end(); $result = $coverage->getResponse(); - if($result !== false) { + if ($result !== false) { $this->outputBody($result); } else { $this->command->error("Error: Code coverage is not reachable"); @@ -43,10 +42,10 @@ public function run(RunTestService $service): ResponseInterface private function outputBody(array $result): void { $block = new Blocks($this->command); - $block->addSection("Code coverage", function(Blocks $block) use ($result) { + $block->addSection("Code coverage", function (Blocks $block) use ($result) { return $block->addList("Total lines:", $result['totalLines']) ->addList("Executed lines:", $result['executedLines']) ->addList("Code coverage percent:", $result['percent'] . "%"); }); } -} \ No newline at end of file +} diff --git a/src/Console/Controllers/DefaultController.php b/src/Console/Controllers/DefaultController.php index 7885938..c368e76 100644 --- a/src/Console/Controllers/DefaultController.php +++ b/src/Console/Controllers/DefaultController.php @@ -28,7 +28,8 @@ abstract class DefaultController * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface */ - public function __construct(ContainerInterface $container) { + public function __construct(ContainerInterface $container) + { $this->container = $container; $this->args = $this->container->get("args"); $this->command = $this->container->get("command"); @@ -50,12 +51,12 @@ public function __construct(ContainerInterface $container) { */ private function getInitProps(): ConfigProps { - if($this->props === null) { + if ($this->props === null) { try { $props = array_merge($this->configs->getProps()->toArray(), $this->args); $this->props = new ConfigProps($props); - if($this->props->hasMissingProps() !== [] && isset($this->args['verbose'])) { + if ($this->props->hasMissingProps() !== [] && isset($this->args['verbose'])) { $this->command->error('The properties (' . implode(", ", $this->props->hasMissingProps()) . ') is not exist in config props'); $this->command->message( @@ -65,7 +66,7 @@ private function getInitProps(): ConfigProps } } catch (Throwable $e) { - if(isset($this->args['verbose'])) { + if (isset($this->args['verbose'])) { $this->command->error($e->getMessage()); exit(1); } @@ -74,4 +75,4 @@ private function getInitProps(): ConfigProps return $this->props; } -} \ No newline at end of file +} diff --git a/src/Console/Controllers/RunTestController.php b/src/Console/Controllers/RunTestController.php index 836a97e..09e95d7 100644 --- a/src/Console/Controllers/RunTestController.php +++ b/src/Console/Controllers/RunTestController.php @@ -10,7 +10,6 @@ class RunTestController extends DefaultController { - /** * Main test runner */ @@ -33,7 +32,7 @@ public function help(): void $blocks->addHeadline("\n--- Unitary Help ---"); $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); - $blocks->addSection("Options", function(Blocks $inst) { + $blocks->addSection("Options", function (Blocks $inst) { return $inst ->addOption("help", "Show this help message") ->addOption("show=", "Run a specific test by hash or manual test name") @@ -43,7 +42,7 @@ public function help(): void ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); }); - $blocks->addSection("Examples", function(Blocks $inst) { + $blocks->addSection("Examples", function (Blocks $inst) { return $inst ->addExamples( "php vendor/bin/unitary", @@ -79,7 +78,7 @@ public function help(): void protected function buildFooter(): void { $inst = TestDiscovery::getUnitaryInst(); - if($inst !== null) { + if ($inst !== null) { $dot = $this->command->getAnsi()->middot(); $peakMemory = (string)round(memory_get_peak_usage() / 1024, 2); @@ -95,4 +94,4 @@ protected function buildFooter(): void } -} \ No newline at end of file +} diff --git a/src/Console/Controllers/TemplateController.php b/src/Console/Controllers/TemplateController.php index 40dbb3a..b737281 100644 --- a/src/Console/Controllers/TemplateController.php +++ b/src/Console/Controllers/TemplateController.php @@ -6,7 +6,6 @@ class TemplateController extends DefaultController { - /** * Display a template for the Unitary testing tool * Shows a basic template for the Unitary testing tool @@ -35,4 +34,4 @@ public function run(): void } -} \ No newline at end of file +} diff --git a/src/Console/Enum/CoverageIssue.php b/src/Console/Enum/CoverageIssue.php index 77623e0..ed8e74c 100644 --- a/src/Console/Enum/CoverageIssue.php +++ b/src/Console/Enum/CoverageIssue.php @@ -1,4 +1,5 @@ 'Xdebug is enabled, but coverage mode is missing.', }; } -} \ No newline at end of file +} diff --git a/src/Console/Kernel.php b/src/Console/Kernel.php index 5f2711b..7fb4740 100644 --- a/src/Console/Kernel.php +++ b/src/Console/Kernel.php @@ -1,4 +1,5 @@ userMiddlewares)) { + if (!in_array(AddCommandMiddleware::class, $this->userMiddlewares)) { $this->userMiddlewares[] = AddCommandMiddleware::class; } EmitronKernel::setConfigFilePath(self::CONFIG_FILE_PATH); @@ -64,7 +65,7 @@ public function __construct( */ public function run(ServerRequestInterface $request): void { - if($this->config === null) { + if ($this->config === null) { $this->config = $this->configuration($request); } $kernel = new EmitronKernel($this->container, $this->userMiddlewares, $this->config); @@ -82,10 +83,10 @@ private function configuration(ServerRequestInterface $request): DispatchConfigI { $config = new DispatchConfig(EmitronKernel::getConfigFilePath()); return $config - ->setRouter(function($path) use ($request) { + ->setRouter(function ($path) use ($request) { $routerFile = $path . self::DEFAULT_ROUTER_FILE; $router = new Router($request->getCliKeyword(), $request->getCliArgs()); - if(!is_file($routerFile)) { + if (!is_file($routerFile)) { throw new Exception('The routes file (' . $routerFile . ') is missing.'); } require_once $routerFile; @@ -93,4 +94,4 @@ private function configuration(ServerRequestInterface $request): DispatchConfigI }) ->setProp('exitCode', 0); } -} \ No newline at end of file +} diff --git a/src/Console/Middlewares/AddCommandMiddleware.php b/src/Console/Middlewares/AddCommandMiddleware.php index 13663dc..83e2d34 100644 --- a/src/Console/Middlewares/AddCommandMiddleware.php +++ b/src/Console/Middlewares/AddCommandMiddleware.php @@ -37,4 +37,4 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $this->container->set("command", new Command($response)); return $response; } -} \ No newline at end of file +} diff --git a/src/Console/Services/AbstractMainService.php b/src/Console/Services/AbstractMainService.php index 5c5ff68..5213316 100644 --- a/src/Console/Services/AbstractMainService.php +++ b/src/Console/Services/AbstractMainService.php @@ -37,4 +37,4 @@ public function __construct(ResponseInterface $response, ContainerInterface $con $this->props = $this->container->get("props"); } -} \ No newline at end of file +} diff --git a/src/Console/Services/RunTestService.php b/src/Console/Services/RunTestService.php index 0bea39c..cc02f80 100644 --- a/src/Console/Services/RunTestService.php +++ b/src/Console/Services/RunTestService.php @@ -24,7 +24,7 @@ public function run(BodyInterface $handler): ResponseInterface $iterator = $this->iterateTest($iterator, $handler); // CLI Response - if(PHP_SAPI === 'cli') { + if (PHP_SAPI === 'cli') { return $this->response->withStatus($iterator->getExitCode()); } // Text/Browser Response @@ -45,14 +45,14 @@ private function iterateTest(TestDiscovery $iterator, BodyInterface $handler): T $defaultPath = ($this->configs->getProps()->path !== null) ? $this->configs->getProps()->path : $defaultPath; $path = ($this->args['path'] ?? $defaultPath); - if(!isset($path)) { + if (!isset($path)) { throw new RuntimeException("Path not specified: --path=path/to/dir"); } $testDir = realpath($path); - if(!file_exists($testDir)) { + if (!file_exists($testDir)) { throw new RuntimeException("Test directory '$path' does not exist"); } - $iterator->executeAll($testDir, $defaultPath, function($file) use ($handler) { + $iterator->executeAll($testDir, $defaultPath, function ($file) use ($handler) { $unit = new Unit($handler); $unit->setShowErrorsOnly($this->props->errorsOnly); $unit->setShow($this->props->show); @@ -64,4 +64,4 @@ private function iterateTest(TestDiscovery $iterator, BodyInterface $handler): T return $iterator; } -} \ No newline at end of file +} diff --git a/src/Discovery/TestDiscovery.php b/src/Discovery/TestDiscovery.php index 95cf3ef..9eae20e 100755 --- a/src/Discovery/TestDiscovery.php +++ b/src/Discovery/TestDiscovery.php @@ -1,4 +1,5 @@ verbose = $isVerbose; return $this; @@ -52,7 +53,7 @@ function enableVerbose(bool $isVerbose): self * @param bool $smartSearch * @return $this */ - function enableSmartSearch(bool $smartSearch): self + public function enableSmartSearch(bool $smartSearch): self { $this->smartSearch = $smartSearch; return $this; @@ -64,9 +65,9 @@ function enableSmartSearch(bool $smartSearch): self * @param string|array|null $exclude * @return $this */ - function addExcludePaths(string|array|null $exclude): self + public function addExcludePaths(string|array|null $exclude): self { - if($exclude !== null) { + if ($exclude !== null) { $this->exclude = is_string($exclude) ? explode(', ', $exclude) : $exclude; } return $this; @@ -83,9 +84,9 @@ function addExcludePaths(string|array|null $exclude): self * @param ?string $pattern null value will fall back to the default value * @return $this */ - function setDiscoverPattern(?string $pattern): self + public function setDiscoverPattern(?string $pattern): self { - if($pattern !== null) { + if ($pattern !== null) { $pattern = rtrim($pattern, '*'); $pattern = ltrim($pattern, '*'); $pattern = ltrim($pattern, '/'); @@ -120,7 +121,7 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable { $rootDir = is_string($rootDir) ? realpath($rootDir) : false; $path = (!$path && $rootDir !== false) ? $rootDir : $path; - if($rootDir !== false && !str_starts_with($path, "/") && !str_starts_with($path, $rootDir)) { + if ($rootDir !== false && !str_starts_with($path, "/") && !str_starts_with($path, $rootDir)) { $path = $rootDir . "/" . $path; } $files = $this->findFiles($path, $rootDir); @@ -217,15 +218,15 @@ private function findFiles(string $path, string|bool $rootDir = false): array if (is_file($path) && str_starts_with(basename($path), "unitary-")) { $files[] = $path; } else { - if(is_file($path)) { + if (is_file($path)) { $path = dirname($path) . "/"; } - if(is_dir($path)) { + if (is_dir($path)) { $files += $this->getFileIterateReclusive($path); } } // If smart search flag then step back if no test files have been found and try again - if($rootDir !== false && count($files) <= 0 && str_starts_with($path, $rootDir) && $this->smartSearch) { + if ($rootDir !== false && count($files) <= 0 && str_starts_with($path, $rootDir) && $this->smartSearch) { $path = (string)realpath($path . "/..") . "/"; return $this->findFiles($path, $rootDir); } diff --git a/src/Expect.php b/src/Expect.php index 216d618..36f92d6 100644 --- a/src/Expect.php +++ b/src/Expect.php @@ -1,4 +1,5 @@ getException()) { + if ($except = $this->getException()) { $this->setValue($except); } /** @psalm-suppress PossiblyInvalidCast */ - $this->validateExcept(__METHOD__, $compare, fn() => $this->isClass((string)$compare)); + $this->validateExcept(__METHOD__, $compare, fn () => $this->isClass((string)$compare)); return $this; } @@ -50,11 +50,11 @@ public function isThrowable(string|object|callable $compare): self */ public function hasThrowableMessage(string|callable $compare): self { - if($except = $this->getException()) { + if ($except = $this->getException()) { $this->setValue($except->getMessage()); } /** @psalm-suppress PossiblyInvalidCast */ - $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + $this->validateExcept(__METHOD__, $compare, fn () => $this->isEqualTo($compare)); return $this; } @@ -67,11 +67,11 @@ public function hasThrowableMessage(string|callable $compare): self */ public function hasThrowableCode(int|callable $compare): self { - if($except = $this->getException()) { + if ($except = $this->getException()) { $this->setValue($except->getCode()); } /** @psalm-suppress PossiblyInvalidCast */ - $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + $this->validateExcept(__METHOD__, $compare, fn () => $this->isEqualTo($compare)); return $this; } @@ -84,12 +84,12 @@ public function hasThrowableCode(int|callable $compare): self */ public function hasThrowableSeverity(int|callable $compare): self { - if($except = $this->getException()) { + if ($except = $this->getException()) { $value = method_exists($except, 'getSeverity') ? $except->getSeverity() : 0; $this->setValue($value); } /** @psalm-suppress PossiblyInvalidCast */ - $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + $this->validateExcept(__METHOD__, $compare, fn () => $this->isEqualTo($compare)); return $this; } @@ -102,11 +102,11 @@ public function hasThrowableSeverity(int|callable $compare): self */ public function hasThrowableFile(string|callable $compare): self { - if($except = $this->getException()) { + if ($except = $this->getException()) { $this->setValue($except->getFile()); } /** @psalm-suppress PossiblyInvalidCast */ - $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + $this->validateExcept(__METHOD__, $compare, fn () => $this->isEqualTo($compare)); return $this; } @@ -119,11 +119,11 @@ public function hasThrowableFile(string|callable $compare): self */ public function hasThrowableLine(int|callable $compare): self { - if($except = $this->getException()) { + if ($except = $this->getException()) { $this->setValue($except->getLine()); } /** @psalm-suppress PossiblyInvalidCast */ - $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + $this->validateExcept(__METHOD__, $compare, fn () => $this->isEqualTo($compare)); return $this; } @@ -140,17 +140,17 @@ protected function validateExcept(string $name, int|string|object|callable $comp $pos = strrpos($name, '::'); $name = ($pos !== false) ? substr($name, $pos + 2) : $name; $this->mapErrorValidationName($name); - if(is_callable($compare)) { + if (is_callable($compare)) { $compare($this); } else { $fall($this); } - if(is_null($this->initValue)) { + if (is_null($this->initValue)) { $this->initValue = $this->getValue(); } - if($this->except === false) { + if ($this->except === false) { $this->setValue(null); } return $this; @@ -181,7 +181,7 @@ protected function getException(): Throwable|false } $expect = $this->getValue(); - if(!is_callable($expect)) { + if (!is_callable($expect)) { throw new Exception("Except method only accepts callable"); } try { @@ -194,4 +194,4 @@ protected function getException(): Throwable|false return false; } -} \ No newline at end of file +} diff --git a/src/Interfaces/BodyInterface.php b/src/Interfaces/BodyInterface.php index a08ad89..cecd57d 100644 --- a/src/Interfaces/BodyInterface.php +++ b/src/Interfaces/BodyInterface.php @@ -7,7 +7,6 @@ interface BodyInterface { - /** * Show hidden messages * @@ -96,4 +95,4 @@ public function buildNotes(): void; * @return StreamInterface */ public function getBody(): StreamInterface; -} \ No newline at end of file +} diff --git a/src/Interfaces/RouterDispatchInterface.php b/src/Interfaces/RouterDispatchInterface.php index 80bdfc6..d857d5f 100644 --- a/src/Interfaces/RouterDispatchInterface.php +++ b/src/Interfaces/RouterDispatchInterface.php @@ -10,5 +10,5 @@ interface RouterDispatchInterface * @param callable $call * @return bool */ - function dispatch(callable $call): bool; -} \ No newline at end of file + public function dispatch(callable $call): bool; +} diff --git a/src/Interfaces/RouterInterface.php b/src/Interfaces/RouterInterface.php index 40a979a..c9f6aa7 100644 --- a/src/Interfaces/RouterInterface.php +++ b/src/Interfaces/RouterInterface.php @@ -4,7 +4,6 @@ interface RouterInterface extends RouterDispatchInterface { - /** * Map one or more needles to controller * @@ -17,4 +16,4 @@ interface RouterInterface extends RouterDispatchInterface * @return $this */ public function map(string|array $needles, array $controller, array $args = []): self; -} \ No newline at end of file +} diff --git a/src/Interfaces/TestEmitterInterface.php b/src/Interfaces/TestEmitterInterface.php index 6574de2..af99f1e 100644 --- a/src/Interfaces/TestEmitterInterface.php +++ b/src/Interfaces/TestEmitterInterface.php @@ -4,6 +4,5 @@ interface TestEmitterInterface { - public function emit(string $file): void; -} \ No newline at end of file +} diff --git a/src/Mocker/MethodRegistry.php b/src/Mocker/MethodRegistry.php index 438864a..41bb3a9 100644 --- a/src/Mocker/MethodRegistry.php +++ b/src/Mocker/MethodRegistry.php @@ -1,4 +1,5 @@ mocker)) { + if (is_null($this->mocker)) { throw new BadMethodCallException("MockBuilder is not set yet."); } self::$methods[$this->mocker->getMockedClassName()][$name] = new MockedMethod($this->mocker); @@ -72,7 +73,7 @@ public function method(string $name): MockedMethod */ public function get(string $key): MockedMethod|null { - if(is_null($this->mocker)) { + if (is_null($this->mocker)) { throw new BadMethodCallException("MockBuilder is not set yet."); } return self::$methods[$this->mocker->getMockedClassName()][$key] ?? null; @@ -96,7 +97,7 @@ public function getAll(): array */ public function has(string $name): bool { - if(is_null($this->mocker)) { + if (is_null($this->mocker)) { throw new BadMethodCallException("MockBuilder is not set yet."); } return isset(self::$methods[$this->mocker->getMockedClassName()][$name]); @@ -104,11 +105,11 @@ public function has(string $name): bool public function getSelected(array $names): array { - if(is_null($this->mocker)) { + if (is_null($this->mocker)) { throw new BadMethodCallException("MockBuilder is not set yet."); } - return array_filter($names, fn($name) => $this->has($name)); + return array_filter($names, fn ($name) => $this->has($name)); } } diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php index 451b368..aa4edea 100755 --- a/src/Mocker/MockBuilder.php +++ b/src/Mocker/MockBuilder.php @@ -1,4 +1,5 @@ className; } - + /** * Returns the constructor arguments provided during instantiation. * @@ -155,8 +156,8 @@ public function hasFinal(): bool */ public function mockDataType(string $dataType, mixed $value, ?string $bindToMethod = null): self { - if($bindToMethod) { - $this->dataTypeMock = $this->dataTypeMock->withCustomBoundDefault($bindToMethod, $dataType, $value); + if ($bindToMethod) { + $this->dataTypeMock = $this->dataTypeMock->withCustomBoundDefault($bindToMethod, $dataType, $value); } else { $this->dataTypeMock = $this->dataTypeMock->withCustomDefault($dataType, $value); } @@ -245,7 +246,7 @@ protected function getReturnValue(array $types, mixed $method, ?MockedMethod $me } return "return 'MockedValue';"; } - + /** * Builds and returns PHP code that overrides all public methods in the class being mocked. * Each overridden method returns a predefined mock value or delegates to the original logic. @@ -276,10 +277,10 @@ protected function generateMockMethodOverrides(string $mockClassName): string $returnValue = $this->getReturnValue($types, $method, $methodItem); $paramList = $this->generateMethodSignature($method); - if($method->isConstructor()) { + if ($method->isConstructor()) { $types = []; $returnValue = ""; - if(count($this->constructorArgs) === 0) { + if (count($this->constructorArgs) === 0) { $paramList = ""; } } @@ -300,7 +301,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); } - if($methodItem && $methodItem->keepOriginal) { + if ($methodItem && $methodItem->keepOriginal) { $returnValue = "parent::$methodName(...func_get_args());"; if (!in_array('void', $types)) { $returnValue = "return $returnValue"; @@ -333,7 +334,7 @@ protected function generateMockMethodOverrides(string $mockClassName): string */ protected function handleModifiers(array $modifiersArr): string { - $modifiersArr = array_filter($modifiersArr, fn($val) => $val !== "abstract"); + $modifiersArr = array_filter($modifiersArr, fn ($val) => $val !== "abstract"); return implode(" ", $modifiersArr); } diff --git a/src/Mocker/MockController.php b/src/Mocker/MockController.php index 288c660..7c0260f 100644 --- a/src/Mocker/MockController.php +++ b/src/Mocker/MockController.php @@ -1,4 +1,5 @@ mocker->getReflectionClass()->isInterface()) { + if ($this->mocker->getReflectionClass()->isInterface()) { throw new BadMethodCallException('You only use "wrap()" on regular classes and not "interfaces".'); } @@ -112,7 +113,7 @@ public function getThrowable(): ?Throwable /** * Check if a method has been called x times - * + * * @param int $times * @return $this */ @@ -125,7 +126,7 @@ public function called(int $times): self /** * Check if a method has been called x times - * + * * @return $this */ public function hasBeenCalled(): self @@ -195,7 +196,7 @@ public function withArgumentsForCalls(mixed ...$args): self { $inst = $this; foreach ($args as $called => $data) { - if(!is_array($data)) { + if (!is_array($data)) { throw new InvalidArgumentException( 'The argument must be a array that contains the expected method arguments.' ); diff --git a/src/Renders/AbstractRenderHandler.php b/src/Renders/AbstractRenderHandler.php index 0815ec8..b0a046e 100644 --- a/src/Renders/AbstractRenderHandler.php +++ b/src/Renders/AbstractRenderHandler.php @@ -131,4 +131,4 @@ protected function formatFileTitle(string $file, int $length = 3, bool $removeSu return ".." . $file; } -} \ No newline at end of file +} diff --git a/src/Renders/CliRenderer.php b/src/Renders/CliRenderer.php index b0081b4..f2d42cd 100644 --- a/src/Renders/CliRenderer.php +++ b/src/Renders/CliRenderer.php @@ -41,7 +41,7 @@ public function buildBody(): void $this->command->getAnsi()->style(["bold", $this->color], (string)$this->case->getMessage()) ); - if(($this->show || $this->alwaysShowFiles || $this->verbose) && !$this->case->hasFailed()) { + if (($this->show || $this->alwaysShowFiles || $this->verbose) && !$this->case->hasFailed()) { $this->command->message(""); $this->command->message( $this->command->getAnsi()->style(["italic", $this->color], "Test file: " . $this->suitName) @@ -50,7 +50,7 @@ public function buildBody(): void if (($this->show || !$this->case->getConfig()->skip)) { // Show possible warnings - if($this->case->getWarning()) { + if ($this->case->getWarning()) { $this->command->message(""); $this->command->message( $this->command->getAnsi()->style(["italic", "yellow"], $this->case->getWarning()) @@ -69,7 +69,7 @@ public function buildBody(): void */ public function buildNotes(): void { - if($this->outputBuffer) { + if ($this->outputBuffer) { $lineLength = 80; $output = wordwrap($this->outputBuffer, $lineLength); $line = $this->command->getAnsi()->line($lineLength); @@ -192,4 +192,4 @@ protected function initDefault(): void $this->flag = $this->command->getAnsi()->style(['yellowBg', 'black'], " SKIP "); } } -} \ No newline at end of file +} diff --git a/src/Renders/SilentRender.php b/src/Renders/SilentRender.php index e5ff932..b713f39 100644 --- a/src/Renders/SilentRender.php +++ b/src/Renders/SilentRender.php @@ -2,10 +2,9 @@ namespace MaplePHP\Unitary\Renders; - class SilentRender extends AbstractRenderHandler { public function buildBody(): void { } -} \ No newline at end of file +} diff --git a/src/Setup/assert-polyfill.php b/src/Setup/assert-polyfill.php index 4ba3656..7250b8c 100644 --- a/src/Setup/assert-polyfill.php +++ b/src/Setup/assert-polyfill.php @@ -1,4 +1,5 @@ 1) { + if ($levels > 1) { return "[$str]"; } return $str; @@ -43,7 +44,7 @@ public static function stringifyArgs(mixed $args): string public static function stringify(mixed $arg, int &$levels = 0): string { if (is_array($arg)) { - $items = array_map(function($item) use(&$levels) { + $items = array_map(function ($item) use (&$levels) { $levels++; return self::stringify($item, $levels); }, $arg); @@ -75,7 +76,7 @@ public static function createFile(string $filename, string $input): void $tempFile = rtrim($tempDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename; file_put_contents($tempFile, "')) { $code = substr($code, 2); @@ -155,7 +156,7 @@ public static function stringifyDataTypes(mixed $value = null, bool $minify = fa } if (is_array($value)) { $json = json_encode($value); - if($json === false) { + if ($json === false) { return "(unknown type)"; } return '"' . self::excerpt($json) . '"' . ($minify ? "" : " (type: array)"); @@ -175,4 +176,4 @@ public static function stringifyDataTypes(mixed $value = null, bool $minify = fa return "(unknown type)"; } -} \ No newline at end of file +} diff --git a/src/Support/Performance.php b/src/Support/Performance.php index cd87c9c..01a4280 100755 --- a/src/Support/Performance.php +++ b/src/Support/Performance.php @@ -1,4 +1,5 @@ controllers[$this->needle])) { + if (isset($this->controllers[$this->needle])) { $call($this->controllers[$this->needle], $this->args, $this->needle); return true; } @@ -71,4 +72,4 @@ function dispatch(callable $call): bool } return false; } -} \ No newline at end of file +} diff --git a/src/Support/TestUtils/CodeCoverage.php b/src/Support/TestUtils/CodeCoverage.php index bc5bf79..7ae94b0 100644 --- a/src/Support/TestUtils/CodeCoverage.php +++ b/src/Support/TestUtils/CodeCoverage.php @@ -1,4 +1,5 @@ hasIssue()) { + if ($this->hasIssue()) { return false; } if (!function_exists('xdebug_info')) { @@ -69,7 +70,7 @@ public function hasXdebug(): bool */ public function hasXdebugCoverage(): bool { - if(!$this->hasXdebug()) { + if (!$this->hasXdebug()) { return false; } $mode = ini_get('xdebug.mode'); @@ -109,7 +110,7 @@ public function exclude(array $exclude, bool $reset = false): void public function start(): void { $this->data = []; - if($this->hasXdebugCoverage()) { + if ($this->hasXdebugCoverage()) { \xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE); } } @@ -124,10 +125,10 @@ public function start(): void */ public function end(): void { - if($this->data === null) { + if ($this->data === null) { throw new BadMethodCallException("You must start code coverage before you can end it"); } - if($this->hasXdebugCoverage()) { + if ($this->hasXdebugCoverage()) { $this->data = \xdebug_get_code_coverage(); \xdebug_stop_code_coverage(); @@ -166,7 +167,7 @@ protected function excludePattern(string $file): bool */ public function getResponse(): array|false { - if($this->hasIssue()) { + if ($this->hasIssue()) { return false; } @@ -178,7 +179,9 @@ public function getResponse(): array|false } foreach ($lines as $line => $status) { - if ($status === -2) continue; + if ($status === -2) { + continue; + } $totalLines++; if ($status === 1) { $executedLines++; @@ -220,4 +223,4 @@ public function hasIssue(): bool { return $this->coverageIssue !== CoverageIssue::None; } -} \ No newline at end of file +} diff --git a/src/Support/TestUtils/DataTypeMock.php b/src/Support/TestUtils/DataTypeMock.php index e12594d..e69fecc 100644 --- a/src/Support/TestUtils/DataTypeMock.php +++ b/src/Support/TestUtils/DataTypeMock.php @@ -1,4 +1,5 @@ ['item1', 'item2', 'item3'], 'object' => (object)['item1' => 'value1', 'item2' => 'value2', 'item3' => 'value3'], 'resource' => "fopen('php://memory', 'r+')", - 'callable' => fn() => 'called', + 'callable' => fn () => 'called', 'iterable' => new ArrayIterator(['a', 'b']), 'null' => null, ], $this->defaultArguments); } - + /** * Exports a value to a parsable string representation * @@ -78,9 +78,9 @@ public function getMockValues(): array public static function exportValue(mixed $value): string { return var_export($value, true); - + } - + /** * Creates a new instance with merged default and custom arguments. * Handles resource type arguments separately by converting them to string content. @@ -91,7 +91,7 @@ public static function exportValue(mixed $value): string public function withCustomDefaults(array $dataTypeArgs): self { $inst = clone $this; - foreach($dataTypeArgs as $key => $value) { + foreach ($dataTypeArgs as $key => $value) { $inst = $this->withCustomDefault($key, $value); } return $inst; @@ -109,7 +109,7 @@ public function withCustomDefaults(array $dataTypeArgs): self public function withCustomDefault(string $dataType, mixed $value): self { $inst = clone $this; - if(isset($value) && is_resource($value)) { + if (isset($value) && is_resource($value)) { $value = $this->handleResourceContent($value); } $inst->defaultArguments[$dataType] = $value; @@ -129,13 +129,13 @@ public function withCustomBoundDefault(string $key, string $dataType, mixed $val { $inst = clone $this; $tempInst = $this->withCustomDefault($dataType, $value); - if($inst->bindArguments === null) { + if ($inst->bindArguments === null) { $inst->bindArguments = []; } $inst->bindArguments[$key][$dataType] = $tempInst->defaultArguments[$dataType]; return $inst; } - + /** * Converts default argument values to their string representations * using var_export for each value in the default arguments array @@ -144,7 +144,7 @@ public function withCustomBoundDefault(string $key, string $dataType, mixed $val */ public function getDataTypeListToString(): array { - return array_map(fn($value) => self::exportValue($value), $this->getMockValues()); + return array_map(fn ($value) => self::exportValue($value), $this->getMockValues()); } /** @@ -157,21 +157,21 @@ public function getDataTypeListToString(): array */ public function getDataTypeValue(string $dataType, ?string $bindKey = null): string { - if(is_string($bindKey) && isset($this->bindArguments[$bindKey][$dataType])) { + if (is_string($bindKey) && isset($this->bindArguments[$bindKey][$dataType])) { return self::exportValue($this->bindArguments[$bindKey][$dataType]); } - if($this->types === null) { + if ($this->types === null) { $this->types = $this->getDataTypeListToString(); } - if(!isset($this->types[$dataType])) { + if (!isset($this->types[$dataType])) { throw new InvalidArgumentException("Invalid data type: $dataType"); } return (string)$this->types[$dataType]; - + } - + /** * Will return a streamable content * @@ -185,4 +185,4 @@ public function handleResourceContent(mixed $resourceValue): ?string } return var_export(stream_get_contents($resourceValue), true); } -} \ No newline at end of file +} diff --git a/src/Support/TestUtils/ExecutionWrapper.php b/src/Support/TestUtils/ExecutionWrapper.php index db3dff8..0fc11ea 100755 --- a/src/Support/TestUtils/ExecutionWrapper.php +++ b/src/Support/TestUtils/ExecutionWrapper.php @@ -1,4 +1,5 @@ bindTo($this->instance); - if(!is_callable($closure)) { + if (!is_callable($closure)) { throw new Exception("Closure is not callable."); } return $closure; diff --git a/src/Support/functions.php b/src/Support/functions.php index 11d60e8..06942af 100644 --- a/src/Support/functions.php +++ b/src/Support/functions.php @@ -1,4 +1,5 @@ hasAssertError = true; } @@ -97,7 +98,7 @@ function setHasAssertError(): void * * @return bool */ - function getHasAssertError(): bool + public function getHasAssertError(): bool { return $this->hasAssertError; } @@ -132,7 +133,7 @@ public function warning(string $message): self */ public function error(?string $message): self { - if($message !== null) { + if ($message !== null) { $this->error = $message; } return $this; @@ -164,10 +165,14 @@ public function dispatchTest(self &$row): array $newInst->setHasAssertError(); $msg = "Assertion failed"; $newInst->expectAndValidate( - true, fn() => false, $msg, $e->getMessage(), $e->getTrace()[0] + true, + fn () => false, + $msg, + $e->getMessage(), + $e->getTrace()[0] ); } catch (Throwable $e) { - if(str_contains($e->getFile(), "eval()")) { + if (str_contains($e->getFile(), "eval()")) { throw new BlunderErrorException($e->getMessage(), (int)$e->getCode()); } throw $e; @@ -242,7 +247,7 @@ protected function expectAndValidate( foreach ($listArr as $list) { - if(is_bool($list)) { + if (is_bool($list)) { $item = new TestItem(); $item = $item->setIsValid($list)->setValidation("Validation"); $test->setTestItem($item); @@ -251,7 +256,7 @@ protected function expectAndValidate( $item = new TestItem(); /** @var array|bool $valid */ $item = $item->setIsValid(false)->setValidation((string)$method); - if(is_array($valid)) { + if (is_array($valid)) { $item = $item->setValidationArgs($valid); } else { $item = $item->setHasArgs(false); @@ -278,7 +283,7 @@ protected function expectAndValidate( } } if (!$test->isValid()) { - if($trace === null || $trace === []) { + if ($trace === null || $trace === []) { $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; } @@ -365,7 +370,7 @@ public function withMock(string $class, array $args = []): self */ public function buildMock(?Closure $validate = null): mixed { - if(!($this->mocker instanceof MockBuilder)) { + if (!($this->mocker instanceof MockBuilder)) { throw new BadMethodCallException("The mocker is not set yet!"); } if ($validate instanceof Closure) { @@ -373,9 +378,9 @@ public function buildMock(?Closure $validate = null): mixed } /** @psalm-suppress MixedReturnStatement */ $class = $this->mocker->execute(); - if($this->mocker->hasFinal() && isset($pool)) { + if ($this->mocker->hasFinal() && isset($pool)) { $finalMethods = $pool->getSelected($this->mocker->getFinalMethods()); - if($finalMethods !== []) { + if ($finalMethods !== []) { $this->warning = "Warning: Final methods cannot be mocked or have their behavior modified: " . implode(", ", $finalMethods); } } @@ -403,7 +408,7 @@ public function mock(string $class, ?Closure $validate = null, array $args = []) public function getMocker(): MockBuilder { - if(!($this->mocker instanceof MockBuilder)) { + if (!($this->mocker instanceof MockBuilder)) { throw new BadMethodCallException("The mocker is not set yet!"); } return $this->mocker; @@ -486,7 +491,7 @@ private function validateRow(object $row, MethodRegistry $pool): array continue; } - if(!property_exists($row, $property)) { + if (!property_exists($row, $property)) { throw new ErrorException( "The mock method meta data property name '$property' is undefined in mock object. " . "To resolve this either use MockController::buildMethodData() to add the property dynamically " . @@ -495,7 +500,7 @@ private function validateRow(object $row, MethodRegistry $pool): array } $currentValue = $row->{$property}; - if(!in_array($property, self::EXCLUDE_VALIDATE)) { + if (!in_array($property, self::EXCLUDE_VALIDATE)) { if (is_array($value)) { $validPool = $this->validateArrayValue($value, $currentValue); $valid = $validPool->isValid(); @@ -618,7 +623,7 @@ public function runDeferredValidations(): array foreach ($arr as $data) { // We do not want to validate the return here automatically /** @var TestItem $data */ - if(!in_array($data->getValidation(), self::EXCLUDE_VALIDATE)) { + if (!in_array($data->getValidation(), self::EXCLUDE_VALIDATE)) { $test->setTestItem($data); if (!isset($hasValidated[$method]) && !$data->isValid()) { $hasValidated[$method] = true; @@ -737,11 +742,11 @@ protected function buildClosureTest(Closure $validation, Expect $validPool, ?str } $error = $validPool->getError(); - if($bool === false && $message !== null) { + if ($bool === false && $message !== null) { $error[] = [ $message => true ]; - } else if (is_bool($bool) && !$bool) { + } elseif (is_bool($bool) && !$bool) { $error['customError'] = false; } } diff --git a/src/TestEmitter.php b/src/TestEmitter.php index c73c78b..596d807 100644 --- a/src/TestEmitter.php +++ b/src/TestEmitter.php @@ -1,4 +1,5 @@ args['verbose'] ?? false); - if(!is_file($file)) { + if (!is_file($file)) { throw new RuntimeException("The test file \"$file\" do not exists."); } @@ -50,7 +51,7 @@ public function emit(string $file): void $hasExecutedTest = $this->unit->execute(); - if(!$hasExecutedTest && $verbose) { + if (!$hasExecutedTest && $verbose) { trigger_error( "Could not find any tests inside the test file:\n" . $file . "\n\n" . @@ -82,4 +83,4 @@ protected function runBlunder(AbstractHandlerInterface $errorHandler): void $run->setExitCode(1); $run->load(); } -} \ No newline at end of file +} diff --git a/src/TestItem.php b/src/TestItem.php index 230620f..fc1ae16 100755 --- a/src/TestItem.php +++ b/src/TestItem.php @@ -1,4 +1,5 @@ hasArgs) { + if ($this->hasArgs) { $args = array_map(fn ($value) => Helpers::stringifyArgs($value), $this->args); return "(" . implode(", ", $args) . ")"; } diff --git a/src/TestUnit.php b/src/TestUnit.php index d1518b7..97f8266 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -1,4 +1,5 @@ hasSubject()) { + if ($config !== null && !$config->hasSubject()) { $addMessage = ($message instanceof TestConfig && $message->hasSubject()) ? $message->message : $message; $message = $config->withSubject($addMessage); } @@ -228,7 +229,7 @@ public function execute(): bool ob_start(); //$countCases = count($this->cases); $handler = $this->handler; - if(count($this->cases) === 0) { + if (count($this->cases) === 0) { return false; } @@ -242,7 +243,7 @@ public function execute(): bool $checksum = $fileChecksum . $index; $show = ($row->getConfig()->select === $this->show || $this->show === $checksum); - if(($this->show !== null) && !$show) { + if (($this->show !== null) && !$show) { continue; } // Success, no need to try to show errors, continue with the next test @@ -340,4 +341,3 @@ public function addTitle(): self return $this; } } - diff --git a/unitary.config.php b/unitary.config.php index 82548c7..758a7ce 100644 --- a/unitary.config.php +++ b/unitary.config.php @@ -1,4 +1,5 @@ false, 'alwaysShowFiles' => false, //'exit_error_code' => 1, ?? -]; \ No newline at end of file +]; From e54cf01353075524eb50dd8a2d1cca666082c3d4 Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Thu, 21 Aug 2025 15:10:39 +0200 Subject: [PATCH 69/78] fix: add track errors and exceptions --- src/Config/ConfigProps.php | 4 + src/Console/ConsoleRouter.php | 1 + src/Console/Controllers/RunTestController.php | 21 +- src/Console/Services/RunTestService.php | 2 + src/Discovery/TestDiscovery.php | 53 +++-- src/Interfaces/BodyInterface.php | 3 + src/Renders/AbstractRenderHandler.php | 3 +- src/Renders/CliRenderer.php | 6 +- src/Renders/JUnitRenderer.php | 190 ++++++++++++++++++ src/TestCase.php | 56 +++++- src/Unit.php | 48 +++++ tests/unitary-unitary.php | 6 +- unitary.config.php | 1 + 13 files changed, 377 insertions(+), 17 deletions(-) create mode 100644 src/Renders/JUnitRenderer.php diff --git a/src/Config/ConfigProps.php b/src/Config/ConfigProps.php index b052010..a1877c4 100644 --- a/src/Config/ConfigProps.php +++ b/src/Config/ConfigProps.php @@ -27,6 +27,7 @@ class ConfigProps extends AbstractConfigProps public ?bool $alwaysShowFiles = null; public ?bool $errorsOnly = null; public ?bool $smartSearch = null; + public ?bool $failFast = null; /** * Hydrate the properties/object with expected data, and handle unexpected data @@ -65,6 +66,9 @@ protected function propsHydration(string $key, mixed $value): void case 'errorsOnly': $this->errorsOnly = $this->dataToBool($value); break; + case 'failFast': + $this->failFast = $this->dataToBool($value); + break; } } diff --git a/src/Console/ConsoleRouter.php b/src/Console/ConsoleRouter.php index 556918f..88abb34 100644 --- a/src/Console/ConsoleRouter.php +++ b/src/Console/ConsoleRouter.php @@ -6,5 +6,6 @@ $router->map("coverage", [CoverageController::class, "run"]); $router->map("template", [TemplateController::class, "run"]); +$router->map("junit", [RunTestController::class, "runJUnit"]); $router->map(["", "test", "run"], [RunTestController::class, "run"]); $router->map(["__404", "help"], [RunTestController::class, "help"]); diff --git a/src/Console/Controllers/RunTestController.php b/src/Console/Controllers/RunTestController.php index 09e95d7..a5fa822 100644 --- a/src/Console/Controllers/RunTestController.php +++ b/src/Console/Controllers/RunTestController.php @@ -7,6 +7,7 @@ use MaplePHP\Unitary\Console\Services\RunTestService; use MaplePHP\Http\Interfaces\ResponseInterface; use MaplePHP\Prompts\Themes\Blocks; +use MaplePHP\Unitary\Renders\JUnitRenderer; class RunTestController extends DefaultController { @@ -21,6 +22,23 @@ public function run(RunTestService $service): ResponseInterface return $response; } + /** + * Main test runner + */ + public function runJUnit(RunTestService $service): ResponseInterface + { + $xml = new \XMLWriter(); + $xml->openMemory(); + $xml->startDocument('1.0', 'UTF-8'); + + $xml->startElement('testsuites'); + + $handler = new JUnitRenderer($xml); + $response = $service->run($handler); + $this->buildFooter(); + return $response; + } + /** * Main help page * @@ -85,7 +103,8 @@ protected function buildFooter(): void $this->command->message( $this->command->getAnsi()->style( ["italic", "grey"], - "Total: " . $inst->getPassedTests() . "/" . $inst->getTotalTests() . " $dot " . + "Tests: " . $inst::getPassedTests() . "/" . $inst::getTotalTests() . " $dot " . + "Errors: " . $inst::getTotalErrors() . " $dot " . "Peak memory usage: " . $peakMemory . " KB" ) ); diff --git a/src/Console/Services/RunTestService.php b/src/Console/Services/RunTestService.php index cc02f80..ecc97d4 100644 --- a/src/Console/Services/RunTestService.php +++ b/src/Console/Services/RunTestService.php @@ -17,6 +17,7 @@ public function run(BodyInterface $handler): ResponseInterface { $iterator = new TestDiscovery(); $iterator->enableVerbose($this->props->verbose); + $iterator->enableFailFast($this->props->failFast); $iterator->enableSmartSearch($this->props->smartSearch); $iterator->addExcludePaths($this->props->exclude); $iterator->setDiscoverPattern($this->props->discoverPattern); @@ -55,6 +56,7 @@ private function iterateTest(TestDiscovery $iterator, BodyInterface $handler): T $iterator->executeAll($testDir, $defaultPath, function ($file) use ($handler) { $unit = new Unit($handler); $unit->setShowErrorsOnly($this->props->errorsOnly); + $unit->setFailFast($this->props->failFast); $unit->setShow($this->props->show); $unit->setFile($file); $unit->setVerbose($this->props->verbose); diff --git a/src/Discovery/TestDiscovery.php b/src/Discovery/TestDiscovery.php index 9eae20e..2be0c4b 100755 --- a/src/Discovery/TestDiscovery.php +++ b/src/Discovery/TestDiscovery.php @@ -14,6 +14,7 @@ use Closure; use ErrorException; +use MaplePHP\Blunder\ExceptionItem; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use RuntimeException; @@ -30,6 +31,7 @@ final class TestDiscovery { private string $pattern = '*/unitary-*.php'; private bool $verbose = false; + private bool $failFast = false; private bool $smartSearch = false; private ?array $exclude = null; private static ?Unit $unitary = null; @@ -46,6 +48,18 @@ public function enableVerbose(bool $isVerbose): self return $this; } + /** + * Enable verbose flag which will show errors that should not always be visible + * + * @param bool $failFast + * @return $this + */ + public function enableFailFast(bool $failFast): self + { + $this->failFast = $failFast; + return $this; + } + /** * Enabling smart search; If no tests I found Unitary will try to traverse * backwards until a test is found @@ -135,7 +149,31 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable "\" and its subdirectories."); } else { foreach ($files as $file) { - $this->executeUnitFile((string)$file, $callback); + try { + if (!is_file($file)) { + throw new RuntimeException("File \"$file\" do not exists."); + } + + $instance = $callback($file); + if (!$instance instanceof Unit) { + throw new UnexpectedValueException('Callable must return ' . Unit::class); + } + self::$unitary = $instance; + + $this->executeUnitFile((string)$file); + + } catch (\Throwable $exception) { + + if ($this->failFast) { + throw $exception; + } + + $exceptionItem = new ExceptionItem($exception); + $cliErrorHandler = new CliHandler(); + self::getUnitaryInst()->getBody()->write($cliErrorHandler->getErrorMessage($exceptionItem)); + self::getUnitaryInst()::incrementErrors(); + } + } } } @@ -153,18 +191,9 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable * @throws BlunderErrorException * @throws Throwable */ - private function executeUnitFile(string $file, Closure $callback): void + private function executeUnitFile(string $file): void { $verbose = $this->verbose; - if (!is_file($file)) { - throw new RuntimeException("File \"$file\" do not exists."); - } - - $instance = $callback($file); - if (!$instance instanceof Unit) { - throw new UnexpectedValueException('Callable must return ' . Unit::class); - } - self::$unitary = $instance; $unitInst = $this->isolateRequire($file); @@ -318,7 +347,7 @@ protected function runBlunder(): void { $run = new Run(new CliHandler()); $run->severity() - ->excludeSeverityLevels([E_USER_WARNING]) + ->excludeSeverityLevels([E_USER_WARNING, E_NOTICE, E_USER_NOTICE, E_DEPRECATED, E_USER_DEPRECATED]) ->redirectTo(function () { // Let PHP’s default error handler process excluded severities return false; diff --git a/src/Interfaces/BodyInterface.php b/src/Interfaces/BodyInterface.php index cecd57d..44db603 100644 --- a/src/Interfaces/BodyInterface.php +++ b/src/Interfaces/BodyInterface.php @@ -92,6 +92,9 @@ public function buildNotes(): void; /** * Must return a valid PSR Stream * + * IMPORTANT: For everything to work correctly it should return a active + * instance of a PSR stream, instead of returning a new instance. + * * @return StreamInterface */ public function getBody(): StreamInterface; diff --git a/src/Renders/AbstractRenderHandler.php b/src/Renders/AbstractRenderHandler.php index b0a046e..eb45d1b 100644 --- a/src/Renders/AbstractRenderHandler.php +++ b/src/Renders/AbstractRenderHandler.php @@ -18,6 +18,7 @@ class AbstractRenderHandler implements BodyInterface protected bool $alwaysShowFiles = false; protected array $tests; protected string $outputBuffer = ""; + protected StreamInterface $body; /** * {@inheritDoc} @@ -107,7 +108,7 @@ public function buildNotes(): void */ public function getBody(): StreamInterface { - return new Stream(); + return $this->body; } /** diff --git a/src/Renders/CliRenderer.php b/src/Renders/CliRenderer.php index f2d42cd..b7d9376 100644 --- a/src/Renders/CliRenderer.php +++ b/src/Renders/CliRenderer.php @@ -3,10 +3,12 @@ namespace MaplePHP\Unitary\Renders; use ErrorException; +use MaplePHP\Http\Interfaces\StreamInterface; use MaplePHP\Prompts\Command; use MaplePHP\Unitary\TestItem; use MaplePHP\Unitary\TestUnit; use MaplePHP\Unitary\Support\Helpers; +use MaplePHP\Unitary\Unit; use RuntimeException; class CliRenderer extends AbstractRenderHandler @@ -23,6 +25,8 @@ class CliRenderer extends AbstractRenderHandler public function __construct(Command $command) { $this->command = $command; + // Pass the active stream to `AbstractRenderHandler::getBody()` + $this->body = $this->command->getStream(); } /** @@ -96,7 +100,7 @@ protected function showFooter(): void $this->command->message(""); $passed = $this->command->getAnsi()->bold("Passed: "); - if ($this->case->getHasAssertError()) { + if ($this->case->getHasAssertError() || $this->case->getHasError()) { $passed .= $this->command->getAnsi()->style(["grey"], "N/A"); } else { $passed .= $this->command->getAnsi()->style([$this->color], $this->case->getCount() . "/" . $this->case->getTotal()); diff --git a/src/Renders/JUnitRenderer.php b/src/Renders/JUnitRenderer.php new file mode 100644 index 0000000..8501712 --- /dev/null +++ b/src/Renders/JUnitRenderer.php @@ -0,0 +1,190 @@ +xml = $xml; + } + + /** + * {@inheritDoc} + * @throws ErrorException + */ + public function buildBody(): void + { + + + $this->xml->startElement('testsuite'); + + $this->xml->writeAttribute('name', $this->formatFileTitle($this->suitName) . " - " . (string)$this->case->getMessage()); + $this->xml->writeAttribute('tests', (string)$this->case->getCount()); + $this->xml->writeAttribute('failures', (string)$this->case->getFailedCount()); + + var_dump($this->case->getCount()); + var_dump($this->case->getFailedCount()); + die; + if (($this->show || !$this->case->getConfig()->skip)) { + // Show possible warnings + /* + if ($this->case->getWarning()) { + $this->xml->message(""); + $this->xml->message( + $this->xml->getAnsi()->style(["italic", "yellow"], $this->case->getWarning()) + ); + } + */ + + // Show Failed tests + $this->showFailedTests(); + } + + $this->showFooter(); + } + + /** + * {@inheritDoc} + */ + public function buildNotes(): void + { + if ($this->outputBuffer) { + $lineLength = 80; + $output = wordwrap($this->outputBuffer, $lineLength); + $line = $this->xml->getAnsi()->line($lineLength); + + $this->xml->message(""); + $this->xml->message($this->xml->getAnsi()->style(["bold"], "Note:")); + $this->xml->message($line); + $this->xml->message($output); + $this->xml->message($line); + } + } + + /** + * Footer template part + * + * @return void + */ + protected function showFooter(): void + { + $select = $this->checksum; + if ($this->case->getConfig()->select) { + $select .= " (" . $this->case->getConfig()->select . ")"; + } + $this->xml->message(""); + + $passed = $this->xml->getAnsi()->bold("Passed: "); + if ($this->case->getHasAssertError()) { + $passed .= $this->xml->getAnsi()->style(["grey"], "N/A"); + } else { + $passed .= $this->xml->getAnsi()->style([$this->color], $this->case->getCount() . "/" . $this->case->getTotal()); + } + + $footer = $passed . + $this->xml->getAnsi()->style(["italic", "grey"], " - ". $select); + if (!$this->show && $this->case->getConfig()->skip) { + $footer = $this->xml->getAnsi()->style(["italic", "grey"], $select); + } + $this->xml->message($footer); + $this->xml->message(""); + + } + + /** + * Failed tests template part + * + * @return void + * @throws ErrorException + */ + protected function showFailedTests(): void + { + if (($this->show || !$this->case->getConfig()->skip)) { + foreach ($this->tests as $test) { + + if (!($test instanceof TestUnit)) { + throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); + } + + if (!$test->isValid()) { + $msg = (string)$test->getMessage(); + $this->xml->message(""); + $this->xml->message( + $this->xml->getAnsi()->style(["bold", $this->color], "Error: ") . + $this->xml->getAnsi()->bold($msg) + ); + $this->xml->message(""); + + $trace = $test->getCodeLine(); + if (!empty($trace['code'])) { + $this->xml->message($this->xml->getAnsi()->style(["bold", "grey"], "Failed on {$trace['file']}:{$trace['line']}")); + $this->xml->message($this->xml->getAnsi()->style(["grey"], " → {$trace['code']}")); + } + + foreach ($test->getUnits() as $unit) { + + /** @var TestItem $unit */ + if (!$unit->isValid()) { + $lengthA = $test->getValidationLength(); + $validation = $unit->getValidationTitle(); + $title = str_pad($validation, $lengthA); + $compare = $unit->hasComparison() ? $unit->getComparison() : ""; + + $failedMsg = " " .$title . " → failed"; + $this->xml->message($this->xml->getAnsi()->style($this->color, $failedMsg)); + + if ($compare) { + $lengthB = (strlen($compare) + strlen($failedMsg) - 8); + $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); + $this->xml->message( + $this->xml->getAnsi()->style($this->color, $comparePad) + ); + } + } + } + if ($test->hasValue()) { + $this->xml->message(""); + $this->xml->message( + $this->xml->getAnsi()->bold("Input value: ") . + Helpers::stringifyDataTypes($test->getValue()) + ); + } + } + } + } + } + + /** + * Init some default styled object + * + * @return void + */ + protected function initDefault(): void + { + $this->color = ($this->case->hasFailed() ? "brightRed" : "brightBlue"); + $this->flag = $this->xml->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); + if ($this->case->hasFailed()) { + $this->flag = $this->xml->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); + } + if ($this->case->getConfig()->skip) { + $this->color = "yellow"; + $this->flag = $this->xml->getAnsi()->style(['yellowBg', 'black'], " SKIP "); + } + } +} diff --git a/src/TestCase.php b/src/TestCase.php index 9dea115..b5c1721 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -17,7 +17,9 @@ use Closure; use ErrorException; use Exception; +use MaplePHP\Blunder\ExceptionItem; use MaplePHP\Blunder\Exceptions\BlunderErrorException; +use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\DTO\Format\Str; use MaplePHP\DTO\Traverse; use MaplePHP\Unitary\Config\TestConfig; @@ -54,7 +56,9 @@ final class TestCase private array $deferredValidation = []; private ?MockBuilder $mocker = null; + private bool $hasError = false; private bool $hasAssertError = false; + private bool $failFast = false; /** * Initialize a new TestCase instance with an optional message. @@ -70,6 +74,18 @@ public function __construct(TestConfig|string|null $config = null) } } + /** + * Will exit script if errors is thrown + * + * @param bool $failFast + * @return $this + */ + public function setFailFast(bool $failFast): self + { + $this->failFast = $failFast; + return $this; + } + /** * Bind the test case to the Closure * @@ -83,6 +99,26 @@ public function bind(Closure $bind, bool $bindToClosure = false): void $this->bind = ($bindToClosure) ? $bind->bindTo($this) : $bind; } + /** + * Sets the error flag to true + * + * @return void + */ + public function setHasError(): void + { + $this->hasError = true; + } + + /** + * Gets the current state of the error flag + * + * @return bool + */ + public function getHasError(): bool + { + return $this->hasError; + } + /** * Sets the assertion error flag to true * @@ -157,6 +193,7 @@ public function dispatchTest(self &$row): array { $row = $this; $test = $this->bind; + $newInst = null; if ($test !== null) { try { $newInst = $test($this); @@ -175,7 +212,24 @@ public function dispatchTest(self &$row): array if (str_contains($e->getFile(), "eval()")) { throw new BlunderErrorException($e->getMessage(), (int)$e->getCode()); } - throw $e; + if($this->failFast) { + throw $e; + } + + $exceptionItem = new ExceptionItem($e); + $cliErrorHandler = new CliHandler(); + + $newInst = clone $this; + $newInst->setHasError(); + $msg = "PHP " . $exceptionItem->getSeverityTitle(); + $newInst->expectAndValidate( + true, + fn () => false, + $msg, + $cliErrorHandler->getSmallErrorMessage($exceptionItem), + $e->getTrace()[0] + ); + } if ($newInst instanceof self) { $row = $newInst; diff --git a/src/Unit.php b/src/Unit.php index e411c60..3510373 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -16,6 +16,7 @@ use Closure; use ErrorException; use MaplePHP\Blunder\Exceptions\BlunderErrorException; +use MaplePHP\Http\Interfaces\StreamInterface; use MaplePHP\Prompts\Command; use MaplePHP\Unitary\Config\TestConfig; use MaplePHP\Unitary\Renders\CliRenderer; @@ -32,11 +33,13 @@ final class Unit private bool $executed = false; private string $file = ""; private bool $showErrorsOnly = false; + private bool $failFast = false; private ?string $show = null; private bool $verbose = false; private bool $alwaysShowFiles = false; private static int $totalPassedTests = 0; private static int $totalTests = 0; + private static int $totalErrors = 0; /** * Initialize Unit test instance with optional handler @@ -48,6 +51,11 @@ public function __construct(BodyInterface|null $handler = null) $this->handler = ($handler === null) ? new CliRenderer(new Command()) : $handler; } + public function getBody(): StreamInterface + { + return $this->handler->getBody(); + } + /** * Will pass a test file name to script used to: * - Allocate tests @@ -62,6 +70,18 @@ public function setFile(string $file): Unit return $this; } + /** + * Will exit script if errors is thrown + * + * @param bool $failFast + * @return $this + */ + public function setFailFast(bool $failFast): Unit + { + $this->failFast = $failFast; + return $this; + } + /** * Will only display error and hide passed tests * @@ -120,6 +140,7 @@ public function inheritConfigs(Unit $inst): Unit $this->setFile($inst->file); $this->setShow($inst->show); $this->setShowErrorsOnly($inst->showErrorsOnly); + $this->setFailFast($inst->failFast); $this->setVerbose($inst->verbose); $this->setAlwaysShowFiles($inst->alwaysShowFiles); return $this; @@ -155,6 +176,28 @@ public static function getTotalTests(): int return self::$totalTests; } + /** + * Get the total number of error + * + * NOTE: That an error is a PHP failure or a exception that has been thrown. + * + * @return int + */ + public static function getTotalErrors(): int + { + return self::$totalErrors; + } + + /** + * Increment error count + * + * @return void + */ + public static function incrementErrors(): void + { + self::$totalErrors++; + } + /** * This will disable "ALL" tests in the test file * If you want to skip a specific test, use the TestConfig class instead @@ -259,6 +302,10 @@ public function execute(): bool $handler->setAlwaysShowFiles($this->alwaysShowFiles); $handler->buildBody(); + if($row->getHasError()) { + self::incrementErrors(); + } + // Important to add test from skip as successfully count to make sure that // the total passed tests are correct, and it will not exit with code 1 self::$totalPassedTests += ($row->getConfig()->skip) ? $row->getTotal() : $row->getCount(); @@ -307,6 +354,7 @@ public function assert(): self protected function addCase(string|TestConfig $message, Closure $expect, bool $bindToClosure = false): void { $testCase = new TestCase($message); + $testCase->setFailFast($this->failFast); $testCase->bind($expect, $bindToClosure); $this->cases[$this->index] = $testCase; $this->index++; diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 70e17f7..69e7880 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -13,15 +13,17 @@ $config = TestConfig::make()->withName("unitary"); +echo $qwdqewdwq; + group($config->withSubject("Test mocker"), function (TestCase $case) { + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") ->withArguments("john.doe@gmail.com", "John Doe") ->called(2); }); - $mail->addFromEmail("john.doe@gmail.com", "John Doe"); }); @@ -31,6 +33,8 @@ }); group($config->withSubject("Can not mock final or private"), function(TestCase $case) { + + $user = $case->mock(UserService::class, function(MethodRegistry $method) { $method->method("getUserRole")->willReturn("admin"); $method->method("getUserType")->willReturn("admin"); diff --git a/unitary.config.php b/unitary.config.php index 758a7ce..f7729f6 100644 --- a/unitary.config.php +++ b/unitary.config.php @@ -13,5 +13,6 @@ 'discoverPattern' => false, // string|false (paths (`tests/`) and files (`unitary-*.php`).) 'show' => false, 'alwaysShowFiles' => false, + 'failFast' => false, //'exit_error_code' => 1, ?? ]; From fc09bf579ea13c02dde013d9a0fbb799eacfc452 Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Mon, 25 Aug 2025 19:20:25 +0200 Subject: [PATCH 70/78] fix: error and exception management --- src/Console/Controllers/RunTestController.php | 2 +- src/Discovery/TestDiscovery.php | 16 +++--- src/Renders/CliRenderer.php | 6 ++- src/Support/Helpers.php | 16 ++++++ src/TestCase.php | 53 ++++++++++--------- tests/unitary-unitary.php | 7 --- unitary.config.php | 2 +- 7 files changed, 58 insertions(+), 44 deletions(-) diff --git a/src/Console/Controllers/RunTestController.php b/src/Console/Controllers/RunTestController.php index a5fa822..fff7ea8 100644 --- a/src/Console/Controllers/RunTestController.php +++ b/src/Console/Controllers/RunTestController.php @@ -103,7 +103,7 @@ protected function buildFooter(): void $this->command->message( $this->command->getAnsi()->style( ["italic", "grey"], - "Tests: " . $inst::getPassedTests() . "/" . $inst::getTotalTests() . " $dot " . + "Total tests: " . $inst::getPassedTests() . "/" . $inst::getTotalTests() . " $dot " . "Errors: " . $inst::getTotalErrors() . " $dot " . "Peak memory usage: " . $peakMemory . " KB" ) diff --git a/src/Discovery/TestDiscovery.php b/src/Discovery/TestDiscovery.php index 2be0c4b..9d181a3 100755 --- a/src/Discovery/TestDiscovery.php +++ b/src/Discovery/TestDiscovery.php @@ -15,6 +15,8 @@ use Closure; use ErrorException; use MaplePHP\Blunder\ExceptionItem; +use MaplePHP\Unitary\Support\Helpers; +use MaplePHP\Unitary\TestCase; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use RuntimeException; @@ -153,25 +155,23 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable if (!is_file($file)) { throw new RuntimeException("File \"$file\" do not exists."); } - $instance = $callback($file); if (!$instance instanceof Unit) { throw new UnexpectedValueException('Callable must return ' . Unit::class); } self::$unitary = $instance; - $this->executeUnitFile((string)$file); } catch (\Throwable $exception) { - if ($this->failFast) { throw $exception; } - - $exceptionItem = new ExceptionItem($exception); - $cliErrorHandler = new CliHandler(); - self::getUnitaryInst()->getBody()->write($cliErrorHandler->getErrorMessage($exceptionItem)); - self::getUnitaryInst()::incrementErrors(); + self::$unitary->group("PHP error", function (TestCase $case) use ($exception) { + $newInst = $case->createTraceError($exception); + $newInst->setHasError(); + return $newInst; + }); + self::$unitary->execute(); } } diff --git a/src/Renders/CliRenderer.php b/src/Renders/CliRenderer.php index b7d9376..a4ded2b 100644 --- a/src/Renders/CliRenderer.php +++ b/src/Renders/CliRenderer.php @@ -193,7 +193,11 @@ protected function initDefault(): void } if ($this->case->getConfig()->skip) { $this->color = "yellow"; - $this->flag = $this->command->getAnsi()->style(['yellowBg', 'black'], " SKIP "); + $this->flag = $this->command->getAnsi()->style(['brightYellowBg', 'black'], " SKIP "); + } + + if ($this->case->getHasError()) { + $this->flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " ERROR "); } } } diff --git a/src/Support/Helpers.php b/src/Support/Helpers.php index c487800..7ff6e7a 100644 --- a/src/Support/Helpers.php +++ b/src/Support/Helpers.php @@ -14,10 +14,26 @@ use ErrorException; use Exception; +use MaplePHP\Blunder\ExceptionItem; +use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\DTO\Format\Str; final class Helpers { + /** + * Get a pretty exception message from a Throwable instance + * + * @param \Throwable $exception + * @param ExceptionItem|null $exceptionItem Use ExceptionItem to get more options + * @return string + */ + public static function getExceptionMessage(\Throwable $exception, ?ExceptionItem &$exceptionItem = null): string + { + $exceptionItem = new ExceptionItem($exception); + $cliErrorHandler = new CliHandler(); + return $cliErrorHandler->getSmallErrorMessage($exceptionItem); + } + /** * Used to stringify arguments to show in a test * diff --git a/src/TestCase.php b/src/TestCase.php index b5c1721..4278fd9 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -17,15 +17,14 @@ use Closure; use ErrorException; use Exception; -use MaplePHP\Blunder\ExceptionItem; use MaplePHP\Blunder\Exceptions\BlunderErrorException; -use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\DTO\Format\Str; use MaplePHP\DTO\Traverse; use MaplePHP\Unitary\Config\TestConfig; use MaplePHP\Unitary\Mocker\MethodRegistry; use MaplePHP\Unitary\Mocker\MockBuilder; use MaplePHP\Unitary\Mocker\MockController; +use MaplePHP\Unitary\Support\Helpers; use MaplePHP\Unitary\Support\TestUtils\ExecutionWrapper; use MaplePHP\Validate\Validator; use ReflectionClass; @@ -54,7 +53,6 @@ final class TestCase private ?string $error = null; private ?string $warning = null; private array $deferredValidation = []; - private ?MockBuilder $mocker = null; private bool $hasError = false; private bool $hasAssertError = false; @@ -198,16 +196,9 @@ public function dispatchTest(self &$row): array try { $newInst = $test($this); } catch (AssertionError $e) { - $newInst = clone $this; + $newInst = $this->createTraceError($e, "Assertion failed"); $newInst->setHasAssertError(); - $msg = "Assertion failed"; - $newInst->expectAndValidate( - true, - fn () => false, - $msg, - $e->getMessage(), - $e->getTrace()[0] - ); + } catch (Throwable $e) { if (str_contains($e->getFile(), "eval()")) { throw new BlunderErrorException($e->getMessage(), (int)$e->getCode()); @@ -215,21 +206,8 @@ public function dispatchTest(self &$row): array if($this->failFast) { throw $e; } - - $exceptionItem = new ExceptionItem($e); - $cliErrorHandler = new CliHandler(); - - $newInst = clone $this; + $newInst = $this->createTraceError($e); $newInst->setHasError(); - $msg = "PHP " . $exceptionItem->getSeverityTitle(); - $newInst->expectAndValidate( - true, - fn () => false, - $msg, - $cliErrorHandler->getSmallErrorMessage($exceptionItem), - $e->getTrace()[0] - ); - } if ($newInst instanceof self) { $row = $newInst; @@ -349,6 +327,29 @@ protected function expectAndValidate( return $this; } + /** + * Will assert a php error + * + * @param Throwable $exception + * @param string|null $title + * @return $this + * @throws ErrorException + */ + public function createTraceError(Throwable $exception, ?string $title = null): self + { + $newInst = clone $this; + $message = Helpers::getExceptionMessage($exception, $exceptionItem); + $title = ($title !== null) ? $title : "PHP " . $exceptionItem->getSeverityTitle(); + $newInst->expectAndValidate( + true, + fn () => false, + $title, + $message, + $exception->getTrace()[0] + ); + return $newInst; + } + /** * Adds a deferred validation to be executed after all immediate tests. * diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 69e7880..843454c 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -10,14 +10,9 @@ use TestLib\Mailer; use TestLib\UserService; - $config = TestConfig::make()->withName("unitary"); -echo $qwdqewdwq; - group($config->withSubject("Test mocker"), function (TestCase $case) { - - $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") ->withArguments("john.doe@gmail.com", "John Doe") @@ -27,14 +22,12 @@ $mail->addFromEmail("john.doe@gmail.com", "John Doe"); }); - group("Example of assert in group", function(TestCase $case) { assert(1 === 2, "This is a error message"); }); group($config->withSubject("Can not mock final or private"), function(TestCase $case) { - $user = $case->mock(UserService::class, function(MethodRegistry $method) { $method->method("getUserRole")->willReturn("admin"); $method->method("getUserType")->willReturn("admin"); diff --git a/unitary.config.php b/unitary.config.php index f7729f6..f915baa 100644 --- a/unitary.config.php +++ b/unitary.config.php @@ -13,6 +13,6 @@ 'discoverPattern' => false, // string|false (paths (`tests/`) and files (`unitary-*.php`).) 'show' => false, 'alwaysShowFiles' => false, - 'failFast' => false, + 'failFast' => false, // bool //'exit_error_code' => 1, ?? ]; From cf8f4f9e185be050cbb875d3cbce0f1004a409d0 Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Tue, 26 Aug 2025 18:54:56 +0200 Subject: [PATCH 71/78] refactor: unify kernel and request handler logic for clearer structure --- bin/unitary | 6 ++ src/Config/ConfigProps.php | 6 ++ src/Console/Controllers/DefaultController.php | 40 +-------- src/Console/Kernel.php | 13 +-- .../Middlewares/AddCommandMiddleware.php | 15 ++-- src/Console/Middlewares/CliInitMiddleware.php | 41 +++++++++ .../Middlewares/ConfigPropsMiddleware.php | 87 +++++++++++++++++++ src/Console/Middlewares/LocalMiddleware.php | 45 ++++++++++ src/Discovery/TestDiscovery.php | 2 +- src/Renders/JUnitRenderer.php | 4 + src/TestCase.php | 57 ++++++++++-- tests/unitary-unitary.php | 1 + unitary.config.php | 1 + 13 files changed, 258 insertions(+), 60 deletions(-) create mode 100644 src/Console/Middlewares/CliInitMiddleware.php create mode 100644 src/Console/Middlewares/ConfigPropsMiddleware.php create mode 100644 src/Console/Middlewares/LocalMiddleware.php diff --git a/bin/unitary b/bin/unitary index 6295d62..92ea014 100755 --- a/bin/unitary +++ b/bin/unitary @@ -10,6 +10,9 @@ use MaplePHP\Http\ServerRequest; use MaplePHP\Http\Uri; use MaplePHP\Unitary\Console\Kernel; use MaplePHP\Unitary\Console\Middlewares\AddCommandMiddleware; +use MaplePHP\Unitary\Console\Middlewares\CliInitMiddleware; +use MaplePHP\Unitary\Console\Middlewares\ConfigPropsMiddleware; +use MaplePHP\Unitary\Console\Middlewares\LocalMiddleware; $autoload = __DIR__ . '/../../../../vendor/autoload.php'; $autoload = is_file($autoload) ? $autoload : __DIR__ . '/../vendor/autoload.php'; @@ -34,5 +37,8 @@ $request = new ServerRequest(new Uri($env->getUriParts([ $kernel = new Kernel(new Container(), [ AddCommandMiddleware::class, + LocalMiddleware::class, + ConfigPropsMiddleware::class, + CliInitMiddleware::class ]); $kernel->run($request); diff --git a/src/Config/ConfigProps.php b/src/Config/ConfigProps.php index a1877c4..d41a9ac 100644 --- a/src/Config/ConfigProps.php +++ b/src/Config/ConfigProps.php @@ -22,6 +22,7 @@ class ConfigProps extends AbstractConfigProps public ?string $discoverPattern = null; public ?string $exclude = null; public ?string $show = null; + public ?string $timezone = null; public ?int $exitCode = null; public ?bool $verbose = null; public ?bool $alwaysShowFiles = null; @@ -29,6 +30,7 @@ class ConfigProps extends AbstractConfigProps public ?bool $smartSearch = null; public ?bool $failFast = null; + /** * Hydrate the properties/object with expected data, and handle unexpected data * @@ -51,6 +53,10 @@ protected function propsHydration(string $key, mixed $value): void case 'show': $this->show = (!is_string($value) || $value === '') ? null : $value; break; + case 'timezone': + // The default timezone is 'CET' + $this->timezone = (!is_string($value) || $value === '') ? 'Europe/Stockholm' : $value; + break; case 'exitCode': $this->exitCode = ($value === null) ? null : (int)$value; break; diff --git a/src/Console/Controllers/DefaultController.php b/src/Console/Controllers/DefaultController.php index c368e76..03dc79d 100644 --- a/src/Console/Controllers/DefaultController.php +++ b/src/Console/Controllers/DefaultController.php @@ -5,6 +5,7 @@ use MaplePHP\Container\Interfaces\ContainerExceptionInterface; use MaplePHP\Container\Interfaces\ContainerInterface; use MaplePHP\Container\Interfaces\NotFoundExceptionInterface; +use MaplePHP\DTO\Format\Clock; use MaplePHP\Emitron\Contracts\DispatchConfigInterface; use MaplePHP\Http\Interfaces\RequestInterface; use MaplePHP\Http\Interfaces\ServerRequestInterface; @@ -35,44 +36,5 @@ public function __construct(ContainerInterface $container) $this->command = $this->container->get("command"); $this->request = $this->container->get("request"); $this->configs = $this->container->get("dispatchConfig"); - - // $this->props is set in getInitProps - $this->container->set("props", $this->getInitProps()); } - - /** - * Builds the list of allowed CLI arguments from ConfigProps. - * - * These properties can be defined either in the configuration file or as CLI arguments. - * If invalid arguments are passed, and verbose mode is enabled, an error will be displayed - * along with a warning about the unknown properties. - * - * @return ConfigProps - */ - private function getInitProps(): ConfigProps - { - if ($this->props === null) { - try { - $props = array_merge($this->configs->getProps()->toArray(), $this->args); - $this->props = new ConfigProps($props); - - if ($this->props->hasMissingProps() !== [] && isset($this->args['verbose'])) { - $this->command->error('The properties (' . - implode(", ", $this->props->hasMissingProps()) . ') is not exist in config props'); - $this->command->message( - "One or more arguments you passed are not recognized as valid options.\n" . - "Check your command syntax or configuration." - ); - } - - } catch (Throwable $e) { - if (isset($this->args['verbose'])) { - $this->command->error($e->getMessage()); - exit(1); - } - } - } - return $this->props; - } - } diff --git a/src/Console/Kernel.php b/src/Console/Kernel.php index 7fb4740..d940e78 100644 --- a/src/Console/Kernel.php +++ b/src/Console/Kernel.php @@ -18,7 +18,7 @@ use MaplePHP\Emitron\Contracts\DispatchConfigInterface; use MaplePHP\Emitron\DispatchConfig; use MaplePHP\Http\Interfaces\ServerRequestInterface; -use MaplePHP\Unitary\Console\Middlewares\AddCommandMiddleware; +use MaplePHP\Http\Interfaces\StreamInterface; use MaplePHP\Unitary\Support\Router; use MaplePHP\Container\Interfaces\ContainerInterface; use MaplePHP\Emitron\EmitronKernel; @@ -47,12 +47,6 @@ public function __construct( $this->container = $container; $this->userMiddlewares = $userMiddlewares; $this->config = $dispatchConfig; - - // This middleware is used in the DefaultController, which is why I always load it, - // It will not change any response but will load a CLI helper Command library - if (!in_array(AddCommandMiddleware::class, $this->userMiddlewares)) { - $this->userMiddlewares[] = AddCommandMiddleware::class; - } EmitronKernel::setConfigFilePath(self::CONFIG_FILE_PATH); } @@ -60,16 +54,17 @@ public function __construct( * This will run Emitron kernel with Unitary configuration * * @param ServerRequestInterface $request + * @param StreamInterface|null $stream * @return void * @throws Exception */ - public function run(ServerRequestInterface $request): void + public function run(ServerRequestInterface $request, ?StreamInterface $stream = null): void { if ($this->config === null) { $this->config = $this->configuration($request); } $kernel = new EmitronKernel($this->container, $this->userMiddlewares, $this->config); - $kernel->run($request); + $kernel->run($request, $stream); } /** diff --git a/src/Console/Middlewares/AddCommandMiddleware.php b/src/Console/Middlewares/AddCommandMiddleware.php index 83e2d34..b649f6e 100644 --- a/src/Console/Middlewares/AddCommandMiddleware.php +++ b/src/Console/Middlewares/AddCommandMiddleware.php @@ -7,24 +7,28 @@ use MaplePHP\Emitron\Contracts\RequestHandlerInterface; use MaplePHP\Http\Interfaces\ResponseInterface; use MaplePHP\Http\Interfaces\ServerRequestInterface; +use MaplePHP\Http\Interfaces\StreamInterface; use MaplePHP\Prompts\Command; class AddCommandMiddleware implements MiddlewareInterface { private ContainerInterface $container; + private StreamInterface $stream; /** - * Get the active Container instance with the Dependency injector + * Get the active Container and Stream instance with the Dependency injector * * @param ContainerInterface $container + * @param StreamInterface $stream */ - public function __construct(ContainerInterface $container) + public function __construct(ContainerInterface $container, StreamInterface $stream) { $this->container = $container; + $this->stream = $stream; } /** - * Will bind current Response and Stream to the Command CLI library class + * Will bind current Stream to the Command CLI library class * this is initialized and passed to the Container * * @param ServerRequestInterface $request @@ -33,8 +37,7 @@ public function __construct(ContainerInterface $container) */ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $response = $handler->handle($request); - $this->container->set("command", new Command($response)); - return $response; + $this->container->set("command", new Command($this->stream)); + return $handler->handle($request); } } diff --git a/src/Console/Middlewares/CliInitMiddleware.php b/src/Console/Middlewares/CliInitMiddleware.php new file mode 100644 index 0000000..f95f12e --- /dev/null +++ b/src/Console/Middlewares/CliInitMiddleware.php @@ -0,0 +1,41 @@ +handle($request); + if ($this->isCli()) { + $response = $response->withStatus(0); + } + return $response; + } + + /** + * Check if is inside a command line interface (CLI) + * + * @return bool + */ + protected function isCli(): bool + { + return PHP_SAPI === 'cli'; + } +} diff --git a/src/Console/Middlewares/ConfigPropsMiddleware.php b/src/Console/Middlewares/ConfigPropsMiddleware.php new file mode 100644 index 0000000..3162989 --- /dev/null +++ b/src/Console/Middlewares/ConfigPropsMiddleware.php @@ -0,0 +1,87 @@ +container = $container; + } + + /** + * Will bind current Response and Stream to the Command CLI library class + * this is initialized and passed to the Container + * + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + * @throws \DateInvalidTimeZoneException + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $this->container->set("props", $this->getInitProps()); + return $handler->handle($request); + } + + + /** + * Builds the list of allowed CLI arguments from ConfigProps. + * + * These properties can be defined either in the configuration file or as CLI arguments. + * If invalid arguments are passed, and verbose mode is enabled, an error will be displayed + * along with a warning about the unknown properties. + * + * @return ConfigProps + */ + private function getInitProps(): ConfigProps + { + + if ($this->props === null) { + + $args = $this->container->get("args"); + $configs = $this->container->get("dispatchConfig"); + $command = $this->container->get("command"); + + try { + $props = array_merge($configs->getProps()->toArray(), $args); + $this->props = new ConfigProps($props); + + if ($this->props->hasMissingProps() !== [] && isset($args['verbose'])) { + $command->error('The properties (' . + implode(", ", $this->props->hasMissingProps()) . ') is not exist in config props'); + $command->message( + "One or more arguments you passed are not recognized as valid options.\n" . + "Check your command syntax or configuration." + ); + } + + } catch (Throwable $e) { + if (isset($args['verbose'])) { + $command->error($e->getMessage()); + exit(1); + } + } + } + return $this->props; + } +} diff --git a/src/Console/Middlewares/LocalMiddleware.php b/src/Console/Middlewares/LocalMiddleware.php new file mode 100644 index 0000000..8047041 --- /dev/null +++ b/src/Console/Middlewares/LocalMiddleware.php @@ -0,0 +1,45 @@ +container = $container; + } + + /** + * Will bind current Response and Stream to the Command CLI library class + * this is initialized and passed to the Container + * + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + * @throws \DateInvalidTimeZoneException + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + + + Clock::setDefaultLocale("sv_SE"); + Clock::setDefaultTimezone("Europe/Stockholm"); + + return $handler->handle($request); + } +} diff --git a/src/Discovery/TestDiscovery.php b/src/Discovery/TestDiscovery.php index 9d181a3..8016f7e 100755 --- a/src/Discovery/TestDiscovery.php +++ b/src/Discovery/TestDiscovery.php @@ -168,7 +168,7 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable } self::$unitary->group("PHP error", function (TestCase $case) use ($exception) { $newInst = $case->createTraceError($exception); - $newInst->setHasError(); + $newInst->incrementError(); return $newInst; }); self::$unitary->execute(); diff --git a/src/Renders/JUnitRenderer.php b/src/Renders/JUnitRenderer.php index 8501712..c5f45a2 100644 --- a/src/Renders/JUnitRenderer.php +++ b/src/Renders/JUnitRenderer.php @@ -37,9 +37,13 @@ public function buildBody(): void $this->xml->writeAttribute('name', $this->formatFileTitle($this->suitName) . " - " . (string)$this->case->getMessage()); $this->xml->writeAttribute('tests', (string)$this->case->getCount()); $this->xml->writeAttribute('failures', (string)$this->case->getFailedCount()); + $this->xml->writeAttribute('errors', (string)$this->case->getErrors()); + $this->xml->writeAttribute('skipped', (string)$this->case->getSkipped()); + $this->xml->writeAttribute('skipped', (string)$this->case->getSkipped()); var_dump($this->case->getCount()); var_dump($this->case->getFailedCount()); + var_dump($this->case->getErrors()); die; if (($this->show || !$this->case->getConfig()->skip)) { // Show possible warnings diff --git a/src/TestCase.php b/src/TestCase.php index 4278fd9..625c0b8 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -54,7 +54,8 @@ final class TestCase private ?string $warning = null; private array $deferredValidation = []; private ?MockBuilder $mocker = null; - private bool $hasError = false; + private int $hasError = 0; + private int $skipped = 0; private bool $hasAssertError = false; private bool $failFast = false; @@ -97,14 +98,55 @@ public function bind(Closure $bind, bool $bindToClosure = false): void $this->bind = ($bindToClosure) ? $bind->bindTo($this) : $bind; } + + /** + * Get the total number of skipped group test + * + * @return int + */ + public function getSkipped(): int + { + return $this->skipped; + } + + /** + * Check if group has any skipped tests + * + * @return bool + */ + public function hasSkipped(): bool + { + return $this->skipped > 0; + } + + /** + * Increment skipped test + * + * @return void + */ + public function incrementSkipped(): void + { + $this->skipped++; + } + /** * Sets the error flag to true * * @return void */ - public function setHasError(): void + public function incrementError(): void + { + $this->hasError++; + } + + /** + * Gets the errors count + * + * @return int + */ + public function getErrors(): int { - $this->hasError = true; + return $this->hasError; } /** @@ -114,7 +156,7 @@ public function setHasError(): void */ public function getHasError(): bool { - return $this->hasError; + return ($this->hasError > 0); } /** @@ -195,6 +237,11 @@ public function dispatchTest(self &$row): array if ($test !== null) { try { $newInst = $test($this); + $inst = ($newInst instanceof self) ? $newInst : $this; + if($inst->getConfig()->skip) { + $inst->incrementSkipped(); + } + } catch (AssertionError $e) { $newInst = $this->createTraceError($e, "Assertion failed"); $newInst->setHasAssertError(); @@ -207,7 +254,7 @@ public function dispatchTest(self &$row): array throw $e; } $newInst = $this->createTraceError($e); - $newInst->setHasError(); + $newInst->incrementError(); } if ($newInst instanceof self) { $row = $newInst; diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 843454c..2f341a2 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -12,6 +12,7 @@ $config = TestConfig::make()->withName("unitary"); + group($config->withSubject("Test mocker"), function (TestCase $case) { $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") diff --git a/unitary.config.php b/unitary.config.php index f915baa..9be72d3 100644 --- a/unitary.config.php +++ b/unitary.config.php @@ -12,6 +12,7 @@ 'exclude' => false, // false|string|array 'discoverPattern' => false, // string|false (paths (`tests/`) and files (`unitary-*.php`).) 'show' => false, + 'timezone' => 'Europe/Stockholm', 'alwaysShowFiles' => false, 'failFast' => false, // bool //'exit_error_code' => 1, ?? From b8140b359322f66b73e28cc4d9c05244d4609b92 Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Tue, 26 Aug 2025 19:11:51 +0200 Subject: [PATCH 72/78] feat: support configurable locale and timezone --- bin/unitary | 2 +- src/Config/ConfigProps.php | 11 +++++++++++ src/Console/Middlewares/ConfigPropsMiddleware.php | 9 ++++++--- src/Console/Middlewares/LocalMiddleware.php | 13 +++++++------ src/Renders/JUnitRenderer.php | 3 ++- unitary.config.php | 1 + 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/bin/unitary b/bin/unitary index 92ea014..b74857f 100755 --- a/bin/unitary +++ b/bin/unitary @@ -37,8 +37,8 @@ $request = new ServerRequest(new Uri($env->getUriParts([ $kernel = new Kernel(new Container(), [ AddCommandMiddleware::class, - LocalMiddleware::class, ConfigPropsMiddleware::class, + LocalMiddleware::class, CliInitMiddleware::class ]); $kernel->run($request); diff --git a/src/Config/ConfigProps.php b/src/Config/ConfigProps.php index d41a9ac..cff61c1 100644 --- a/src/Config/ConfigProps.php +++ b/src/Config/ConfigProps.php @@ -4,6 +4,7 @@ namespace MaplePHP\Unitary\Config; +use InvalidArgumentException; use MaplePHP\Emitron\AbstractConfigProps; /** @@ -23,6 +24,7 @@ class ConfigProps extends AbstractConfigProps public ?string $exclude = null; public ?string $show = null; public ?string $timezone = null; + public ?string $local = null; public ?int $exitCode = null; public ?bool $verbose = null; public ?bool $alwaysShowFiles = null; @@ -57,6 +59,15 @@ protected function propsHydration(string $key, mixed $value): void // The default timezone is 'CET' $this->timezone = (!is_string($value) || $value === '') ? 'Europe/Stockholm' : $value; break; + case 'local': + // The default timezone is 'CET' + $this->local = (!is_string($value) || $value === '') ? 'en_US' : $value; + if(!$this->isValidLocale($this->local)) { + throw new InvalidArgumentException( + "Invalid locale '{$this->local}'. Expected format like 'en_US' (language_COUNTRY)." + ); + } + break; case 'exitCode': $this->exitCode = ($value === null) ? null : (int)$value; break; diff --git a/src/Console/Middlewares/ConfigPropsMiddleware.php b/src/Console/Middlewares/ConfigPropsMiddleware.php index 3162989..cd885a8 100644 --- a/src/Console/Middlewares/ConfigPropsMiddleware.php +++ b/src/Console/Middlewares/ConfigPropsMiddleware.php @@ -2,13 +2,13 @@ namespace MaplePHP\Unitary\Console\Middlewares; +use MaplePHP\Container\Interfaces\ContainerExceptionInterface; use MaplePHP\Container\Interfaces\ContainerInterface; -use MaplePHP\DTO\Format\Clock; +use MaplePHP\Container\Interfaces\NotFoundExceptionInterface; use MaplePHP\Emitron\Contracts\MiddlewareInterface; use MaplePHP\Emitron\Contracts\RequestHandlerInterface; use MaplePHP\Http\Interfaces\ResponseInterface; use MaplePHP\Http\Interfaces\ServerRequestInterface; -use MaplePHP\Prompts\Command; use MaplePHP\Unitary\Config\ConfigProps; use Throwable; @@ -35,7 +35,8 @@ public function __construct(ContainerInterface $container) * @param ServerRequestInterface $request * @param RequestHandlerInterface $handler * @return ResponseInterface - * @throws \DateInvalidTimeZoneException + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface */ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { @@ -52,6 +53,8 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface * along with a warning about the unknown properties. * * @return ConfigProps + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface */ private function getInitProps(): ConfigProps { diff --git a/src/Console/Middlewares/LocalMiddleware.php b/src/Console/Middlewares/LocalMiddleware.php index 8047041..28dce98 100644 --- a/src/Console/Middlewares/LocalMiddleware.php +++ b/src/Console/Middlewares/LocalMiddleware.php @@ -2,13 +2,14 @@ namespace MaplePHP\Unitary\Console\Middlewares; +use MaplePHP\Container\Interfaces\ContainerExceptionInterface; use MaplePHP\Container\Interfaces\ContainerInterface; +use MaplePHP\Container\Interfaces\NotFoundExceptionInterface; use MaplePHP\DTO\Format\Clock; use MaplePHP\Emitron\Contracts\MiddlewareInterface; use MaplePHP\Emitron\Contracts\RequestHandlerInterface; use MaplePHP\Http\Interfaces\ResponseInterface; use MaplePHP\Http\Interfaces\ServerRequestInterface; -use MaplePHP\Prompts\Command; class LocalMiddleware implements MiddlewareInterface { @@ -32,14 +33,14 @@ public function __construct(ContainerInterface $container) * @param RequestHandlerInterface $handler * @return ResponseInterface * @throws \DateInvalidTimeZoneException + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface */ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - - - Clock::setDefaultLocale("sv_SE"); - Clock::setDefaultTimezone("Europe/Stockholm"); - + $props = $this->container->get("props"); + Clock::setDefaultLocale($props->local); + Clock::setDefaultTimezone($props->timezone); return $handler->handle($request); } } diff --git a/src/Renders/JUnitRenderer.php b/src/Renders/JUnitRenderer.php index c5f45a2..d5d03ca 100644 --- a/src/Renders/JUnitRenderer.php +++ b/src/Renders/JUnitRenderer.php @@ -3,6 +3,7 @@ namespace MaplePHP\Unitary\Renders; use ErrorException; +use MaplePHP\DTO\Format\Clock; use MaplePHP\Prompts\Command; use MaplePHP\Unitary\TestItem; use MaplePHP\Unitary\TestUnit; @@ -39,11 +40,11 @@ public function buildBody(): void $this->xml->writeAttribute('failures', (string)$this->case->getFailedCount()); $this->xml->writeAttribute('errors', (string)$this->case->getErrors()); $this->xml->writeAttribute('skipped', (string)$this->case->getSkipped()); - $this->xml->writeAttribute('skipped', (string)$this->case->getSkipped()); var_dump($this->case->getCount()); var_dump($this->case->getFailedCount()); var_dump($this->case->getErrors()); + var_dump(Clock::value("now")->dateTime()); die; if (($this->show || !$this->case->getConfig()->skip)) { // Show possible warnings diff --git a/unitary.config.php b/unitary.config.php index 9be72d3..8718724 100644 --- a/unitary.config.php +++ b/unitary.config.php @@ -13,6 +13,7 @@ 'discoverPattern' => false, // string|false (paths (`tests/`) and files (`unitary-*.php`).) 'show' => false, 'timezone' => 'Europe/Stockholm', + 'local' => 'en_US', 'alwaysShowFiles' => false, 'failFast' => false, // bool //'exit_error_code' => 1, ?? From 0c0f62bc19077b217df398ed7935212bdb4a6523 Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Wed, 27 Aug 2025 18:05:25 +0200 Subject: [PATCH 73/78] update: start adding junit xml render update: add duration counter --- src/Console/Controllers/RunTestController.php | 26 +++++++++++--- src/Renders/AbstractRenderHandler.php | 1 - src/Renders/JUnitRenderer.php | 34 ++++++++++++------- src/TestCase.php | 18 ++++++++++ src/Unit.php | 27 ++++++++++++++- 5 files changed, 88 insertions(+), 18 deletions(-) diff --git a/src/Console/Controllers/RunTestController.php b/src/Console/Controllers/RunTestController.php index fff7ea8..2c34987 100644 --- a/src/Console/Controllers/RunTestController.php +++ b/src/Console/Controllers/RunTestController.php @@ -27,18 +27,36 @@ public function run(RunTestService $service): ResponseInterface */ public function runJUnit(RunTestService $service): ResponseInterface { + $suites = new \XMLWriter(); + $suites->openMemory(); + $handler = new JUnitRenderer($suites); + $response = $service->run($handler); + + // 2) Get the suites XML fragment + $suitesXml = $suites->outputMemory(); + + // Duration: pick your source (internal timer is fine) + $inst = TestDiscovery::getUnitaryInst(); $xml = new \XMLWriter(); $xml->openMemory(); $xml->startDocument('1.0', 'UTF-8'); - $xml->startElement('testsuites'); + $xml->writeAttribute('tests', (string)$inst::getTotalTests()); + $xml->writeAttribute('failures', (string)$inst::getTotalFailed()); + $xml->writeAttribute('errors', (string)$inst::getTotalErrors()); + $xml->writeAttribute('time', (string)$inst::getDuration(6)); + // Optional: $xml->writeAttribute('skipped', (string)$totalSkipped); - $handler = new JUnitRenderer($xml); - $response = $service->run($handler); - $this->buildFooter(); + $xml->writeRaw($suitesXml); + + $xml->endElement(); + $xml->endDocument(); + + $response->getBody()->write($xml->outputMemory()); return $response; } + /** * Main help page * diff --git a/src/Renders/AbstractRenderHandler.php b/src/Renders/AbstractRenderHandler.php index eb45d1b..7131893 100644 --- a/src/Renders/AbstractRenderHandler.php +++ b/src/Renders/AbstractRenderHandler.php @@ -3,7 +3,6 @@ namespace MaplePHP\Unitary\Renders; use MaplePHP\Http\Interfaces\StreamInterface; -use MaplePHP\Http\Stream; use MaplePHP\Unitary\Interfaces\BodyInterface; use MaplePHP\Unitary\TestCase; use RuntimeException; diff --git a/src/Renders/JUnitRenderer.php b/src/Renders/JUnitRenderer.php index d5d03ca..9fdd52a 100644 --- a/src/Renders/JUnitRenderer.php +++ b/src/Renders/JUnitRenderer.php @@ -27,41 +27,51 @@ public function __construct(XMLWriter $xml) /** * {@inheritDoc} - * @throws ErrorException + * @throws \Exception */ public function buildBody(): void { - $this->xml->startElement('testsuite'); + $title = $this->formatFileTitle($this->suitName); + $msg = (string)$this->case->getMessage(); - $this->xml->writeAttribute('name', $this->formatFileTitle($this->suitName) . " - " . (string)$this->case->getMessage()); + $this->xml->startElement('testsuite'); + $this->xml->writeAttribute('name', $title . " - " . $msg); $this->xml->writeAttribute('tests', (string)$this->case->getCount()); $this->xml->writeAttribute('failures', (string)$this->case->getFailedCount()); $this->xml->writeAttribute('errors', (string)$this->case->getErrors()); $this->xml->writeAttribute('skipped', (string)$this->case->getSkipped()); + $this->xml->writeAttribute('time', (string)$this->case->getDuration(6)); + $this->xml->writeAttribute('timestamp', Clock::value("now")->iso()); + - var_dump($this->case->getCount()); + $this->xml->endElement(); + + /* + var_dump($this->case->getCount()); var_dump($this->case->getFailedCount()); var_dump($this->case->getErrors()); + var_dump($this->case->getDuration(6)); var_dump(Clock::value("now")->dateTime()); die; if (($this->show || !$this->case->getConfig()->skip)) { // Show possible warnings - /* - if ($this->case->getWarning()) { - $this->xml->message(""); - $this->xml->message( - $this->xml->getAnsi()->style(["italic", "yellow"], $this->case->getWarning()) - ); - } - */ + +// if ($this->case->getWarning()) { +// $this->xml->message(""); +// $this->xml->message( +// $this->xml->getAnsi()->style(["italic", "yellow"], $this->case->getWarning()) +// ); +// } + // Show Failed tests $this->showFailedTests(); } $this->showFooter(); + */ } /** diff --git a/src/TestCase.php b/src/TestCase.php index 625c0b8..b259d5d 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -56,6 +56,7 @@ final class TestCase private ?MockBuilder $mocker = null; private int $hasError = 0; private int $skipped = 0; + private float $duration = 0; private bool $hasAssertError = false; private bool $failFast = false; @@ -109,6 +110,20 @@ public function getSkipped(): int return $this->skipped; } + /** + * Get current test group duration + * + * @param int $precision + * @return float + */ + public function getDuration(int $precision = 0): float + { + if($precision > 0) { + return round($this->duration, $precision); + } + return $this->duration; + } + /** * Check if group has any skipped tests * @@ -233,6 +248,7 @@ public function dispatchTest(self &$row): array { $row = $this; $test = $this->bind; + $start = microtime(true); $newInst = null; if ($test !== null) { try { @@ -260,6 +276,8 @@ public function dispatchTest(self &$row): array $row = $newInst; } } + + $this->duration = (float)(microtime(true) - $start); return $this->test; } diff --git a/src/Unit.php b/src/Unit.php index 3510373..d8a4c15 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -37,6 +37,7 @@ final class Unit private ?string $show = null; private bool $verbose = false; private bool $alwaysShowFiles = false; + private static float $duration = 0; private static int $totalPassedTests = 0; private static int $totalTests = 0; private static int $totalErrors = 0; @@ -176,6 +177,16 @@ public static function getTotalTests(): int return self::$totalTests; } + /** + * Get the total number of failed tests + * + * @return int + */ + public static function getTotalFailed(): int + { + return self::$totalTests-self::$totalPassedTests; + } + /** * Get the total number of error * @@ -198,6 +209,20 @@ public static function incrementErrors(): void self::$totalErrors++; } + /** + * Get total duration of all tests + * + * @param int $precision + * @return float + */ + public static function getDuration(int $precision = 0): float + { + if($precision > 0) { + return round(self::$duration, $precision); + } + return self::$duration; + } + /** * This will disable "ALL" tests in the test file * If you want to skip a specific test, use the TestConfig class instead @@ -275,7 +300,6 @@ public function execute(): bool if (count($this->cases) === 0) { return false; } - $fileChecksum = md5($this->file); foreach ($this->cases as $index => $row) { if (!($row instanceof TestCase)) { @@ -310,6 +334,7 @@ public function execute(): bool // the total passed tests are correct, and it will not exit with code 1 self::$totalPassedTests += ($row->getConfig()->skip) ? $row->getTotal() : $row->getCount(); self::$totalTests += $row->getTotal(); + self::$duration += $row->getDuration(); } $out = $handler->outputBuffer(); if ($out) { From f825f1ff07839f358854fd08dd449278f294fad3 Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Thu, 28 Aug 2025 18:23:01 +0200 Subject: [PATCH 74/78] update: junit xml structure --- src/Console/Controllers/RunTestController.php | 4 + src/Renders/AbstractRenderHandler.php | 1 - src/Renders/JUnitRenderer.php | 80 ++++++++++++++++--- src/TestUnit.php | 13 +++ tests/unitary-unitary.php | 2 + 5 files changed, 88 insertions(+), 12 deletions(-) diff --git a/src/Console/Controllers/RunTestController.php b/src/Console/Controllers/RunTestController.php index 2c34987..9d6f366 100644 --- a/src/Console/Controllers/RunTestController.php +++ b/src/Console/Controllers/RunTestController.php @@ -29,6 +29,8 @@ public function runJUnit(RunTestService $service): ResponseInterface { $suites = new \XMLWriter(); $suites->openMemory(); + $suites->setIndent(true); + $suites->setIndentString(" "); $handler = new JUnitRenderer($suites); $response = $service->run($handler); @@ -39,6 +41,8 @@ public function runJUnit(RunTestService $service): ResponseInterface $inst = TestDiscovery::getUnitaryInst(); $xml = new \XMLWriter(); $xml->openMemory(); + $xml->setIndent(true); + $xml->setIndentString(" "); $xml->startDocument('1.0', 'UTF-8'); $xml->startElement('testsuites'); $xml->writeAttribute('tests', (string)$inst::getTotalTests()); diff --git a/src/Renders/AbstractRenderHandler.php b/src/Renders/AbstractRenderHandler.php index 7131893..3ba06f3 100644 --- a/src/Renders/AbstractRenderHandler.php +++ b/src/Renders/AbstractRenderHandler.php @@ -130,5 +130,4 @@ protected function formatFileTitle(string $file, int $length = 3, bool $removeSu //$file = reset($exp); return ".." . $file; } - } diff --git a/src/Renders/JUnitRenderer.php b/src/Renders/JUnitRenderer.php index 9fdd52a..12422e9 100644 --- a/src/Renders/JUnitRenderer.php +++ b/src/Renders/JUnitRenderer.php @@ -33,19 +33,79 @@ public function buildBody(): void { - $title = $this->formatFileTitle($this->suitName); + $testFile = $this->formatFileTitle($this->suitName, 3, false); $msg = (string)$this->case->getMessage(); + $duration = (string)$this->case->getDuration(6); $this->xml->startElement('testsuite'); - $this->xml->writeAttribute('name', $title . " - " . $msg); + $this->xml->writeAttribute('name', $testFile); $this->xml->writeAttribute('tests', (string)$this->case->getCount()); $this->xml->writeAttribute('failures', (string)$this->case->getFailedCount()); $this->xml->writeAttribute('errors', (string)$this->case->getErrors()); $this->xml->writeAttribute('skipped', (string)$this->case->getSkipped()); - $this->xml->writeAttribute('time', (string)$this->case->getDuration(6)); + $this->xml->writeAttribute('time', $duration); $this->xml->writeAttribute('timestamp', Clock::value("now")->iso()); + foreach ($this->tests as $test) { + + if (!($test instanceof TestUnit)) { + throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); + } + + $value = Helpers::stringifyDataTypes($test->getValue()); + $value = str_replace('"', "'", $value); + + $this->xml->startElement('testcase'); + + $this->xml->writeAttribute('classname', $this->checksum); + if($this->case->getConfig()->select) { + $this->xml->writeAttribute('testname', $this->case->getConfig()->select); + } + $this->xml->writeAttribute('name', $msg); + $this->xml->writeAttribute('time', $duration); + if (!$test->isValid()) { + + $trace = $test->getCodeLine(); + $this->xml->writeAttribute('file', $trace['file']); + $this->xml->writeAttribute('line', $trace['line']); + $this->xml->writeAttribute('value', $value); + + + + + foreach ($test->getUnits() as $unit) { + + /** @var TestItem $unit */ + if (!$unit->isValid()) { + + $validation = $unit->getValidationTitle(); + $compare = $unit->hasComparison() ? ": " . $unit->getComparison() : ""; + $compare = str_replace('"', "'", $compare); + $type = str_replace('"', "'", $test->getMessage()); + + $tag = $this->case->getHasError() ? "error" : "failure"; + + $this->xml->startElement($tag); + $this->xml->writeAttribute('message', $validation . $compare); + $this->xml->writeAttribute('type', $type); + + $this->xml->endElement(); + } + + } + + + //$this->xml->writeCData($t['details']); + + } + + $this->xml->endElement(); + + + } + + $this->xml->endElement(); /* @@ -80,15 +140,13 @@ public function buildBody(): void public function buildNotes(): void { if ($this->outputBuffer) { - $lineLength = 80; + /* + $lineLength = 80; $output = wordwrap($this->outputBuffer, $lineLength); - $line = $this->xml->getAnsi()->line($lineLength); - - $this->xml->message(""); - $this->xml->message($this->xml->getAnsi()->style(["bold"], "Note:")); - $this->xml->message($line); - $this->xml->message($output); - $this->xml->message($line); + $this->xml->startElement('output'); + $this->xml->writeAttribute('message', $output); + $this->xml->endElement(); + */ } } diff --git a/src/TestUnit.php b/src/TestUnit.php index 97f8266..122c261 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -21,6 +21,7 @@ final class TestUnit private mixed $value = null; private bool $hasValue = false; private ?string $message; + private ?string $validation = null; private array $unit = []; private int $count = 0; private int $valLength = 0; @@ -73,6 +74,8 @@ public function setTestItem(TestItem $item): self $this->count++; } + $this->validation = $item->getValidation(); + $valLength = $item->getValidationLengthWithArgs(); if ($this->valLength < $valLength) { $this->valLength = $valLength; @@ -82,6 +85,16 @@ public function setTestItem(TestItem $item): self return $this; } + /** + * Get the validation type + * + * @return ?string + */ + public function getValidationMsg(): ?string + { + return $this->validation; + } + /** * Get the length of the validation string with the maximum length * diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 2f341a2..4330ebc 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -14,6 +14,8 @@ group($config->withSubject("Test mocker"), function (TestCase $case) { + throw new Exception("dwddwqdwdwdqdq"); + echo $wdwdw; $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") ->withArguments("john.doe@gmail.com", "John Doe") From 55d60c70c303bbc081eb013665749a25d3ff9850 Mon Sep 17 00:00:00 2001 From: danielRConsid Date: Sat, 30 Aug 2025 14:08:15 +0200 Subject: [PATCH 75/78] fix: add middleware to router --- bin/unitary | 31 +++------- src/Console/Application.php | 58 +++++++++++++++++++ src/Console/ConsoleRouter.php | 12 ++-- src/Console/Controllers/DefaultController.php | 7 ++- src/Console/Kernel.php | 8 ++- src/Console/Middlewares/TestMiddleware.php | 27 +++++++++ src/Discovery/TestDiscovery.php | 3 +- src/Support/Router.php | 33 +++++++++-- 8 files changed, 140 insertions(+), 39 deletions(-) create mode 100644 src/Console/Application.php create mode 100644 src/Console/Middlewares/TestMiddleware.php diff --git a/bin/unitary b/bin/unitary index b74857f..236af2d 100755 --- a/bin/unitary +++ b/bin/unitary @@ -4,15 +4,9 @@ * MaplePHP Unitary bin file */ -use MaplePHP\Container\Container; -use MaplePHP\Http\Environment; -use MaplePHP\Http\ServerRequest; -use MaplePHP\Http\Uri; -use MaplePHP\Unitary\Console\Kernel; -use MaplePHP\Unitary\Console\Middlewares\AddCommandMiddleware; -use MaplePHP\Unitary\Console\Middlewares\CliInitMiddleware; -use MaplePHP\Unitary\Console\Middlewares\ConfigPropsMiddleware; -use MaplePHP\Unitary\Console\Middlewares\LocalMiddleware; +use MaplePHP\Blunder\Handlers\CliHandler; +use MaplePHP\Unitary\Console\Application; + $autoload = __DIR__ . '/../../../../vendor/autoload.php'; $autoload = is_file($autoload) ? $autoload : __DIR__ . '/../vendor/autoload.php'; @@ -29,16 +23,9 @@ if (!$autoload || !is_file($autoload)) { require $autoload; -$env = new Environment(); -$request = new ServerRequest(new Uri($env->getUriParts([ - "argv" => $argv, - "dir" => getcwd() -])), $env); - -$kernel = new Kernel(new Container(), [ - AddCommandMiddleware::class, - ConfigPropsMiddleware::class, - LocalMiddleware::class, - CliInitMiddleware::class -]); -$kernel->run($request); +$app = (new Application()) + ->withErrorHandler(new CliHandler()) + ->boot([ + "argv" => $argv, + "dir" => getcwd() + ]); diff --git a/src/Console/Application.php b/src/Console/Application.php new file mode 100644 index 0000000..a35fe8b --- /dev/null +++ b/src/Console/Application.php @@ -0,0 +1,58 @@ +severity() + ->excludeSeverityLevels([E_USER_WARNING, E_NOTICE, E_USER_NOTICE, E_DEPRECATED, E_USER_DEPRECATED]) + ->redirectTo(function () { + // Let PHP’s default error handler process excluded severities + return false; + }); + $run->setExitCode(1); + $run->load(); + return $inst; + } + + /** + * @param array $parts + * @return Kernel + * @throws \Exception + */ + public function boot(array $parts): Kernel + { + $env = new Environment(); + $request = new ServerRequest(new Uri($env->getUriParts($parts)), $env); + $kernel = new Kernel(new Container(), [ + AddCommandMiddleware::class, + ConfigPropsMiddleware::class, + LocalMiddleware::class, + CliInitMiddleware::class + ]); + $kernel->run($request); + return $kernel; + } +} diff --git a/src/Console/ConsoleRouter.php b/src/Console/ConsoleRouter.php index 88abb34..e5e2d88 100644 --- a/src/Console/ConsoleRouter.php +++ b/src/Console/ConsoleRouter.php @@ -3,9 +3,11 @@ use MaplePHP\Unitary\Console\Controllers\CoverageController; use MaplePHP\Unitary\Console\Controllers\RunTestController; use MaplePHP\Unitary\Console\Controllers\TemplateController; +use MaplePHP\Unitary\Console\Middlewares\TestMiddleware; -$router->map("coverage", [CoverageController::class, "run"]); -$router->map("template", [TemplateController::class, "run"]); -$router->map("junit", [RunTestController::class, "runJUnit"]); -$router->map(["", "test", "run"], [RunTestController::class, "run"]); -$router->map(["__404", "help"], [RunTestController::class, "help"]); +return $router + ->map("coverage", [CoverageController::class, "run"]) + ->map("template", [TemplateController::class, "run"]) + ->map("junit", [RunTestController::class, "runJUnit"]) + ->map(["", "test", "run"], [RunTestController::class, "run"])->with(TestMiddleware::class) + ->map(["__404", "help"], [RunTestController::class, "help"]); diff --git a/src/Console/Controllers/DefaultController.php b/src/Console/Controllers/DefaultController.php index 03dc79d..2633c42 100644 --- a/src/Console/Controllers/DefaultController.php +++ b/src/Console/Controllers/DefaultController.php @@ -2,16 +2,17 @@ namespace MaplePHP\Unitary\Console\Controllers; +use MaplePHP\Blunder\Handlers\CliHandler; +use MaplePHP\Blunder\Interfaces\HandlerInterface; use MaplePHP\Container\Interfaces\ContainerExceptionInterface; use MaplePHP\Container\Interfaces\ContainerInterface; use MaplePHP\Container\Interfaces\NotFoundExceptionInterface; -use MaplePHP\DTO\Format\Clock; use MaplePHP\Emitron\Contracts\DispatchConfigInterface; use MaplePHP\Http\Interfaces\RequestInterface; use MaplePHP\Http\Interfaces\ServerRequestInterface; use MaplePHP\Prompts\Command; use MaplePHP\Unitary\Config\ConfigProps; -use Throwable; +use MaplePHP\Unitary\Console\Services\ErrorHandlerService; abstract class DefaultController { @@ -29,7 +30,7 @@ abstract class DefaultController * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface */ - public function __construct(ContainerInterface $container) + public function __construct(ContainerInterface $container, ?HandlerInterface $handler = null) { $this->container = $container; $this->args = $this->container->get("args"); diff --git a/src/Console/Kernel.php b/src/Console/Kernel.php index d940e78..fdde9ba 100644 --- a/src/Console/Kernel.php +++ b/src/Console/Kernel.php @@ -84,8 +84,12 @@ private function configuration(ServerRequestInterface $request): DispatchConfigI if (!is_file($routerFile)) { throw new Exception('The routes file (' . $routerFile . ') is missing.'); } - require_once $routerFile; - return $router; + $newRouterInst = require_once $routerFile; + if (!($newRouterInst instanceof Router)) { + throw new \RuntimeException('You need to return the router instance ' . + 'at the end of the router file (' . $routerFile . ').'); + } + return $newRouterInst; }) ->setProp('exitCode', 0); } diff --git a/src/Console/Middlewares/TestMiddleware.php b/src/Console/Middlewares/TestMiddleware.php new file mode 100644 index 0000000..5e9287c --- /dev/null +++ b/src/Console/Middlewares/TestMiddleware.php @@ -0,0 +1,27 @@ +handle($request); + $response->getBody()->write("\n"); + $response->getBody()->write("Hello World from: " . get_class($this)); + $response->getBody()->write("\n"); + return $response; + } +} diff --git a/src/Discovery/TestDiscovery.php b/src/Discovery/TestDiscovery.php index 8016f7e..8eeb1ee 100755 --- a/src/Discovery/TestDiscovery.php +++ b/src/Discovery/TestDiscovery.php @@ -143,7 +143,8 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable $files = $this->findFiles($path, $rootDir); // Init Blunder error handling framework - $this->runBlunder(); + //$this->runBlunder(); + //echo $wddwwqd; if (empty($files) && $this->verbose) { throw new BlunderSoftException("Unitary could not find any test files matching the pattern \"" . diff --git a/src/Support/Router.php b/src/Support/Router.php index 13f755d..f97fbf2 100644 --- a/src/Support/Router.php +++ b/src/Support/Router.php @@ -14,13 +14,16 @@ namespace MaplePHP\Unitary\Support; use InvalidArgumentException; +use MaplePHP\Emitron\Contracts\MiddlewareInterface; use MaplePHP\Unitary\Interfaces\RouterInterface; class Router implements RouterInterface { private array $controllers = []; private string $needle; + private ?array $mapId = null; private array $args; + private array $middlewares = []; public function __construct(string $needle, array $args) { @@ -38,6 +41,7 @@ public function __construct(string $needle, array $args) */ public function map(string|array $needles, array $controller, array $args = []): self { + $inst = clone $this; if (isset($args['handler'])) { throw new InvalidArgumentException('The handler argument is reserved, you can not use that key.'); } @@ -45,14 +49,31 @@ public function map(string|array $needles, array $controller, array $args = []): if (is_string($needles)) { $needles = [$needles]; } - - foreach ($needles as $key) { - $this->controllers[$key] = [ + $inst->mapId = $needles; + foreach ($inst->mapId as $key) { + $inst->controllers[$key] = [ "handler" => $controller, ...$args ]; } - return $this; + return $inst; + } + + /** + * @param MiddlewareInterface|string $middleware + * @return $this + */ + public function with(MiddlewareInterface|string $middleware): self + { + if($this->mapId === null) { + throw new \BadMethodCallException('You need to map a route before calling the with method.'); + } + $inst = clone $this; + foreach ($inst->mapId as $key) { + $inst->middlewares[$key][] = $middleware; + } + $this->mapId = null; + return $inst; } /** @@ -64,11 +85,11 @@ public function map(string|array $needles, array $controller, array $args = []): public function dispatch(callable $call): bool { if (isset($this->controllers[$this->needle])) { - $call($this->controllers[$this->needle], $this->args, $this->needle); + $call($this->controllers[$this->needle], $this->args, ($this->middlewares[$this->needle] ?? []), $this->needle); return true; } if (isset($this->controllers["__404"])) { - $call($this->controllers["__404"], $this->args, $this->needle); + $call($this->controllers["__404"], $this->args, ($this->middlewares[$this->needle] ?? []), $this->needle); } return false; } From ae2cdde82a8de16eb9796417c1cfdb42c2fb276d Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sat, 27 Sep 2025 16:34:46 +0200 Subject: [PATCH 76/78] Add junit xml format --- src/Discovery/TestDiscovery.php | 5 +- src/Renders/AbstractRenderHandler.php | 141 ++++++++++++++++++++++++++ src/Renders/CliRenderer.php | 22 ++-- src/Renders/JUnitRenderer.php | 77 +++++++------- src/TestCase.php | 54 +++++++--- src/TestUnit.php | 28 ++++- tests/TestLib/Mailer.php | 1 + tests/unitary-test-item.php | 5 +- tests/unitary-unitary.php | 20 ++-- 9 files changed, 280 insertions(+), 73 deletions(-) diff --git a/src/Discovery/TestDiscovery.php b/src/Discovery/TestDiscovery.php index 8eeb1ee..5fb5e25 100755 --- a/src/Discovery/TestDiscovery.php +++ b/src/Discovery/TestDiscovery.php @@ -168,7 +168,10 @@ public function executeAll(string $path, string|bool $rootDir = false, ?callable throw $exception; } self::$unitary->group("PHP error", function (TestCase $case) use ($exception) { - $newInst = $case->createTraceError($exception); + $newInst = $case->createTraceError($exception, trace: [ + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + ]); $newInst->incrementError(); return $newInst; }); diff --git a/src/Renders/AbstractRenderHandler.php b/src/Renders/AbstractRenderHandler.php index 3ba06f3..caf8dd4 100644 --- a/src/Renders/AbstractRenderHandler.php +++ b/src/Renders/AbstractRenderHandler.php @@ -2,9 +2,15 @@ namespace MaplePHP\Unitary\Renders; +use AssertionError; +use MaplePHP\Blunder\Exceptions\BlunderErrorException; +use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\Http\Interfaces\StreamInterface; use MaplePHP\Unitary\Interfaces\BodyInterface; +use MaplePHP\Unitary\Support\Helpers; use MaplePHP\Unitary\TestCase; +use MaplePHP\Unitary\TestItem; +use MaplePHP\Unitary\TestUnit; use RuntimeException; class AbstractRenderHandler implements BodyInterface @@ -110,8 +116,143 @@ public function getBody(): StreamInterface return $this->body; } + /** + * Get error type + * + * @param TestUnit $test + * @return string + */ + public function getType(TestUnit $test): string + { + return $this->case->getHasError() ? get_class($this->case->getThrowable()->getException()) : $test->getMessage(); + } + + + /** + * Get a expected case name/message + * + * @param TestUnit $test + * @return string + */ + public function getCaseName(TestUnit $test): string + { + $msg = $test->getMessage(); + if($msg !== "") { + return $msg; + } + return $test->isValid() ? "All validations passed" : "Checks could not be validated"; + } + + /** + * Get expected error type + * + * @param TestUnit $test + * @return string + */ + public function getErrorType(TestUnit $test): string + { + if($test->isValid()) { + return ""; + } + return $this->case->getHasError() ? "error" : "failure"; + } + + /** + * Check if Error is a PHP error, if false and has error then the error is an unhandled exception error. + * + * @return bool + */ + public function isPHPError(): bool + { + return ($this->case->getHasError() && $this->case->getThrowable()->getException() instanceof BlunderErrorException); + } + + /** + * Returns true if an assert error as been triggered + * + * @return bool + */ + public function hasAssertError(): bool + { + return $this->case->getThrowable() !== null && $this->case->getThrowable()->getException() instanceof AssertionError; + } + + /** + * Returns assert message if an assert error has been triggered + * + * @return string + */ + public function getAssertMessage(): string + { + if($this->hasAssertError()) { + return $this->case->getThrowable()->getException()->getMessage(); + } + return ""; + } + + /** + * Get error message + * + * @return string + */ + public function getErrorMessage(): string + { + if(!$this->case->getHasError()) { + return ""; + } + $cliErrorHandler = new CliHandler(); + return $cliErrorHandler->getErrorMessage($this->case->getThrowable()); + + } + + /** + * Get message, will return an autogenerated message of validation errors + * + * @param TestUnit $test + * @param TestItem $unit + * @return string + */ + public function getMessage(TestUnit $test, TestItem $unit): string + { + $lengthA = $test->getValidationLength(); + $validation = $unit->getValidationTitle(); + return " " . str_pad($validation, $lengthA) . " → failed"; + } + + /** + * @param TestItem $unit + * @param string $failedMsg + * @return string + * @throws \ErrorException + */ + public function getComparison(TestItem $unit, string $failedMsg): string + { + if ($unit->hasComparison()) { + $compare = $unit->getComparison(); + $lengthB = (strlen($compare) + strlen($failedMsg) - 8); + return str_pad($compare, $lengthB, " ", STR_PAD_LEFT); + } + + return ""; + } + + /** + * Get input value as string + * + * Note: Will stringify every value into something more readable + * + * @param TestUnit $test + * @return string + * @throws \ErrorException + */ + public function getValue(TestUnit $test): string + { + return Helpers::stringifyDataTypes($test->getValue()); + } + /** * Make a file path into a title + * * @param string $file * @param int $length * @param bool $removeSuffix diff --git a/src/Renders/CliRenderer.php b/src/Renders/CliRenderer.php index a4ded2b..d0495c6 100644 --- a/src/Renders/CliRenderer.php +++ b/src/Renders/CliRenderer.php @@ -132,11 +132,13 @@ protected function showFailedTests(): void } if (!$test->isValid()) { + $errorType = $this->getErrorType($test); + //$type = $this->getType($test); $msg = (string)$test->getMessage(); $this->command->message(""); $this->command->message( - $this->command->getAnsi()->style(["bold", $this->color], "Error: ") . - $this->command->getAnsi()->bold($msg) + $this->command->getAnsi()->style(["bold", $this->color], ucfirst($errorType) . ": ") . + $this->command->getAnsi()->bold(($msg !== "" ? $msg : $this->getCaseName($test))) ); $this->command->message(""); @@ -150,19 +152,13 @@ protected function showFailedTests(): void /** @var TestItem $unit */ if (!$unit->isValid()) { - $lengthA = $test->getValidationLength(); - $validation = $unit->getValidationTitle(); - $title = str_pad($validation, $lengthA); - $compare = $unit->hasComparison() ? $unit->getComparison() : ""; - - $failedMsg = " " .$title . " → failed"; + $failedMsg = $this->getMessage($test, $unit); + $compare = $this->getComparison($unit, $failedMsg); $this->command->message($this->command->getAnsi()->style($this->color, $failedMsg)); - if ($compare) { - $lengthB = (strlen($compare) + strlen($failedMsg) - 8); - $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); + if ($compare !== "") { $this->command->message( - $this->command->getAnsi()->style($this->color, $comparePad) + $this->command->getAnsi()->style($this->color, $compare) ); } } @@ -171,7 +167,7 @@ protected function showFailedTests(): void $this->command->message(""); $this->command->message( $this->command->getAnsi()->bold("Input value: ") . - Helpers::stringifyDataTypes($test->getValue()) + $this->getValue($test) ); } } diff --git a/src/Renders/JUnitRenderer.php b/src/Renders/JUnitRenderer.php index 12422e9..c9dbb5f 100644 --- a/src/Renders/JUnitRenderer.php +++ b/src/Renders/JUnitRenderer.php @@ -3,6 +3,8 @@ namespace MaplePHP\Unitary\Renders; use ErrorException; +use MaplePHP\Blunder\Exceptions\BlunderErrorException; +use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\DTO\Format\Clock; use MaplePHP\Prompts\Command; use MaplePHP\Unitary\TestItem; @@ -31,14 +33,12 @@ public function __construct(XMLWriter $xml) */ public function buildBody(): void { - - - $testFile = $this->formatFileTitle($this->suitName, 3, false); + //$testFile = $this->formatFileTitle($this->suitName, 3, false); $msg = (string)$this->case->getMessage(); $duration = (string)$this->case->getDuration(6); $this->xml->startElement('testsuite'); - $this->xml->writeAttribute('name', $testFile); + $this->xml->writeAttribute('name', $msg); $this->xml->writeAttribute('tests', (string)$this->case->getCount()); $this->xml->writeAttribute('failures', (string)$this->case->getFailedCount()); $this->xml->writeAttribute('errors', (string)$this->case->getErrors()); @@ -46,66 +46,71 @@ public function buildBody(): void $this->xml->writeAttribute('time', $duration); $this->xml->writeAttribute('timestamp', Clock::value("now")->iso()); - foreach ($this->tests as $test) { - if (!($test instanceof TestUnit)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); } - - $value = Helpers::stringifyDataTypes($test->getValue()); - $value = str_replace('"', "'", $value); - + $caseMsg = str_replace('"', "'", (string)$this->getCaseName($test)); $this->xml->startElement('testcase'); - - $this->xml->writeAttribute('classname', $this->checksum); + $this->xml->writeAttribute('name', $caseMsg); if($this->case->getConfig()->select) { - $this->xml->writeAttribute('testname', $this->case->getConfig()->select); + $this->xml->writeAttribute('name', $this->case->getConfig()->select); } - $this->xml->writeAttribute('name', $msg); + $this->xml->writeAttribute('id', $this->checksum); $this->xml->writeAttribute('time', $duration); if (!$test->isValid()) { $trace = $test->getCodeLine(); $this->xml->writeAttribute('file', $trace['file']); $this->xml->writeAttribute('line', $trace['line']); - $this->xml->writeAttribute('value', $value); - - - + $errorType = $this->getErrorType($test); + $type = str_replace('"', "'", $this->getType($test)); foreach ($test->getUnits() as $unit) { /** @var TestItem $unit */ if (!$unit->isValid()) { + $this->xml->startElement($errorType); + $this->xml->writeAttribute('type', $type); - $validation = $unit->getValidationTitle(); - $compare = $unit->hasComparison() ? ": " . $unit->getComparison() : ""; - $compare = str_replace('"', "'", $compare); - $type = str_replace('"', "'", $test->getMessage()); - - $tag = $this->case->getHasError() ? "error" : "failure"; + if($this->case->getHasError()) { + $errorMsg = ($this->isPHPError()) ? "PHP Error" : "Unhandled exception"; + $this->xml->writeAttribute('message', $errorMsg); + $this->xml->writeCdata("\n" .$this->getErrorMessage()); - $this->xml->startElement($tag); - $this->xml->writeAttribute('message', $validation . $compare); - $this->xml->writeAttribute('type', $type); + } else { + $testMsg = (string)$test->getMessage(); + $failedMsg = $this->getMessage($test, $unit); + $compare = $this->getComparison($unit, $failedMsg); - $this->xml->endElement(); - } + $output = "\n\n"; + $output .= ucfirst($errorType) . ": " . ($testMsg !== "" ? $testMsg : $caseMsg) ."\n\n"; + $output .= "Failed on {$trace['file']}:{$trace['line']}\n"; + $output .= " → {$trace['code']}\n"; + $output .= $this->getMessage($test, $unit) . "\n"; - } + if($compare !== "") { + $output .= $compare . "\n"; + } + if ($test->hasValue()) { + $output .= "\nInput value: " . $this->getValue($test) . "\n"; + } + $output .= "\n"; - //$this->xml->writeCData($t['details']); + $validation = $unit->getValidationTitle(); + $compare = $unit->hasComparison() ? ": " . $unit->getComparison() : ""; + $compare = str_replace('"', "'", $compare); + $this->xml->writeAttribute('message', $this->hasAssertError() ? $this->getAssertMessage() : $validation . $compare); + $this->xml->writeCdata($output); + } + $this->xml->endElement(); + } + } } - $this->xml->endElement(); - - } - - $this->xml->endElement(); /* diff --git a/src/TestCase.php b/src/TestCase.php index b259d5d..9c64ec3 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -17,6 +17,7 @@ use Closure; use ErrorException; use Exception; +use MaplePHP\Blunder\ExceptionItem; use MaplePHP\Blunder\Exceptions\BlunderErrorException; use MaplePHP\DTO\Format\Str; use MaplePHP\DTO\Traverse; @@ -59,6 +60,7 @@ final class TestCase private float $duration = 0; private bool $hasAssertError = false; private bool $failFast = false; + private ?ExceptionItem $throwable = null; /** * Initialize a new TestCase instance with an optional message. @@ -174,6 +176,16 @@ public function getHasError(): bool return ($this->hasError > 0); } + /** + * If an error as occurred then you can access the error object through this method + * + * @return ExceptionItem|null + */ + public function getThrowable(): ?ExceptionItem + { + return $this->throwable; + } + /** * Sets the assertion error flag to true * @@ -222,7 +234,7 @@ public function warning(string $message): self * @param ?string $message * @return $this */ - public function error(?string $message): self + public function describe(?string $message): self { if ($message !== null) { $this->error = $message; @@ -230,10 +242,16 @@ public function error(?string $message): self return $this; } - // Alias to error + // Alias to describe + public function error(?string $message): self + { + return $this->describe($message); + } + + // Alias to describe public function message(?string $message): self { - return $this->error($message); + return $this->describe($message); } /** @@ -269,7 +287,12 @@ public function dispatchTest(self &$row): array if($this->failFast) { throw $e; } - $newInst = $this->createTraceError($e); + + $newInst = $this->createTraceError($e, trace: [ + "file" => $e->getFile(), + "line" => $e->getLine(), + ]); + $newInst->incrementError(); } if ($newInst instanceof self) { @@ -286,16 +309,15 @@ public function dispatchTest(self &$row): array * * @param mixed $expect The expected value * @param Closure(Expect, Traverse): bool $validation - * @return $this + * @return TestUnit * @throws ErrorException */ - public function validate(mixed $expect, Closure $validation): self + public function validate(mixed $expect, Closure $validation): TestUnit { - $this->expectAndValidate($expect, function (mixed $value, Expect $inst) use ($validation) { + return $this->expectAndValidate($expect, function (mixed $value, Expect $inst) use ($validation) { return $validation($inst, new Traverse($value)); }, $this->error); - return $this; } /** @@ -325,7 +347,7 @@ public function assert(bool $expect, ?string $message = null): self * @param array|Closure $validation A list of validation methods with arguments, * or a closure defining the test logic. * @param string|null $message Optional custom message for test reporting. - * @return $this + * @return TestUnit * @throws ErrorException If validation fails during runtime execution. */ protected function expectAndValidate( @@ -334,7 +356,7 @@ protected function expectAndValidate( ?string $message = null, ?string $description = null, ?array $trace = null - ): self { + ): TestUnit { $this->value = $expect; $test = new TestUnit($message); $test->setTestValue($this->value); @@ -389,7 +411,7 @@ protected function expectAndValidate( } $this->test[] = $test; $this->error = null; - return $this; + return $test; } /** @@ -397,10 +419,11 @@ protected function expectAndValidate( * * @param Throwable $exception * @param string|null $title + * @param array|null $trace * @return $this * @throws ErrorException */ - public function createTraceError(Throwable $exception, ?string $title = null): self + public function createTraceError(Throwable $exception, ?string $title = null, ?array $trace = null): self { $newInst = clone $this; $message = Helpers::getExceptionMessage($exception, $exceptionItem); @@ -410,8 +433,10 @@ public function createTraceError(Throwable $exception, ?string $title = null): s fn () => false, $title, $message, - $exception->getTrace()[0] + ($trace !== null) ? $trace : $exception->getTrace()[0] ); + + $newInst->throwable = $exceptionItem; return $newInst; } @@ -446,7 +471,8 @@ public function deferValidation(Closure $validation): void */ public function add(mixed $expect, array|Closure $validation, ?string $message = null): TestCase { - return $this->expectAndValidate($expect, $validation, $message); + $this->expectAndValidate($expect, $validation, $message); + return $this; } /** diff --git a/src/TestUnit.php b/src/TestUnit.php index 122c261..ec54962 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -35,7 +35,33 @@ final class TestUnit public function __construct(?string $message = null) { $this->valid = true; - $this->message = $message === null ? "Could not validate" : $message; + $this->message = $message === null ? "" : $message; + } + + /** + * Add custom error message if validation fails + * + * @param ?string $message + * @return $this + */ + public function describe(?string $message): self + { + if ($message !== null) { + $this->message = $message; + } + return $this; + } + + // Alias to describe + public function error(?string $message): self + { + return $this->describe($message); + } + + // Alias to describe + public function message(?string $message): self + { + return $this->describe($message); } /** diff --git a/tests/TestLib/Mailer.php b/tests/TestLib/Mailer.php index a31a19b..64fe41d 100644 --- a/tests/TestLib/Mailer.php +++ b/tests/TestLib/Mailer.php @@ -13,6 +13,7 @@ public function __construct() } + public function send(): string { $this->sendEmail($this->getFromEmail()); diff --git a/tests/unitary-test-item.php b/tests/unitary-test-item.php index 1332c36..712b284 100644 --- a/tests/unitary-test-item.php +++ b/tests/unitary-test-item.php @@ -17,8 +17,9 @@ ->setHasArgs(true); $case->validate($item->isValid(), function(Expect $valid) { - $valid->isTrue(); - }); + $valid->isFalse(); + + })->describe("Testing TestItem is validMethod"); $case->validate($item->getValidation(), function(Expect $valid) { $valid->isEqualTo("validation"); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 4330ebc..f722544 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -12,10 +12,8 @@ $config = TestConfig::make()->withName("unitary"); - group($config->withSubject("Test mocker"), function (TestCase $case) { - throw new Exception("dwddwqdwdwdqdq"); - echo $wdwdw; + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") ->withArguments("john.doe@gmail.com", "John Doe") @@ -26,7 +24,7 @@ }); group("Example of assert in group", function(TestCase $case) { - assert(1 === 2, "This is a error message"); + assert(1 === 2, "Should return true"); }); group($config->withSubject("Can not mock final or private"), function(TestCase $case) { @@ -48,7 +46,11 @@ }); -group($config->withSubject("Test mocker"), function (TestCase $case) { +group($config->withSubject("Test mocker")->withName("wdqdwdwq"), function (TestCase $case) { + + + //echo $wdqdw; + //throw new InvalidArgumentException("dwqdwqdw"); $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { $method->method("addFromEmail") @@ -249,7 +251,13 @@ group("Example API Response", function(TestCase $case) { - $case->validate('{"response":{"status":200,"message":"ok"}}', function(Expect $expect) { + $case->error("qwdwqqdwdwq 1")->validate('{"response":{"status":200,"message":"ok"}}', function(Expect $expect) { + + $expect->isJson()->hasJsonValueAt("response.status", 404); + assert($expect->isValid(), "Expected JSON structure did not match."); + }); + + $case->error("qwdwqqdwdwq 2")->validate('{"response":{"status":501,"message":"Server"}}', function(Expect $expect) { $expect->isJson()->hasJsonValueAt("response.status", 404); assert($expect->isValid(), "Expected JSON structure did not match."); From e7b55dae26f568c31d6c71fe2bc8b03ff0ed02b2 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sat, 4 Oct 2025 11:51:03 +0200 Subject: [PATCH 77/78] refactoring and structure MVC fix: junit structure --- src/Console/ConsoleRouter.php | 10 +- src/Console/Controllers/DefaultController.php | 4 +- src/Console/Controllers/HelpController.php | 98 +++++++++++++++++++ src/Console/Controllers/RunTestController.php | 51 ---------- src/Renders/AbstractRenderHandler.php | 5 +- src/Renders/JUnitRenderer.php | 21 ++-- tests/unitary-unitary.php | 2 +- 7 files changed, 121 insertions(+), 70 deletions(-) create mode 100644 src/Console/Controllers/HelpController.php diff --git a/src/Console/ConsoleRouter.php b/src/Console/ConsoleRouter.php index e5e2d88..926cfba 100644 --- a/src/Console/ConsoleRouter.php +++ b/src/Console/ConsoleRouter.php @@ -1,13 +1,17 @@ map(["", "test", "run"], [RunTestController::class, "run"])->with(TestMiddleware::class) return $router ->map("coverage", [CoverageController::class, "run"]) ->map("template", [TemplateController::class, "run"]) ->map("junit", [RunTestController::class, "runJUnit"]) - ->map(["", "test", "run"], [RunTestController::class, "run"])->with(TestMiddleware::class) - ->map(["__404", "help"], [RunTestController::class, "help"]); + ->map(["", "test", "run"], [RunTestController::class, "run"]) + ->map(["__404", "help"], [HelpController::class, "index"]); diff --git a/src/Console/Controllers/DefaultController.php b/src/Console/Controllers/DefaultController.php index 2633c42..69f04dc 100644 --- a/src/Console/Controllers/DefaultController.php +++ b/src/Console/Controllers/DefaultController.php @@ -2,7 +2,6 @@ namespace MaplePHP\Unitary\Console\Controllers; -use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\Blunder\Interfaces\HandlerInterface; use MaplePHP\Container\Interfaces\ContainerExceptionInterface; use MaplePHP\Container\Interfaces\ContainerInterface; @@ -12,7 +11,6 @@ use MaplePHP\Http\Interfaces\ServerRequestInterface; use MaplePHP\Prompts\Command; use MaplePHP\Unitary\Config\ConfigProps; -use MaplePHP\Unitary\Console\Services\ErrorHandlerService; abstract class DefaultController { @@ -30,7 +28,7 @@ abstract class DefaultController * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface */ - public function __construct(ContainerInterface $container, ?HandlerInterface $handler = null) + public function __construct(ContainerInterface $container) { $this->container = $container; $this->args = $this->container->get("args"); diff --git a/src/Console/Controllers/HelpController.php b/src/Console/Controllers/HelpController.php new file mode 100644 index 0000000..0d977e3 --- /dev/null +++ b/src/Console/Controllers/HelpController.php @@ -0,0 +1,98 @@ +command); + $blocks->addHeadline("\n--- Unitary Help ---"); + $blocks->addSection("Usage", "php vendor/bin/unitary [function] [options]"); + + $blocks->addSection("Options", function (Blocks $inst) { + return $inst + ->addOption("--help", "Show this help message") + ->addOption("--show=", "Run a specific test by hash or manual test name") + ->addOption("--errorsOnly", "Show only failing tests and skip passed test output") + ->addOption("--path=", "Specify test path (absolute or relative)") + ->addOption("--exclude=", "Exclude files or directories (comma-separated, relative to --path)") + ->addOption("--smartSearch", "If no test is found in sub-directory then Unitary will try to traverse back and auto find tests.") + ; + }); + + + $blocks->addSection("Function list", function (Blocks $inst) { + return $inst + ->addOption("template", "Will give you a boilerplate test code") + ->addOption("coverage", "Will show you a how much code this is used"); + }); + + + $blocks->addSection("Examples", function (Blocks $inst) { + return $inst + ->addExamples( + "php vendor/bin/unitary", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary run", + "Same as above" + )->addExamples( + "php vendor/bin/unitary --show=b0620ca8ef6ea7598e5ed56a530b1983", + "Run the test with a specific hash ID" + )->addExamples( + "php vendor/bin/unitary --show=YourNameHere", + "Run a manually named test case" + )->addExamples( + "php vendor/bin/unitary coverage", + "Run a and will give you template code for a new test" + )->addExamples( + 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', + 'Run all tests under "tests/" excluding specified directories' + ); + }); + // Make sure nothing else is executed when help is triggered + exit(0); + } + + /** + * Create a footer showing and end of script command + * + * This is not really part of the Unit test library, as other stuff might be present here + * + * @return void + */ + protected function buildFooter(): void + { + $inst = TestDiscovery::getUnitaryInst(); + if ($inst !== null) { + $dot = $this->command->getAnsi()->middot(); + $peakMemory = (string)round(memory_get_peak_usage() / 1024, 2); + + $this->command->message( + $this->command->getAnsi()->style( + ["italic", "grey"], + "Total tests: " . $inst::getPassedTests() . "/" . $inst::getTotalTests() . " $dot " . + "Errors: " . $inst::getTotalErrors() . " $dot " . + "Peak memory usage: " . $peakMemory . " KB" + ) + ); + $this->command->message(""); + } + + } + +} diff --git a/src/Console/Controllers/RunTestController.php b/src/Console/Controllers/RunTestController.php index 9d6f366..c71e8b4 100644 --- a/src/Console/Controllers/RunTestController.php +++ b/src/Console/Controllers/RunTestController.php @@ -6,7 +6,6 @@ use MaplePHP\Unitary\Renders\CliRenderer; use MaplePHP\Unitary\Console\Services\RunTestService; use MaplePHP\Http\Interfaces\ResponseInterface; -use MaplePHP\Prompts\Themes\Blocks; use MaplePHP\Unitary\Renders\JUnitRenderer; class RunTestController extends DefaultController @@ -60,54 +59,6 @@ public function runJUnit(RunTestService $service): ResponseInterface return $response; } - - /** - * Main help page - * - * @return void - */ - public function help(): void - { - $blocks = new Blocks($this->command); - $blocks->addHeadline("\n--- Unitary Help ---"); - $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); - - $blocks->addSection("Options", function (Blocks $inst) { - return $inst - ->addOption("help", "Show this help message") - ->addOption("show=", "Run a specific test by hash or manual test name") - ->addOption("errorsOnly", "Show only failing tests and skip passed test output") - ->addOption("template", "Will give you a boilerplate test code") - ->addOption("path=", "Specify test path (absolute or relative)") - ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); - }); - - $blocks->addSection("Examples", function (Blocks $inst) { - return $inst - ->addExamples( - "php vendor/bin/unitary", - "Run all tests in the default path (./tests)" - )->addExamples( - "php vendor/bin/unitary --show=b0620ca8ef6ea7598e5ed56a530b1983", - "Run the test with a specific hash ID" - )->addExamples( - "php vendor/bin/unitary --errorsOnly", - "Run all tests in the default path (./tests)" - )->addExamples( - "php vendor/bin/unitary --show=YourNameHere", - "Run a manually named test case" - )->addExamples( - "php vendor/bin/unitary --template", - "Run a and will give you template code for a new test" - )->addExamples( - 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', - 'Run all tests under "tests/" excluding specified directories' - ); - }); - // Make sure nothing else is executed when help is triggered - exit(0); - } - /** * Create a footer showing and end of script command * @@ -132,7 +83,5 @@ protected function buildFooter(): void ); $this->command->message(""); } - } - } diff --git a/src/Renders/AbstractRenderHandler.php b/src/Renders/AbstractRenderHandler.php index caf8dd4..799b6f7 100644 --- a/src/Renders/AbstractRenderHandler.php +++ b/src/Renders/AbstractRenderHandler.php @@ -119,12 +119,11 @@ public function getBody(): StreamInterface /** * Get error type * - * @param TestUnit $test * @return string */ - public function getType(TestUnit $test): string + public function getType(): string { - return $this->case->getHasError() ? get_class($this->case->getThrowable()->getException()) : $test->getMessage(); + return $this->case->getHasError() ? get_class($this->case->getThrowable()->getException()) : "Validation error"; } diff --git a/src/Renders/JUnitRenderer.php b/src/Renders/JUnitRenderer.php index c9dbb5f..85b76dc 100644 --- a/src/Renders/JUnitRenderer.php +++ b/src/Renders/JUnitRenderer.php @@ -34,8 +34,9 @@ public function __construct(XMLWriter $xml) public function buildBody(): void { //$testFile = $this->formatFileTitle($this->suitName, 3, false); + $basename = basename($this->suitName); $msg = (string)$this->case->getMessage(); - $duration = (string)$this->case->getDuration(6); + $duration = number_format($this->case->getDuration(6), 6, '.', ''); $this->xml->startElement('testsuite'); $this->xml->writeAttribute('name', $msg); @@ -45,6 +46,10 @@ public function buildBody(): void $this->xml->writeAttribute('skipped', (string)$this->case->getSkipped()); $this->xml->writeAttribute('time', $duration); $this->xml->writeAttribute('timestamp', Clock::value("now")->iso()); + $this->xml->writeAttribute('id', $this->checksum); + if($this->case->getConfig()->select) { + $this->xml->writeAttribute('name', $this->case->getConfig()->select); + } foreach ($this->tests as $test) { if (!($test instanceof TestUnit)) { @@ -52,19 +57,17 @@ public function buildBody(): void } $caseMsg = str_replace('"', "'", (string)$this->getCaseName($test)); $this->xml->startElement('testcase'); + $this->xml->writeAttribute('classname', $basename); $this->xml->writeAttribute('name', $caseMsg); - if($this->case->getConfig()->select) { - $this->xml->writeAttribute('name', $this->case->getConfig()->select); - } - $this->xml->writeAttribute('id', $this->checksum); + + $this->xml->writeAttribute('time', $duration); if (!$test->isValid()) { - $trace = $test->getCodeLine(); - $this->xml->writeAttribute('file', $trace['file']); - $this->xml->writeAttribute('line', $trace['line']); + //$this->xml->writeAttribute('file', $trace['file']); + //$this->xml->writeAttribute('line', $trace['line']); $errorType = $this->getErrorType($test); - $type = str_replace('"', "'", $this->getType($test)); + $type = str_replace('"', "'", $this->getType()); foreach ($test->getUnits() as $unit) { diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index f722544..82bf26a 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -259,7 +259,7 @@ $case->error("qwdwqqdwdwq 2")->validate('{"response":{"status":501,"message":"Server"}}', function(Expect $expect) { - $expect->isJson()->hasJsonValueAt("response.status", 404); + $expect->isJson()->hasJsonValueAt("response.status", 501); assert($expect->isValid(), "Expected JSON structure did not match."); }); From 8e649b9b522726d8e52f01076da90d2b2f09e45b Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Sat, 4 Oct 2025 17:55:46 +0200 Subject: [PATCH 78/78] junit structure and attributes Cleaning up code and test Improve presenation of errors and exceptions --- src/Config/ConfigProps.php | 11 +- src/Console/Controllers/HelpController.php | 21 +- src/Console/Enum/UnitStatusType.php | 28 +++ src/Console/Middlewares/LocalMiddleware.php | 2 +- src/Discovery/TestDiscovery.php | 4 +- src/Renders/AbstractRenderHandler.php | 64 ++++- src/Renders/CliRenderer.php | 41 ++-- src/Renders/JUnitRenderer.php | 247 +++++--------------- src/Support/Helpers.php | 14 +- src/TestCase.php | 10 +- src/TestUnit.php | 39 +++- src/Unit.php | 32 ++- tests/unitary-mock.php | 173 ++++++++++++++ tests/unitary-test-item.php | 2 +- tests/unitary-test.php | 24 -- tests/unitary-unitary.php | 243 +------------------ unitary.config.php | 2 +- 17 files changed, 461 insertions(+), 496 deletions(-) create mode 100644 src/Console/Enum/UnitStatusType.php create mode 100755 tests/unitary-mock.php delete mode 100755 tests/unitary-test.php diff --git a/src/Config/ConfigProps.php b/src/Config/ConfigProps.php index cff61c1..e86b850 100644 --- a/src/Config/ConfigProps.php +++ b/src/Config/ConfigProps.php @@ -24,7 +24,7 @@ class ConfigProps extends AbstractConfigProps public ?string $exclude = null; public ?string $show = null; public ?string $timezone = null; - public ?string $local = null; + public ?string $locale = null; public ?int $exitCode = null; public ?bool $verbose = null; public ?bool $alwaysShowFiles = null; @@ -32,7 +32,6 @@ class ConfigProps extends AbstractConfigProps public ?bool $smartSearch = null; public ?bool $failFast = null; - /** * Hydrate the properties/object with expected data, and handle unexpected data * @@ -59,12 +58,12 @@ protected function propsHydration(string $key, mixed $value): void // The default timezone is 'CET' $this->timezone = (!is_string($value) || $value === '') ? 'Europe/Stockholm' : $value; break; - case 'local': + case 'locale': // The default timezone is 'CET' - $this->local = (!is_string($value) || $value === '') ? 'en_US' : $value; - if(!$this->isValidLocale($this->local)) { + $this->locale = (!is_string($value) || $value === '') ? 'en_US' : $value; + if(!$this->isValidLocale($this->locale)) { throw new InvalidArgumentException( - "Invalid locale '{$this->local}'. Expected format like 'en_US' (language_COUNTRY)." + "Invalid locale '{$this->locale}'. Expected format like 'en_US' (language_COUNTRY)." ); } break; diff --git a/src/Console/Controllers/HelpController.php b/src/Console/Controllers/HelpController.php index 0d977e3..c41ac2a 100644 --- a/src/Console/Controllers/HelpController.php +++ b/src/Console/Controllers/HelpController.php @@ -25,16 +25,23 @@ public function index(): void $blocks->addSection("Options", function (Blocks $inst) { return $inst - ->addOption("--help", "Show this help message") - ->addOption("--show=", "Run a specific test by hash or manual test name") - ->addOption("--errorsOnly", "Show only failing tests and skip passed test output") - ->addOption("--path=", "Specify test path (absolute or relative)") - ->addOption("--exclude=", "Exclude files or directories (comma-separated, relative to --path)") - ->addOption("--smartSearch", "If no test is found in sub-directory then Unitary will try to traverse back and auto find tests.") + ->addOption("--help", "Display this help message.") + ->addOption("--show=", "Run a specific test by hash or test name.") + ->addOption("--errorsOnly", "Show only failing tests, hide passed ones.") + ->addOption("--path=", "Set test path (absolute or relative).") + ->addOption("--exclude=", "Exclude files or directories (comma-separated, relative to --path).") + ->addOption("--discoverPattern", "Override test discovery pattern (`tests/` directories or default: `unitary-*.php` files).") + ->addOption("--smartSearch", "If no tests are found in a subdirectory, Unitary will traverse down to locate tests automatically.") + ->addOption("--alwaysShowFiles", "Always display full test file paths, even for passing tests.") + ->addOption("--timezone=", "Set default timezone (e.g. `Europe/Stockholm`). Affects date handling.") + ->addOption("--locale=", "Set default locale (e.g. `en_US`). Affects date formatting.") + ->addOption("--verbose", "Show all warnings, including hidden ones.") + ->addOption("--failFast", "Stop immediately on the first error or exception.") ; }); + $blocks->addSection("Function list", function (Blocks $inst) { return $inst ->addOption("template", "Will give you a boilerplate test code") @@ -64,8 +71,6 @@ public function index(): void 'Run all tests under "tests/" excluding specified directories' ); }); - // Make sure nothing else is executed when help is triggered - exit(0); } /** diff --git a/src/Console/Enum/UnitStatusType.php b/src/Console/Enum/UnitStatusType.php new file mode 100644 index 0000000..89e1680 --- /dev/null +++ b/src/Console/Enum/UnitStatusType.php @@ -0,0 +1,28 @@ + 'failure', + self::Error => 'error', + }; + } +} diff --git a/src/Console/Middlewares/LocalMiddleware.php b/src/Console/Middlewares/LocalMiddleware.php index 28dce98..3712a53 100644 --- a/src/Console/Middlewares/LocalMiddleware.php +++ b/src/Console/Middlewares/LocalMiddleware.php @@ -39,7 +39,7 @@ public function __construct(ContainerInterface $container) public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $props = $this->container->get("props"); - Clock::setDefaultLocale($props->local); + Clock::setDefaultLocale($props->locale); Clock::setDefaultTimezone($props->timezone); return $handler->handle($request); } diff --git a/src/Discovery/TestDiscovery.php b/src/Discovery/TestDiscovery.php index 5fb5e25..361d855 100755 --- a/src/Discovery/TestDiscovery.php +++ b/src/Discovery/TestDiscovery.php @@ -209,11 +209,11 @@ private function executeUnitFile(string $file): void if (!$ok && $verbose) { trigger_error( - "Could not find any tests inside the test file:\n$file\n\nPossible causes:\n" . + "\n\nCould not find any tests inside the test file:\n$file\n\nPossible causes:\n" . " • There are no test in test group/case.\n" . " • Unitary could not locate the Unit instance.\n" . " • You did not use the `group()` function.\n" . - " • You created a new Unit in the test file but did not return it at the end.", + " • You created a new Unit in the test file but did not return it at the end.\n\n", E_USER_WARNING ); } diff --git a/src/Renders/AbstractRenderHandler.php b/src/Renders/AbstractRenderHandler.php index 799b6f7..e00f049 100644 --- a/src/Renders/AbstractRenderHandler.php +++ b/src/Renders/AbstractRenderHandler.php @@ -3,6 +3,7 @@ namespace MaplePHP\Unitary\Renders; use AssertionError; +use MaplePHP\Blunder\ExceptionItem; use MaplePHP\Blunder\Exceptions\BlunderErrorException; use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\Http\Interfaces\StreamInterface; @@ -119,11 +120,13 @@ public function getBody(): StreamInterface /** * Get error type * + * @param TestUnit $test * @return string */ - public function getType(): string + public function getType(TestUnit $test): string { - return $this->case->getHasError() ? get_class($this->case->getThrowable()->getException()) : "Validation error"; + $throwable = $this->getThrowable($test); + return $throwable !== null ? get_class($throwable->getException()) : "Validation error"; } @@ -153,17 +156,35 @@ public function getErrorType(TestUnit $test): string if($test->isValid()) { return ""; } - return $this->case->getHasError() ? "error" : "failure"; + return ($test->hasError() || $this->case->getHasError()) ? "error" : "failure"; + } + + /** + * Get throwable from TestCase or TestUnit + * @param TestUnit $test + * @return ExceptionItem|null + */ + public function getThrowable(TestUnit $test): ?ExceptionItem + { + if(!$this->case->getHasError() && !$test->hasError()) { + return null; + } + if($this->case->getThrowable() !== null) { + return $this->case->getThrowable(); + } + return $test->getThrowable(); } /** * Check if Error is a PHP error, if false and has error then the error is an unhandled exception error. * + * @param TestUnit $test * @return bool */ - public function isPHPError(): bool + public function isPHPError(TestUnit $test): bool { - return ($this->case->getHasError() && $this->case->getThrowable()->getException() instanceof BlunderErrorException); + $throwable = $this->getThrowable($test); + return ($throwable !== null && $throwable->getException() instanceof BlunderErrorException); } /** @@ -189,19 +210,46 @@ public function getAssertMessage(): string return ""; } + /** + * Get class name + * + * @return string + */ + protected function getClassName(): string + { + return str_replace(['_', '-'], '.', basename($this->suitName, ".php")); + } + /** * Get error message * + * @param TestUnit $test * @return string */ - public function getErrorMessage(): string + public function getErrorMessage(TestUnit $test): string { - if(!$this->case->getHasError()) { + $throwable = $this->getThrowable($test); + if($throwable === null) { return ""; } $cliErrorHandler = new CliHandler(); - return $cliErrorHandler->getErrorMessage($this->case->getThrowable()); + return $cliErrorHandler->getErrorMessage($throwable); + } + /** + * Get error message + * + * @param TestUnit $test + * @return string + */ + public function getSmallErrorMessage(TestUnit $test): string + { + $throwable = $this->getThrowable($test); + if($throwable === null) { + return ""; + } + $cliErrorHandler = new CliHandler(); + return $cliErrorHandler->getSmallErrorMessage($throwable); } /** diff --git a/src/Renders/CliRenderer.php b/src/Renders/CliRenderer.php index d0495c6..7611678 100644 --- a/src/Renders/CliRenderer.php +++ b/src/Renders/CliRenderer.php @@ -3,12 +3,9 @@ namespace MaplePHP\Unitary\Renders; use ErrorException; -use MaplePHP\Http\Interfaces\StreamInterface; use MaplePHP\Prompts\Command; use MaplePHP\Unitary\TestItem; use MaplePHP\Unitary\TestUnit; -use MaplePHP\Unitary\Support\Helpers; -use MaplePHP\Unitary\Unit; use RuntimeException; class CliRenderer extends AbstractRenderHandler @@ -148,18 +145,36 @@ protected function showFailedTests(): void $this->command->message($this->command->getAnsi()->style(["grey"], " → {$trace['code']}")); } - foreach ($test->getUnits() as $unit) { + if($test->hasError()) { + // IF error has been triggered in validation closure + if($this->show) { + $this->command->message($this->getErrorMessage($test)); + } else { + $this->command->message($this->getSmallErrorMessage($test) . "→ failed"); + } + + } else { + + foreach ($test->getUnits() as $unit) { + + /** @var TestItem $unit */ + if (!$unit->isValid()) { + + + if($this->show && $this->case->getHasError()) { + $this->command->message($this->getErrorMessage($test)); + } else { - /** @var TestItem $unit */ - if (!$unit->isValid()) { - $failedMsg = $this->getMessage($test, $unit); - $compare = $this->getComparison($unit, $failedMsg); - $this->command->message($this->command->getAnsi()->style($this->color, $failedMsg)); + $failedMsg = $this->getMessage($test, $unit); + $compare = $this->getComparison($unit, $failedMsg); + $this->command->message($this->command->getAnsi()->style($this->color, $failedMsg)); - if ($compare !== "") { - $this->command->message( - $this->command->getAnsi()->style($this->color, $compare) - ); + if ($compare !== "") { + $this->command->message( + $this->command->getAnsi()->style($this->color, $compare) + ); + } + } } } } diff --git a/src/Renders/JUnitRenderer.php b/src/Renders/JUnitRenderer.php index 85b76dc..447f74c 100644 --- a/src/Renders/JUnitRenderer.php +++ b/src/Renders/JUnitRenderer.php @@ -2,22 +2,15 @@ namespace MaplePHP\Unitary\Renders; -use ErrorException; -use MaplePHP\Blunder\Exceptions\BlunderErrorException; -use MaplePHP\Blunder\Handlers\CliHandler; use MaplePHP\DTO\Format\Clock; -use MaplePHP\Prompts\Command; use MaplePHP\Unitary\TestItem; use MaplePHP\Unitary\TestUnit; -use MaplePHP\Unitary\Support\Helpers; use RuntimeException; use XMLWriter; class JUnitRenderer extends AbstractRenderHandler { private XMLWriter $xml; - private string $color; - private string $flag; /** * Pass the main command and stream to handler @@ -33,8 +26,7 @@ public function __construct(XMLWriter $xml) */ public function buildBody(): void { - //$testFile = $this->formatFileTitle($this->suitName, 3, false); - $basename = basename($this->suitName); + $className = $this->getClassName(); $msg = (string)$this->case->getMessage(); $duration = number_format($this->case->getDuration(6), 6, '.', ''); @@ -51,95 +43,95 @@ public function buildBody(): void $this->xml->writeAttribute('name', $this->case->getConfig()->select); } + if ($this->show || $this->alwaysShowFiles || $this->verbose) { + $this->xml->writeAttribute('file', $this->suitName); + } + foreach ($this->tests as $test) { if (!($test instanceof TestUnit)) { throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); } $caseMsg = str_replace('"', "'", (string)$this->getCaseName($test)); $this->xml->startElement('testcase'); - $this->xml->writeAttribute('classname', $basename); + $this->xml->writeAttribute('classname', $className); $this->xml->writeAttribute('name', $caseMsg); - - $this->xml->writeAttribute('time', $duration); if (!$test->isValid()) { $trace = $test->getCodeLine(); //$this->xml->writeAttribute('file', $trace['file']); //$this->xml->writeAttribute('line', $trace['line']); $errorType = $this->getErrorType($test); - $type = str_replace('"', "'", $this->getType()); - - foreach ($test->getUnits() as $unit) { + $type = str_replace('"', "'", $this->getType($test)); + //$errorMsg = ($this->isPHPError($test)) ? "PHP Error" : "Unhandled exception"; + if($test->hasError()) { + // IF error has been triggered in validation closure + $this->buildErrors($test, $errorType, $type); - /** @var TestItem $unit */ - if (!$unit->isValid()) { - $this->xml->startElement($errorType); - $this->xml->writeAttribute('type', $type); - - if($this->case->getHasError()) { - $errorMsg = ($this->isPHPError()) ? "PHP Error" : "Unhandled exception"; - $this->xml->writeAttribute('message', $errorMsg); - $this->xml->writeCdata("\n" .$this->getErrorMessage()); - - } else { - $testMsg = (string)$test->getMessage(); - $failedMsg = $this->getMessage($test, $unit); - $compare = $this->getComparison($unit, $failedMsg); - - $output = "\n\n"; - $output .= ucfirst($errorType) . ": " . ($testMsg !== "" ? $testMsg : $caseMsg) ."\n\n"; - $output .= "Failed on {$trace['file']}:{$trace['line']}\n"; - $output .= " → {$trace['code']}\n"; - $output .= $this->getMessage($test, $unit) . "\n"; + } else { + foreach ($test->getUnits() as $unit) { + /** @var TestItem $unit */ + if (!$unit->isValid()) { - if($compare !== "") { - $output .= $compare . "\n"; - } - if ($test->hasValue()) { - $output .= "\nInput value: " . $this->getValue($test) . "\n"; + if($this->case->getHasError()) { + $this->buildErrors($test, $errorType, $type); + + } else { + //$testMsg = (string)$test->getMessage(); + $failedMsg = $this->getMessage($test, $unit); + $compare = $this->getComparison($unit, $failedMsg); + + $output = "\n\n"; + //$output .= ucfirst($errorType) . ": " . ($testMsg !== "" ? $testMsg : $caseMsg) ."\n\n"; + $output .= "Failed on {$trace['file']}:{$trace['line']}\n"; + $output .= " → {$trace['code']}\n"; + $output .= $this->getMessage($test, $unit) . "\n"; + + if($compare !== "") { + $output .= $compare . "\n"; + } + if ($test->hasValue()) { + $output .= "\nInput value: " . $this->getValue($test) . "\n"; + } + + $output .= "\n"; + + $validation = $unit->getValidationTitle(); + $compare = $unit->hasComparison() ? ": " . $unit->getComparison() : ""; + $compare = str_replace('"', "'", $compare); + + $this->xml->startElement($errorType); + $this->xml->writeAttribute('type', $type); + $this->xml->writeAttribute('message', $this->hasAssertError() ? $this->getAssertMessage() : $validation . $compare); + $this->xml->writeCdata($output); } - $output .= "\n"; - - $validation = $unit->getValidationTitle(); - $compare = $unit->hasComparison() ? ": " . $unit->getComparison() : ""; - $compare = str_replace('"', "'", $compare); - $this->xml->writeAttribute('message', $this->hasAssertError() ? $this->getAssertMessage() : $validation . $compare); - $this->xml->writeCdata($output); + $this->xml->endElement(); } - - $this->xml->endElement(); } + } } $this->xml->endElement(); } $this->xml->endElement(); - - /* - var_dump($this->case->getCount()); - var_dump($this->case->getFailedCount()); - var_dump($this->case->getErrors()); - var_dump($this->case->getDuration(6)); - var_dump(Clock::value("now")->dateTime()); - die; - if (($this->show || !$this->case->getConfig()->skip)) { - // Show possible warnings - -// if ($this->case->getWarning()) { -// $this->xml->message(""); -// $this->xml->message( -// $this->xml->getAnsi()->style(["italic", "yellow"], $this->case->getWarning()) -// ); -// } + } - // Show Failed tests - $this->showFailedTests(); - } - - $this->showFooter(); - */ + /** + * Build errors + * + * @param TestUnit $test + * @param string $errorType + * @param string $type + * @return void + */ + protected function buildErrors(TestUnit $test, string $errorType, string $type): void + { + $errorMsg = ($this->isPHPError($test)) ? "PHP Error" : "Unhandled exception"; + $this->xml->startElement($errorType); + $this->xml->writeAttribute('type', $type); + $this->xml->writeAttribute('message', $errorMsg); + $this->xml->writeCdata("\n" .$this->getErrorMessage($test)); } /** @@ -157,115 +149,4 @@ public function buildNotes(): void */ } } - - /** - * Footer template part - * - * @return void - */ - protected function showFooter(): void - { - $select = $this->checksum; - if ($this->case->getConfig()->select) { - $select .= " (" . $this->case->getConfig()->select . ")"; - } - $this->xml->message(""); - - $passed = $this->xml->getAnsi()->bold("Passed: "); - if ($this->case->getHasAssertError()) { - $passed .= $this->xml->getAnsi()->style(["grey"], "N/A"); - } else { - $passed .= $this->xml->getAnsi()->style([$this->color], $this->case->getCount() . "/" . $this->case->getTotal()); - } - - $footer = $passed . - $this->xml->getAnsi()->style(["italic", "grey"], " - ". $select); - if (!$this->show && $this->case->getConfig()->skip) { - $footer = $this->xml->getAnsi()->style(["italic", "grey"], $select); - } - $this->xml->message($footer); - $this->xml->message(""); - - } - - /** - * Failed tests template part - * - * @return void - * @throws ErrorException - */ - protected function showFailedTests(): void - { - if (($this->show || !$this->case->getConfig()->skip)) { - foreach ($this->tests as $test) { - - if (!($test instanceof TestUnit)) { - throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); - } - - if (!$test->isValid()) { - $msg = (string)$test->getMessage(); - $this->xml->message(""); - $this->xml->message( - $this->xml->getAnsi()->style(["bold", $this->color], "Error: ") . - $this->xml->getAnsi()->bold($msg) - ); - $this->xml->message(""); - - $trace = $test->getCodeLine(); - if (!empty($trace['code'])) { - $this->xml->message($this->xml->getAnsi()->style(["bold", "grey"], "Failed on {$trace['file']}:{$trace['line']}")); - $this->xml->message($this->xml->getAnsi()->style(["grey"], " → {$trace['code']}")); - } - - foreach ($test->getUnits() as $unit) { - - /** @var TestItem $unit */ - if (!$unit->isValid()) { - $lengthA = $test->getValidationLength(); - $validation = $unit->getValidationTitle(); - $title = str_pad($validation, $lengthA); - $compare = $unit->hasComparison() ? $unit->getComparison() : ""; - - $failedMsg = " " .$title . " → failed"; - $this->xml->message($this->xml->getAnsi()->style($this->color, $failedMsg)); - - if ($compare) { - $lengthB = (strlen($compare) + strlen($failedMsg) - 8); - $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); - $this->xml->message( - $this->xml->getAnsi()->style($this->color, $comparePad) - ); - } - } - } - if ($test->hasValue()) { - $this->xml->message(""); - $this->xml->message( - $this->xml->getAnsi()->bold("Input value: ") . - Helpers::stringifyDataTypes($test->getValue()) - ); - } - } - } - } - } - - /** - * Init some default styled object - * - * @return void - */ - protected function initDefault(): void - { - $this->color = ($this->case->hasFailed() ? "brightRed" : "brightBlue"); - $this->flag = $this->xml->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); - if ($this->case->hasFailed()) { - $this->flag = $this->xml->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); - } - if ($this->case->getConfig()->skip) { - $this->color = "yellow"; - $this->flag = $this->xml->getAnsi()->style(['yellowBg', 'black'], " SKIP "); - } - } } diff --git a/src/Support/Helpers.php b/src/Support/Helpers.php index 7ff6e7a..b431d06 100644 --- a/src/Support/Helpers.php +++ b/src/Support/Helpers.php @@ -20,6 +20,18 @@ final class Helpers { + + /** + * Convert a throwable into ExceptionItem + * + * @param \Throwable $exception + * @return ExceptionItem + */ + public static function getExceptionItem(\Throwable $exception): ExceptionItem + { + return new ExceptionItem($exception); + } + /** * Get a pretty exception message from a Throwable instance * @@ -29,7 +41,7 @@ final class Helpers */ public static function getExceptionMessage(\Throwable $exception, ?ExceptionItem &$exceptionItem = null): string { - $exceptionItem = new ExceptionItem($exception); + $exceptionItem = self::getExceptionItem($exception); $cliErrorHandler = new CliHandler(); return $cliErrorHandler->getSmallErrorMessage($exceptionItem); } diff --git a/src/TestCase.php b/src/TestCase.php index 9c64ec3..368f915 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -357,12 +357,20 @@ protected function expectAndValidate( ?string $description = null, ?array $trace = null ): TestUnit { + + $listArr = []; $this->value = $expect; $test = new TestUnit($message); $test->setTestValue($this->value); if ($validation instanceof Closure) { $validPool = new Expect($this->value); - $listArr = $this->buildClosureTest($validation, $validPool, $description); + + try { + $listArr = $this->buildClosureTest($validation, $validPool, $description); + } catch (Throwable $e) { + $test->setValid(false); + $test->setThrowable(Helpers::getExceptionItem($e)); + } foreach ($listArr as $list) { diff --git a/src/TestUnit.php b/src/TestUnit.php index ec54962..6526038 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -13,6 +13,8 @@ namespace MaplePHP\Unitary; use ErrorException; +use MaplePHP\Blunder\ExceptionItem; +use MaplePHP\Unitary\Console\Enum\UnitStatusType; use MaplePHP\Unitary\Support\Helpers; final class TestUnit @@ -26,7 +28,8 @@ final class TestUnit private int $count = 0; private int $valLength = 0; private array $codeLine = ['line' => 0, 'code' => '', 'file' => '']; - + private UnitStatusType $type = UnitStatusType::Failure; + private ?ExceptionItem $throwable = null; /** * Initiate the test * @@ -38,6 +41,27 @@ public function __construct(?string $message = null) $this->message = $message === null ? "" : $message; } + public function setThrowable(ExceptionItem $throwable): void + { + $this->type = UnitStatusType::Error; + $this->throwable = $throwable; + } + + public function hasError(): bool + { + return $this->type === UnitStatusType::Error; + } + + public function getType(): UnitStatusType + { + return $this->type; + } + + public function getThrowable(): ?ExceptionItem + { + return $this->throwable; + } + /** * Add custom error message if validation fails * @@ -87,6 +111,17 @@ public function setTestValue(mixed $value): void } + /** + * Set if validation is valid + * + * @param bool $isValid + * @return void + */ + public function setValid(bool $isValid): void + { + $this->valid = $isValid; + } + /** * Create a test item * @@ -96,7 +131,7 @@ public function setTestValue(mixed $value): void public function setTestItem(TestItem $item): self { if (!$item->isValid()) { - $this->valid = false; + $this->setValid(false); $this->count++; } diff --git a/src/Unit.php b/src/Unit.php index d8a4c15..c0e231c 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -49,14 +49,31 @@ final class Unit */ public function __construct(BodyInterface|null $handler = null) { - $this->handler = ($handler === null) ? new CliRenderer(new Command()) : $handler; + $this->setHandler($handler); } + /** + * Get the PSR stream from the handler + * + * @return StreamInterface + */ public function getBody(): StreamInterface { return $this->handler->getBody(); } + /** + * Set output handler + * + * @param BodyInterface|null $handler + * @return $this + */ + public function setHandler(BodyInterface|null $handler = null): self + { + $this->handler = ($handler === null) ? new CliRenderer(new Command()) : $handler; + return $this; + } + /** * Will pass a test file name to script used to: * - Allocate tests @@ -131,19 +148,18 @@ public function setAlwaysShowFiles(bool $alwaysShowFiles): void } /** - * This will help pass over some default for custom Unit instances + * This will pass over all relevant configurations to new Unit instances * * @param Unit $inst * @return $this */ public function inheritConfigs(Unit $inst): Unit { - $this->setFile($inst->file); - $this->setShow($inst->show); - $this->setShowErrorsOnly($inst->showErrorsOnly); - $this->setFailFast($inst->failFast); - $this->setVerbose($inst->verbose); - $this->setAlwaysShowFiles($inst->alwaysShowFiles); + foreach (get_object_vars($inst) as $prop => $value) { + if($prop !== "index" && $prop !== "cases") { + $this->$prop = $value; + } + } return $this; } diff --git a/tests/unitary-mock.php b/tests/unitary-mock.php new file mode 100755 index 0000000..75ec4ee --- /dev/null +++ b/tests/unitary-mock.php @@ -0,0 +1,173 @@ +withName("mocker"); + +group($config->withSubject("Can not mock final or private"), function(TestCase $case) { + + $user = $case->mock(UserService::class, function(MethodRegistry $method) { + $method->method("getUserRole")->willReturn("admin"); + $method->method("getUserType")->willReturn("admin"); + }); + + // You cannot mock final with data (should return a warning) + $case->validate($user->getUserType(), function(Expect $expect) { + $expect->isEqualTo("guest"); + }); + + // You can of course mock regular methods with data + $case->validate($user->getUserRole(), function(Expect $expect) { + $expect->isEqualTo("admin"); + }); + +}); + +group($config->withSubject("Validating all mocker methods"), function (TestCase $case) { + + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("addFromEmail") + ->withArguments("john.doe@gmail.com", "John Doe") + ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) + ->willThrowOnce(new InvalidArgumentException("Lowrem ipsum")) + ->called(2); + + $method->method("addBCC") + ->isPublic() + ->hasDocComment() + ->hasParams() + ->paramHasType(0) + ->paramIsType(0, "string") + ->paramHasDefault(1, "Daniel") + ->paramIsOptional(1) + ->paramIsReference(2) + ->called(0); + }); + + $case->validate(fn() => $mail->addFromEmail("john.doe@gmail.com", "John Doe"), function(Expect $inst) { + $inst->isThrowable(InvalidArgumentException::class); + }); + + + $mail->addFromEmail("jane.doe@gmail.com", "Jane Doe"); + + $case->error("Test all exception validations") + ->validate(fn() => throw new ErrorException("Lorem ipsum", 1, 1, "example.php", 22), function(Expect $inst) { + $inst->isThrowable(ErrorException::class); + $inst->hasThrowableMessage("Lorem ipsum"); + $inst->hasThrowableSeverity(1); + $inst->hasThrowableCode(1); + $inst->hasThrowableFile("example.php"); + $inst->hasThrowableLine(22); + }); + + $case->validate(fn() => throw new TypeError("Lorem ipsum"), function(Expect $inst) { + $inst->isThrowable(TypeError::class); + }); + + $case->validate(fn() => throw new TypeError("Lorem ipsum"), function(Expect $inst) { + $inst->isThrowable(function(Expect $inst) { + $inst->isClass(TypeError::class); + }); + }); +}); + +group($config->withSubject("Mocking PSR Stream"), function (TestCase $case) { + + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('HelloWorld', 'HelloWorld2') + ->calledAtLeast(1); + }); + $response = new Response($stream); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->hasResponse(); + $inst->isEqualTo('HelloWorld'); + $inst->notIsEqualTo('HelloWorldNot'); + }); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->isEqualTo('HelloWorld2'); + }); +}); + + +group($config->withSubject("Test partial mocking"), function (TestCase $case) { + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("send")->keepOriginal()->called(1); + $method->method("isValidEmail")->keepOriginal()->called(1); + $method->method("sendEmail")->keepOriginal()->called(1); + }); + + $case->validate(fn() => $mail->send(), function(Expect $inst) { + $inst->hasThrowableMessage("Invalid email"); + }); +}); + + +group($config->withSubject("Test immutable PSR Response mocking and default mock values"), function (TestCase $case) { + + // Quickly mock the Stream class + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('{"test":"test"}') + ->calledAtLeast(1); + + $method->method("fopen")->isPrivate(); + }); + // Mock with configuration + // + // Notice: this will handle TestCase as immutable, and because of this + // the new instance of TestCase must be return to the group callable below + // + // By passing the mocked Stream class to the Response constructor, we + // will actually also test that the argument has the right data type + $case = $case->withMock(Response::class, [$stream]); + + // We can override all "default" mocking values tide to TestCase Instance + $case->getMocker() + ->mockDataType("string", "myCustomMockStringValue") + ->mockDataType("array", ["myCustomMockArrayItem"]) + ->mockDataType("int", 200, "getStatusCode"); + + $response = $case->buildMock(function (MethodRegistry $method) use($stream) { + $method->method("getBody")->willReturn($stream); + }); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->isString(); + $inst->isJson(); + }); + + $case->validate($response->getStatusCode(), function(Expect $inst) { + // Overriding the default making it a 200 integer + $inst->isHttpSuccess(); + }); + + $case->validate($response->getHeader("lorem"), function(Expect $inst) { + // Overriding the default the array would be ['item1', 'item2', 'item3'] + $inst->isInArray("myCustomMockArrayItem"); + }); + + $case->validate($response->getProtocolVersion(), function(Expect $inst) { + // MockedValue is the default value that the mocked class will return + $inst->isEqualTo("MockedValue"); + }); + + $case->validate($response->getBody(), function(Expect $inst) { + $inst->isInstanceOf(Stream::class); + }); + + // You need to return a new instance of TestCase for new mocking settings + return $case; +}); + diff --git a/tests/unitary-test-item.php b/tests/unitary-test-item.php index 712b284..9efd326 100644 --- a/tests/unitary-test-item.php +++ b/tests/unitary-test-item.php @@ -17,7 +17,7 @@ ->setHasArgs(true); $case->validate($item->isValid(), function(Expect $valid) { - $valid->isFalse(); + $valid->isTrue(); })->describe("Testing TestItem is validMethod"); diff --git a/tests/unitary-test.php b/tests/unitary-test.php deleted file mode 100755 index 716af1c..0000000 --- a/tests/unitary-test.php +++ /dev/null @@ -1,24 +0,0 @@ -withName("unitary-test"); - -group("Hello world 0", function(TestCase $case) { - - $case->assert(1 === 2, "wdwdq 2"); - -}, $config); - -group("Hello world 1", function(TestCase $case) { - - $case->validate(1 === 2, function(Expect $expect) { - $expect->isEqualTo(true); - }); - -}, $config); - -group($config->withSubject("Hello world 2"), function(TestCase $case) { - $case->validate(2 === 2, fn() => true); -}); \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 82bf26a..a5e3ea2 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,124 +1,9 @@ withName("unitary"); - -group($config->withSubject("Test mocker"), function (TestCase $case) { - - $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { - $method->method("addFromEmail") - ->withArguments("john.doe@gmail.com", "John Doe") - ->called(2); - }); - $mail->addFromEmail("john.doe@gmail.com", "John Doe"); -}); - -group("Example of assert in group", function(TestCase $case) { - assert(1 === 2, "Should return true"); -}); - -group($config->withSubject("Can not mock final or private"), function(TestCase $case) { - - $user = $case->mock(UserService::class, function(MethodRegistry $method) { - $method->method("getUserRole")->willReturn("admin"); - $method->method("getUserType")->willReturn("admin"); - }); - // You cannot mock final with data (should return a warning) - $case->validate($user->getUserType(), function(Expect $expect) { - $expect->isEqualTo("guest"); - }); - - // You can of course mock regular methods with data - $case->validate($user->getUserRole(), function(Expect $expect) { - $expect->isEqualTo("admin"); - }); - -}); - -group($config->withSubject("Test mocker")->withName("wdqdwdwq"), function (TestCase $case) { - - - //echo $wdqdw; - //throw new InvalidArgumentException("dwqdwqdw"); - - $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { - $method->method("addFromEmail") - ->withArguments("john.doe@gmail.com", "John Doe") - ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) - ->willThrowOnce(new InvalidArgumentException("Lowrem ipsum")) - ->called(2); - - $method->method("addBCC") - ->isPublic() - ->hasDocComment() - ->hasParams() - ->paramHasType(0) - ->paramIsType(0, "string") - ->paramHasDefault(1, "Daniel") - ->paramIsOptional(1) - ->paramIsReference(2) - ->called(0); - }); - - $case->validate(fn() => $mail->addFromEmail("john.doe@gmail.com", "John Doe"), function(Expect $inst) { - $inst->isThrowable(InvalidArgumentException::class); - }); - - - $mail->addFromEmail("jane.doe@gmail.com", "Jane Doe"); - - $case->error("Test all exception validations") - ->validate(fn() => throw new ErrorException("Lorem ipsum", 1, 1, "example.php", 22), function(Expect $inst, Traverse $obj) { - $inst->isThrowable(ErrorException::class); - $inst->hasThrowableMessage("Lorem ipsum"); - $inst->hasThrowableSeverity(1); - $inst->hasThrowableCode(1); - $inst->hasThrowableFile("example.php"); - $inst->hasThrowableLine(22); - }); - - $case->validate(fn() => throw new TypeError("Lorem ipsum"), function(Expect $inst, Traverse $obj) { - $inst->isThrowable(TypeError::class); - }); - - - $case->validate(fn() => throw new TypeError("Lorem ipsum"), function(Expect $inst, Traverse $obj) { - $inst->isThrowable(function(Expect $inst) { - $inst->isClass(TypeError::class); - }); - }); -}); - -group($config->withSubject("Mocking response"), function (TestCase $case) { - - $stream = $case->mock(Stream::class, function (MethodRegistry $method) { - $method->method("getContents") - ->willReturn('HelloWorld', 'HelloWorld2') - ->calledAtLeast(1); - }); - $response = new Response($stream); - - $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->hasResponse(); - $inst->isEqualTo('HelloWorld'); - $inst->notIsEqualTo('HelloWorldNot'); - }); - - $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->isEqualTo('HelloWorld2'); - }); -}); +$config = TestConfig::make()->withName("unitary"); group($config->withSubject("Assert validations"), function ($case) { $case->validate("HelloWorld", function(Expect $inst) { @@ -127,7 +12,7 @@ assert(1 === 1, "Assert has failed"); }); -group($config->withSubject("Old validation syntax"), function ($case) { +group($config->withSubject("Tets old validation syntax"), function ($case) { $case->add("HelloWorld", [ "isString" => [], "User validation" => function($value) { @@ -140,127 +25,11 @@ ], "Failed to validate"); }); -group($config->withSubject("Validate partial mock"), function (TestCase $case) { - $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { - $method->method("send")->keepOriginal(); - $method->method("isValidEmail")->keepOriginal(); - $method->method("sendEmail")->keepOriginal(); - }); - - $case->validate(fn() => $mail->send(), function(Expect $inst) { - $inst->hasThrowableMessage("Invalid email"); - }); -}); - -group($config->withSubject("Advanced App Response Test"), function (TestCase $case) { - - - // Quickly mock the Stream class - $stream = $case->mock(Stream::class, function (MethodRegistry $method) { - $method->method("getContents") - ->willReturn('{"test":"test"}') - ->calledAtLeast(1); - - $method->method("fopen")->isPrivate(); - }); - // Mock with configuration - // - // Notice: this will handle TestCase as immutable, and because of this - // the new instance of TestCase must be return to the group callable below - // - // By passing the mocked Stream class to the Response constructor, we - // will actually also test that the argument has the right data type - $case = $case->withMock(Response::class, [$stream]); - - // We can override all "default" mocking values tide to TestCase Instance - $case->getMocker() - ->mockDataType("string", "myCustomMockStringValue") - ->mockDataType("array", ["myCustomMockArrayItem"]) - ->mockDataType("int", 200, "getStatusCode"); - - $response = $case->buildMock(function (MethodRegistry $method) use($stream) { - $method->method("getBody")->willReturn($stream); - }); - - $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->isString(); - $inst->isJson(); - }); - - $case->validate($response->getStatusCode(), function(Expect $inst) { - // Overriding the default making it a 200 integer - $inst->isHttpSuccess(); - }); - - $case->validate($response->getHeader("lorem"), function(Expect $inst) { - // Overriding the default the array would be ['item1', 'item2', 'item3'] - $inst->isInArray("myCustomMockArrayItem"); - }); - - $case->validate($response->getProtocolVersion(), function(Expect $inst) { - // MockedValue is the default value that the mocked class will return - $inst->isEqualTo("MockedValue"); - }); - - $case->validate($response->getBody(), function(Expect $inst) { - $inst->isInstanceOf(Stream::class); - }); - - // You need to return a new instance of TestCase for new mocking settings - return $case; -}); - +group($config->withSubject("Test json validation"), function(TestCase $case) { -group($config->withSubject("Testing User service"), function (TestCase $case) { - - $mailer = $case->mock(Mailer::class, function (MethodRegistry $method) { - $method->method("addFromEmail") - ->keepOriginal() - ->called(1); - $method->method("getFromEmail") - ->keepOriginal() - ->called(1); - }); - - $service = new UserService($mailer); - $case->validate($service->registerUser("john.doe@gmail.com"), function(Expect $inst) { - $inst->isTrue(); - }); -}); - -group($config->withSubject("Mocking response"), function (TestCase $case) { - - - $stream = $case->mock(Stream::class, function (MethodRegistry $method) { - $method->method("getContents") - ->willReturn('HelloWorld', 'HelloWorld2') - ->calledAtLeast(1); - }); - $response = new Response($stream); - - $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->hasResponse(); - $inst->isEqualTo('HelloWorld'); - $inst->notIsEqualTo('HelloWorldNot'); - }); - - $case->validate($response->getBody()->getContents(), function(Expect $inst) { - $inst->isEqualTo('HelloWorld2'); - }); -}); - -group("Example API Response", function(TestCase $case) { - - $case->error("qwdwqqdwdwq 1")->validate('{"response":{"status":200,"message":"ok"}}', function(Expect $expect) { - - $expect->isJson()->hasJsonValueAt("response.status", 404); + $case->validate('{"response":{"status":200,"message":"ok"}}', function(Expect $expect) { + $expect->isJson()->hasJsonValueAt("response.status", 200); assert($expect->isValid(), "Expected JSON structure did not match."); - }); - - $case->error("qwdwqqdwdwq 2")->validate('{"response":{"status":501,"message":"Server"}}', function(Expect $expect) { - - $expect->isJson()->hasJsonValueAt("response.status", 501); - assert($expect->isValid(), "Expected JSON structure did not match."); - }); + })->describe("Test json validation"); }); \ No newline at end of file diff --git a/unitary.config.php b/unitary.config.php index 8718724..f064986 100644 --- a/unitary.config.php +++ b/unitary.config.php @@ -13,7 +13,7 @@ 'discoverPattern' => false, // string|false (paths (`tests/`) and files (`unitary-*.php`).) 'show' => false, 'timezone' => 'Europe/Stockholm', - 'local' => 'en_US', + 'locale' => 'en_US', 'alwaysShowFiles' => false, 'failFast' => false, // bool //'exit_error_code' => 1, ??