diff --git a/README.md b/README.md index 2c6b2ea..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 @@ -63,7 +65,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", @@ -117,8 +118,121 @@ php vendor/bin/unitary With that, you are ready to create your own tests! + +## Mocking +Unitary comes with a built-in mocker that makes it super simple for you to mock classes. + + +### 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. + +```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\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(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. + +```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. + +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->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; +}); +``` + + ## Configurations +### Show help +```bash +php vendor/bin/unitary --help +``` + +### 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. @@ -160,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/FileIterator.php b/src/FileIterator.php index d0aaf9c..057ea99 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; @@ -12,7 +13,7 @@ use RecursiveIteratorIterator; use SplFileInfo; -class FileIterator +final class FileIterator { public const PATTERN = 'unitary-*.php'; @@ -33,7 +34,8 @@ 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\" "); + /* @var string static::PATTERN */ + 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"); @@ -48,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\"."); } } @@ -66,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)); @@ -76,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(); } } @@ -91,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); @@ -107,7 +109,7 @@ public function exclude(): array } /** - * Validate a exclude path + * Validate an exclude path * @param array $exclArr * @param string $relativeDir * @param string $file @@ -117,8 +119,9 @@ 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); - if(fnmatch($relativeExclPath, $file)) { + if (fnmatch($relativeExclPath, $file)) { return true; } } @@ -126,7 +129,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 +139,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 */ @@ -146,10 +149,11 @@ 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); + $run->setExitCode(1); $run->load(); //ob_start(); @@ -172,7 +176,7 @@ private function requireUnitFile(string $file): ?Closure /** * @return Unit - * @throws RuntimeException|\Exception + * @throws RuntimeException|Exception */ protected function getUnit(): Unit { diff --git a/src/Handlers/FileHandler.php b/src/Handlers/FileHandler.php index f599c03..2acc8dd 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; @@ -17,6 +17,7 @@ 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/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 new file mode 100644 index 0000000..6a29a00 --- /dev/null +++ b/src/Mocker/MethodItem.php @@ -0,0 +1,480 @@ +mocker = $mocker; + } + + /** + * Will create a method wrapper making it possible to mock + * + * @param Closure $call + * @return $this + */ + 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 { + public function __construct(string $class, array $args = []) + { + parent::__construct($class, $args); + } + }; + $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; + } + + /** + * Check if a 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; + } + + /** + * Check if parameter exists + * + * @return $this + */ + public function hasParams(): self + { + $inst = $this; + $inst->parameters[] = [ + "isCountMoreThan" => [0], + ]; + return $inst; + } + + /** + * Check if all parameters have a data type + * + * @return $this + */ + public function hasParamsTypes(): self + { + $inst = $this; + $inst->parameters[] = [ + "itemsAreTruthy" => ['hasType', true], + ]; + return $inst; + } + + /** + * Check if parameter does not exist + * + * @return $this + */ + public function hasNotParams(): self + { + $inst = $this; + $inst->parameters[] = [ + "isArrayEmpty" => [], + ]; + return $inst; + } + + /** + * Check a parameter type for method + * + * @param int $length + * @return $this + */ + public function hasParamsCount(int $length): self + { + $inst = $this; + $inst->parameters[] = [ + "isCountEqualTo" => [$length], + ]; + return $inst; + } + + /** + * Check a parameter type for method + * + * @param int $paramPosition + * @param string $dataType + * @return $this + */ + public function paramIsType(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 paramHasDefault(int $paramPosition, string $defaultArgValue): self + { + $inst = $this; + $inst->parameters[] = [ + "validateInData" => ["$paramPosition.default", "equal", [$defaultArgValue]], + ]; + return $inst; + } + + /** + * Check a 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 a 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 paramIsReference(int $paramPosition): self + { + $inst = $this; + $inst->parameters[] = [ + "validateInData" => ["$paramPosition.isReference", "equal", [true]], + ]; + return $inst; + } + + /** + * Check the parameter is variadic (spread) for a 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. + * + * @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; + } +} diff --git a/src/Mocker/MethodPool.php b/src/Mocker/MethodPool.php new file mode 100644 index 0000000..6a3a007 --- /dev/null +++ b/src/Mocker/MethodPool.php @@ -0,0 +1,61 @@ + */ + private array $methods = []; + + public function __construct(?Mocker $mocker = null) + { + $this->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]); + } + +} diff --git a/src/Mocker/Mocker.php b/src/Mocker/Mocker.php new file mode 100755 index 0000000..22eb427 --- /dev/null +++ b/src/Mocker/Mocker.php @@ -0,0 +1,439 @@ + */ + protected array $constructorArgs = []; + protected array $methods; + protected array $methodList = []; + protected static ?MethodPool $methodPool = null; + protected array $defaultArguments = []; + private DataTypeMock $dataTypeMock; + + /** + * @param string $className + * @param array $args + */ + public function __construct(string $className, array $args = []) + { + $this->className = $className; + /** @var class-string $className */ + $this->reflection = new ReflectionClass($className); + + $this->dataTypeMock = new DataTypeMock(); + /* + // 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 + { + return $this->className; + } + + public function getClassArgs(): array + { + return $this->constructorArgs; + } + + /** + * 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; + } + + /** + * @throws Exception + */ + public function getMockedClassName(): string + { + if (!$this->mockClassName) { + throw new Exception("Mock class name is not set"); + } + 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. + * + * @return mixed An instance of the dynamically created mock class. + * @throws Exception + */ + 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); + + $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 + */ + 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 (string)$this->getMockValueForType((string)$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. + * + * @param string $mockClassName + * @return string PHP code defining the overridden methods. + * @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->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); + + if($method->isConstructor()) { + $types = []; + $returnValue = ""; + if(count($this->constructorArgs) === 0) { + $paramList = ""; + } + } + $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); + if ($info === false) { + throw new RuntimeException('JSON encoding failed: ' . json_last_error_msg(), json_last_error()); + } + + MockerController::getInstance()->buildMethodData($info); + 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('$safeJson', true); + \$data = \\MaplePHP\\Unitary\\Mocker\\MockerController::getDataItem(\$obj->mocker, \$obj->name); + {$returnValue} + } + "; + } + return $overrides; + } + + + /** + * 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): string + { + MockerController::addData((string)$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()); + } + {$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()) { + $getType = (string)$param->getType(); + $paramStr .= $getType . ' '; + } + if ($param->isPassedByReference()) { + $paramStr .= '&'; + } + $paramStr .= '$' . $param->getName(); + if ($param->isDefaultValueAvailable()) { + $paramStr .= ' = ' . var_export($param->getDefaultValue(), true); + } + + if ($param->isVariadic()) { + $paramStr = "...$paramStr"; + } + + $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(ReflectionMethod $method): array + { + $types = []; + $returnType = $method->getReturnType(); + if ($returnType instanceof ReflectionNamedType) { + $types[] = $returnType->getName(); + } elseif ($returnType instanceof ReflectionUnionType) { + foreach ($returnType->getTypes() as $type) { + if (method_exists($type, "getName")) { + $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 array_unique($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 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): ?string + { + $dataTypeName = strtolower($typeName); + if (!is_null($value)) { + 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 " . $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 */ + default => (class_exists($typeName)) + ? "return new class() extends " . $typeName . " {};" + : "return null;", + + }; + return $nullable && rand(0, 1) ? null : $mock; + } + + /** + * Build a method information array from a ReflectionMethod instance + * + * @param ReflectionMethod $refMethod + * @return array + */ + public 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(), + 'isReference' => $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(), + ]; + } +} diff --git a/src/Mocker/MockerController.php b/src/Mocker/MockerController.php new file mode 100644 index 0000000..ea2c2b9 --- /dev/null +++ b/src/Mocker/MockerController.php @@ -0,0 +1,95 @@ +> */ + private static array $data = []; + + /** + * 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)) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Get the method information + * + * @param string $mockIdentifier + * @return array|bool + */ + public static function getData(string $mockIdentifier): array|bool + { + $data = isset(self::$data[$mockIdentifier]) ? self::$data[$mockIdentifier] : false; + 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 + */ + public static function getDataItem(string $mockIdentifier, string $method): mixed + { + 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 + { + 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, 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])) { + $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 66a79c8..d95cbb9 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -4,21 +4,44 @@ namespace MaplePHP\Unitary; +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\Validate\Validator; +use MaplePHP\Validate\ValidationChain; +use ReflectionClass; +use ReflectionMethod; +use Throwable; +use Exception; +use ReflectionException; +use RuntimeException; use BadMethodCallException; use ErrorException; -use RuntimeException; use Closure; -use Throwable; -use MaplePHP\Validate\Inp; -class TestCase +/** + * @template T of object + */ +final class TestCase { private mixed $value; private ?string $message; private array $test = []; 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. + * + * @param string|null $message A message to associate with the test case. + */ public function __construct(?string $message = null) { $this->message = $message; @@ -26,6 +49,7 @@ public function __construct(?string $message = null) /** * Bind the test case to the Closure + * * @param Closure $bind * @return void */ @@ -36,49 +60,447 @@ 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) { + if(str_contains($e->getFile(), "eval()")) { + throw new BlunderErrorException($e->getMessage(), $e->getCode()); + } + throw $e; + } + if ($newInst instanceof self) { + $row = $newInst; + } } return $this->test; } /** - * Create a test - * @param mixed $expect - * @param array|Closure $validation - * @param string|null $message - * @return TestCase + * 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(ValidationChain, mixed): bool $validation The validation logic + * @return $this * @throws ErrorException */ - public function add(mixed $expect, array|Closure $validation, ?string $message = null): self + public function validate(mixed $expect, Closure $validation): self { + $this->expectAndValidate($expect, function (mixed $value, ValidationChain $inst) use ($validation) { + return $validation($inst, new Traverse($value)); + }, $this->errorMessage); + + return $this; + } + + /** + * Executes a test case at runtime by validating the expected value. + * + * 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 If validation fails during runtime execution. + */ + protected function expectAndValidate( + mixed $expect, + array|Closure $validation, + ?string $message = null + ): self { $this->value = $expect; - $test = new TestUnit($this->value, $message); - if($validation instanceof Closure) { - $test->setUnit($this->buildClosureTest($validation)); + $test = new TestUnit($message); + $test->setTestValue($this->value); + if ($validation instanceof Closure) { + $listArr = $this->buildClosureTest($validation); + foreach ($listArr as $list) { + if(is_bool($list)) { + $test->setUnit($list, "Validation"); + } else { + foreach ($list as $method => $_valid) { + $test->setUnit(false, (string)$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++; } $this->test[] = $test; + $this->errorMessage = null; 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): 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]; + $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): TestCase + { + return $this->expectAndValidate($expect, $validation, $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 + * creates and simulates an actual instance of the original class with its real constructor. + * + * @param string $class + * @param array $args + * @return TestWrapper + */ + public function wrap(string $class, array $args = []): TestWrapper + { + return new class ($class, $args) extends TestWrapper { + public function __construct(string $class, array $args = []) + { + parent::__construct($class, $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. + * + * 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(). + * + * @template T of object + * @param class-string $class + * @param Closure|null $validate + * @param array $args + * @return T + * @throws Exception + */ + public function mock(string $class, ?Closure $validate = null, array $args = []) + { + $this->mocker = new Mocker($class, $args); + return $this->buildMock($validate); + } + + + public function getMocker(): Mocker + { + return $this->mocker; + } + + /** + * 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 + * @throws ErrorException + */ + 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)); + } + + /** + * 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 + * @throws Exception + */ + 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) { + if (is_object($row) && isset($row->name) && $pool->has($row->name)) { + $error[(string)$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 calls 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((string)($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)) { + 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); + } else { + /** @psalm-suppress MixedArgument */ + $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) { + if (is_array($argsB) && count($argsB) >= 2) { + $validPool + ->mapErrorToKey((string)$argsB[0]) + ->mapErrorValidationName((string)$argsB[1]) + ->{$methodB}(...$argsB); + } + } + } else { + $validPool->{$method}(...$args); + } + } + + return $validPool; + } + + /** + * Create a comparison from a validation collection + * + * @param ValidationChain $validPool + * @param array $value + * @param array $currentValue + * @return void + */ + protected function compareFromValidCollection(ValidationChain $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($value[0]) && isset($value[2]) && isset($error[(string)$value[0]])) { + $error[(string)$value[0]] = $value[2]; + } + } + } + return $error; + } + + /** + * 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, + * it increases the internal failure count and stores the test details for later reporting. + * + * @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'](); + $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) { + // 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; + } + } + + return $this->test; + } + /** * Get failed test counts + * * @return int */ public function getTotal(): int @@ -88,6 +510,7 @@ public function getTotal(): int /** * Get failed test counts + * * @return int */ public function getCount(): int @@ -97,6 +520,7 @@ public function getCount(): int /** * Get failed test counts + * * @return int */ public function getFailedCount(): int @@ -106,6 +530,7 @@ public function getFailedCount(): int /** * Check if it has failed tests + * * @return bool */ public function hasFailed(): bool @@ -115,6 +540,7 @@ public function hasFailed(): bool /** * Get original value + * * @return mixed */ public function getValue(): mixed @@ -124,6 +550,7 @@ public function getValue(): mixed /** * Get user added message + * * @return string|null */ public function getMessage(): ?string @@ -132,7 +559,8 @@ public function getMessage(): ?string } /** - * Get test array object + * Get a test array object + * * @return array */ public function getTest(): array @@ -142,48 +570,53 @@ 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 + protected function buildClosureTest(Closure $validation): array { - $bool = false; - $validation = $validation->bindTo($this->valid($this->value)); - if(!is_null($validation)) { - $bool = $validation($this->value); - } - if(!is_bool($bool)) { - throw new RuntimeException("A callable validation must return a boolean!"); + //$bool = false; + $validPool = new ValidationChain($this->value); + $validation = $validation->bindTo($validPool); + + $error = []; + if (!is_null($validation)) { + $bool = $validation($this->value, $validPool); + $error = $validPool->getError(); + 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"); } - return $bool; + return $error; } /** * This will build the array test + * * @param string $method * @param array|Closure $args * @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) { + 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(Inp::class, $method)) { + if (!method_exists(Validator::class, $method)) { throw new BadMethodCallException("The validation $method does not exist!"); } @@ -199,15 +632,78 @@ public 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); } + /** + * This is a helper function that will list all inherited proxy methods + * + * @param string $class + * @param string|null $prefixMethods + * @param bool $isolateClass + * @return void + * @throws ReflectionException + */ + 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) : []; + + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + 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() . ' ' : ''; + $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); + } + echo "@method self $name(" . implode(', ', $params) . ")\n"; + } + } + } + + /** + * 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 = []; + foreach ($reflection->getTraits() as $trait) { + foreach ($trait->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $traitMethods[] = $method->getName(); + } + } + return $traitMethods; + } } diff --git a/src/TestUnit.php b/src/TestUnit.php index 70f0a3c..a87b1db 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -10,46 +10,139 @@ 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; + private int $valLength = 0; + private array $codeLine = ['line' => 0, 'code' => '', 'file' => '']; /** * Initiate the test - * @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 - { - if(!$valid) { + public function setUnit( + bool|null $valid, + null|string|\Closure $validation = null, + array $args = [], + array $compare = [] + ): self { + + if (!$valid) { $this->valid = false; $this->count++; } + + 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; } + /** + * 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 + */ + public 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 +152,7 @@ public function getUnits(): array /** * Get failed test count + * * @return int */ public function getFailedTestCount(): int @@ -68,6 +162,7 @@ public function getFailedTestCount(): int /** * Get test message + * * @return string|null */ public function getMessage(): ?string @@ -77,6 +172,7 @@ public function getMessage(): ?string /** * Get if test is valid + * * @return bool */ public function isValid(): bool @@ -86,6 +182,7 @@ public function isValid(): bool /** * Gte the original value + * * @return mixed */ public function getValue(): mixed @@ -95,34 +192,38 @@ 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 "(bool): " . ($this->value ? "true" : "false"); + $value = is_null($value) ? $this->value : $value; + if (is_bool($value)) { + return '"' . ($value ? "true" : "false") . '"' . ($minify ? "" : " (type: bool)"); } - if (is_int($this->value)) { - return "(int): " . $this->excerpt((string)$this->value); + if (is_int($value)) { + return '"' . $this->excerpt((string)$value) . '"' . ($minify ? "" : " (type: int)"); } - if (is_float($this->value)) { - return "(float): " . $this->excerpt((string)$this->value); + if (is_float($value)) { + return '"' . $this->excerpt((string)$value) . '"' . ($minify ? "" : " (type: float)"); } - if (is_string($this->value)) { - return "(string): " . $this->excerpt($this->value); + if (is_string($value)) { + return '"' . $this->excerpt($value) . '"' . ($minify ? "" : " (type: string)"); } - if (is_array($this->value)) { - return "(array): " . $this->excerpt(json_encode($this->value)); + if (is_array($value)) { + return '"' . $this->excerpt(json_encode($value)) . '"' . ($minify ? "" : " (type: array)"); } - if (is_object($this->value)) { - return "(object): " . $this->excerpt(get_class($this->value)); + if (is_object($value)) { + return '"' . $this->excerpt(get_class($value)) . '"' . ($minify ? "" : " (type: object)"); } - if (is_null($this->value)) { - return "(null)"; + if (is_null($value)) { + return '"null"'. ($minify ? '' : ' (type: null)'); } - if (is_resource($this->value)) { - return "(resource): " . $this->excerpt(get_resource_type($this->value)); + if (is_resource($value)) { + return '"' . $this->excerpt(get_resource_type($value)) . '"' . ($minify ? "" : " (type: resource)"); } return "(unknown type)"; @@ -130,14 +231,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(42)->get(); + return (string)$format->excerpt($length)->get(); } } diff --git a/src/TestUtils/DataTypeMock.php b/src/TestUtils/DataTypeMock.php new file mode 100644 index 0000000..be8d24a --- /dev/null +++ b/src/TestUtils/DataTypeMock.php @@ -0,0 +1,177 @@ + 123456, + 'float' => 3.14, + 'string' => "mockString", + 'bool' => true, + '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']), + '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 self::exportValue($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/src/TestWrapper.php b/src/TestWrapper.php new file mode 100755 index 0000000..2b8a2e4 --- /dev/null +++ b/src/TestWrapper.php @@ -0,0 +1,126 @@ +ref = new Reflection($className); + $this->instance = $this->createInstance($this->ref, $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 Reflection $ref + * @param array $args + * @return mixed|object + * @throws \ReflectionException + */ + final protected function createInstance(Reflection $ref, array $args): mixed + { + if (count($args) === 0) { + return $ref->dependencyInjector(); + } + return $ref->getReflect()->newInstanceArgs($args); + } +} diff --git a/src/Unit.php b/src/Unit.php index a87e314..869ea15 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -7,11 +7,11 @@ use Closure; use ErrorException; use Exception; -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 { @@ -28,9 +28,18 @@ 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) { + if ($handler instanceof HandlerInterface) { $this->handler = $handler; $this->command = $this->handler->getCommand(); } else { @@ -57,7 +66,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."); @@ -130,11 +139,12 @@ 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 + public function group(string $message, Closure $callback): void { $testCase = new TestCase($message); $testCase->bind($callback); @@ -142,12 +152,18 @@ public function case(string $message, Closure $callback): void $this->index++; } + // Alias to group + public function case(string $message, Closure $callback): void + { + $this->group($message, $callback); + } + public function performance(Closure $func, ?string $title = null): void { $start = new TestMem(); $func = $func->bindTo($this); - if(!is_null($func)) { - $func(); + if (!is_null($func)) { + $func($this); } $line = $this->command->getAnsi()->line(80); $this->command->message(""); @@ -173,36 +189,39 @@ public function performance(Closure $func, ?string $title = null): void /** * Execute tests suite + * * @return bool * @throws ErrorException */ public function execute(): bool { - if($this->executed || !$this->validate()) { + + $this->help(); + + 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."); } - 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); - } - + $errArg = self::getArgs("errors-only"); + $row->dispatchTest($row); + $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()) { + continue; + } + $this->command->message(""); $this->command->message( $flag . " " . @@ -211,26 +230,61 @@ public function execute(): bool $this->command->getAnsi()->style(["bold", $color], (string)$row->getMessage()) ); - foreach($tests as $test) { - if(!($test instanceof TestUnit)) { - throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); - } + 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: " . $msg)); - /** @var array $unit */ - foreach($test->getUnits() as $unit) { + if (!$test->isValid()) { + $msg = (string)$test->getMessage(); + $this->command->message(""); $this->command->message( - $this->command->getAnsi()->bold("Validation: ") . - $this->command->getAnsi()->style( - ((!$unit['valid']) ? "brightRed" : null), - $unit['validation'] . ((!$unit['valid']) ? " (fail)" : "") - ) + $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) { + 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->getAnsi()->bold("Value: ") . $test->getReadValue()); } } @@ -248,10 +302,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; @@ -259,13 +313,14 @@ 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 { - if($this->executed) { - if($this->getStream()->isSeekable()) { + if ($this->executed) { + if ($this->getStream()->isSeekable()) { $this->getStream()->rewind(); } $this->executed = false; @@ -274,18 +329,32 @@ 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'] : ""; - 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; @@ -324,11 +393,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; } @@ -388,7 +456,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; @@ -400,7 +468,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(""); @@ -423,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 37e4984..407208e 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,22 +1,245 @@ + add("Unitary test", function () { +class Mailer +{ + public $from = ""; + public $bcc = ""; + + + public function __construct(string $arg1) + { + + } + + 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 getFromEmail(string $email): string + { + return $this->from; + } + + /** + * Add from email address + * + * @param string $email + * @return void + */ + public function addFromEmail(string $email, string $name = ""): void + { + $this->from = $email; + } + + public function addBCC(string $email, &$name = "Daniel"): void + { + $this->bcc = $email; + } + + public function test(...$params): void + { + } + + public function test2(): void + { + } + +} - $this->add("Lorem ipsum dolor", [ - "isString" => [], - "length" => [1,200] +class UserService { + public function __construct(private Mailer $mailer) {} - ])->add(92928, [ - "isInt" => [] + public function registerUser(string $email, string $name = "Daniel"): void { + // register user logic... - ])->add("Lorem", [ - "isString" => [], - "length" => function () { - return $this->length(1, 50); + if(!$this->mailer->isValidEmail($email)) { + throw new \Exception("Invalid email"); } - ], "The length is not correct!"); + echo $this->mailer->sendEmail($email, $name)."\n"; + echo $this->mailer->sendEmail($email, $name); + } +} +$unit = new Unit(); + + +$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) { + $inst->hasResponse(); + }); }); + + +$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"}'); + }); + + // 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); + }); + + + $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'] + $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->isHttpSuccess(); + }); + + $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("Mailer test", function (TestCase $inst) use($unit) { + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + $pool->method("addBCC") + ->paramIsType(0, "string") + ->paramHasDefault(1, "Daniel") + ->paramIsOptional(1) + ->paramIsReference(1) + ->count(1); + }); + $mock->addBCC("World"); +}); + + + + +/* + +$unit = new Unit(); +$unit->group("Unitary test 2", function (TestCase $inst) { + + $mock = $inst->mock(Mailer::class, function (MethodPool $pool) use($inst) { + $pool->method("addFromEmail") + ->hasParamsTypes() + ->isPublic() + ->hasDocComment() + ->hasReturnType() + ->count(0); + + $pool->method("addBCC") + ->isPublic() + ->hasDocComment() + ->hasParams() + ->paramHasType(0) + ->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"); + $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->isBool(); + $inst->isInt(); + $inst->isJson(); + $inst->isString(); + $inst->isResource(); + }); + +}); + +*/ +