diff --git a/README.md b/README.md index 2c6b2ea..b9a8715 100644 --- a/README.md +++ b/README.md @@ -1,360 +1,174 @@ -# MaplePHP - Unitary +# MaplePHP Unitary — Fast Testing, Full Control, Zero Friction -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. +Unitary is a modern PHP testing framework built for developers who want speed, precision, and complete freedom. No config. No noise. Just a clean, purpose-built system that runs 100,000+ tests in a second and scales effortlessly—from quick checks to full-suite validation. -![Prompt demo](http://wazabii.se/github-assets/maplephp-unitary.png) +Mocking, validation, and assertions are all built in—ready to use without setup or plugins. The CLI is intuitive, the experience is consistent, and getting started takes seconds. Whether you’re testing one function or an entire system, Unitary helps you move fast and test with confidence. -### Syntax You Will Love -```php -$unit->case("MaplePHP Request URI path test", function() { - $response = new Response(200); +![Prompt demo](http://wazabii.se/github-assets/unitary/unitary-cli-states.png) - $this->add($response->getStatusCode(), function() { - return $this->equal(200); - }, "Did not return HTTP status code 200"); -}); -``` +_Do you like the CLI theme? [Download it here](https://github.com/MaplePHP/DarkBark)_ -## Documentation -The documentation is divided into several sections: -- [Installation](#installation) -- [Guide](#guide) - - [1. Create a Test File](#1-create-a-test-file) - - [2. Create a Test Case](#2-create-a-test-case) - - [3. Run the Tests](#3-run-the-tests) -- [Configurations](#configurations) -- [Validation List](#validation-list) - - [Data Type Checks](#data-type-checks) - - [Equality and Length Checks](#equality-and-length-checks) - - [Numeric Range Checks](#numeric-range-checks) - - [String and Pattern Checks](#string-and-pattern-checks) - - [Required and Boolean-Like Checks](#required-and-boolean-like-checks) - - [Date and Time Checks](#date-and-time-checks) - - [Version Checks](#version-checks) - - [Logical Checks](#logical-checks) - - -## Installation - -To install MaplePHP Unitary, run the following command: -```bash -composer require --dev maplephp/unitary -``` +### Familiar Syntax. Fast Feedback. -## Guide +Unitary is designed to feel natural for developers. With clear syntax, built-in validation, and zero setup required, writing tests becomes a smooth part of your daily flow—not a separate chore. -### 1. Create a Test File +```php +$unit->group("Has a about page", function(TestCase $case) { + + $response = $this->get("/about"); + $statusCode = $response->getStatusCode(); + + $case->validate($statusCode, function(Expect $valid) { + $valid->isHttpSuccess(); + }); +}); +``` -Unitary will, by default, find all files prefixed with "unitary-" recursively from your project's root directory (where your "composer.json" file exists). The vendor directory will be excluded by default. +--- -Start by creating a test file with a name that starts with "unitary-", e.g., "unitary-request.php". You can place the file inside your library directory, for example like this: `tests/unitary-request.php`. +## Next-Gen PHP Testing Framework -**Note: All of your library classes will automatically be autoloaded through Composer's autoloader inside your test file!** +**Unitary** is a blazing-fast, developer-first testing framework for PHP, built from scratch with zero dependencies on legacy tools like many others. It’s simple to get started, lightning-fast to run, and powerful enough to test everything from units to mocks. -### 2. Create a Test Case +> 🚀 *Test 100,000+ cases in \~1 second. No config. No bloat. Just results.* -Now that we have created a test file, e.g., `tests/unitary-request.php`, we will need to add our test cases and tests. I will create a test for one of my other libraries below, which is MaplePHP/HTTP, specifically the Request library that has full PSR-7 support. +--- -I will show you three different ways to test your application below. +## 🔧 Why Use Unitary? -```php -case("MaplePHP Request URI path test", function() use($request) { +Unitary runs large test suites in a fraction of the time — even **100,000+** tests in just **1 second**. - // Then add tests to your case: - // Test 1: Access the validation instance inside the add closure - $this->add($request->getMethod(), function($value) { - return $this->equal("GET"); +🚀 That’s up to 46× faster than the most widely used testing frameworks. - }, "HTTP Request method type does not equal GET"); - // Adding an error message is not required, but it is highly recommended. - // Test 2: Built-in validation shortcuts - $this->add($request->getUri()->getPort(), [ - "isInt" => [], // Has no arguments = empty array - "min" => [1], // The strict way is to pass each argument as an array item - "max" => 65535, // If it's only one argument, then this is acceptable too - "length" => [1, 5] +> Benchmarks based on real-world test cases. +> 👉 [See full benchmark comparison →](https://your-docs-link.com/benchmarks) - ], "Is not a valid port number"); +--- - // Test 3: It is also possible to combine them all in one. - $this->add($request->getUri()->getUserInfo(), [ - "isString" => [], - "User validation" => function($value) { - $arr = explode(":", $value); - return ($this->withValue($arr[0])->equal("admin") && $this->withValue($arr[1])->equal("mypass")); - } - ], "Did not get the expected user info credentials"); -}); -``` - -The example above uses both built-in validation and custom validation (see below for all built-in validation options). +## Getting Started (Under 1 Minute) -### 3. Run the Tests +Set up your first test in three easy steps: -Now you are ready to execute the tests. Open your command line of choice, navigate (cd) to your project's root directory (where your `composer.json` file exists), and execute the following command: +### 1. Install ```bash -php vendor/bin/unitary +composer require --dev maplephp/unitary ``` -#### The Output: -![Prompt demo](http://wazabii.se/github-assets/maplephp-unitary-result.png) -*And that is it! Your tests have been successfully executed!* - -With that, you are ready to create your own tests! - -## Configurations - -### Select a Test File to Run +_You can run unitary globally if preferred with `composer global require maplephp/unitary`._ -After each test, a hash key is shown, allowing you to run specific tests instead of all. +--- -```bash -php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983 -``` +### 2. Create a Test File -### Run Test Case Manually +Create a file like `tests/unitary-request.php`. Unitary automatically scans all files prefixed with `unitary-` (excluding `vendor/`). -You can also mark a test case to run manually, excluding it from the main test batch. +Paste this test boilerplate to get started: ```php -$unit->manual('maplePHPRequest')->case("MaplePHP Request URI path test", function() { - ... +use MaplePHP\Unitary\{Unit, TestCase, TestConfig, Expect}; + +$unit = new Unit(); +$unit->group("Your test subject", function (TestCase $case) { + $case->validate("Your test value", function(Expect $valid) { + $valid->isString(); + }); }); ``` -And this will only run the manual test: -```bash -php vendor/bin/unitary --show=maplePHPRequest -``` +> 💡 Tip: Run `php vendor/bin/unitary --template` to auto-generate this boilerplate code above. -### Change Test Path +--- -The path argument takes both absolute and relative paths. The command below will find all tests recursively from the "tests" directory. +### 3. Run Tests ```bash -php vendor/bin/unitary --path="/tests/" +php vendor/bin/unitary ``` -**Note: The `vendor` directory will be excluded from tests by default. However, if you change the `--path`, you will need to manually exclude the `vendor` directory.** - -### Exclude Files or Directories - -The exclude argument will always be a relative path from the `--path` argument's path. +Need help? ```bash -php vendor/bin/unitary --exclude="./tests/unitary-query-php, tests/otherTests/*, */extras/*" +php vendor/bin/unitary --help ``` +#### The Output: +![Prompt demo](http://wazabii.se/github-assets/maplephp-unitary-result.png) +*And that is it! Your tests have been successfully executed!* -## 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" => []` +With that, you are ready to create your own tests! -4. **isBool** - - **Description**: Checks if the value is a boolean. - - **Usage**: `"isBool" => []` +--- -5. **isArray** - - **Description**: Checks if the value is an array. - - **Usage**: `"isArray" => []` +## 📅 Latest Release -6. **isObject** - - **Description**: Checks if the value is an object. - - **Usage**: `"isObject" => []` +**v1.3.0 – 2025-06-20** +This release marks Unitary’s transition from a testing utility to a full framework. With the core in place, expect rapid improvements in upcoming versions. -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" => []` +## 🧱 Built From the Ground Up -9. **isResource** - - **Description**: Checks if the value is a valid resource. - - **Usage**: `"isResource" => []` +Unitary stands on a solid foundation of years of groundwork. Before Unitary was possible, these independent components were developed: -10. **number** - - **Description**: Checks if the value is numeric. - - **Usage**: `"number" => []` +* [`maplephp/http`](https://github.com/maplephp/http) – PSR-7 HTTP messaging +* [`maplephp/stream`](https://github.com/maplephp/stream) – PHP stream handling +* [`maplephp/cli`](https://github.com/maplephp/prompts) – Interactive prompt/command engine +* [`maplephp/blunder`](https://github.com/maplephp/blunder) – A pretty error handling framework +* [`maplephp/validate`](https://github.com/maplephp/validate) – Type-safe input validation +* [`maplephp/dto`](https://github.com/maplephp/dto) – Strong data transport +* [`maplephp/container`](https://github.com/maplephp/container) – PSR-11 Container, container and DI system -### Equality and Length Checks -11. **equal** - - **Description**: Checks if the value is equal to a specified value. - - **Usage**: `"equal" => ["someValue"]` +This full control means everything works together, no patching, no adapters and no guesswork. -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]` +## Philosophy -14. **equalLength** - - **Description**: Checks if the string length is equal to a specified length. - - **Usage**: `"equalLength" => [10]` +> **Test everything. All the time. Without friction.** -### Numeric Range Checks -15. **min** - - **Description**: Checks if the value is greater than or equal to a specified minimum. - - **Usage**: `"min" => [10]` +TDD becomes natural when your test suite runs in under a second, even with 100,000 cases. No more cherry-picking. No more skipping. -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" => []` +## Like The CLI Theme? +That’s DarkBark. Dark, quiet, confident, like a rainy-night synthwave playlist for your CLI. -18. **negative** - - **Description**: Checks if the value is a negative number. - - **Usage**: `"negative" => []` +[Download it here](https://github.com/MaplePHP/DarkBark) -### 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" => []` +## 🤝 Contribute -22. **upperAtoZ** - - **Description**: Checks if the value consists of uppercase characters between `A-Z`. - - **Usage**: `"upperAtoZ" => []` +Unitary is still young — your bug reports, feedback, and suggestions are hugely appreciated. -23. **hex** - - **Description**: Checks if the value is a valid hex color code. - - **Usage**: `"hex" => []` +If you like what you see, consider: -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]` +* Reporting issues +* Sharing feedback +* Submitting PRs +* Starring the repo ⭐ -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"]` +## 📬 Stay in Touch -44. **allOf** - - **Description**: Validates if all the provided conditions are met. - - **Usage**: `"allOf" => [["length", [1, 200]], "email"]` +Follow the full suite of MaplePHP tools: -### Additional Validations - -45. **creditCard** - - **Description**: Validates credit card numbers. - - **Usage**: `"creditCard" => []` - -56. **vatNumber** - - **Description**: Validates Swedish VAT numbers. - - **Usage**: `"vatNumber" => []` +* [https://github.com/maplephp](https://github.com/maplephp) diff --git a/bin/unitary b/bin/unitary index 0ea0f98..af261f9 100755 --- a/bin/unitary +++ b/bin/unitary @@ -5,36 +5,31 @@ * @example php unitary --path=fullDirPath --exclude="dir1/dir2/ */ -require $GLOBALS['_composer_autoload_path']; - +use MaplePHP\Container\Container; use MaplePHP\Http\Environment; use MaplePHP\Http\ServerRequest; use MaplePHP\Http\Uri; -use MaplePHP\Prompts\Command; -use MaplePHP\Unitary\FileIterator; +use MaplePHP\Unitary\Kernel\Kernel; + +$autoload = __DIR__ . '/../vendor/autoload.php'; +if (!file_exists($autoload)) { + if (!empty($GLOBALS['_composer_autoload_path'])) { + $autoload = $GLOBALS['_composer_autoload_path']; + } else { + fwrite(STDERR, "Autoloader not found. Run `composer install`.\n"); + exit(1); + } +} + +require $autoload; -$command = new Command(); +$container = new Container(); $env = new Environment(); +// Pass argv and expected start directory path where to run tests $request = new ServerRequest(new Uri($env->getUriParts([ - "argv" => $argv + "argv" => $argv, + "dir" => (defined("UNITARY_PATH") ? UNITARY_PATH : "./") ])), $env); -$data = $request->getCliArgs(); -$defaultPath = (defined("UNITARY_PATH") ? UNITARY_PATH : "./"); - -try { - $path = ($data['path'] ?? $defaultPath); - if(!isset($path)) { - throw new Exception("Path not specified: --path=path/to/dir"); - } - - $testDir = realpath($path); - if(!is_dir($testDir)) { - throw new Exception("Test directory '$testDir' does not exist"); - } - $unit = new FileIterator($data); - $unit->executeAll($testDir); - -} catch (Exception $e) { - $command->error($e->getMessage()); -} +$kernel = new Kernel($request, $container); +$kernel->dispatch(); diff --git a/composer.json b/composer.json index 58ebb2d..c003376 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,9 @@ "maplephp/prompts": "^1.0" }, "autoload": { + "files": [ + "src/Setup/assert-polyfill.php" + ], "psr-4": { "MaplePHP\\Unitary\\": "src" } diff --git a/src/Contracts/AbstractHandler.php b/src/Contracts/AbstractHandler.php new file mode 100644 index 0000000..b94cddb --- /dev/null +++ b/src/Contracts/AbstractHandler.php @@ -0,0 +1,119 @@ +case = $testCase; + } + + /** + * {@inheritDoc} + */ + public function setSuitName(string $title): void + { + $this->suitName = $title; + } + + /** + * {@inheritDoc} + */ + public function setChecksum(string $checksum): void + { + $this->checksum = $checksum; + } + + /** + * {@inheritDoc} + */ + public function setTests(array $tests): void + { + $this->tests = $tests; + } + + /** + * {@inheritDoc} + */ + public function setShow(bool $show): void + { + $this->show = $show; + } + + /** + * {@inheritDoc} + */ + public function outputBuffer(string $outputBuffer): void + { + $this->outputBuffer = $outputBuffer; + } + + /** + * {@inheritDoc} + */ + public function buildBody(): void + { + throw new \RuntimeException('Your handler is missing the execution method.'); + } + + /** + * {@inheritDoc} + */ + public function buildNotes(): void + { + + throw new \RuntimeException('Your handler is missing the execution method.'); + } + + /** + * {@inheritDoc} + */ + public function returnStream(): StreamInterface + { + return new Stream(); + } + + + /** + * Make a file path into a title + * @param string $file + * @param int $length + * @param bool $removeSuffix + * @return string + */ + protected function formatFileTitle(string $file, int $length = 3, bool $removeSuffix = true): string + { + $file = explode("/", $file); + if ($removeSuffix) { + $pop = array_pop($file); + $file[] = substr($pop, (int)strpos($pop, 'unitary') + 8); + } + $file = array_chunk(array_reverse($file), $length); + $file = implode("\\", array_reverse($file[0])); + //$exp = explode('.', $file); + //$file = reset($exp); + return ".." . $file; + } + +} \ No newline at end of file diff --git a/src/Contracts/HandlerInterface.php b/src/Contracts/HandlerInterface.php new file mode 100644 index 0000000..651e16f --- /dev/null +++ b/src/Contracts/HandlerInterface.php @@ -0,0 +1,83 @@ +getException()) { + $this->setValue($except); + } + /** @psalm-suppress PossiblyInvalidCast */ + $this->validateExcept(__METHOD__, $compare, fn() => $this->isClass((string)$compare)); + return $this; + } + + /** + * Validate exception message + * + * @param string|callable $compare + * @return $this + * @throws Exception + */ + public function hasThrowableMessage(string|callable $compare): self + { + if($except = $this->getException()) { + $this->setValue($except->getMessage()); + } + /** @psalm-suppress PossiblyInvalidCast */ + $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + return $this; + } + + /** + * Validate exception code + * + * @param int|callable $compare + * @return $this + * @throws Exception + */ + public function hasThrowableCode(int|callable $compare): self + { + if($except = $this->getException()) { + $this->setValue($except->getCode()); + } + /** @psalm-suppress PossiblyInvalidCast */ + $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + return $this; + } + + /** + * Validate exception Severity + * + * @param int|callable $compare + * @return $this + * @throws Exception + */ + public function hasThrowableSeverity(int|callable $compare): self + { + if($except = $this->getException()) { + $value = method_exists($except, 'getSeverity') ? $except->getSeverity() : 0; + $this->setValue($value); + } + /** @psalm-suppress PossiblyInvalidCast */ + $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + return $this; + } + + /** + * Validate exception file + * + * @param string|callable $compare + * @return $this + * @throws Exception + */ + public function hasThrowableFile(string|callable $compare): self + { + if($except = $this->getException()) { + $this->setValue($except->getFile()); + } + /** @psalm-suppress PossiblyInvalidCast */ + $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + return $this; + } + + /** + * Validate exception line + * + * @param int|callable $compare + * @return $this + * @throws Exception + */ + public function hasThrowableLine(int|callable $compare): self + { + if($except = $this->getException()) { + $this->setValue($except->getLine()); + } + /** @psalm-suppress PossiblyInvalidCast */ + $this->validateExcept(__METHOD__, $compare, fn() => $this->isEqualTo($compare)); + return $this; + } + + /** + * Helper to validate the exception instance against the provided callable. + * + * @param string $name + * @param string|int|object|callable $compare + * @param callable $fall + * @return self + */ + protected function validateExcept(string $name, int|string|object|callable $compare, callable $fall): self + { + $pos = strrpos($name, '::'); + $name = ($pos !== false) ? substr($name, $pos + 2) : $name; + $this->mapErrorValidationName($name); + if(is_callable($compare)) { + $compare($this); + } else { + $fall($this); + } + + if(is_null($this->initValue)) { + $this->initValue = $this->getValue(); + } + + if($this->except === false) { + $this->setValue(null); + } + return $this; + } + + /** + * Used to get the first value before any validation is performed and + * any changes to the value are made. + * + * @return mixed + */ + public function getInitValue(): mixed + { + return $this->initValue; + } + + /** + * Retrieves the exception instance if one has been caught, + * otherwise attempts to invoke a callable value to detect any exception. + * + * @return Throwable|false Returns the caught exception if available, or false if no exception occurs. + * @throws Exception Throws an exception if the provided value is not callable. + */ + protected function getException(): Throwable|false + { + if (!is_null($this->except)) { + return $this->except; + } + + $expect = $this->getValue(); + if(!is_callable($expect)) { + throw new Exception("Except method only accepts callable"); + } + try { + $expect(); + $this->except = false; + } catch (Throwable $exception) { + $this->except = $exception; + return $this->except; + } + return false; + + } +} \ No newline at end of file diff --git a/src/FileIterator.php b/src/FileIterator.php deleted file mode 100755 index d0aaf9c..0000000 --- a/src/FileIterator.php +++ /dev/null @@ -1,186 +0,0 @@ -args = $args; - } - - /** - * Will Execute all unitary test files. - * @param string $directory - * @return void - * @throws RuntimeException - */ - 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\" "); - } else { - foreach ($files as $file) { - extract($this->args, EXTR_PREFIX_SAME, "wddx"); - Unit::resetUnit(); - Unit::setHeaders([ - "args" => $this->args, - "file" => $file, - "checksum" => md5((string)$file) - ]); - - $call = $this->requireUnitFile((string)$file); - if (!is_null($call)) { - $call(); - } - if(!Unit::hasUnit()) { - throw new RuntimeException("The Unitary Unit class has not been initiated inside \"$file\"."); - } - } - Unit::completed(); - exit((int)Unit::isSuccessful()); - } - } - - /** - * Will Scan and find all unitary test files - * @param string $dir - * @return array - */ - private function findFiles(string $dir): array - { - $files = []; - $realDir = realpath($dir); - if($realDir === false) { - throw new RuntimeException("Directory \"$dir\" does not exist. Try using a absolut path!"); - } - $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)); - - /** @var string $pattern */ - $pattern = static::PATTERN; - foreach ($iterator as $file) { - if (($file instanceof SplFileInfo) && fnmatch($pattern, $file->getFilename()) && - (isset($this->args['path']) || !str_contains($file->getPathname(), DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR))) { - if(!$this->findExcluded($this->exclude(), $dir, $file->getPathname())) { - $files[] = $file->getPathname(); - } - } - } - return $files; - } - - /** - * Get exclude parameter - * @return array - */ - public function exclude(): array - { - $excl = []; - 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) { - $new .= "*"; - } - $excl[] = trim($new); - } - } - return $excl; - } - - /** - * Validate a exclude path - * @param array $exclArr - * @param string $relativeDir - * @param string $file - * @return bool - */ - public function findExcluded(array $exclArr, string $relativeDir, string $file): bool - { - $file = $this->getNaturalPath($file); - foreach ($exclArr as $excl) { - $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . (string)$excl); - if(fnmatch($relativeExclPath, $file)) { - return true; - } - } - return false; - } - - /** - * Get path as natural path - * @param string $path - * @return string - */ - public function getNaturalPath(string $path): string - { - return str_replace("\\", "/", $path); - } - - /** - * Require file without inheriting any class information - * @param string $file - * @return Closure|null - */ - private function requireUnitFile(string $file): ?Closure - { - $clone = clone $this; - $call = function () use ($file, $clone): void { - $cli = new CliHandler(); - - if(Unit::getArgs('trace') !== false) { - $cli->enableTraceLines(true); - } - $run = new Run($cli); - $run->load(); - - //ob_start(); - if (!is_file($file)) { - throw new RuntimeException("File \"$file\" do not exists."); - } - require_once($file); - - $clone->getUnit()->execute(); - - /* - $outputBuffer = ob_get_clean(); - if (strlen($outputBuffer) && Unit::hasUnit()) { - $clone->getUnit()->buildNotice("Note:", $outputBuffer, 80); - } - */ - }; - return $call->bindTo(null); - } - - /** - * @return Unit - * @throws RuntimeException|\Exception - */ - protected function getUnit(): Unit - { - $unit = Unit::getUnit(); - if (is_null($unit)) { - throw new RuntimeException("The Unit instance has not been initiated."); - } - return $unit; - - } -} diff --git a/src/Handlers/CliHandler.php b/src/Handlers/CliHandler.php new file mode 100644 index 0000000..6be2371 --- /dev/null +++ b/src/Handlers/CliHandler.php @@ -0,0 +1,191 @@ +command = $command; + } + + /** + * {@inheritDoc} + */ + public function buildBody(): void + { + $this->initDefault(); + + $this->command->message(""); + $this->command->message( + $this->flag . " " . + $this->command->getAnsi()->style(["bold"], $this->formatFileTitle($this->suitName)) . + " - " . + $this->command->getAnsi()->style(["bold", $this->color], (string)$this->case->getMessage()) + ); + + if($this->show && !$this->case->hasFailed()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["italic", $this->color], "Test file: " . $this->suitName) + ); + } + + if (($this->show || !$this->case->getConfig()->skip)) { + // Show possible warnings + if($this->case->getWarning()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["italic", "yellow"], $this->case->getWarning()) + ); + } + + $this->showFailedTests(); + } + + $this->showFooter(); + } + + /** + * {@inheritDoc} + */ + public function buildNotes(): void + { + if($this->outputBuffer) { + $lineLength = 80; + $output = wordwrap($this->outputBuffer, $lineLength); + $line = $this->command->getAnsi()->line($lineLength); + + $this->command->message(""); + $this->command->message($this->command->getAnsi()->style(["bold"], "Note:")); + $this->command->message($line); + $this->command->message($output); + $this->command->message($line); + } + } + + /** + * {@inheritDoc} + */ + public function returnStream(): StreamInterface + { + return $this->command->getStream(); + } + + protected function showFooter(): void + { + $select = $this->checksum; + if ($this->case->getConfig()->select) { + $select .= " (" . $this->case->getConfig()->select . ")"; + } + $this->command->message(""); + + $passed = $this->command->getAnsi()->bold("Passed: "); + if ($this->case->getHasAssertError()) { + $passed .= $this->command->getAnsi()->style(["grey"], "N/A"); + } else { + $passed .= $this->command->getAnsi()->style([$this->color], $this->case->getCount() . "/" . $this->case->getTotal()); + } + + $footer = $passed . + $this->command->getAnsi()->style(["italic", "grey"], " - ". $select); + if (!$this->show && $this->case->getConfig()->skip) { + $footer = $this->command->getAnsi()->style(["italic", "grey"], $select); + } + $this->command->message($footer); + $this->command->message(""); + + } + + protected function showFailedTests(): void + { + if (($this->show || !$this->case->getConfig()->skip)) { + // Show possible warnings + if($this->case->getWarning()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["italic", "yellow"], $this->case->getWarning()) + ); + } + foreach ($this->tests as $test) { + + if (!($test instanceof TestUnit)) { + throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); + } + + if (!$test->isValid()) { + $msg = (string)$test->getMessage(); + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->style(["bold", $this->color], "Error: ") . + $this->command->getAnsi()->bold($msg) + ); + $this->command->message(""); + + $trace = $test->getCodeLine(); + if (!empty($trace['code'])) { + $this->command->message($this->command->getAnsi()->style(["bold", "grey"], "Failed on {$trace['file']}:{$trace['line']}")); + $this->command->message($this->command->getAnsi()->style(["grey"], " → {$trace['code']}")); + } + + foreach ($test->getUnits() as $unit) { + + /** @var TestItem $unit */ + if (!$unit->isValid()) { + $lengthA = $test->getValidationLength(); + $validation = $unit->getValidationTitle(); + $title = str_pad($validation, $lengthA); + $compare = $unit->hasComparison() ? $unit->getComparison() : ""; + + $failedMsg = " " .$title . " → failed"; + $this->command->message($this->command->getAnsi()->style($this->color, $failedMsg)); + + if ($compare) { + $lengthB = (strlen($compare) + strlen($failedMsg) - 8); + $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); + $this->command->message( + $this->command->getAnsi()->style($this->color, $comparePad) + ); + } + } + } + if ($test->hasValue()) { + $this->command->message(""); + $this->command->message( + $this->command->getAnsi()->bold("Input value: ") . + Helpers::stringifyDataTypes($test->getValue()) + ); + } + } + } + } + } + + protected function initDefault(): void + { + $this->color = ($this->case->hasFailed() ? "brightRed" : "brightBlue"); + if ($this->case->hasFailed()) { + $this->flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); + } + if ($this->case->getConfig()->skip) { + $this->color = "yellow"; + $this->flag = $this->command->getAnsi()->style(['yellowBg', 'black'], " SKIP "); + } + $this->flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); + } +} \ No newline at end of file diff --git a/src/Handlers/FileHandler.php b/src/Handlers/FileHandler.php index f599c03..a3b6c7c 100755 --- a/src/Handlers/FileHandler.php +++ b/src/Handlers/FileHandler.php @@ -1,5 +1,12 @@ enableExitScript(false); + + $coverage->start(); + $this->iterateTest($commandInMem, $iterator, $args); + $coverage->end(); + + $result = $coverage->getResponse(); + + $block = new Blocks($command); + + $block->addSection("Code coverage", function(Blocks $block) use ($result) { + return $block->addList("Total lines:", $result['totalLines']) + ->addList("Executed lines:", $result['executedLines']) + ->addList("Code coverage percent:", $result['percent']); + }); + + $command->message(""); + $iterator->exitScript(); + } + + /** + * Main help page + * + * @param array $args + * @param Command $command + * @return void + */ + public function help(array $args, Command $command): void + { + $blocks = new Blocks($command); + $blocks->addHeadline("\n--- Unitary Help ---"); + $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); + + $blocks->addSection("Options", function(Blocks $inst) { + return $inst + ->addOption("help", "Show this help message") + ->addOption("show=", "Run a specific test by hash or manual test name") + ->addOption("errors-only", "Show only failing tests and skip passed test output") + ->addOption("template", "Will give you a boilerplate test code") + ->addOption("path=", "Specify test path (absolute or relative)") + ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); + }); + + $blocks->addSection("Examples", function(Blocks $inst) { + return $inst + ->addExamples( + "php vendor/bin/unitary", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983", + "Run the test with a specific hash ID" + )->addExamples( + "php vendor/bin/unitary --errors-only", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=YourNameHere", + "Run a manually named test case" + )->addExamples( + "php vendor/bin/unitary --template", + "Run a and will give you template code for a new test" + )->addExamples( + 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', + 'Run all tests under "tests/" excluding specified directories' + ); + }); + // Make sure nothing else is executed when help is triggered + exit(0); + } + +} \ No newline at end of file diff --git a/src/Kernel/Controllers/DefaultController.php b/src/Kernel/Controllers/DefaultController.php new file mode 100644 index 0000000..999552e --- /dev/null +++ b/src/Kernel/Controllers/DefaultController.php @@ -0,0 +1,21 @@ +request = $request; + $this->container = $container; + } +} \ No newline at end of file diff --git a/src/Kernel/Controllers/RunTestController.php b/src/Kernel/Controllers/RunTestController.php new file mode 100644 index 0000000..dd8b5a0 --- /dev/null +++ b/src/Kernel/Controllers/RunTestController.php @@ -0,0 +1,103 @@ +iterateTest($command, $iterator, $args); + } + + protected function iterateTest(Command $command, FileIterator $iterator, array $args): void + { + Configs::getInstance()->setCommand($command); + + $defaultPath = $this->container->get("request")->getUri()->getDir(); + try { + $path = ($args['path'] ?? $defaultPath); + if(!isset($path)) { + throw new RuntimeException("Path not specified: --path=path/to/dir"); + } + $testDir = realpath($path); + if(!file_exists($testDir)) { + throw new RuntimeException("Test directory '$testDir' does not exist"); + } + + $iterator->executeAll($testDir, $defaultPath); + + } catch (Exception $e) { + $command->error($e->getMessage()); + } + } + + /** + * Main help page + * + * @param array $args + * @param Command $command + * @return void + */ + public function help(array $args, Command $command): void + { + $blocks = new Blocks($command); + $blocks->addHeadline("\n--- Unitary Help ---"); + $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); + + $blocks->addSection("Options", function(Blocks $inst) { + return $inst + ->addOption("help", "Show this help message") + ->addOption("show=", "Run a specific test by hash or manual test name") + ->addOption("errors-only", "Show only failing tests and skip passed test output") + ->addOption("template", "Will give you a boilerplate test code") + ->addOption("path=", "Specify test path (absolute or relative)") + ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); + }); + + $blocks->addSection("Examples", function(Blocks $inst) { + return $inst + ->addExamples( + "php vendor/bin/unitary", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983", + "Run the test with a specific hash ID" + )->addExamples( + "php vendor/bin/unitary --errors-only", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=YourNameHere", + "Run a manually named test case" + )->addExamples( + "php vendor/bin/unitary --template", + "Run a and will give you template code for a new test" + )->addExamples( + 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', + 'Run all tests under "tests/" excluding specified directories' + ); + }); + // Make sure nothing else is executed when help is triggered + exit(0); + } + +} \ No newline at end of file diff --git a/src/Kernel/Controllers/TemplateController.php b/src/Kernel/Controllers/TemplateController.php new file mode 100644 index 0000000..c73cf66 --- /dev/null +++ b/src/Kernel/Controllers/TemplateController.php @@ -0,0 +1,98 @@ +addHeadline("\n--- Unitary template ---"); + $blocks->addCode( + <<<'PHP' + use MaplePHP\Unitary\{Unit, TestCase, TestConfig, Expect}; + + $unit = new Unit(); + $unit->group("Your test subject", function (TestCase $case) { + + $case->validate("Your test value", function(Expect $valid) { + $valid->isString(); + }); + + }); + PHP + ); + exit(0); + } + + /** + * Main help page + * + * @param array $args + * @param Command $command + * @return void + */ + public function help(array $args, Command $command): void + { + $blocks = new Blocks($command); + $blocks->addHeadline("\n--- Unitary Help ---"); + $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); + + $blocks->addSection("Options", function(Blocks $inst) { + return $inst + ->addOption("help", "Show this help message") + ->addOption("show=", "Run a specific test by hash or manual test name") + ->addOption("errors-only", "Show only failing tests and skip passed test output") + ->addOption("template", "Will give you a boilerplate test code") + ->addOption("path=", "Specify test path (absolute or relative)") + ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); + }); + + $blocks->addSection("Examples", function(Blocks $inst) { + return $inst + ->addExamples( + "php vendor/bin/unitary", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=b0620ca8ef6ea7598eaed56a530b1983", + "Run the test with a specific hash ID" + )->addExamples( + "php vendor/bin/unitary --errors-only", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=YourNameHere", + "Run a manually named test case" + )->addExamples( + "php vendor/bin/unitary --template", + "Run a and will give you template code for a new test" + )->addExamples( + 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', + 'Run all tests under "tests/" excluding specified directories' + ); + }); + // Make sure nothing else is executed when help is triggered + exit(0); + } + +} \ No newline at end of file diff --git a/src/Kernel/Kernel.php b/src/Kernel/Kernel.php new file mode 100644 index 0000000..317126d --- /dev/null +++ b/src/Kernel/Kernel.php @@ -0,0 +1,60 @@ +request = $request; + $this->container = $container; + $this->router = new Router($this->request->getCliKeyword(), $this->request->getCliArgs()); + } + + /** + * Dispatch routes and call controller + * + * @return void + */ + function dispatch() + { + $router = $this->router; + require_once __DIR__ . "/routes.php"; + + $this->container->set("request", $this->request); + + $router->dispatch(function($controller, $args) { + $command = new Command(); + [$class, $method] = $controller; + if(method_exists($class, $method)) { + $inst = new $class($this->request, $this->container); + $inst->{$method}($args, $command); + + } else { + $command->error("The controller {$class}::{$method}() not found"); + } + }); + } +} \ No newline at end of file diff --git a/src/Kernel/routes.php b/src/Kernel/routes.php new file mode 100644 index 0000000..1aa5fe0 --- /dev/null +++ b/src/Kernel/routes.php @@ -0,0 +1,11 @@ +map("coverage", [CoverageController::class, "run"]); +$router->map("template", [TemplateController::class, "run"]); +$router->map(["", "test", "run"], [RunTestController::class, "run"]); +$router->map(["__404", "help"], [RunTestController::class, "help"]); \ No newline at end of file diff --git a/src/Mocker/MethodRegistry.php b/src/Mocker/MethodRegistry.php new file mode 100644 index 0000000..2f7be72 --- /dev/null +++ b/src/Mocker/MethodRegistry.php @@ -0,0 +1,112 @@ +> */ + private static array $methods = []; + + public function __construct(?MockBuilder $mocker = null) + { + $this->mocker = $mocker; + } + + /** + * @param string $class + * @return void + */ + public static function reset(string $class): void + { + self::$methods[$class] = []; + } + + /** + * Access method pool + * @param string $class + * @param string $name + * @return MockedMethod|null + */ + public static function getMethod(string $class, string $name): ?MockedMethod + { + $mockedMethod = self::$methods[$class][$name] ?? null; + if($mockedMethod instanceof MockedMethod) { + return $mockedMethod; + } + return null; + } + + /** + * 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 MockedMethod The newly created MethodItem instance. + */ + public function method(string $name): MockedMethod + { + if(is_null($this->mocker)) { + throw new \BadMethodCallException("MockBuilder is not set yet."); + } + self::$methods[$this->mocker->getMockedClassName()][$name] = new MockedMethod($this->mocker); + return self::$methods[$this->mocker->getMockedClassName()][$name]; + } + + /** + * Get method + * + * @param string $key + * @return MockedMethod|null + */ + public function get(string $key): MockedMethod|null + { + if(is_null($this->mocker)) { + throw new \BadMethodCallException("MockBuilder is not set yet."); + } + return self::$methods[$this->mocker->getMockedClassName()][$key] ?? null; + } + + /** + * Get all methods + * + * @return array True if the method exists, false otherwise. + */ + public function getAll(): array + { + return self::$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 + { + if(is_null($this->mocker)) { + throw new \BadMethodCallException("MockBuilder is not set yet."); + } + return isset(self::$methods[$this->mocker->getMockedClassName()][$name]); + } + + public function getSelected(array $names): array + { + if(is_null($this->mocker)) { + throw new \BadMethodCallException("MockBuilder is not set yet."); + } + + return array_filter($names, fn($name) => $this->has($name)); + } + +} diff --git a/src/Mocker/MockBuilder.php b/src/Mocker/MockBuilder.php new file mode 100755 index 0000000..aeca100 --- /dev/null +++ b/src/Mocker/MockBuilder.php @@ -0,0 +1,551 @@ + */ + protected array $constructorArgs = []; + protected array $methods; + protected array $methodList = []; + protected array $isFinal = []; + 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(); + $this->methods = $this->reflection->getMethods(); + $this->constructorArgs = $args; + + $shortClassName = explode("\\", $className); + $shortClassName = end($shortClassName); + /** + * @var class-string $shortClassName + * @psalm-suppress PropertyTypeCoercion + */ + $this->mockClassName = "Unitary_" . uniqid() . "_Mock_" . $shortClassName; + $this->copyClassName = "Unitary_Mock_" . $shortClassName; + /* + // Auto fill the Constructor args! + $test = $this->reflection->getConstructor(); + $test = $this->generateMethodSignature($test); + $param = $test->getParameters(); + */ + } + + protected function getMockClass(?MockedMethod $methodItem, callable $call, mixed $fallback = null): mixed + { + return ($methodItem instanceof MockedMethod) ? $call($methodItem) : $fallback; + } + + /** + * Adds metadata to the mock method, including the mock class name, return value. + * This is possible custom-added data that "has to" validate against the MockedMethod instance + * + * @param array $data The base data array to add metadata to + * @param string $mockClassName The name of the mock class + * @param mixed $returnValue + * @param mixed $methodItem + * @return array The data array with added metadata + */ + protected function addMockMetadata(array $data, string $mockClassName, mixed $returnValue, ?MockedMethod $methodItem): array + { + $data['mocker'] = $mockClassName; + $data['return'] = ($methodItem instanceof MockedMethod && $methodItem->hasReturn()) ? $methodItem->return : eval($returnValue); + $data['keepOriginal'] = ($methodItem instanceof MockedMethod && $methodItem->keepOriginal) ? $methodItem->keepOriginal : false; + $data['throwOnce'] = ($methodItem instanceof MockedMethod && $methodItem->throwOnce) ? $methodItem->throwOnce : false; + return $data; + } + + /** + * Get reflection of the expected class + * @return ReflectionClass + */ + public function getReflectionClass(): ReflectionClass + { + return $this->reflection; + } + + /** + * Gets the fully qualified name of the class being mocked. + * + * @return string The class name that was provided during instantiation + */ + public function getClassName(): string + { + return $this->className; + } + + + /** + * Returns the constructor arguments provided during instantiation. + * + * @return array The array of constructor arguments used to create the mock instance + */ + public function getClassArgs(): array + { + return $this->constructorArgs; + } + + /** + * Gets the mock class name generated during mock creation. + * This method should only be called after execute() has been invoked. + * + * @return string The generated mock class name + */ + public function getMockedClassName(): string + { + return (string)$this->mockClassName; + } + + /** + * Return all final methods + * + * @return array + */ + public function getFinalMethods(): array + { + return $this->isFinal; + } + + /** + * Gets the list of methods that are mocked. + * + * @return bool + */ + public function hasFinal(): bool + { + return $this->isFinal !== []; + } + + /** + * 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 !== null && $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(); + $overrides = $this->generateMockMethodOverrides((string)$this->mockClassName); + $unknownMethod = $this->errorHandleUnknownMethod($className, !$this->reflection->isInterface()); + $extends = $this->reflection->isInterface() ? "implements $className" : "extends $className"; + + $code = " + class $this->mockClassName $extends { + {$overrides} + {$unknownMethod} + public static function __set_state(array \$an_array): self + { + \$obj = new self(..." . var_export($this->constructorArgs, true) . "); + return \$obj; + } + } + "; + + eval($code); + + if(!is_string($this->mockClassName)) { + throw new Exception("Mock class name is not a string"); + } + + /** + * @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, bool $checkOriginal = true): string + { + if (!in_array('__call', $this->methodList)) { + + $checkOriginalCall = $checkOriginal ? " + if (method_exists(get_parent_class(\$this), '__call')) { + return parent::__call(\$name, \$arguments); + } + " : ""; + + return " + public function __call(string \$name, array \$arguments) { + {$checkOriginalCall} + throw new \\BadMethodCallException(\"Method '\$name' does not exist in class '$className'.\"); + } + "; + } + return ""; + } + + /** + * @param array $types + * @param mixed $method + * @param MockedMethod|null $methodItem + * @return string + */ + protected function getReturnValue(array $types, mixed $method, ?MockedMethod $methodItem = null): string + { + // Will overwrite the auto generated value + if ($methodItem && $methodItem->hasReturn()) { + return " + \$returnData = " . var_export($methodItem->return, true) . "; + return \$returnData[\$data->called-1] ?? \$returnData[0]; + "; + } + 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"); + } + + $methodName = $method->getName(); + if ($method->isFinal()) { + $this->isFinal[] = $methodName; + continue; + } + $this->methodList[] = $methodName; + + // The MethodItem contains all items that are validatable + $methodItem = MethodRegistry::getMethod($this->getMockedClassName(), $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 = $this->handleModifiers($modifiersArr); + + $arr = $this->getMethodInfoAsArray($method); + $arr = $this->addMockMetadata($arr, $mockClassName, $returnValue, $methodItem); + + $info = json_encode($arr); + if ($info === false) { + throw new RuntimeException('JSON encoding failed: ' . json_last_error_msg(), json_last_error()); + } + + MockController::getInstance()->buildMethodData($info); + if ($methodItem) { + $returnValue = $this->generateWrapperReturn($methodItem->getWrap(), $methodName, $returnValue); + } + + if($methodItem && $methodItem->keepOriginal) { + $returnValue = "parent::$methodName(...func_get_args());"; + if (!in_array('void', $types)) { + $returnValue = "return $returnValue"; + } + } + + $exception = ($methodItem && $methodItem->getThrowable()) ? $this->handleThrownExceptions($methodItem->getThrowable()) : ""; + $safeJson = base64_encode($info); + $overrides .= " + $modifiers function $methodName($paramList){$returnType} + { + \$obj = \\MaplePHP\\Unitary\\Mocker\\MockController::getInstance()->buildMethodData('$safeJson', func_get_args(), true); + \$data = \\MaplePHP\\Unitary\\Mocker\\MockController::getDataItem(\$obj->mocker, \$obj->name); + + if(\$data->throwOnce === false || \$data->called <= 1) { + {$exception} + } + {$returnValue} + } + "; + } + return $overrides; + } + + /** + * Will handle modifier correctly + * + * @param array $modifiersArr + * @return string + */ + protected function handleModifiers(array $modifiersArr): string + { + $modifiersArr = array_filter($modifiersArr, fn($val) => $val !== "abstract"); + return implode(" ", $modifiersArr); + } + + /** + * Will mocked a handle the thrown exception + * + * @param \Throwable $exception + * @return string + */ + protected function handleThrownExceptions(\Throwable $exception): string + { + $class = get_class($exception); + $reflection = new \ReflectionClass($exception); + $constructor = $reflection->getConstructor(); + $args = []; + if ($constructor) { + foreach ($constructor->getParameters() as $param) { + $name = $param->getName(); + $value = $exception->{$name} ?? null; + switch ($name) { + case 'message': + $value = $exception->getMessage(); + break; + case 'code': + $value = $exception->getCode(); + break; + case 'previous': + $value = null; + break; + } + $args[] = var_export($value, true); + } + } + + return "throw new \\{$class}(" . implode(', ', $args) . ");"; + } + + /** + * Will build the wrapper return + * + * @param Closure|null $wrapper + * @param string $methodName + * @param string $returnValue + * @return string + */ + protected function generateWrapperReturn(?Closure $wrapper, string $methodName, string $returnValue): string + { + MockController::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 (ReflectionNamedType $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 ($value !== null) { + return "return " . DataTypeMock::exportValue($value) . ";"; + } + + $methodName = ($method instanceof ReflectionMethod) ? $method->getName() : null; + + $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/MockController.php b/src/Mocker/MockController.php new file mode 100644 index 0000000..48ac8c3 --- /dev/null +++ b/src/Mocker/MockController.php @@ -0,0 +1,118 @@ +> */ + private static array $data = []; + + /** + * Get a singleton instance of MockController + * Creates a new instance if none exists + * + * @return static The singleton instance of MockController + */ + public static function getInstance(): self + { + if (self::$instance === null) { + 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, array $args = [], bool $isBase64Encoded = false): object + { + $method = $isBase64Encoded ? base64_decode($method) : $method; + $data = (object)json_decode($method); + + if (isset($data->mocker) && isset($data->name)) { + $mocker = (string)$data->mocker; + $name = (string)$data->name; + if (empty(self::$data[$mocker][$name])) { + // This is outside the mocked method + // You can prepare values here with defaults + $data->called = 0; + $data->arguments = []; + $data->throw = null; + self::$data[$mocker][$name] = $data; + // Mocked method has trigger "once"! + } else { + // This is the mocked method + // You can overwrite the default with the expected mocked values here + if (isset(self::$data[$mocker][$name])) { + /** @psalm-suppress MixedArrayAssignment */ + self::$data[$mocker][$name]->arguments[] = $args; + self::$data[$mocker][$name]->called = (int)self::$data[$mocker][$name]->called + 1; + // Mocked method has trigger "More Than" once! + } + } + } + return $data; + } + +} diff --git a/src/Mocker/MockedMethod.php b/src/Mocker/MockedMethod.php new file mode 100644 index 0000000..af7bbae --- /dev/null +++ b/src/Mocker/MockedMethod.php @@ -0,0 +1,655 @@ +mocker = $mocker; + } + + /** + * Creates a proxy wrapper around a method to enable integration testing. + * The wrapper allows intercepting and modifying method behavior during tests. + * + * @param Closure $call The closure to be executed as the wrapper function + * @return $this Method chain + * @throws BadMethodCallException When mocker is not set + */ + public function wrap(Closure $call): self + { + if ($this->mocker === null) { + throw new BadMethodCallException('Mocker is not set. Use the method "mock" to set the mocker.'); + } + + if($this->mocker->getReflectionClass()->isInterface()) { + throw new BadMethodCallException('You only use "wrap()" on regular classes and not "interfaces".'); + } + + $inst = $this; + $wrap = new class ($this->mocker->getClassName(), $this->mocker->getClassArgs()) extends ExecutionWrapper { + public function __construct(string $class, array $args = []) + { + 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; + } + + /** + * Get the throwable if added as Throwable + * + * @return Throwable|null + */ + public function getThrowable(): ?Throwable + { + return $this->throwable; + } + + /** + * Check if a method has been called x times + * + * @param int $times + * @return $this + */ + public function called(int $times): self + { + $inst = $this; + $inst->called = $times; + return $inst; + } + + /** + * Check if a method has been called x times + * + * @return $this + */ + public function hasBeenCalled(): self + { + $inst = $this; + $inst->called = [ + "isAtLeast" => [1], + ]; + return $inst; + } + + /** + * Check if a method has been called x times + * + * @param int $times + * @return $this + */ + public function calledAtLeast(int $times): self + { + $inst = $this; + $inst->called = [ + "isAtLeast" => [$times], + ]; + return $inst; + } + + /** + * Check if a method has been called x times + * + * @param int $times + * @return $this + */ + public function calledAtMost(int $times): self + { + $inst = $this; + $inst->called = [ + "isAtMost" => [$times], + ]; + return $inst; + } + + /** + * Validates arguments for the first called method + * + * @example method('addEmail')->withArguments('john.doe@gmail.com', 'John Doe') + * @param mixed ...$args + * @return $this + */ + public function withArguments(mixed ...$args): self + { + foreach ($args as $key => $value) { + $this->withArgumentAt($key, $value); + } + return $this; + } + + /** + * Validates arguments for multiple method calls with different argument sets + * + * @example method('addEmail')->withArguments( + * ['john.doe@gmail.com', 'John Doe'], ['jane.doe@gmail.com', 'Jane Doe'] + * ) + * @param mixed ...$args + * @return $this + */ + public function withArgumentsForCalls(mixed ...$args): self + { + $inst = $this; + foreach ($args as $called => $data) { + if(!is_array($data)) { + throw new InvalidArgumentException( + 'The argument must be a array that contains the expected method arguments.' + ); + } + foreach ($data as $key => $value) { + $inst = $inst->withArgumentAt($key, $value, $called); + } + } + return $inst; + } + + /** + * This will validate an argument at position + * + * @param int $called + * @param int $position + * @param mixed $value + * @return $this + */ + public function withArgumentAt(int $position, mixed $value, int $called = 0): self + { + $inst = $this; + $inst->arguments[] = [ + "validateInData" => ["$called.$position", "equal", [$value]], + ]; + return $inst; + } + + /** + * Preserve the original method functionality instead of mocking it. + * When this is set, the method will execute its original implementation instead of any mock behavior. + * + * @return $this Method chain + */ + public function keepOriginal(): self + { + $inst = $this; + $inst->keepOriginal = true; + return $inst; + } + + /** + * Check if a return value has been added + * + * @return bool + */ + public function hasReturn(): bool + { + return $this->hasReturn; + } + + /** + * Change what the method should return + * + * @param mixed $value + * @return $this + */ + public function willReturn(mixed ...$value): self + { + $inst = $this; + $inst->hasReturn = true; + $inst->return = $value; + return $inst; + } + + /** + * Configures the method to throw an exception every time it's called + * + * @param Throwable $throwable + * @return $this + */ + public function willThrow(Throwable $throwable): self + { + $this->throwable = $throwable; + $this->throw = []; + return $this; + } + + /** + * Configures the method to throw an exception only once + * + * @param Throwable $throwable + * @return $this + */ + public function willThrowOnce(Throwable $throwable): self + { + $this->throwOnce = true; + $this->willThrow($throwable); + return $this; + } + + /** + * Compare if method has expected class name. + * + * @param string $class + * @return self + */ + public function hasClass(string $class): self + { + $inst = $this; + $inst->class = $class; + return $inst; + } + + /** + * Compare if method has expected method name. + * + * @param string $name + * @return self + */ + public function hasName(string $name): self + { + $inst = $this; + $inst->name = $name; + return $inst; + } + + /** + * Check if the method is expected to be static + * + * @return self + */ + public function isStatic(): self + { + $inst = $this; + $inst->isStatic = true; + return $inst; + } + + /** + * Check if the method is expected to be public + * + * @return self + */ + public function isPublic(): self + { + $inst = $this; + $inst->isPublic = true; + return $inst; + } + + /** + * Check if the method is expected to be private + * + * @return self + */ + public function isPrivate(): self + { + $inst = $this; + $inst->isPrivate = true; + return $inst; + } + + /** + * Check if the method is expected to be protected. + * + * @return self + */ + public function isProtected(): self + { + $inst = $this; + $inst->isProtected = true; + return $inst; + } + + /** + * Check if the method is expected to be abstract. + * + * @return self + */ + public function isAbstract(): self + { + $inst = $this; + $inst->isAbstract = true; + return $inst; + } + + /** + * Check if the method is expected to be final. + * + * @return self + */ + public function isFinal(): self + { + $inst = $this; + $inst->isFinal = true; + return $inst; + } + + /** + * Check if the method is expected to return a reference + * + * @return self + */ + public function returnsReference(): self + { + $inst = $this; + $inst->returnsReference = true; + return $inst; + } + + /** + * Check if the method has a return type. + * + * @return self + */ + public function hasReturnType(): self + { + $inst = $this; + $inst->hasReturnType = true; + return $inst; + } + + /** + * Check if the method return type has expected type + * + * @param string $type + * @return self + */ + public function isReturnType(string $type): self + { + $inst = $this; + $inst->returnType = $type; + return $inst; + } + + /** + * Check if the method is the constructor. + * + * @return self + */ + public function isConstructor(): self + { + $inst = $this; + $inst->isConstructor = true; + return $inst; + } + + /** + * Check if the method is the destructor. + * + * @return self + */ + public function isDestructor(): self + { + $inst = $this; + $inst->isDestructor = true; + return $inst; + } + + /** + * Check if the method parameters exists + * + * @return $this + */ + public function hasParams(): self + { + $inst = $this; + $inst->parameters[] = [ + "isCountMoreThan" => [0], + ]; + return $inst; + } + + /** + * Check if the method has parameter types + * + * @return $this + */ + public function hasParamsTypes(): self + { + $inst = $this; + $inst->parameters[] = [ + "itemsAreTruthy" => ['hasType', true], + ]; + return $inst; + } + + /** + * Check if the method is missing parameters + * + * @return $this + */ + public function hasNotParams(): self + { + $inst = $this; + $inst->parameters[] = [ + "isArrayEmpty" => [], + ]; + return $inst; + } + + /** + * Check if the method has equal number of parameters as expected + * + * @param int $length + * @return $this + */ + public function paramsHasCount(int $length): self + { + $inst = $this; + $inst->parameters[] = [ + "isCountEqualTo" => [$length], + ]; + return $inst; + } + + /** + * Check if the method parameter at given index location has expected data type + * + * @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 if the method parameter at given index location has a default value + * + * @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 if the method parameter at given index location has a data type + * + * @param int $paramPosition + * @return $this + */ + public function paramHasType(int $paramPosition): self + { + $inst = $this; + $inst->parameters[] = [ + "validateInData" => ["$paramPosition.hasType", "equal", [true]], + ]; + return $inst; + } + + /** + * Check if the method parameter at given index location is optional + * + * @param int $paramPosition + * @return $this + */ + public function paramIsOptional(int $paramPosition): self + { + $inst = $this; + $inst->parameters[] = [ + "validateInData" => ["$paramPosition.isOptional", "equal", [true]], + ]; + return $inst; + } + + /** + * Check if the method parameter at given index location is a reference + * + * @param int $paramPosition + * @return $this + */ + public function paramIsReference(int $paramPosition): self + { + $inst = $this; + $inst->parameters[] = [ + "validateInData" => ["$paramPosition.isReference", "equal", [true]], + ]; + return $inst; + } + + /** + * Check if the method parameter at given index location is a variadic (spread) + * + * @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); + } + + /** + * Check if the method has comment block + * + * @return self + */ + public function hasDocComment(): self + { + $inst = $this; + $inst->hasDocComment = [ + "isString" => [], + "startsWith" => ["/**"] + ]; + return $inst; + } + + /** + * Check if the method exist in file with name + * + * @param string $file + * @return self + */ + public function hasFileName(string $file): self + { + $inst = $this; + $inst->fileName = $file; + return $inst; + } + + /** + * Check if the method starts at line number + * + * @param int $line + * @return self + */ + public function startLine(int $line): self + { + $inst = $this; + $inst->startLine = $line; + return $inst; + } + + /** + * Check if the method return ends at line number + * + * @param int $line + * @return self + */ + public function endLine(int $line): self + { + $inst = $this; + $inst->endLine = $line; + return $inst; + } +} diff --git a/src/Setup/assert-polyfill.php b/src/Setup/assert-polyfill.php new file mode 100644 index 0000000..5753be4 --- /dev/null +++ b/src/Setup/assert-polyfill.php @@ -0,0 +1,22 @@ + + */ + private const EXCLUDE_VALIDATE = ["return"]; private mixed $value; - private ?string $message; + private TestConfig $config; private array $test = []; private int $count = 0; private ?Closure $bind = null; + private ?string $error = null; + private ?string $warning = null; + private array $deferredValidation = []; + + private ?MockBuilder $mocker = null; + private bool $hasAssertError = false; - public function __construct(?string $message = null) + /** + * Initialize a new TestCase instance with an optional message. + * + * @param TestConfig|string|null $config + */ + public function __construct(TestConfig|string|null $config = null) { - $this->message = $message; + if (!($config instanceof TestConfig)) { + $this->config = new TestConfig((string)$config); + } else { + $this->config = $config; + } } /** * Bind the test case to the Closure + * * @param Closure $bind + * @param bool $bindToClosure choose bind to closure or not (recommended) + * Used primary as a fallback for older versions of Unitary + * @return void + */ + public function bind(Closure $bind, bool $bindToClosure = false): void + { + $this->bind = ($bindToClosure) ? $bind->bindTo($this) : $bind; + } + + /** + * Sets the assertion error flag to true + * * @return void */ - public function bind(Closure $bind): void + function setHasAssertError(): void + { + $this->hasAssertError = true; + } + + /** + * Gets the current state of the assertion error flag + * + * @return bool + */ + function getHasAssertError(): bool + { + return $this->hasAssertError; + } + + /** + * Get a possible warning message if exists + * + * @return string|null + */ + public function getWarning(): ?string + { + return $this->warning; + } + + /** + * Set a possible warning in the test group + * + * @param string $message + * @return $this + */ + public function warning(string $message): self + { + $this->warning = $message; + return $this; + } + + /** + * Add custom error message if validation fails + * + * @param string $message + * @return $this + */ + public function error(string $message): self { - $this->bind = $bind->bindTo($this); + $this->error = $message; + return $this; } /** * Will dispatch the case tests and return them as an array + * + * @param self $row * @return array + * @throws BlunderErrorException + * @throws Throwable */ - public function dispatchTest(): array + public function dispatchTest(self &$row): array { + $row = $this; $test = $this->bind; - if (!is_null($test)) { - $test($this); + if ($test !== null) { + try { + $newInst = $test($this); + } catch (AssertionError $e) { + $newInst = clone $this; + $newInst->setHasAssertError(); + $msg = "Assertion failed"; + $newInst->expectAndValidate( + true, fn() => false, $msg, $e->getMessage(), $e->getTrace()[0] + ); + } catch (Throwable $e) { + if(str_contains($e->getFile(), "eval()")) { + throw new BlunderErrorException($e->getMessage(), (int)$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 a test unit validation using the provided expectation and validation logic + * + * @param mixed $expect The expected value + * @param Closure(Expect, Traverse): bool $validation + * @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, Expect $inst) use ($validation) { + return $validation($inst, new Traverse($value)); + }, $this->error); + + 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, + ?string $description = null, + ?array $trace = 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) { + $validPool = new Expect($this->value); + $listArr = $this->buildClosureTest($validation, $validPool, $description); + + foreach ($listArr as $list) { + + if(is_bool($list)) { + $item = new TestItem(); + $item = $item->setIsValid($list)->setValidation("Validation"); + $test->setTestItem($item); + } else { + foreach ($list as $method => $valid) { + $item = new TestItem(); + /** @var array|bool $valid */ + $item = $item->setIsValid(false)->setValidation((string)$method); + if(is_array($valid)) { + $item = $item->setValidationArgs($valid); + } else { + $item = $item->setHasArgs(false); + } + $test->setTestItem($item); + } + } + } + // In some rare cases the validation value might change along the rode + // tell the test to use the new value + $initValue = $validPool->getInitValue(); + $initValue = ($initValue !== null) ? $initValue : $this->getValue(); + $test->setTestValue($initValue); } else { - foreach($validation as $method => $args) { - if(!($args instanceof Closure) && !is_array($args)) { + 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 : [])); + $item = new TestItem(); + $item = $item->setIsValid($this->buildArrayTest($method, $args)) + ->setValidation($method) + ->setValidationArgs((is_array($args) ? $args : [])); + $test->setTestItem($item); } } - if(!$test->isValid()) { + if (!$test->isValid()) { + if($trace === null || $trace === []) { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + } + + $test->setCodeLine($trace); $this->count++; } $this->test[] = $test; + $this->error = 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, 4)[3]; + $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 ExecutionWrapper + */ + public function wrap(string $class, array $args = []): ExecutionWrapper + { + return new class ($class, $args) extends ExecutionWrapper { + 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 MockBuilder($class, $args); + return $inst; + } + + /** + * @param Closure|null $validate + * @return T + * @throws ErrorException + * @throws Exception + */ + public function buildMock(?Closure $validate = null): mixed + { + if(!($this->mocker instanceof MockBuilder)) { + throw new BadMethodCallException("The mocker is not set yet!"); + } + if (is_callable($validate)) { + $pool = $this->prepareValidation($this->mocker, $validate); + } + /** @psalm-suppress MixedReturnStatement */ + $class = $this->mocker->execute(); + if($this->mocker->hasFinal()) { + $finalMethods = $pool->getSelected($this->mocker->getFinalMethods()); + if($finalMethods !== []) { + $this->warning = "Warning: It is not possible to mock final methods: " . implode(", ", $finalMethods); + } + } + return $class; + } + + /** + * Creates and returns an instance of a dynamically generated mock class. + * + * The mock class is based on the provided class name and optional constructor arguments. + * A validation closure can also be provided to define mock expectations. These + * validations are deferred and will be executed later via runDeferredValidations(). + * + * @param class-string $class + * @param (Closure(MethodRegistry): void)|null $callback + * @param array $args + * @return T + * @throws Exception + */ + public function mock(string $class, ?Closure $validate = null, array $args = []): mixed + { + $this->mocker = new MockBuilder($class, $args); + return $this->buildMock($validate); + } + + public function getMocker(): MockBuilder + { + if(!($this->mocker instanceof MockBuilder)) { + throw new BadMethodCallException("The mocker is not set yet!"); + } + 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 MockBuilder $mocker The mocker instance containing the mock object + * @param Closure $validate The closure containing validation rules + * @return MethodRegistry + * @throws ErrorException + */ + private function prepareValidation(MockBuilder $mocker, Closure $validate): MethodRegistry + { + $pool = new MethodRegistry($mocker); + $fn = $validate->bindTo($pool); + if ($fn === null) { + throw new ErrorException("A callable Closure could not be bound to the method pool!"); + } + $fn($pool); + $this->deferValidation(fn () => $this->runValidation($mocker, $pool)); + return $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 MockBuilder $mocker The mocker instance containing the mocked class + * @param MethodRegistry $pool The pool containing method expectations + * @return array An array of validation errors indexed by method name + * @throws ErrorException + * @throws Exception + */ + private function runValidation(MockBuilder $mocker, MethodRegistry $pool): array + { + $error = []; + $data = MockController::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) && is_string($row->name) && $pool->has($row->name)) { + $error[$row->name] = $this->validateRow($row, $pool); + } + } + return $error; + } + + /** + * Validates a specific method row against the method pool expectations + * + * This method compares the actual method call data with the expected validation + * rules defined in the method pool. It handles both simple value comparisons + * and complex array validations. + * + * @param object $row The method calls data to validate + * @param MethodRegistry $pool The pool containing validation expectations + * @return array Array of validation results containing property comparisons + * @throws ErrorException + */ + private function validateRow(object $row, MethodRegistry $pool): array + { + $item = $pool->get((string)($row->name ?? "")); + if (!$item) { + return []; + } + + $errors = []; + foreach (get_object_vars($item) as $property => $value) { + if ($value === null) { + continue; + } + + if(!property_exists($row, $property)) { + throw new ErrorException( + "The mock method meta data property name '$property' is undefined in mock object. " . + "To resolve this either use MockController::buildMethodData() to add the property dynamically " . + "or define a default value through Mocker::addMockMetadata()" + ); + } + $currentValue = $row->{$property}; + + if(!in_array($property, self::EXCLUDE_VALIDATE)) { + if (is_array($value)) { + $validPool = $this->validateArrayValue($value, $currentValue); + $valid = $validPool->isValid(); + if (is_array($currentValue)) { + $this->compareFromValidCollection($validPool, $value, $currentValue); + } + } else { + /** @psalm-suppress MixedArgument */ + $valid = Validator::value($currentValue)->equal($value); + } + + $item = new TestItem(); + $item = $item->setIsValid($valid) + ->setValidation($property) + ->setValue($value) + ->setCompareToValue($currentValue); + $errors[] = $item; + } + } + + 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 Expect The validation chain instance with applied validations + */ + private function validateArrayValue(array $value, mixed $currentValue): Expect + { + $validPool = new Expect($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 Expect $validPool + * @param array $value + * @param array $currentValue + * @return void + */ + protected function compareFromValidCollection(Expect $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 = []; + /** @var string $method */ + 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 + /** @var TestItem $data */ + if(!in_array($data->getValidation(), self::EXCLUDE_VALIDATE)) { + $test->setTestItem($data); + if (!isset($hasValidated[$method]) && !$data->isValid()) { + $hasValidated[$method] = true; + $this->count++; + } + } + } + + $this->test[] = $test; + } + } + + return $this->test; + } + /** * Get failed test counts + * * @return int */ public function getTotal(): int @@ -88,6 +622,7 @@ public function getTotal(): int /** * Get failed test counts + * * @return int */ public function getCount(): int @@ -97,6 +632,7 @@ public function getCount(): int /** * Get failed test counts + * * @return int */ public function getFailedCount(): int @@ -106,6 +642,7 @@ public function getFailedCount(): int /** * Check if it has failed tests + * * @return bool */ public function hasFailed(): bool @@ -115,6 +652,7 @@ public function hasFailed(): bool /** * Get original value + * * @return mixed */ public function getValue(): mixed @@ -122,17 +660,29 @@ public function getValue(): mixed return $this->value; } + /** + * Get the test configuration + * + * @return TestConfig + */ + public function getConfig(): TestConfig + { + return $this->config; + } + /** * Get user added message + * * @return string|null */ public function getMessage(): ?string { - return $this->message; + return $this->config->message; } /** - * Get test array object + * Get a test array object + * * @return array */ public function getTest(): array @@ -142,48 +692,63 @@ public function getTest(): array /** * This will build the closure test + * * @param Closure $validation - * @return bool - * @throws ErrorException + * @param Expect $validPool + * @param string|null $message + * @return array */ - public function buildClosureTest(Closure $validation): bool + protected function buildClosureTest(Closure $validation, Expect $validPool, ?string $message = null): 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; + $validation = $validation->bindTo($validPool); + $error = []; + if ($validation !== null) { + try { + $bool = $validation($this->value, $validPool); + } catch (AssertionError $e) { + $bool = false; + $message = $e->getMessage(); + } + + $error = $validPool->getError(); + if($bool === false && $message !== null) { + $error[] = [ + $message => true + ]; + } else if (is_bool($bool) && !$bool) { + $error['customError'] = false; + } } - if(is_null($this->message)) { - throw new RuntimeException("When testing with closure the third argument message is required"); + if ($this->getMessage() === null) { + throw new RuntimeException("You need to specify a \"message\" in first parameter of ->group(string|TestConfig \$message, ...)."); } - return $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 ($args === null) { 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 +764,77 @@ 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() ? (string)$param->getType() . ' ' : ''; + $value = $param->isDefaultValueAvailable() ? ' = ' . (string)Str::value($param->getDefaultValue())->exportReadableValue()->get() : ""; + return $type . '$' . $param->getName() . $value; + }, $method->getParameters()); + $name = $method->getName(); + if (!$method->isStatic() && !str_starts_with($name, '__')) { + if ($prefixMethods !== null) { + $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/TestConfig.php b/src/TestConfig.php new file mode 100644 index 0000000..1df5011 --- /dev/null +++ b/src/TestConfig.php @@ -0,0 +1,81 @@ +message = $message; + } + + /** + * Statically make instance. + * + * @param string $message + * @return self + */ + public static function make(string $message = "Validating"): self + { + return new self($message); + } + + /** + * Sets the select state for the current instance. + * + * @param string $key The key to set. + * @return self + */ + public function withName(string $key): self + { + $inst = clone $this; + $inst->select = $key; + return $inst; + } + + // Alias for setName() + public function setSelect(string $key): self + { + return $this->withName($key); + } + + /** + * Sets the message for the current instance. + * + * @param string $subject The message to set. + * @return self + */ + public function withSubject(string $subject): self + { + $inst = clone $this; + $inst->message = $subject; + return $inst; + } + + /** + * Sets the skip state for the current instance. + * + * @param bool $bool Optional. The value to set for the skip state. Defaults to true. + * @return self + */ + public function withSkip(bool $bool = true): self + { + $inst = clone $this; + $inst->skip = $bool; + return $inst; + } +} \ No newline at end of file diff --git a/src/TestItem.php b/src/TestItem.php new file mode 100755 index 0000000..0551136 --- /dev/null +++ b/src/TestItem.php @@ -0,0 +1,245 @@ +valid = $isValid; + return $inst; + } + + /** + * Sets the validation type that has been used. + * + * @param string $validation + * @return $this + */ + public function setValidation(string $validation): self + { + $inst = clone $this; + $inst->validation = $validation; + return $inst; + } + + /** + * Sets the validation arguments. + * + * @param array $args + * @return $this + */ + public function setValidationArgs(array $args): self + { + $inst = clone $this; + $inst->args = $args; + return $inst; + } + + /** + * Sets if the validation has arguments. If not, it will not be enclosed in parentheses. + * + * @param bool $enable + * @return $this + */ + public function setHasArgs(bool $enable): self + { + $inst = clone $this; + $inst->hasArgs = $enable; + return $inst; + } + + /** + * Sets the value that has been used in validation. + * + * @param mixed $value + * @return $this + */ + public function setValue(mixed $value): self + { + $inst = clone $this; + $inst->value = $value; + return $inst; + } + + /** + * Sets a compare value for the current value. + * + * @param mixed ...$compareValue + * @return $this + */ + public function setCompareToValue(mixed ...$compareValue): self + { + $inst = clone $this; + $inst->compareValues = $compareValue; + return $inst; + } + + /** + * Converts the value to its string representation using a helper function. + * + * @return string The stringify representation of the value. + * @throws ErrorException + */ + public function getStringifyValue(): string + { + return Helpers::stringifyDataTypes($this->value, true); + } + + /** + * Converts the comparison values to their string representations using a helper function. + * + * @return array The array of stringify comparison values. + * @throws ErrorException + */ + public function getCompareToValue(): array + { + return array_map(fn ($value) => Helpers::stringifyDataTypes($value, true), $this->compareValues); + } + + /** + * Checks if the current state is valid. + * + * @return bool True if the state is valid, false otherwise. + */ + public function isValid(): bool + { + return $this->valid; + } + + /** + * Retrieves the validation string associated with the object. + * + * @return string The validation string. + */ + public function getValidation(): string + { + return $this->validation; + } + + /** + * Retrieves the validation arguments. + * + * @return array The validation arguments. + */ + public function getValidationArgs(): array + { + return $this->args; + } + + /** + * Retrieves the stored raw value. + * + * @return mixed + */ + public function getValue(): mixed + { + return $this->value; + } + + /** + * Determines if there are any comparison values present. + * + * @return bool + */ + public function hasComparison(): bool + { + return ($this->compareValues !== []); + } + + /** + * Returns the RAW comparison collection. + * + * @return array + */ + public function getCompareValues(): array + { + return $this->compareValues; + } + + /** + * Return a string representation of the comparison between expected and actual values. + * + * @return string + * @throws ErrorException + */ + public function getComparison(): string + { + return "Expected: " . $this->getStringifyValue() . " | Actual: " . implode(":", $this->getCompareToValue()); + } + + /** + * Retrieves the string representation of the arguments, enclosed in parentheses if present. + * + * @return string + */ + public function getStringifyArgs(): string + { + if($this->hasArgs) { + $args = array_map(fn ($value) => Helpers::stringifyArgs($value), $this->args); + return "(" . implode(", ", $args) . ")"; + } + return ""; + } + + /** + * Retrieves the validation title by combining validation data and arguments. + * + * @return string + */ + public function getValidationTitle(): string + { + return $this->getValidation() . $this->getStringifyArgs(); + } + + /** + * Retrieves the length of the validation string. + * + * @return int + */ + public function getValidationLength(): int + { + return strlen($this->getValidation()); + } + + /** + * Retrieves the length of the validation title. + * + * @return int + */ + public function getValidationLengthWithArgs(): int + { + return strlen($this->getValidationTitle()); + } +} diff --git a/src/TestUnit.php b/src/TestUnit.php index 70f0a3c..21a284c 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -1,55 +1,122 @@ 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->message = $message === null ? "Could not validate" : $message; + } + + /** + * Check if the 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): void + { $this->value = $value; - $this->message = is_null($message) ? "Could not validate" : $message; + $this->hasValue = true; } + /** - * Set the test unit - * @param bool $valid - * @param string|null $validation - * @param array $args + * Create a test item + * + * @param TestItem $item * @return $this */ - public function setUnit(bool $valid, ?string $validation = null, array $args = []): self + public function setTestItem(TestItem $item): self { - if(!$valid) { + if (!$item->isValid()) { $this->valid = false; $this->count++; } - $this->unit[] = [ - 'valid' => $valid, - 'validation' => $validation, - 'args' => $args - ]; + + $valLength = $item->getValidationLengthWithArgs(); + if ($this->valLength < $valLength) { + $this->valLength = $valLength; + } + + $this->unit[] = $item; + 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 = Helpers::getTrace($trace); 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 @@ -58,7 +125,8 @@ public function getUnits(): array } /** - * Get failed test count + * Get a failed test count + * * @return int */ public function getFailedTestCount(): int @@ -67,7 +135,8 @@ public function getFailedTestCount(): int } /** - * Get test message + * Get a test message + * * @return string|null */ public function getMessage(): ?string @@ -76,7 +145,8 @@ public function getMessage(): ?string } /** - * Get if test is valid + * Get if the test is valid + * * @return bool */ public function isValid(): bool @@ -86,58 +156,11 @@ public function isValid(): bool /** * Gte the original value + * * @return mixed */ public function getValue(): mixed { return $this->value; } - - /** - * Used to get a readable value - * @return string - * @throws ErrorException - */ - public function getReadValue(): string - { - if (is_bool($this->value)) { - return "(bool): " . ($this->value ? "true" : "false"); - } - if (is_int($this->value)) { - return "(int): " . $this->excerpt((string)$this->value); - } - if (is_float($this->value)) { - return "(float): " . $this->excerpt((string)$this->value); - } - if (is_string($this->value)) { - return "(string): " . $this->excerpt($this->value); - } - if (is_array($this->value)) { - return "(array): " . $this->excerpt(json_encode($this->value)); - } - if (is_object($this->value)) { - return "(object): " . $this->excerpt(get_class($this->value)); - } - if (is_null($this->value)) { - return "(null)"; - } - if (is_resource($this->value)) { - return "(resource): " . $this->excerpt(get_resource_type($this->value)); - } - - return "(unknown type)"; - } - - /** - * Used to get exception to the readable value - * @param string $value - * @return string - * @throws ErrorException - */ - final protected function excerpt(string $value): string - { - $format = new Str($value); - return (string)$format->excerpt(42)->get(); - } - } diff --git a/src/TestUtils/CodeCoverage.php b/src/TestUtils/CodeCoverage.php new file mode 100644 index 0000000..3d05c41 --- /dev/null +++ b/src/TestUtils/CodeCoverage.php @@ -0,0 +1,228 @@ + */ + const ERROR = [ + "No error", + "Xdebug is not available", + "Xdebug is enabled, but coverage mode is missing" + ]; + + private ?array $data = null; + private int $errorCode = 0; + + private array $allowedDirs = []; + private array $exclude = [ + "vendor", + "tests", + "test", + "unitary-*", + "unit-tests", + "spec", + "bin", + "public", + "storage", + "bootstrap", + "resources", + "database", + "config", + "node_modules", + "coverage-report", + // Exclude below to protect against edge cases + // (like someone accidentally putting a .php file in .github/scripts/ and including it) + ".idea", + ".vscode", + ".git", + ".github" + ]; + + /** + * Check if Xdebug is enabled + * + * @return bool + */ + public function hasXdebug(): bool + { + if($this->errorCode > 0) { + return false; + } + if (!function_exists('xdebug_info')) { + $this->errorCode = 1; + return false; + } + return true; + } + + /** + * Check if Xdebug has coverage mode enabled. + * + * @return bool + */ + public function hasXdebugCoverage(): bool + { + if(!$this->hasXdebug()) { + return false; + } + $mode = ini_get('xdebug.mode'); + if ($mode === false || !str_contains($mode, 'coverage')) { + $this->errorCode = 1; + return false; + } + return true; + } + + + public function exclude(array $exclude): void + { + $this->exclude = $exclude; + } + + public function whitelist(string|array $path): void + { + + } + + /** + * Start coverage listening + * + * @psalm-suppress UndefinedFunction + * @psalm-suppress UndefinedConstant + * @noinspection PhpUndefinedFunctionInspection + * @noinspection PhpUndefinedConstantInspection + * + * @return void + */ + public function start(): void + { + $this->data = []; + if($this->hasXdebugCoverage()) { + xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE); + } + } + + /** + * End coverage listening + * + * @psalm-suppress UndefinedFunction + * @noinspection PhpUndefinedFunctionInspection + * + * @return void + */ + public function end(): void + { + if($this->data === null) { + throw new BadMethodCallException("You must start code coverage before you can end it"); + } + if($this->hasXdebugCoverage()) { + + $this->data = xdebug_get_code_coverage(); + xdebug_stop_code_coverage(); + } + } + + protected function excludePattern(string $file): bool + { + $filename = basename($file); + + foreach ($this->exclude as $pattern) { + if (preg_match('#/' . preg_quote($pattern, '#') . '(/|$)#', $file)) { + return true; + } + if (str_ends_with($pattern, '*')) { + $prefix = substr($pattern, 0, -1); + if (str_starts_with($filename, $prefix)) { + return true; + } + } + } + return false; + } + + + + /** + * Get a Coverage result, will return false if there is an error + * + * @return array|false + */ + public function getResponse(): array|false + { + if($this->errorCode > 0) { + return false; + } + + $totalLines = 0; + $executedLines = 0; + foreach ($this->data as $file => $lines) { + if ($this->excludePattern($file)) { + continue; + } + + foreach ($lines as $line => $status) { + if ($status === -2) continue; + $totalLines++; + if ($status === 1) { + $executedLines++; + } + } + } + + $percent = $totalLines > 0 ? round(($executedLines / $totalLines) * 100, 2) : 0; + return [ + 'totalLines' => $totalLines, + 'executedLines' => $executedLines, + 'percent' => $percent + ]; + } + + public function getRawData(): array + { + return $this->data ?? []; + } + + /** + * Get an error message + * + * @return string + */ + public function getError(): string + { + return self::ERROR[$this->errorCode]; + } + + /** + * Get an error code + * + * @return int + */ + public function getCode(): int + { + return $this->errorCode; + } + + /** + * Check if error exists + * + * @return bool + */ + public function hasError(): bool + { + return ($this->errorCode > 0); + } +} \ No newline at end of file diff --git a/src/TestUtils/Configs.php b/src/TestUtils/Configs.php new file mode 100644 index 0000000..58bd579 --- /dev/null +++ b/src/TestUtils/Configs.php @@ -0,0 +1,35 @@ +command = $command; + } + + public function getCommand(): Command + { + return self::getInstance()->command; + } + +} diff --git a/src/TestUtils/DataTypeMock.php b/src/TestUtils/DataTypeMock.php new file mode 100644 index 0000000..9506c4c --- /dev/null +++ b/src/TestUtils/DataTypeMock.php @@ -0,0 +1,189 @@ +>|null + */ + private ?array $bindArguments = null; + + private static ?self $inst = null; + + public static function inst(): self + { + if (self::$inst === null) { + self::$inst = new self(); + } + return self::$inst; + } + + /** + * Returns an array of default arguments for different data types + * + * @return array Array of default arguments with mock values for different data types + */ + public function getMockValues(): array + { + return array_merge([ + 'int' => 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 the bindArguments array. + * + * @param string $key The binding key to store the value under + * @param string $dataType The data type to set the custom default for + * @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); + if($inst->bindArguments === null) { + $inst->bindArguments = []; + } + $inst->bindArguments[$key][$dataType] = $tempInst->defaultArguments[$dataType]; + return $inst; + } + + /** + * Converts default argument values to their string representations + * using var_export for each value in the default arguments array + * + * @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 string The string representation of the value for the specified data type + * @throws InvalidArgumentException If the specified data type is invalid + */ + public function getDataTypeValue(string $dataType, ?string $bindKey = null): string + { + if(is_string($bindKey) && isset($this->bindArguments[$bindKey][$dataType])) { + return self::exportValue($this->bindArguments[$bindKey][$dataType]); + } + + if($this->types === null) { + $this->types = $this->getDataTypeListToString(); + } + + if(!isset($this->types[$dataType])) { + throw new InvalidArgumentException("Invalid data type: $dataType"); + } + return (string)$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/TestUtils/ExecutionWrapper.php b/src/TestUtils/ExecutionWrapper.php new file mode 100755 index 0000000..446ae75 --- /dev/null +++ b/src/TestUtils/ExecutionWrapper.php @@ -0,0 +1,142 @@ + */ + private array $methods = []; + + /** + * Pass class and the class arguments if exists + * + * @param string $className + * @param array $args + * @throws Exception + */ + public function __construct(string $className, array $args = []) + { + if (!class_exists($className)) { + throw new Exception("Class $className does not exist."); + } + $this->ref = new Reflection($className); + $this->instance = $this->createInstance($this->ref, $args); + } + + /** + * Will bind Closure to a class instance and directly return the Closure + * + * @param Closure $call + * @return Closure + * @throws Exception + */ + public function bind(Closure $call): Closure + { + $closure = $call->bindTo($this->instance); + if(!is_callable($closure)) { + throw new Exception("Closure is not callable."); + } + return $closure; + } + + /** + * Overrides a method in the instance + * + * @param string $method + * @param Closure $call + * @return $this + * @throws Exception + */ + 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); + if (!is_callable($call)) { + throw new Exception("Closure is not callable."); + } + $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 + * @throws Exception + */ + 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); + if (!is_callable($call)) { + throw new Exception("Closure is not callable."); + } + $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..168a1d0 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -1,4 +1,12 @@ handler = $handler; $this->command = $this->handler->getCommand(); } else { - $this->command = new Command($handler); + $this->command = ($handler === null) ? Configs::getInstance()->getCommand() : new Command($handler); } self::$current = $this; } /** - * Skip you can add this if you want to turn of validation of a unit case - * @param bool $skip - * @return $this + * This will disable "ALL" tests in the test file + * If you want to skip a specific test, use the TestConfig class instead + * + * @param bool $disable + * @return void */ - public function skip(bool $skip): self + public function disableAllTest(bool $disable): void { - $this->skip = $skip; + $this->disableAllTests = $disable; + } + + // Deprecated: Almost same as `disableAllTest`, for older versions + public function skip(bool $disable): self + { + $this->disableAllTests = $disable; return $this; } /** - * Make script manually callable - * @param string $key - * @return $this + * DEPRECATED: Use TestConfig::setSelect instead + * See documentation for more information + * + * @return void */ - public function manual(string $key): self + public function manual(): void { - if(isset(self::$manual[$key])) { - $file = (string)(self::$headers['file'] ?? "none"); - throw new RuntimeException("The manual key \"$key\" already exists. - Please set a unique key in the " . $file. " file."); - } - self::$manual[$key] = self::$headers['checksum']; - return $this->skip(true); + throw new RuntimeException("Manual method has been deprecated, use TestConfig::setSelect instead. " . + "See documentation for more information."); } /** @@ -116,51 +141,63 @@ public function confirm(string $message = "Do you wish to continue?"): bool } /** - * DEPRECATED: Name has been changed to case + * Name has been changed to case + * WILL BECOME DEPRECATED VERY SOON * @param string $message * @param Closure $callback * @return void */ public function add(string $message, Closure $callback): void { - // Might be trigger in future - //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); $this->case($message, $callback); } /** - * Add a test unit/group - * @param string $message - * @param Closure $callback + * Adds a test case to the collection (group() is preferred over case()) + * The key difference from group() is that this TestCase will NOT be bound the Closure + * + * @param string|TestConfig $message The message or configuration for the test case. + * @param Closure $callback The closure containing the test case logic. * @return void */ - public function case(string $message, Closure $callback): void + public function group(string|TestConfig $message, Closure $callback): void { - $testCase = new TestCase($message); - $testCase->bind($callback); - $this->cases[$this->index] = $testCase; - $this->index++; + $this->addCase($message, $callback); + } + + /** + * Adds a test case to the collection. + * The key difference from group() is that this TestCase will be bound the Closure + * Not Deprecated but might be in the far future + * + * @param string|TestConfig $message The message or configuration for the test case. + * @param Closure $callback The closure containing the test case logic. + * @return void + */ + public function case(string|TestConfig $message, Closure $callback): void + { + $this->addCase($message, $callback, true); } public function performance(Closure $func, ?string $title = null): void { - $start = new TestMem(); + $start = new Performance(); $func = $func->bindTo($this); - if(!is_null($func)) { - $func(); + if ($func !== null) { + $func($this); } $line = $this->command->getAnsi()->line(80); $this->command->message(""); - $this->command->message($this->command->getAnsi()->style(["bold", "yellow"], "Performance" . (!is_null($title) ? " - $title:" : ":"))); + $this->command->message($this->command->getAnsi()->style(["bold", "yellow"], "Performance" . ($title !== null ? " - $title:" : ":"))); $this->command->message($line); $this->command->message( $this->command->getAnsi()->style(["bold"], "Execution time: ") . - (round($start->getExecutionTime(), 3) . " seconds") + ((string)round($start->getExecutionTime(), 3) . " seconds") ); $this->command->message( $this->command->getAnsi()->style(["bold"], "Memory Usage: ") . - (round($start->getMemoryUsage(), 2) . " KB") + ((string)round($start->getMemoryUsage(), 2) . " KB") ); /* $this->command->message( @@ -173,99 +210,81 @@ public function performance(Closure $func, ?string $title = null): void /** * Execute tests suite + * * @return bool * @throws ErrorException + * @throws BlunderErrorException + * @throws Throwable */ public function execute(): bool { - if($this->executed || !$this->validate()) { + if ($this->executed || $this->disableAllTests) { return false; } // LOOP through each case ob_start(); - foreach($this->cases as $row) { + //$countCases = count($this->cases); + + $handler = new CliHandler(); + $handler->setCommand($this->command); - if(!($row instanceof TestCase)) { + foreach ($this->cases as $index => $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(); + $checksum = (string)(self::$headers['checksum'] ?? "") . $index; - $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); - $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); - if($row->hasFailed()) { - $flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); + $show = ($row->getConfig()->select === self::getArgs('show') || self::getArgs('show') === $checksum); + if((self::getArgs('show') !== false) && !$show) { + continue; } - $this->command->message(""); - $this->command->message( - $flag . " " . - $this->command->getAnsi()->style(["bold"], $this->formatFileTitle((string)(self::$headers['file'] ?? ""))) . - " - " . - $this->command->getAnsi()->style(["bold", $color], (string)$row->getMessage()) - ); - - 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) { - $this->command->message( - $this->command->getAnsi()->bold("Validation: ") . - $this->command->getAnsi()->style( - ((!$unit['valid']) ? "brightRed" : null), - $unit['validation'] . ((!$unit['valid']) ? " (fail)" : "") - ) - ); - } - $this->command->message($this->command->getAnsi()->bold("Value: ") . $test->getReadValue()); - } + // Success, no need to try to show errors, continue with the next test + if ($errArg !== false && !$row->hasFailed()) { + continue; } - self::$totalPassedTests += $row->getCount(); - self::$totalTests += $row->getTotal(); - - $checksum = (string)(self::$headers['checksum'] ?? ""); - $this->command->message(""); + $handler->setCase($row); + $handler->setSuitName(self::$headers['file'] ?? ""); + $handler->setChecksum($checksum); + $handler->setTests($tests); + $handler->setShow($show); + $handler->buildBody(); - $this->command->message( - $this->command->getAnsi()->bold("Passed: ") . - $this->command->getAnsi()->style([$color], $row->getCount() . "/" . $row->getTotal()) . - $this->command->getAnsi()->style(["italic", "grey"], " - ". $checksum) - ); + // Important to add test from skip as successfully count to make sure that + // the total passed tests are correct, and it will not exit with code 1 + self::$totalPassedTests += ($row->getConfig()->skip) ? $row->getTotal() : $row->getCount(); + self::$totalTests += $row->getTotal(); } - $this->output .= ob_get_clean(); - - if($this->output) { - $this->buildNotice("Note:", $this->output, 80); + $this->output .= (string)ob_get_clean(); + $handler->outputBuffer($this->output); + if ($this->output) { + $handler->buildNotes(); } - if(!is_null($this->handler)) { - $this->handler->execute(); + $stream = $handler->returnStream(); + if ($stream->isSeekable()) { + $this->getStream()->rewind(); + echo $this->getStream()->getContents(); } + $this->executed = true; return true; } /** - * Will reset the execute and stream if is a seekable stream. + * Will reset the executing and stream if is a seekable stream + * * @return bool */ public function resetExecute(): bool { - if($this->executed) { - if($this->getStream()->isSeekable()) { + if ($this->executed) { + if ($this->getStream()->isSeekable()) { $this->getStream()->rewind(); } $this->executed = false; @@ -275,43 +294,21 @@ public function resetExecute(): bool } /** - * Validate before execute test - * @return bool + * Validate method that must be called within a group method + * + * @return self + * @throws RuntimeException When called outside a group method */ - private function validate(): bool + public function validate(): self { - $args = (array)(self::$headers['args'] ?? []); - $manual = isset($args['show']) ? (string)$args['show'] : ""; - if(isset($args['show'])) { - return !((self::$manual[$manual] ?? "") !== self::$headers['checksum'] && $manual !== self::$headers['checksum']); - } - if($this->skip) { - return false; - } - return true; + throw new RuntimeException("The validate() method must be called inside a group() method! " . + "Move this validate() call inside your group() callback function."); } - /** - * Build the notification stream - * @param string $title - * @param string $output - * @param int $lineLength - * @return void - */ - public function buildNotice(string $title, string $output, int $lineLength): void - { - $this->output = wordwrap($output, $lineLength); - $line = $this->command->getAnsi()->line($lineLength); - $this->command->message(""); - $this->command->message($this->command->getAnsi()->style(["bold"], $title)); - $this->command->message($line); - $this->command->message($this->output); - $this->command->message($line); - } /** - * Make file path into a title + * Make a file path into a title * @param string $file * @param int $length * @param bool $removeSuffix @@ -324,11 +321,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; } @@ -364,7 +360,7 @@ public static function appendHeader(string $key, mixed $value): void } /** - * Used to reset current instance + * Used to reset the current instance * @return void */ public static function resetUnit(): void @@ -373,12 +369,12 @@ public static function resetUnit(): void } /** - * Used to check if instance is set + * Used to check if an instance is set * @return bool */ public static function hasUnit(): bool { - return !is_null(self::$current); + return self::$current !== null; } /** @@ -388,9 +384,14 @@ public static function hasUnit(): bool */ public static function getUnit(): ?Unit { - if(is_null(self::hasUnit())) { + /* + // Testing to comment out Exception in Unit instance is missing + // because this will trigger as soon as it finds a file name with unitary-* + // and can become tedious that this makes the test script stop. + if (self::hasUnit() === false) { throw new Exception("Unit has not been set yet. It needs to be set first."); } + */ return self::$current; } @@ -400,17 +401,18 @@ public static function getUnit(): ?Unit */ public static function completed(): void { - if(!is_null(self::$current) && is_null(self::$current->handler)) { + if (self::$current !== null && self::$current->handler === null) { $dot = self::$current->command->getAnsi()->middot(); - self::$current->command->message(""); + //self::$current->command->message(""); self::$current->command->message( self::$current->command->getAnsi()->style( ["italic", "grey"], "Total: " . self::$totalPassedTests . "/" . self::$totalTests . " $dot " . - "Peak memory usage: " . round(memory_get_peak_usage() / 1024, 2) . " KB" + "Peak memory usage: " . (string)round(memory_get_peak_usage() / 1024, 2) . " KB" ) ); + self::$current->command->message(""); } } @@ -420,7 +422,24 @@ public static function completed(): void */ public static function isSuccessful(): bool { - return (self::$totalPassedTests !== self::$totalTests); + return (self::$totalPassedTests === self::$totalTests); + } + + + /** + * Adds a test case to the collection. + * + * @param string|TestConfig $message The description or configuration of the test case. + * @param Closure $callback The closure that defines the test case logic. + * @param bool $bindToClosure Indicates whether the closure should be bound to TestCase. + * @return void + */ + protected function addCase(string|TestConfig $message, Closure $callback, bool $bindToClosure = false): void + { + $testCase = new TestCase($message); + $testCase->bind($callback, $bindToClosure); + $this->cases[$this->index] = $testCase; + $this->index++; } /** diff --git a/src/Utils/FileIterator.php b/src/Utils/FileIterator.php new file mode 100755 index 0000000..31e1805 --- /dev/null +++ b/src/Utils/FileIterator.php @@ -0,0 +1,238 @@ +args = $args; + } + + /** + * Will Execute all unitary test files. + * @param string $path + * @param string|bool $rootDir + * @return void + * @throws BlunderSoftException + */ + public function executeAll(string $path, string|bool $rootDir = false): void + { + $rootDir = is_string($rootDir) ? realpath($rootDir) : false; + $path = (!$path && $rootDir !== false) ? $rootDir : $path; + if($rootDir !== false && !str_starts_with($path, "/") && !str_starts_with($path, $rootDir)) { + $path = $rootDir . "/" . $path; + } + $files = $this->findFiles($path, $rootDir); + if (empty($files)) { + /* @var string static::PATTERN */ + throw new BlunderSoftException("Unitary could not find any test files matching the pattern \"" . + (FileIterator::PATTERN ?? "") . "\" in directory \"" . dirname($path) . + "\" and its subdirectories."); + } else { + foreach ($files as $file) { + extract($this->args, EXTR_PREFIX_SAME, "wddx"); + Unit::resetUnit(); + Unit::setHeaders([ + "args" => $this->args, + "file" => $file, + "checksum" => md5((string)$file) + ]); + + $call = $this->requireUnitFile((string)$file); + if ($call !== null) { + $call(); + } + if (!Unit::hasUnit()) { + throw new RuntimeException("The Unitary Unit class has not been initiated inside \"$file\"."); + } + } + Unit::completed(); + if ($this->exitScript) { + $this->exitScript(); + } + + } + } + + + /** + * You can change the default exist script from enabled to disabled + * + * @param $exitScript + * @return void + */ + public function enableExitScript($exitScript): void + { + $this->exitScript = $exitScript; + } + + /** + * Exist the script with right expected number + * + * @return void + */ + public function exitScript(): void + { + exit((int)!Unit::isSuccessful()); + } + + /** + * Will Scan and find all unitary test files + * @param string $path + * @param string|false $rootDir + * @return array + */ + private function findFiles(string $path, string|bool $rootDir = false): array + { + $files = []; + $realDir = realpath($path); + if ($realDir === false) { + throw new RuntimeException("Directory \"$path\" does not exist. Try using a absolut path!"); + } + + if (is_file($path) && str_starts_with(basename($path), "unitary-")) { + $files[] = $path; + } else { + if(is_file($path)) { + $path = dirname($path) . "/"; + } + if(is_dir($path)) { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); + /** @var string $pattern */ + $pattern = FileIterator::PATTERN; + foreach ($iterator as $file) { + if (($file instanceof SplFileInfo) && fnmatch($pattern, $file->getFilename()) && + (!empty($this->args['exclude']) || !str_contains($file->getPathname(), DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR))) { + if (!$this->findExcluded($this->exclude(), $path, $file->getPathname())) { + $files[] = $file->getPathname(); + } + } + } + } + } + if($rootDir !== false && count($files) <= 0 && str_starts_with($path, $rootDir) && isset($this->args['smart-search'])) { + $path = (string)realpath($path . "/..") . "/"; + return $this->findFiles($path, $rootDir); + } + return $files; + } + + /** + * Get exclude parameter + * @return array + */ + public function exclude(): array + { + $excl = []; + 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) { + $new .= "*"; + } + $excl[] = trim($new); + } + } + return $excl; + } + + /** + * Validate an exclude path + * @param array $exclArr + * @param string $relativeDir + * @param string $file + * @return bool + */ + public function findExcluded(array $exclArr, string $relativeDir, string $file): bool + { + $file = $this->getNaturalPath($file); + foreach ($exclArr as $excl) { + $relativeExclPath = $this->getNaturalPath($relativeDir . DIRECTORY_SEPARATOR . (string)$excl); + if (fnmatch($relativeExclPath, $file)) { + return true; + } + } + return false; + } + + /** + * Get a path as a natural path + * @param string $path + * @return string + */ + public function getNaturalPath(string $path): string + { + return str_replace("\\", "/", $path); + } + + /** + * Require a file without inheriting any class information + * @param string $file + * @return Closure|null + */ + private function requireUnitFile(string $file): ?Closure + { + $clone = clone $this; + $call = function () use ($file, $clone): void { + $cli = new CliHandler(); + if (Unit::getArgs('trace') !== false) { + $cli->enableTraceLines(true); + } + $run = new Run($cli); + $run->setExitCode(1); + $run->load(); + if (!is_file($file)) { + throw new RuntimeException("File \"$file\" do not exists."); + } + require_once($file); + $clone->getUnit()->execute(); + }; + return $call->bindTo(null); + } + + /** + * @return Unit + * @throws RuntimeException|Exception + */ + protected function getUnit(): Unit + { + $unit = Unit::getUnit(); + if ($unit === null) { + $unit = new Unit(); + //throw new RuntimeException("The Unit instance has not been initiated."); + } + return $unit; + + } +} diff --git a/src/Utils/Helpers.php b/src/Utils/Helpers.php new file mode 100644 index 0000000..fba5c42 --- /dev/null +++ b/src/Utils/Helpers.php @@ -0,0 +1,179 @@ + 1) { + return "[$str]"; + } + return $str; + } + + /** + * Stringify an array and objects + * + * @param mixed $arg + * @param int $levels + * @return string + */ + public static function stringify(mixed $arg, int &$levels = 0): string + { + if (is_array($arg)) { + $items = array_map(function($item) use(&$levels) { + $levels++; + return self::stringify($item, $levels); + }, $arg); + return implode(', ', $items); + } + + if (is_object($arg)) { + return get_class($arg); + } + + return (string)$arg; + } + + /** + * Create a file instead of eval for improved debug + * + * @param string $filename + * @param string $input + * @return void + * @throws Exception + */ + public static function createFile(string $filename, string $input): void + { + $temp = getenv('UNITARY_TEMP_DIR'); + $tempDir = $temp !== false ? $temp : sys_get_temp_dir(); + if (!is_dir($tempDir)) { + mkdir($tempDir, 0777, true); + } + $tempFile = rtrim($tempDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename; + file_put_contents($tempFile, "')) { + $code = substr($code, 2); + } + $code = self::excerpt($code); + } + + $codeLine['line'] = $line; + $codeLine['file'] = $file; + $codeLine['code'] = $code; + + return $codeLine; + } + + + /** + * Generates an excerpt from the given string with a specified maximum length. + * + * @param string $value The input string to be excerpted. + * @param int $length The maximum length of the excerpt. Defaults to 80. + * @return string The resulting excerpted string. + * @throws ErrorException + */ + final public static function excerpt(string $value, int $length = 80): string + { + $format = new Str($value); + return (string)$format->excerpt($length)->get(); + } + + /** + * Used to get a readable value (Move to utility) + * + * @param mixed|null $value + * @param bool $minify + * @return string + * @throws ErrorException + */ + public static function stringifyDataTypes(mixed $value = null, bool $minify = false): string + { + if (is_bool($value)) { + return '"' . ($value ? "true" : "false") . '"' . ($minify ? "" : " (type: bool)"); + } + if (is_int($value)) { + return '"' . self::excerpt((string)$value) . '"' . ($minify ? "" : " (type: int)"); + } + if (is_float($value)) { + return '"' . self::excerpt((string)$value) . '"' . ($minify ? "" : " (type: float)"); + } + if (is_string($value)) { + return '"' . self::excerpt($value) . '"' . ($minify ? "" : " (type: string)"); + } + if (is_array($value)) { + $json = json_encode($value); + if($json === false) { + return "(unknown type)"; + } + return '"' . self::excerpt($json) . '"' . ($minify ? "" : " (type: array)"); + } + if (is_callable($value)) { + return '"' . self::excerpt(get_class((object)$value)) . '"' . ($minify ? "" : " (type: callable)"); + } + if (is_object($value)) { + return '"' . self::excerpt(get_class($value)) . '"' . ($minify ? "" : " (type: object)"); + } + if ($value === null) { + return '"null"'. ($minify ? '' : ' (type: null)'); + } + if (is_resource($value)) { + return '"' . self::excerpt(get_resource_type($value)) . '"' . ($minify ? "" : " (type: resource)"); + } + + return "(unknown type)"; + } +} \ No newline at end of file diff --git a/src/TestMem.php b/src/Utils/Performance.php similarity index 72% rename from src/TestMem.php rename to src/Utils/Performance.php index fb516e2..15ea38f 100755 --- a/src/TestMem.php +++ b/src/Utils/Performance.php @@ -1,10 +1,17 @@ args = $argv; + $this->needle = $needle; + } + + /** + * Map one or more needles to controller + + * @param string|array $needles + * @param array $controller + * @return $this + */ + public function map(string|array $needles, array $controller): self + { + if(is_string($needles)) { + $needles = [$needles]; + } + foreach ($needles as $key) { + $this->controllers[$key] = $controller; + } + return $this; + } + + /** + * Dispatch matched router + * + * @param callable $call + * @return bool + */ + function dispatch(callable $call): bool + { + if(isset($this->controllers[$this->needle])) { + $call($this->controllers[$this->needle], $this->args, $this->needle); + return true; + } + if (isset($this->controllers["__404"])) { + $call($this->controllers["__404"], $this->args, $this->needle); + } + return false; + } +} \ No newline at end of file diff --git a/tests/TestLib/Mailer.php b/tests/TestLib/Mailer.php new file mode 100644 index 0000000..a31a19b --- /dev/null +++ b/tests/TestLib/Mailer.php @@ -0,0 +1,86 @@ +sendEmail($this->getFromEmail()); + + return $this->privateMethod(); + } + + public function sendEmail(string $email, string $name = "daniel"): string + { + if(!$this->isValidEmail($email)) { + throw new \Exception("Invalid email"); + } + return "Sent email"; + } + + public function isValidEmail(string $email): bool + { + return filter_var($email, FILTER_VALIDATE_EMAIL); + } + + public function setFromEmail(string $email): self + { + $this->from = $email; + return $this; + } + + public function getFromEmail(): string + { + return !empty($this->from) ? $this->from : "empty email"; + } + + private function privateMethod(): string + { + return "HEHEHE"; + } + + /** + * Add from email address + * + * @param string $email + * @return void + */ + public function addFromEmail(string $email, string $name = ""): void + { + $this->from = $email; + } + + /** + * Add a BCC (blind carbon copy) email address + * + * @param string $email The email address to be added as BCC + * @param string $name The name associated with the email address, default is "Daniel" + * @param mixed $testRef A reference variable, default is "Daniel" + * @return void + */ + public function addBCC(string $email, string $name = "Daniel", &$testRef = "Daniel"): void + { + $this->bcc = $email; + } + + public function test(...$params): void + { + $this->test2(); + } + + public function test2(): void + { + echo "Hello World\n"; + } + +} \ No newline at end of file diff --git a/tests/TestLib/UserService.php b/tests/TestLib/UserService.php new file mode 100644 index 0000000..c6a0524 --- /dev/null +++ b/tests/TestLib/UserService.php @@ -0,0 +1,39 @@ +mailer->addFromEmail($email); + $this->mailer->addBCC("jane.doe@hotmail.com", "Jane Doe"); + $this->mailer->addBCC("lane.doe@hotmail.com", "Lane Doe"); + + if(!filter_var($this->mailer->getFromEmail(), FILTER_VALIDATE_EMAIL)) { + throw new \Exception("Invalid email: " . $this->mailer->getFromEmail()); + } + return true; + } + + public function getUserRole(): string + { + return "guest"; + } + + final public function getUserType(): string + { + return "guest"; + } + + public function issueToken(): string { + return $this->generateToken(); // private + } + + private function generateToken(): string { + return bin2hex(random_bytes(16)); + } +} \ No newline at end of file diff --git a/tests/unitary-test-item.php b/tests/unitary-test-item.php new file mode 100644 index 0000000..f70cbe5 --- /dev/null +++ b/tests/unitary-test-item.php @@ -0,0 +1,75 @@ +group(TestConfig::make("Test item class") + ->withName("unitary"), function (TestCase $case) { + + $item = new TestItem(); + + $item = $item + ->setValidation("validation") + ->setValidationArgs(["arg1", "arg2"]) + ->setIsValid(true) + ->setValue("value") + ->setCompareToValue("compare") + ->setHasArgs(true); + + $case->validate($item->isValid(), function(Expect $valid) { + $valid->isTrue(); + }); + + $case->validate($item->getValidation(), function(Expect $valid) { + $valid->isEqualTo("validation"); + }); + + $case->validate($item->getValidationArgs(), function(Expect $valid) { + $valid->isInArray("arg1"); + $valid->isInArray("arg2"); + $valid->isCountEqualTo(2); + }); + + $case->validate($item->getValue(), function(Expect $valid) { + $valid->isEqualTo("value"); + }); + + $case->validate($item->hasComparison(), function(Expect $valid) { + $valid->isTrue(); + }); + + $case->validate($item->getCompareValues(), function(Expect $valid) { + $valid->isInArray("compare"); + }); + + $case->validate($item->getComparison(), function(Expect $valid) { + $valid->isEqualTo('Expected: "value" | Actual: "compare"'); + }); + + $case->validate($item->getStringifyArgs(), function(Expect $valid) { + $valid->isEqualTo('(arg1, arg2)'); + }); + + $case->validate($item->getValidationTitle(), function(Expect $valid) { + $valid->isEqualTo('validation(arg1, arg2)'); + }); + + $case->validate($item->getValidationLength(), function(Expect $valid) { + $valid->isEqualTo(10); + }); + + $case->validate($item->getValidationLengthWithArgs(), function(Expect $valid) { + $valid->isEqualTo(22); + }); + + $case->validate($item->getStringifyValue(), function(Expect $valid) { + $valid->isEqualTo('"value"'); + }); + + $case->validate($item->getCompareToValue(), function(Expect $valid) { + $valid->isInArray( '"compare"'); + }); + +}); diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 37e4984..e2fd38e 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,22 +1,263 @@ add("Unitary test", function () { +//$unit->disableAllTest(false); - $this->add("Lorem ipsum dolor", [ - "isString" => [], - "length" => [1,200] +$config = TestConfig::make()->withName("unitary"); + + +$unit->group($config->withSubject("Test mocker"), function (TestCase $case) use($unit) { + + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("addFromEmail") + ->withArguments("john.doe@gmail.com", "John Doe") + ->called(2); + }); + + + $mail->addFromEmail("john.doe@gmail.com", "John Doe"); +}); + + +$unit->group("Example of assert in group", function(TestCase $case) { + assert(1 === 2, "This is a error message"); +}); + +$unit->group($config->withSubject("Can not mock final or private"), function(TestCase $case) { + $user = $case->mock(UserService::class, function(MethodRegistry $method) { + $method->method("getUserRole")->willReturn("admin"); + $method->method("getUserType")->willReturn("admin"); + }); + + // You cannot mock final with data (should return a warning) + $case->validate($user->getUserType(), function(Expect $expect) { + $expect->isEqualTo("guest"); + }); + + // You can of course mock regular methods with data + $case->validate($user->getUserRole(), function(Expect $expect) { + $expect->isEqualTo("admin"); + }); + +}); + +$unit->group($config->withSubject("Test mocker"), function (TestCase $case) use($unit) { + + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("addFromEmail") + ->withArguments("john.doe@gmail.com", "John Doe") + ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) + ->willThrowOnce(new InvalidArgumentException("Lowrem ipsum")) + ->called(2); + + $method->method("addBCC") + ->isPublic() + ->hasDocComment() + ->hasParams() + ->paramHasType(0) + ->paramIsType(0, "string") + ->paramHasDefault(1, "Daniel") + ->paramIsOptional(1) + ->paramIsReference(2) + ->called(0); + }); + + $case->validate(fn() => $mail->addFromEmail("john.doe@gmail.com", "John Doe"), function(Expect $inst) { + $inst->isThrowable(InvalidArgumentException::class); + }); + + + $mail->addFromEmail("jane.doe@gmail.com", "Jane Doe"); + + $case->error("Test all exception validations") + ->validate(fn() => throw new ErrorException("Lorem ipsum", 1, 1, "example.php", 22), function(Expect $inst, Traverse $obj) { + $inst->isThrowable(ErrorException::class); + $inst->hasThrowableMessage("Lorem ipsum"); + $inst->hasThrowableSeverity(1); + $inst->hasThrowableCode(1); + $inst->hasThrowableFile("example.php"); + $inst->hasThrowableLine(22); + }); + + $case->validate(fn() => throw new TypeError("Lorem ipsum"), function(Expect $inst, Traverse $obj) { + $inst->isThrowable(TypeError::class); + }); + + + $case->validate(fn() => throw new TypeError("Lorem ipsum"), function(Expect $inst, Traverse $obj) { + $inst->isThrowable(function(Expect $inst) { + $inst->isClass(TypeError::class); + }); + }); +}); + +$unit->group($config->withSubject("Mocking response"), function (TestCase $case) use($unit) { + + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('HelloWorld', 'HelloWorld2') + ->calledAtLeast(1); + }); + $response = new Response($stream); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->hasResponse(); + $inst->isEqualTo('HelloWorld'); + $inst->notIsEqualTo('HelloWorldNot'); + }); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->isEqualTo('HelloWorld2'); + }); +}); - ])->add(92928, [ - "isInt" => [] +$unit->group($config->withSubject("Assert validations"), function ($case) { + $case->validate("HelloWorld", function(Expect $inst) { + assert($inst->isEqualTo("HelloWorld")->isValid(), "Assert has failed"); + }); + assert(1 === 1, "Assert has failed"); +}); - ])->add("Lorem", [ +$unit->case($config->withSubject("Old validation syntax"), function ($case) { + $case->add("HelloWorld", [ "isString" => [], - "length" => function () { - return $this->length(1, 50); + "User validation" => function($value) { + return $value === "HelloWorld"; } - ], "The length is not correct!"); + ], "Is not a valid port number"); + + $this->add("HelloWorld", [ + "isEqualTo" => ["HelloWorld"], + ], "Failed to validate"); +}); + +$unit->group($config->withSubject("Validate partial mock"), function (TestCase $case) use($unit) { + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("send")->keepOriginal(); + $method->method("isValidEmail")->keepOriginal(); + $method->method("sendEmail")->keepOriginal(); + }); + $case->validate(fn() => $mail->send(), function(Expect $inst) { + $inst->hasThrowableMessage("Invalid email"); + }); }); + +$unit->group($config->withSubject("Advanced App Response Test"), function (TestCase $case) use($unit) { + + + // Quickly mock the Stream class + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('{"test":"test"}') + ->calledAtLeast(1); + + $method->method("fopen")->isPrivate(); + }); + // Mock with configuration + // + // Notice: this will handle TestCase as immutable, and because of this + // the new instance of TestCase must be return to the group callable below + // + // By passing the mocked Stream class to the Response constructor, we + // will actually also test that the argument has the right data type + $case = $case->withMock(Response::class, [$stream]); + + // We can override all "default" mocking values tide to TestCase Instance + $case->getMocker() + ->mockDataType("string", "myCustomMockStringValue") + ->mockDataType("array", ["myCustomMockArrayItem"]) + ->mockDataType("int", 200, "getStatusCode"); + + $response = $case->buildMock(function (MethodRegistry $method) use($stream) { + $method->method("getBody")->willReturn($stream); + }); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->isString(); + $inst->isJson(); + }); + + $case->validate($response->getStatusCode(), function(Expect $inst) { + // Overriding the default making it a 200 integer + $inst->isHttpSuccess(); + }); + + $case->validate($response->getHeader("lorem"), function(Expect $inst) { + // Overriding the default the array would be ['item1', 'item2', 'item3'] + $inst->isInArray("myCustomMockArrayItem"); + }); + + $case->validate($response->getProtocolVersion(), function(Expect $inst) { + // MockedValue is the default value that the mocked class will return + $inst->isEqualTo("MockedValue"); + }); + + $case->validate($response->getBody(), function(Expect $inst) { + $inst->isInstanceOf(Stream::class); + }); + + // You need to return a new instance of TestCase for new mocking settings + return $case; +}); + + +$unit->group($config->withSubject("Testing User service"), function (TestCase $case) { + + $mailer = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("addFromEmail") + ->keepOriginal() + ->called(1); + $method->method("getFromEmail") + ->keepOriginal() + ->called(1); + }); + + $service = new UserService($mailer); + $case->validate($service->registerUser("john.doe@gmail.com"), function(Expect $inst) { + $inst->isTrue(); + }); +}); + +$unit->group($config->withSubject("Mocking response"), function (TestCase $case) use($unit) { + + + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('HelloWorld', 'HelloWorld2') + ->calledAtLeast(1); + }); + $response = new Response($stream); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->hasResponse(); + $inst->isEqualTo('HelloWorld'); + $inst->notIsEqualTo('HelloWorldNot'); + }); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->isEqualTo('HelloWorld2'); + }); +}); + +$unit->group("Example API Response", function(TestCase $case) { + + $case->validate('{"response":{"status":200,"message":"ok"}}', function(Expect $expect) { + + $expect->isJson()->hasJsonValueAt("response.status", 404); + assert($expect->isValid(), "Expected JSON structure did not match."); + }); + +}); \ No newline at end of file diff --git a/tests/unitary-will-fail.php b/tests/unitary-will-fail.php new file mode 100755 index 0000000..92d8620 --- /dev/null +++ b/tests/unitary-will-fail.php @@ -0,0 +1,62 @@ +withName("unitary-fail")->withSkip(); +$unit->group($config, function (TestCase $case) use($unit) { + + $case->error("Default validations")->validate(1, function(Expect $inst) { + $inst->isEmail(); + $inst->length(100, 1); + $inst->isString(); + }); + + $case->error("Return validation")->validate(true, function(Expect $inst) { + return false; + }); + + $case->error("Assert validation")->validate(true, function(Expect $inst) { + assert(1 == 2); + }); + + $case->error("Assert with message validation")->validate(true, function(Expect $inst) { + assert(1 == 2, "Is not equal to 2"); + }); + + $case->error("Assert with all validation")->validate(true, function(Expect $inst) { + assert($inst->isEmail()->isString()->isValid(), "Is not email"); + }); + + $case->add("HelloWorld", [ + "isInt" => [], + "User validation" => function($value) { + return $value === 2; + } + ], "Old validation syntax"); + + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("send")->keepOriginal()->called(0); + $method->method("isValidEmail")->keepOriginal(); + $method->method("sendEmail")->keepOriginal()->called(0); + + $method->method("addBCC") + ->isProtected() + ->hasDocComment() + ->hasParams() + ->paramHasType(0) + ->paramIsType(0, "int") + ->paramHasDefault(1, 1) + ->paramIsOptional(0) + ->paramIsReference(1) + ->called(0); + }); + + $case->error("Mocking validation")->validate(fn() => $mail->send(), function(Expect $inst) { + $inst->hasThrowableMessage("dwdwqdwqwdq email"); + }); + assert(1 == 2, "Assert in group level"); +}); \ No newline at end of file