diff --git a/README.md b/README.md index 2c6b2ea..ba7c8ba 100644 --- a/README.md +++ b/README.md @@ -1,360 +1,173 @@ -# 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 +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\{Expect,TestCase,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..236af2d 100755 --- a/bin/unitary +++ b/bin/unitary @@ -1,40 +1,31 @@ #!/usr/bin/env php getUriParts([ - "argv" => $argv -])), $env); +$autoload = __DIR__ . '/../../../../vendor/autoload.php'; +$autoload = is_file($autoload) ? $autoload : __DIR__ . '/../vendor/autoload.php'; +$autoload = realpath($autoload); -$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"); +if (!$autoload || !is_file($autoload)) { + if (!empty($GLOBALS['_composer_autoload_path'])) { + $autoload = $GLOBALS['_composer_autoload_path']; + } else { + fwrite(STDERR, "Autoloader not found. Run `composer install`.\n"); + exit(1); } +} - $testDir = realpath($path); - if(!is_dir($testDir)) { - throw new Exception("Test directory '$testDir' does not exist"); - } - $unit = new FileIterator($data); - $unit->executeAll($testDir); +require $autoload; -} catch (Exception $e) { - $command->error($e->getMessage()); -} +$app = (new Application()) + ->withErrorHandler(new CliHandler()) + ->boot([ + "argv" => $argv, + "dir" => getcwd() + ]); diff --git a/composer.json b/composer.json index 58ebb2d..54b949a 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,10 @@ "maplephp/prompts": "^1.0" }, "autoload": { + "files": [ + "src/Setup/assert-polyfill.php", + "src/Support/functions.php" + ], "psr-4": { "MaplePHP\\Unitary\\": "src" } diff --git a/src/Config/ConfigProps.php b/src/Config/ConfigProps.php new file mode 100644 index 0000000..cff61c1 --- /dev/null +++ b/src/Config/ConfigProps.php @@ -0,0 +1,92 @@ +path = (!is_string($value) || $value === '') ? null : $value; + break; + case 'discoverPattern': + $this->discoverPattern = (!is_string($value) || $value === '') ? null : $value; + break; + case 'exclude': + $this->exclude = (!is_string($value) || $value === '') ? null : $value; + break; + case 'show': + $this->show = (!is_string($value) || $value === '') ? null : $value; + break; + case 'timezone': + // The default timezone is 'CET' + $this->timezone = (!is_string($value) || $value === '') ? 'Europe/Stockholm' : $value; + break; + case 'local': + // The default timezone is 'CET' + $this->local = (!is_string($value) || $value === '') ? 'en_US' : $value; + if(!$this->isValidLocale($this->local)) { + throw new InvalidArgumentException( + "Invalid locale '{$this->local}'. Expected format like 'en_US' (language_COUNTRY)." + ); + } + break; + case 'exitCode': + $this->exitCode = ($value === null) ? null : (int)$value; + break; + case 'verbose': + $this->verbose = $this->dataToBool($value); + break; + case 'alwaysShowFiles': + $this->alwaysShowFiles = $this->dataToBool($value); + break; + case 'smartSearch': + $this->smartSearch = $this->dataToBool($value); + break; + case 'errorsOnly': + $this->errorsOnly = $this->dataToBool($value); + break; + case 'failFast': + $this->failFast = $this->dataToBool($value); + break; + } + } + +} diff --git a/src/Config/TestConfig.php b/src/Config/TestConfig.php new file mode 100644 index 0000000..cf0f46d --- /dev/null +++ b/src/Config/TestConfig.php @@ -0,0 +1,93 @@ +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->updatedSubject = true; + $inst->message = $subject; + return $inst; + } + + /** + * Check if a subject has been added in `withSubject` + * + * @return bool + */ + public function hasSubject(): bool + { + return $this->updatedSubject; + } + + /** + * Sets the skip state for the current instance. + * + * @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; + } +} diff --git a/src/Console/Application.php b/src/Console/Application.php new file mode 100644 index 0000000..a35fe8b --- /dev/null +++ b/src/Console/Application.php @@ -0,0 +1,58 @@ +severity() + ->excludeSeverityLevels([E_USER_WARNING, E_NOTICE, E_USER_NOTICE, E_DEPRECATED, E_USER_DEPRECATED]) + ->redirectTo(function () { + // Let PHP’s default error handler process excluded severities + return false; + }); + $run->setExitCode(1); + $run->load(); + return $inst; + } + + /** + * @param array $parts + * @return Kernel + * @throws \Exception + */ + public function boot(array $parts): Kernel + { + $env = new Environment(); + $request = new ServerRequest(new Uri($env->getUriParts($parts)), $env); + $kernel = new Kernel(new Container(), [ + AddCommandMiddleware::class, + ConfigPropsMiddleware::class, + LocalMiddleware::class, + CliInitMiddleware::class + ]); + $kernel->run($request); + return $kernel; + } +} diff --git a/src/Console/ConsoleRouter.php b/src/Console/ConsoleRouter.php new file mode 100644 index 0000000..e5e2d88 --- /dev/null +++ b/src/Console/ConsoleRouter.php @@ -0,0 +1,13 @@ +map("coverage", [CoverageController::class, "run"]) + ->map("template", [TemplateController::class, "run"]) + ->map("junit", [RunTestController::class, "runJUnit"]) + ->map(["", "test", "run"], [RunTestController::class, "run"])->with(TestMiddleware::class) + ->map(["__404", "help"], [RunTestController::class, "help"]); diff --git a/src/Console/Controllers/CoverageController.php b/src/Console/Controllers/CoverageController.php new file mode 100644 index 0000000..8abd7bf --- /dev/null +++ b/src/Console/Controllers/CoverageController.php @@ -0,0 +1,51 @@ +start(); + $handler = new SilentRender(); + $response = $service->run($handler); + $coverage->end(); + + $result = $coverage->getResponse(); + if ($result !== false) { + $this->outputBody($result); + } else { + $this->command->error("Error: Code coverage is not reachable"); + $this->command->error("Reason: " . $coverage->getIssue()->message()); + } + $this->command->message(""); + + return $response; + } + + /** + * Will output the main body response in CLI + * + * @param array $result + * @return void + */ + private function outputBody(array $result): void + { + $block = new Blocks($this->command); + $block->addSection("Code coverage", function (Blocks $block) use ($result) { + return $block->addList("Total lines:", $result['totalLines']) + ->addList("Executed lines:", $result['executedLines']) + ->addList("Code coverage percent:", $result['percent'] . "%"); + }); + } +} diff --git a/src/Console/Controllers/DefaultController.php b/src/Console/Controllers/DefaultController.php new file mode 100644 index 0000000..2633c42 --- /dev/null +++ b/src/Console/Controllers/DefaultController.php @@ -0,0 +1,41 @@ +container = $container; + $this->args = $this->container->get("args"); + $this->command = $this->container->get("command"); + $this->request = $this->container->get("request"); + $this->configs = $this->container->get("dispatchConfig"); + } +} diff --git a/src/Console/Controllers/RunTestController.php b/src/Console/Controllers/RunTestController.php new file mode 100644 index 0000000..9d6f366 --- /dev/null +++ b/src/Console/Controllers/RunTestController.php @@ -0,0 +1,138 @@ +command); + $response = $service->run($handler); + $this->buildFooter(); + return $response; + } + + /** + * Main test runner + */ + public function runJUnit(RunTestService $service): ResponseInterface + { + $suites = new \XMLWriter(); + $suites->openMemory(); + $suites->setIndent(true); + $suites->setIndentString(" "); + $handler = new JUnitRenderer($suites); + $response = $service->run($handler); + + // 2) Get the suites XML fragment + $suitesXml = $suites->outputMemory(); + + // Duration: pick your source (internal timer is fine) + $inst = TestDiscovery::getUnitaryInst(); + $xml = new \XMLWriter(); + $xml->openMemory(); + $xml->setIndent(true); + $xml->setIndentString(" "); + $xml->startDocument('1.0', 'UTF-8'); + $xml->startElement('testsuites'); + $xml->writeAttribute('tests', (string)$inst::getTotalTests()); + $xml->writeAttribute('failures', (string)$inst::getTotalFailed()); + $xml->writeAttribute('errors', (string)$inst::getTotalErrors()); + $xml->writeAttribute('time', (string)$inst::getDuration(6)); + // Optional: $xml->writeAttribute('skipped', (string)$totalSkipped); + + $xml->writeRaw($suitesXml); + + $xml->endElement(); + $xml->endDocument(); + + $response->getBody()->write($xml->outputMemory()); + return $response; + } + + + /** + * Main help page + * + * @return void + */ + public function help(): void + { + $blocks = new Blocks($this->command); + $blocks->addHeadline("\n--- Unitary Help ---"); + $blocks->addSection("Usage", "php vendor/bin/unitary [options]"); + + $blocks->addSection("Options", function (Blocks $inst) { + return $inst + ->addOption("help", "Show this help message") + ->addOption("show=", "Run a specific test by hash or manual test name") + ->addOption("errorsOnly", "Show only failing tests and skip passed test output") + ->addOption("template", "Will give you a boilerplate test code") + ->addOption("path=", "Specify test path (absolute or relative)") + ->addOption("exclude=", "Exclude files or directories (comma-separated, relative to --path)"); + }); + + $blocks->addSection("Examples", function (Blocks $inst) { + return $inst + ->addExamples( + "php vendor/bin/unitary", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=b0620ca8ef6ea7598e5ed56a530b1983", + "Run the test with a specific hash ID" + )->addExamples( + "php vendor/bin/unitary --errorsOnly", + "Run all tests in the default path (./tests)" + )->addExamples( + "php vendor/bin/unitary --show=YourNameHere", + "Run a manually named test case" + )->addExamples( + "php vendor/bin/unitary --template", + "Run a and will give you template code for a new test" + )->addExamples( + 'php vendor/bin/unitary --path="tests/" --exclude="tests/legacy/*,*/extras/*"', + 'Run all tests under "tests/" excluding specified directories' + ); + }); + // Make sure nothing else is executed when help is triggered + exit(0); + } + + /** + * Create a footer showing and end of script command + * + * This is not really part of the Unit test library, as other stuff might be present here + * + * @return void + */ + protected function buildFooter(): void + { + $inst = TestDiscovery::getUnitaryInst(); + if ($inst !== null) { + $dot = $this->command->getAnsi()->middot(); + $peakMemory = (string)round(memory_get_peak_usage() / 1024, 2); + + $this->command->message( + $this->command->getAnsi()->style( + ["italic", "grey"], + "Total tests: " . $inst::getPassedTests() . "/" . $inst::getTotalTests() . " $dot " . + "Errors: " . $inst::getTotalErrors() . " $dot " . + "Peak memory usage: " . $peakMemory . " KB" + ) + ); + $this->command->message(""); + } + + } + +} diff --git a/src/Console/Controllers/TemplateController.php b/src/Console/Controllers/TemplateController.php new file mode 100644 index 0000000..b737281 --- /dev/null +++ b/src/Console/Controllers/TemplateController.php @@ -0,0 +1,37 @@ +command); + $blocks->addHeadline("\n--- Copy and paste code --->"); + $blocks->addCode( + <<<'PHP' + use MaplePHP\Unitary\{Expect,TestCase}; + + group("Your test subject", function (TestCase $case) { + + $case->validate("Your test value", function(Expect $valid) { + $valid->isString(); + }); + + }); + PHP + ); + $blocks->addHeadline("---------------------------\n"); + } + + +} diff --git a/src/Console/Enum/CoverageIssue.php b/src/Console/Enum/CoverageIssue.php new file mode 100644 index 0000000..ed8e74c --- /dev/null +++ b/src/Console/Enum/CoverageIssue.php @@ -0,0 +1,30 @@ + 'No error occurred.', + self::MissingXdebug => 'Xdebug is not installed or enabled.', + self::MissingCoverage => 'Xdebug is enabled, but coverage mode is missing.', + }; + } +} diff --git a/src/Console/Kernel.php b/src/Console/Kernel.php new file mode 100644 index 0000000..fdde9ba --- /dev/null +++ b/src/Console/Kernel.php @@ -0,0 +1,96 @@ +container = $container; + $this->userMiddlewares = $userMiddlewares; + $this->config = $dispatchConfig; + EmitronKernel::setConfigFilePath(self::CONFIG_FILE_PATH); + } + + /** + * This will run Emitron kernel with Unitary configuration + * + * @param ServerRequestInterface $request + * @param StreamInterface|null $stream + * @return void + * @throws Exception + */ + public function run(ServerRequestInterface $request, ?StreamInterface $stream = null): void + { + if ($this->config === null) { + $this->config = $this->configuration($request); + } + $kernel = new EmitronKernel($this->container, $this->userMiddlewares, $this->config); + $kernel->run($request, $stream); + } + + /** + * This is the default unitary configuration + * + * @param ServerRequestInterface $request + * @return DispatchConfigInterface + * @throws Exception + */ + private function configuration(ServerRequestInterface $request): DispatchConfigInterface + { + $config = new DispatchConfig(EmitronKernel::getConfigFilePath()); + return $config + ->setRouter(function ($path) use ($request) { + $routerFile = $path . self::DEFAULT_ROUTER_FILE; + $router = new Router($request->getCliKeyword(), $request->getCliArgs()); + if (!is_file($routerFile)) { + throw new Exception('The routes file (' . $routerFile . ') is missing.'); + } + $newRouterInst = require_once $routerFile; + if (!($newRouterInst instanceof Router)) { + throw new \RuntimeException('You need to return the router instance ' . + 'at the end of the router file (' . $routerFile . ').'); + } + return $newRouterInst; + }) + ->setProp('exitCode', 0); + } +} diff --git a/src/Console/Middlewares/AddCommandMiddleware.php b/src/Console/Middlewares/AddCommandMiddleware.php new file mode 100644 index 0000000..b649f6e --- /dev/null +++ b/src/Console/Middlewares/AddCommandMiddleware.php @@ -0,0 +1,43 @@ +container = $container; + $this->stream = $stream; + } + + /** + * Will bind current Stream to the Command CLI library class + * this is initialized and passed to the Container + * + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $this->container->set("command", new Command($this->stream)); + return $handler->handle($request); + } +} diff --git a/src/Console/Middlewares/CliInitMiddleware.php b/src/Console/Middlewares/CliInitMiddleware.php new file mode 100644 index 0000000..f95f12e --- /dev/null +++ b/src/Console/Middlewares/CliInitMiddleware.php @@ -0,0 +1,41 @@ +handle($request); + if ($this->isCli()) { + $response = $response->withStatus(0); + } + return $response; + } + + /** + * Check if is inside a command line interface (CLI) + * + * @return bool + */ + protected function isCli(): bool + { + return PHP_SAPI === 'cli'; + } +} diff --git a/src/Console/Middlewares/ConfigPropsMiddleware.php b/src/Console/Middlewares/ConfigPropsMiddleware.php new file mode 100644 index 0000000..cd885a8 --- /dev/null +++ b/src/Console/Middlewares/ConfigPropsMiddleware.php @@ -0,0 +1,90 @@ +container = $container; + } + + /** + * Will bind current Response and Stream to the Command CLI library class + * this is initialized and passed to the Container + * + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $this->container->set("props", $this->getInitProps()); + return $handler->handle($request); + } + + + /** + * Builds the list of allowed CLI arguments from ConfigProps. + * + * These properties can be defined either in the configuration file or as CLI arguments. + * If invalid arguments are passed, and verbose mode is enabled, an error will be displayed + * along with a warning about the unknown properties. + * + * @return ConfigProps + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + private function getInitProps(): ConfigProps + { + + if ($this->props === null) { + + $args = $this->container->get("args"); + $configs = $this->container->get("dispatchConfig"); + $command = $this->container->get("command"); + + try { + $props = array_merge($configs->getProps()->toArray(), $args); + $this->props = new ConfigProps($props); + + if ($this->props->hasMissingProps() !== [] && isset($args['verbose'])) { + $command->error('The properties (' . + implode(", ", $this->props->hasMissingProps()) . ') is not exist in config props'); + $command->message( + "One or more arguments you passed are not recognized as valid options.\n" . + "Check your command syntax or configuration." + ); + } + + } catch (Throwable $e) { + if (isset($args['verbose'])) { + $command->error($e->getMessage()); + exit(1); + } + } + } + return $this->props; + } +} diff --git a/src/Console/Middlewares/LocalMiddleware.php b/src/Console/Middlewares/LocalMiddleware.php new file mode 100644 index 0000000..28dce98 --- /dev/null +++ b/src/Console/Middlewares/LocalMiddleware.php @@ -0,0 +1,46 @@ +container = $container; + } + + /** + * Will bind current Response and Stream to the Command CLI library class + * this is initialized and passed to the Container + * + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + * @throws \DateInvalidTimeZoneException + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $props = $this->container->get("props"); + Clock::setDefaultLocale($props->local); + Clock::setDefaultTimezone($props->timezone); + return $handler->handle($request); + } +} diff --git a/src/Console/Middlewares/TestMiddleware.php b/src/Console/Middlewares/TestMiddleware.php new file mode 100644 index 0000000..5e9287c --- /dev/null +++ b/src/Console/Middlewares/TestMiddleware.php @@ -0,0 +1,27 @@ +handle($request); + $response->getBody()->write("\n"); + $response->getBody()->write("Hello World from: " . get_class($this)); + $response->getBody()->write("\n"); + return $response; + } +} diff --git a/src/Console/Services/AbstractMainService.php b/src/Console/Services/AbstractMainService.php new file mode 100644 index 0000000..5213316 --- /dev/null +++ b/src/Console/Services/AbstractMainService.php @@ -0,0 +1,40 @@ +response = $response; + $this->container = $container; + $this->args = $this->container->get("args"); + $this->request = $this->container->get("request"); + $this->configs = $this->container->get("dispatchConfig"); + $this->command = $this->container->get("command"); + $this->props = $this->container->get("props"); + } + +} diff --git a/src/Console/Services/RunTestService.php b/src/Console/Services/RunTestService.php new file mode 100644 index 0000000..ecc97d4 --- /dev/null +++ b/src/Console/Services/RunTestService.php @@ -0,0 +1,69 @@ +enableVerbose($this->props->verbose); + $iterator->enableFailFast($this->props->failFast); + $iterator->enableSmartSearch($this->props->smartSearch); + $iterator->addExcludePaths($this->props->exclude); + $iterator->setDiscoverPattern($this->props->discoverPattern); + + $iterator = $this->iterateTest($iterator, $handler); + + // CLI Response + if (PHP_SAPI === 'cli') { + return $this->response->withStatus($iterator->getExitCode()); + } + // Text/Browser Response + return $this->response; + } + + /** + * @param TestDiscovery $iterator + * @param BodyInterface $handler + * @return TestDiscovery + * @throws BlunderSoftException + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + private function iterateTest(TestDiscovery $iterator, BodyInterface $handler): TestDiscovery + { + $defaultPath = $this->container->get("request")->getUri()->getDir(); + $defaultPath = ($this->configs->getProps()->path !== null) ? $this->configs->getProps()->path : $defaultPath; + $path = ($this->args['path'] ?? $defaultPath); + + if (!isset($path)) { + throw new RuntimeException("Path not specified: --path=path/to/dir"); + } + $testDir = realpath($path); + if (!file_exists($testDir)) { + throw new RuntimeException("Test directory '$path' does not exist"); + } + $iterator->executeAll($testDir, $defaultPath, function ($file) use ($handler) { + $unit = new Unit($handler); + $unit->setShowErrorsOnly($this->props->errorsOnly); + $unit->setFailFast($this->props->failFast); + $unit->setShow($this->props->show); + $unit->setFile($file); + $unit->setVerbose($this->props->verbose); + $unit->setAlwaysShowFiles($this->props->alwaysShowFiles); + return $unit; + }); + return $iterator; + } + +} diff --git a/src/Discovery/TestDiscovery.php b/src/Discovery/TestDiscovery.php new file mode 100755 index 0000000..8eeb1ee --- /dev/null +++ b/src/Discovery/TestDiscovery.php @@ -0,0 +1,372 @@ +verbose = $isVerbose; + return $this; + } + + /** + * Enable verbose flag which will show errors that should not always be visible + * + * @param bool $failFast + * @return $this + */ + public function enableFailFast(bool $failFast): self + { + $this->failFast = $failFast; + return $this; + } + + /** + * Enabling smart search; If no tests I found Unitary will try to traverse + * backwards until a test is found + * + * @param bool $smartSearch + * @return $this + */ + public function enableSmartSearch(bool $smartSearch): self + { + $this->smartSearch = $smartSearch; + return $this; + } + + /** + * Exclude paths from file iteration + * + * @param string|array|null $exclude + * @return $this + */ + public function addExcludePaths(string|array|null $exclude): self + { + if ($exclude !== null) { + $this->exclude = is_string($exclude) ? explode(', ', $exclude) : $exclude; + } + return $this; + } + + /** + * Change the default test discovery pattern from `unitary-*.php` to a custom pattern. + * + * Notes: + * - Wildcards can be used for paths (`tests/`) and files (`unitary-*.php`). + * - If no file extension is specified, `.php` is assumed. + * - Only PHP files are supported as test files. + * + * @param ?string $pattern null value will fall back to the default value + * @return $this + */ + public function setDiscoverPattern(?string $pattern): self + { + if ($pattern !== null) { + $pattern = rtrim($pattern, '*'); + $pattern = ltrim($pattern, '*'); + $pattern = ltrim($pattern, '/'); + $this->pattern = "*/" . (!str_ends_with($pattern, '.php') ? rtrim($pattern, '/') . "/*.php" : $pattern); + } + return $this; + } + + /** + * Get expected exit code + * + * @return int + */ + public function getExitCode(): int + { + return (int)!Unit::isSuccessful(); + } + + /** + * Will Execute all unitary test files + * + * @param string $path + * @param string|bool $rootDir + * @param callable|null $callback + * @return void + * @throws BlunderErrorException + * @throws BlunderSoftException + * @throws ErrorException + * @throws Throwable + */ + public function executeAll(string $path, string|bool $rootDir = false, ?callable $callback = null): void + { + $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); + + // Init Blunder error handling framework + //$this->runBlunder(); + //echo $wddwwqd; + + if (empty($files) && $this->verbose) { + throw new BlunderSoftException("Unitary could not find any test files matching the pattern \"" . + $this->pattern . "\" in directory \"" . dirname($path) . + "\" and its subdirectories."); + } else { + foreach ($files as $file) { + try { + if (!is_file($file)) { + throw new RuntimeException("File \"$file\" do not exists."); + } + $instance = $callback($file); + if (!$instance instanceof Unit) { + throw new UnexpectedValueException('Callable must return ' . Unit::class); + } + self::$unitary = $instance; + $this->executeUnitFile((string)$file); + + } catch (\Throwable $exception) { + if ($this->failFast) { + throw $exception; + } + self::$unitary->group("PHP error", function (TestCase $case) use ($exception) { + $newInst = $case->createTraceError($exception); + $newInst->incrementError(); + return $newInst; + }); + self::$unitary->execute(); + } + + } + } + } + + /** + * Prepares a callable that will include and execute a unit test file in isolation. + * + * Wrapping with Closure achieves: + * Scope isolation, $this unbinding, State separation, Deferred execution + * + * @param string $file The full path to the test file to require. + * @param Closure $callback + * @return void + * @throws ErrorException + * @throws BlunderErrorException + * @throws Throwable + */ + private function executeUnitFile(string $file): void + { + $verbose = $this->verbose; + + $unitInst = $this->isolateRequire($file); + + if ($unitInst instanceof Unit) { + $unitInst->inheritConfigs(self::$unitary); + self::$unitary = $unitInst; + } + $ok = self::$unitary->execute(); + + if (!$ok && $verbose) { + trigger_error( + "Could not find any tests inside the test file:\n$file\n\nPossible causes:\n" . + " • There are no test in test group/case.\n" . + " • Unitary could not locate the Unit instance.\n" . + " • You did not use the `group()` function.\n" . + " • You created a new Unit in the test file but did not return it at the end.", + E_USER_WARNING + ); + } + + } + + /** + * Isolate the required file and keep $this out of scope + * + * @param $file + * @return mixed + */ + private function isolateRequire($file): mixed + { + return (static function (string $f) { + return require $f; + })($file); + } + + /** + * 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)) { + $files += $this->getFileIterateReclusive($path); + } + } + // If smart search flag then step back if no test files have been found and try again + if ($rootDir !== false && count($files) <= 0 && str_starts_with($path, $rootDir) && $this->smartSearch) { + $path = (string)realpath($path . "/..") . "/"; + return $this->findFiles($path, $rootDir); + } + return $files; + } + + /** + * Get exclude parameter + * + * @return array + */ + private function exclude(): array + { + $excl = []; + if ($this->exclude !== null && $this->exclude !== []) { + foreach ($this->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 + */ + private 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; + } + + + /** + * Iterate files that match the expected patterns + * + * @param string $path + * @return array + */ + private function getFileIterateReclusive(string $path): array + { + $files = []; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); + /** @var string $pattern */ + foreach ($iterator as $file) { + if (($file instanceof SplFileInfo) && fnmatch($this->pattern, $file->getPathname()) && + ($this->exclude !== null || !str_contains($file->getPathname(), DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR))) { + if (!$this->findExcluded($this->exclude(), $path, $file->getPathname())) { + $files[] = $file->getPathname(); + } + } + } + return $files; + } + + /** + * Get a path as a natural path + * + * @param string $path + * @return string + */ + private function getNaturalPath(string $path): string + { + return str_replace("\\", "/", $path); + } + + /** + * Initialize Blunder error handler + * + * @return void + */ + protected function runBlunder(): void + { + $run = new Run(new CliHandler()); + $run->severity() + ->excludeSeverityLevels([E_USER_WARNING, E_NOTICE, E_USER_NOTICE, E_DEPRECATED, E_USER_DEPRECATED]) + ->redirectTo(function () { + // Let PHP’s default error handler process excluded severities + return false; + }); + $run->setExitCode(1); + $run->load(); + } + + /** + * Get instance of Unit class + * + * This is primary used to access the main test Unit instance that is + * pre-initialized for each test file. Is used by shortcut function like `group()` + * + * @return Unit|null + */ + public static function getUnitaryInst(): ?Unit + { + return self::$unitary; + } +} diff --git a/src/Expect.php b/src/Expect.php new file mode 100644 index 0000000..36f92d6 --- /dev/null +++ b/src/Expect.php @@ -0,0 +1,197 @@ +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; + + } +} 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/FileHandler.php b/src/Handlers/FileHandler.php deleted file mode 100755 index f599c03..0000000 --- a/src/Handlers/FileHandler.php +++ /dev/null @@ -1,49 +0,0 @@ -stream = new Stream(Stream::TEMP); - $this->command = new Command($this->stream); - $this->command->getAnsi()->disableAnsi(true); - $this->file = $file; - } - - /** - * Access the command stream - * @return Command - */ - public function getCommand(): Command - { - return $this->command; - } - - /** - * Execute the handler - * This will automatically be called inside the Unit execution - * @return void - */ - public function execute(): void - { - $upload = new UploadedFile($this->stream); - $upload->moveTo($this->file); - } -} diff --git a/src/Handlers/HandlerInterface.php b/src/Handlers/HandlerInterface.php deleted file mode 100755 index 4d94569..0000000 --- a/src/Handlers/HandlerInterface.php +++ /dev/null @@ -1,23 +0,0 @@ -stream = new Stream(Stream::TEMP); - $this->command = new Command($this->stream); - $this->command->getAnsi()->disableAnsi(true); - } - - /** - * Access the command stream - * @return Command - */ - public function getCommand(): Command - { - return $this->command; - } - - /** - * Execute the handler - * This will automatically be called inside the Unit execution - * @return void - */ - public function execute(): void - { - $this->stream->rewind(); - $out = $this->stream->getContents(); - $style = 'background-color: #F1F1F1; color: #000; font-size: 2rem; font-weight: normal; font-family: "Lucida Console", Monaco, monospace;'; - $out = str_replace(["[", "]"], ['', ''], $out); - echo '
' . $out . '
'; - } -} diff --git a/src/Interfaces/BodyInterface.php b/src/Interfaces/BodyInterface.php new file mode 100644 index 0000000..44db603 --- /dev/null +++ b/src/Interfaces/BodyInterface.php @@ -0,0 +1,101 @@ +> */ + 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..aa4edea --- /dev/null +++ b/src/Mocker/MockBuilder.php @@ -0,0 +1,541 @@ + */ + 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; + } + + 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) { + $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); + + /** + * @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..7c0260f --- /dev/null +++ b/src/Mocker/MockController.php @@ -0,0 +1,119 @@ +> */ + 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 = 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..06baaaf --- /dev/null +++ b/src/Mocker/MockedMethod.php @@ -0,0 +1,658 @@ +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 + * @throws Exception + */ + 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/Renders/AbstractRenderHandler.php b/src/Renders/AbstractRenderHandler.php new file mode 100644 index 0000000..3ba06f3 --- /dev/null +++ b/src/Renders/AbstractRenderHandler.php @@ -0,0 +1,133 @@ +verbose = $verbose; + } + + /** + * {@inheritDoc} + */ + public function setAlwaysShowFiles(bool $alwaysShowFiles): void + { + $this->alwaysShowFiles = $alwaysShowFiles; + } + + /** + * {@inheritDoc} + */ + public function setCase(TestCase $testCase): void + { + $this->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 $addToOutput = ''): string + { + $out = (ob_get_level() > 0) ? ob_get_clean() : ''; + $this->outputBuffer = $out . $addToOutput; + return $this->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 getBody(): StreamInterface + { + return $this->body; + } + + /** + * 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; + } +} diff --git a/src/Renders/CliRenderer.php b/src/Renders/CliRenderer.php new file mode 100644 index 0000000..a4ded2b --- /dev/null +++ b/src/Renders/CliRenderer.php @@ -0,0 +1,203 @@ +command = $command; + // Pass the active stream to `AbstractRenderHandler::getBody()` + $this->body = $this->command->getStream(); + } + + /** + * {@inheritDoc} + * @throws ErrorException + */ + 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->alwaysShowFiles || $this->verbose) && !$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()) + ); + } + + // Show Failed tests + $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); + } + } + + /** + * Footer template part + * + * @return void + */ + 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() || $this->case->getHasError()) { + $passed .= $this->command->getAnsi()->style(["grey"], "N/A"); + } else { + $passed .= $this->command->getAnsi()->style([$this->color], $this->case->getCount() . "/" . $this->case->getTotal()); + } + + $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(""); + + } + + /** + * Failed tests template part + * + * @return void + * @throws ErrorException + */ + protected function showFailedTests(): void + { + if (($this->show || !$this->case->getConfig()->skip)) { + foreach ($this->tests as $test) { + + if (!($test instanceof TestUnit)) { + throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); + } + + if (!$test->isValid()) { + $msg = (string)$test->getMessage(); + $this->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()) + ); + } + } + } + } + } + + /** + * Init some default styled object + * + * @return void + */ + protected function initDefault(): void + { + $this->color = ($this->case->hasFailed() ? "brightRed" : "brightBlue"); + $this->flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); + if ($this->case->hasFailed()) { + $this->flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); + } + if ($this->case->getConfig()->skip) { + $this->color = "yellow"; + $this->flag = $this->command->getAnsi()->style(['brightYellowBg', 'black'], " SKIP "); + } + + if ($this->case->getHasError()) { + $this->flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " ERROR "); + } + } +} diff --git a/src/Renders/JUnitRenderer.php b/src/Renders/JUnitRenderer.php new file mode 100644 index 0000000..12422e9 --- /dev/null +++ b/src/Renders/JUnitRenderer.php @@ -0,0 +1,263 @@ +xml = $xml; + } + + /** + * {@inheritDoc} + * @throws \Exception + */ + public function buildBody(): void + { + + + $testFile = $this->formatFileTitle($this->suitName, 3, false); + $msg = (string)$this->case->getMessage(); + $duration = (string)$this->case->getDuration(6); + + $this->xml->startElement('testsuite'); + $this->xml->writeAttribute('name', $testFile); + $this->xml->writeAttribute('tests', (string)$this->case->getCount()); + $this->xml->writeAttribute('failures', (string)$this->case->getFailedCount()); + $this->xml->writeAttribute('errors', (string)$this->case->getErrors()); + $this->xml->writeAttribute('skipped', (string)$this->case->getSkipped()); + $this->xml->writeAttribute('time', $duration); + $this->xml->writeAttribute('timestamp', Clock::value("now")->iso()); + + + foreach ($this->tests as $test) { + + if (!($test instanceof TestUnit)) { + throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); + } + + $value = Helpers::stringifyDataTypes($test->getValue()); + $value = str_replace('"', "'", $value); + + $this->xml->startElement('testcase'); + + $this->xml->writeAttribute('classname', $this->checksum); + if($this->case->getConfig()->select) { + $this->xml->writeAttribute('testname', $this->case->getConfig()->select); + } + $this->xml->writeAttribute('name', $msg); + $this->xml->writeAttribute('time', $duration); + if (!$test->isValid()) { + + $trace = $test->getCodeLine(); + $this->xml->writeAttribute('file', $trace['file']); + $this->xml->writeAttribute('line', $trace['line']); + $this->xml->writeAttribute('value', $value); + + + + + foreach ($test->getUnits() as $unit) { + + /** @var TestItem $unit */ + if (!$unit->isValid()) { + + $validation = $unit->getValidationTitle(); + $compare = $unit->hasComparison() ? ": " . $unit->getComparison() : ""; + $compare = str_replace('"', "'", $compare); + $type = str_replace('"', "'", $test->getMessage()); + + $tag = $this->case->getHasError() ? "error" : "failure"; + + $this->xml->startElement($tag); + $this->xml->writeAttribute('message', $validation . $compare); + $this->xml->writeAttribute('type', $type); + + $this->xml->endElement(); + } + + } + + + //$this->xml->writeCData($t['details']); + + } + + $this->xml->endElement(); + + + } + + + $this->xml->endElement(); + + /* + var_dump($this->case->getCount()); + var_dump($this->case->getFailedCount()); + var_dump($this->case->getErrors()); + var_dump($this->case->getDuration(6)); + var_dump(Clock::value("now")->dateTime()); + die; + if (($this->show || !$this->case->getConfig()->skip)) { + // Show possible warnings + +// if ($this->case->getWarning()) { +// $this->xml->message(""); +// $this->xml->message( +// $this->xml->getAnsi()->style(["italic", "yellow"], $this->case->getWarning()) +// ); +// } + + + // Show Failed tests + $this->showFailedTests(); + } + + $this->showFooter(); + */ + } + + /** + * {@inheritDoc} + */ + public function buildNotes(): void + { + if ($this->outputBuffer) { + /* + $lineLength = 80; + $output = wordwrap($this->outputBuffer, $lineLength); + $this->xml->startElement('output'); + $this->xml->writeAttribute('message', $output); + $this->xml->endElement(); + */ + } + } + + /** + * Footer template part + * + * @return void + */ + protected function showFooter(): void + { + $select = $this->checksum; + if ($this->case->getConfig()->select) { + $select .= " (" . $this->case->getConfig()->select . ")"; + } + $this->xml->message(""); + + $passed = $this->xml->getAnsi()->bold("Passed: "); + if ($this->case->getHasAssertError()) { + $passed .= $this->xml->getAnsi()->style(["grey"], "N/A"); + } else { + $passed .= $this->xml->getAnsi()->style([$this->color], $this->case->getCount() . "/" . $this->case->getTotal()); + } + + $footer = $passed . + $this->xml->getAnsi()->style(["italic", "grey"], " - ". $select); + if (!$this->show && $this->case->getConfig()->skip) { + $footer = $this->xml->getAnsi()->style(["italic", "grey"], $select); + } + $this->xml->message($footer); + $this->xml->message(""); + + } + + /** + * Failed tests template part + * + * @return void + * @throws ErrorException + */ + protected function showFailedTests(): void + { + if (($this->show || !$this->case->getConfig()->skip)) { + foreach ($this->tests as $test) { + + if (!($test instanceof TestUnit)) { + throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestUnit."); + } + + if (!$test->isValid()) { + $msg = (string)$test->getMessage(); + $this->xml->message(""); + $this->xml->message( + $this->xml->getAnsi()->style(["bold", $this->color], "Error: ") . + $this->xml->getAnsi()->bold($msg) + ); + $this->xml->message(""); + + $trace = $test->getCodeLine(); + if (!empty($trace['code'])) { + $this->xml->message($this->xml->getAnsi()->style(["bold", "grey"], "Failed on {$trace['file']}:{$trace['line']}")); + $this->xml->message($this->xml->getAnsi()->style(["grey"], " → {$trace['code']}")); + } + + foreach ($test->getUnits() as $unit) { + + /** @var TestItem $unit */ + if (!$unit->isValid()) { + $lengthA = $test->getValidationLength(); + $validation = $unit->getValidationTitle(); + $title = str_pad($validation, $lengthA); + $compare = $unit->hasComparison() ? $unit->getComparison() : ""; + + $failedMsg = " " .$title . " → failed"; + $this->xml->message($this->xml->getAnsi()->style($this->color, $failedMsg)); + + if ($compare) { + $lengthB = (strlen($compare) + strlen($failedMsg) - 8); + $comparePad = str_pad($compare, $lengthB, " ", STR_PAD_LEFT); + $this->xml->message( + $this->xml->getAnsi()->style($this->color, $comparePad) + ); + } + } + } + if ($test->hasValue()) { + $this->xml->message(""); + $this->xml->message( + $this->xml->getAnsi()->bold("Input value: ") . + Helpers::stringifyDataTypes($test->getValue()) + ); + } + } + } + } + } + + /** + * Init some default styled object + * + * @return void + */ + protected function initDefault(): void + { + $this->color = ($this->case->hasFailed() ? "brightRed" : "brightBlue"); + $this->flag = $this->xml->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); + if ($this->case->hasFailed()) { + $this->flag = $this->xml->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); + } + if ($this->case->getConfig()->skip) { + $this->color = "yellow"; + $this->flag = $this->xml->getAnsi()->style(['yellowBg', 'black'], " SKIP "); + } + } +} diff --git a/src/Renders/SilentRender.php b/src/Renders/SilentRender.php new file mode 100644 index 0000000..b713f39 --- /dev/null +++ b/src/Renders/SilentRender.php @@ -0,0 +1,10 @@ +getSmallErrorMessage($exceptionItem); + } + + /** + * Used to stringify arguments to show in a test + * + * @param mixed $args + * @return string + */ + public static function stringifyArgs(mixed $args): string + { + $levels = 0; + $str = self::stringify($args, $levels); + if ($levels > 1) { + return "[$str]"; + } + return $str; + } + + /** + * Stringify an array and objects + * + * @param mixed $arg + * @param int $levels + * @return string + */ + public static function stringify(mixed $arg, int &$levels = 0): string + { + if (is_array($arg)) { + $items = array_map(function ($item) use (&$levels) { + $levels++; + return self::stringify($item, $levels); + }, $arg); + return implode(', ', $items); + } + + if (is_object($arg)) { + return get_class($arg); + } + + return (string)$arg; + } + + /** + * Create a file instead of eval for improved debug + * + * @param string $filename + * @param string $input + * @return void + * @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)"; + } +} diff --git a/src/TestMem.php b/src/Support/Performance.php similarity index 72% rename from src/TestMem.php rename to src/Support/Performance.php index fb516e2..01a4280 100755 --- a/src/TestMem.php +++ b/src/Support/Performance.php @@ -1,10 +1,18 @@ args = $args; + $this->needle = $needle; + } + + /** + * Map one or more needles to controller + * + * @param string|array $needles + * @param array $controller + * @param array $args Pass custom data to router + * @return $this + */ + public function map(string|array $needles, array $controller, array $args = []): self + { + $inst = clone $this; + if (isset($args['handler'])) { + throw new InvalidArgumentException('The handler argument is reserved, you can not use that key.'); + } + + if (is_string($needles)) { + $needles = [$needles]; + } + $inst->mapId = $needles; + foreach ($inst->mapId as $key) { + $inst->controllers[$key] = [ + "handler" => $controller, + ...$args + ]; + } + return $inst; + } + + /** + * @param MiddlewareInterface|string $middleware + * @return $this + */ + public function with(MiddlewareInterface|string $middleware): self + { + if($this->mapId === null) { + throw new \BadMethodCallException('You need to map a route before calling the with method.'); + } + $inst = clone $this; + foreach ($inst->mapId as $key) { + $inst->middlewares[$key][] = $middleware; + } + $this->mapId = null; + return $inst; + } + + /** + * Dispatch matched router + * + * @param callable $call + * @return bool + */ + public function dispatch(callable $call): bool + { + if (isset($this->controllers[$this->needle])) { + $call($this->controllers[$this->needle], $this->args, ($this->middlewares[$this->needle] ?? []), $this->needle); + return true; + } + if (isset($this->controllers["__404"])) { + $call($this->controllers["__404"], $this->args, ($this->middlewares[$this->needle] ?? []), $this->needle); + } + return false; + } +} diff --git a/src/Support/TestUtils/CodeCoverage.php b/src/Support/TestUtils/CodeCoverage.php new file mode 100644 index 0000000..7ae94b0 --- /dev/null +++ b/src/Support/TestUtils/CodeCoverage.php @@ -0,0 +1,226 @@ + */ + private const DEFAULT_EXCLUDED_FILES = [ + "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->hasIssue()) { + return false; + } + if (!function_exists('xdebug_info')) { + $this->coverageIssue = CoverageIssue::MissingXdebug; + 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->coverageIssue = CoverageIssue::MissingCoverage; + return false; + } + return true; + } + + /** + * Add files and directories to be excluded from coverage. + * + * By default, this method includes a set of common files and directories + * that are typically excluded. To override and reset the list completely, + * pass `true` as the second argument. + * + * @param array $exclude Additional files or directories to exclude. + * @param bool $reset If true, replaces the default excluded list instead of merging with it. + * @return void + */ + public function exclude(array $exclude, bool $reset = false): void + { + $this->exclude = (!$reset) ? array_merge(self::DEFAULT_EXCLUDED_FILES, $exclude) : $exclude; + } + + /** + * 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(); + } + } + + /** + * This is a simple exclude checker used to exclude a file, directories or files in a pattern + * with the help of wildcard, for example, "unitary-*" will exclude all files with prefix unitary. + * + * @param string $file + * @return bool + */ + protected function excludePattern(string $file): bool + { + $filename = basename($file); + + 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->hasIssue()) { + 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 + ]; + } + + /** + * Get raw data + * + * @return array + */ + public function getRawData(): array + { + return $this->data ?? []; + } + + /** + * @return CoverageIssue + */ + public function getIssue(): CoverageIssue + { + return $this->coverageIssue; + } + + /** + * Check if error exists + * + * @return bool + */ + public function hasIssue(): bool + { + return $this->coverageIssue !== CoverageIssue::None; + } +} diff --git a/src/Support/TestUtils/DataTypeMock.php b/src/Support/TestUtils/DataTypeMock.php new file mode 100644 index 0000000..e69fecc --- /dev/null +++ b/src/Support/TestUtils/DataTypeMock.php @@ -0,0 +1,188 @@ +>|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); + } +} diff --git a/src/Support/TestUtils/ExecutionWrapper.php b/src/Support/TestUtils/ExecutionWrapper.php new file mode 100755 index 0000000..0fc11ea --- /dev/null +++ b/src/Support/TestUtils/ExecutionWrapper.php @@ -0,0 +1,143 @@ + */ + 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/Support/functions.php b/src/Support/functions.php new file mode 100644 index 0000000..06942af --- /dev/null +++ b/src/Support/functions.php @@ -0,0 +1,24 @@ +group($message, $expect, $config); +} + +if (!function_exists('group')) { + function group(string|TestConfig $message, Closure $expect, ?TestConfig $config = null): void + { + unitary_group(...func_get_args()); + } +} diff --git a/src/TestCase.php b/src/TestCase.php index 66a79c8..b259d5d 100755 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -1,84 +1,768 @@ + */ + 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 int $hasError = 0; + private int $skipped = 0; + private float $duration = 0; + private bool $hasAssertError = false; + private bool $failFast = false; + + /** + * Initialize a new TestCase instance with an optional message. + * + * @param TestConfig|string|null $config + */ + public function __construct(TestConfig|string|null $config = null) + { + if (!($config instanceof TestConfig)) { + $this->config = new TestConfig((string)$config); + } else { + $this->config = $config; + } + } - public function __construct(?string $message = null) + /** + * Will exit script if errors is thrown + * + * @param bool $failFast + * @return $this + */ + public function setFailFast(bool $failFast): self { - $this->message = $message; + $this->failFast = $failFast; + return $this; } /** * 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): void + public function bind(Closure $bind, bool $bindToClosure = false): void + { + $this->bind = ($bindToClosure) ? $bind->bindTo($this) : $bind; + } + + + /** + * Get the total number of skipped group test + * + * @return int + */ + public function getSkipped(): int + { + return $this->skipped; + } + + /** + * Get current test group duration + * + * @param int $precision + * @return float + */ + public function getDuration(int $precision = 0): float + { + if($precision > 0) { + return round($this->duration, $precision); + } + return $this->duration; + } + + /** + * Check if group has any skipped tests + * + * @return bool + */ + public function hasSkipped(): bool + { + return $this->skipped > 0; + } + + /** + * Increment skipped test + * + * @return void + */ + public function incrementSkipped(): void + { + $this->skipped++; + } + + /** + * Sets the error flag to true + * + * @return void + */ + public function incrementError(): void + { + $this->hasError++; + } + + /** + * Gets the errors count + * + * @return int + */ + public function getErrors(): int + { + return $this->hasError; + } + + /** + * Gets the current state of the error flag + * + * @return bool + */ + public function getHasError(): bool + { + return ($this->hasError > 0); + } + + /** + * Sets the assertion error flag to true + * + * @return void + */ + public function setHasAssertError(): void + { + $this->hasAssertError = true; + } + + /** + * Gets the current state of the assertion error flag + * + * @return bool + */ + public function getHasAssertError(): bool { - $this->bind = $bind->bindTo($this); + 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 + { + if ($message !== null) { + $this->error = $message; + } + return $this; + } + + // Alias to error + public function message(?string $message): self + { + return $this->error($message); } /** * Will dispatch the case tests and return them as an array + * + * @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); + $start = microtime(true); + $newInst = null; + if ($test !== null) { + try { + $newInst = $test($this); + $inst = ($newInst instanceof self) ? $newInst : $this; + if($inst->getConfig()->skip) { + $inst->incrementSkipped(); + } + + } catch (AssertionError $e) { + $newInst = $this->createTraceError($e, "Assertion failed"); + $newInst->setHasAssertError(); + + } catch (Throwable $e) { + if (str_contains($e->getFile(), "eval()")) { + throw new BlunderErrorException($e->getMessage(), (int)$e->getCode()); + } + if($this->failFast) { + throw $e; + } + $newInst = $this->createTraceError($e); + $newInst->incrementError(); + } + if ($newInst instanceof self) { + $row = $newInst; + } } + + $this->duration = (float)(microtime(true) - $start); return $this->test; } /** - * Create a test - * @param mixed $expect - * @param array|Closure $validation + * 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 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; + } + + /** + * Quickly validate with asserting + * + * @param bool $expect Assert value should be bool * @param string|null $message - * @return TestCase + * @return $this * @throws ErrorException */ - public function add(mixed $expect, array|Closure $validation, ?string $message = null): self + public function assert(bool $expect, ?string $message = null): self { + $this->expectAndValidate($expect, function () use ($expect, $message) { + assert($expect, $assertMsg ?? $message); + }, $this->error); + return $this; + } + + /** + * Executes a test case at runtime by validating the expected value. + * + * 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; } + /** + * Will assert a php error + * + * @param Throwable $exception + * @param string|null $title + * @return $this + * @throws ErrorException + */ + public function createTraceError(Throwable $exception, ?string $title = null): self + { + $newInst = clone $this; + $message = Helpers::getExceptionMessage($exception, $exceptionItem); + $title = ($title !== null) ? $title : "PHP " . $exceptionItem->getSeverityTitle(); + $newInst->expectAndValidate( + true, + fn () => false, + $title, + $message, + $exception->getTrace()[0] + ); + return $newInst; + } + + /** + * Adds a deferred validation to be executed after all immediate tests. + * + * 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 ($validate instanceof Closure) { + $pool = $this->prepareValidation($this->mocker, $validate); + } + /** @psalm-suppress MixedReturnStatement */ + $class = $this->mocker->execute(); + if ($this->mocker->hasFinal() && isset($pool)) { + $finalMethods = $pool->getSelected($this->mocker->getFinalMethods()); + if ($finalMethods !== []) { + $this->warning = "Warning: Final methods cannot be mocked or have their behavior modified: " . implode(", ", $finalMethods); + } + } + 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 +772,7 @@ public function getTotal(): int /** * Get failed test counts + * * @return int */ public function getCount(): int @@ -97,6 +782,7 @@ public function getCount(): int /** * Get failed test counts + * * @return int */ public function getFailedCount(): int @@ -106,6 +792,7 @@ public function getFailedCount(): int /** * Check if it has failed tests + * * @return bool */ public function hasFailed(): bool @@ -115,6 +802,7 @@ public function hasFailed(): bool /** * Get original value + * * @return mixed */ public function getValue(): mixed @@ -122,17 +810,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 +842,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 + ]; + } elseif (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 +914,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/TestEmitter.php b/src/TestEmitter.php new file mode 100644 index 0000000..596d807 --- /dev/null +++ b/src/TestEmitter.php @@ -0,0 +1,86 @@ +unit = new Unit($handler); + $this->args = $args; + $this->runBlunder($errorHandler); + } + + /** + * @throws Throwable + * @throws BlunderErrorException + * @throws ErrorException + */ + public function emit(string $file): void + { + + $verbose = (bool)($this->args['verbose'] ?? false); + + if (!is_file($file)) { + throw new RuntimeException("The test file \"$file\" do not exists."); + } + + require_once($file); + + $hasExecutedTest = $this->unit->execute(); + + if (!$hasExecutedTest && $verbose) { + trigger_error( + "Could not find any tests inside the test file:\n" . + $file . "\n\n" . + "Possible causes:\n" . + " • There are no test in test group/case.\n" . + " • Unitary could not locate the Unit instance.\n" . + " • You did not use the `group()` function.\n" . + " • You created a new Unit in the test file but did not return it at the end.", + E_USER_WARNING + ); + } + + } + + public function getUnit(): Unit + { + return $this->unit; + } + + /** + * Initialize Blunder error handler + * + * @param AbstractHandlerInterface $errorHandler + * @return void + */ + protected function runBlunder(AbstractHandlerInterface $errorHandler): void + { + $run = new Run($errorHandler); + $run->setExitCode(1); + $run->load(); + } +} diff --git a/src/TestItem.php b/src/TestItem.php new file mode 100755 index 0000000..fc1ae16 --- /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..122c261 100755 --- a/src/TestUnit.php +++ b/src/TestUnit.php @@ -1,55 +1,136 @@ 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 - ]; + + $this->validation = $item->getValidation(); + + $valLength = $item->getValidationLengthWithArgs(); + if ($this->valLength < $valLength) { + $this->valLength = $valLength; + } + + $this->unit[] = $item; return $this; } + /** + * Get the validation type + * + * @return ?string + */ + public function getValidationMsg(): ?string + { + return $this->validation; + } + + /** + * Get the length of the validation string with the maximum length + * + * @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 +139,8 @@ public function getUnits(): array } /** - * Get failed test count + * Get a failed test count + * * @return int */ public function getFailedTestCount(): int @@ -67,7 +149,8 @@ public function getFailedTestCount(): int } /** - * Get test message + * Get a test message + * * @return string|null */ public function getMessage(): ?string @@ -76,7 +159,8 @@ public function getMessage(): ?string } /** - * Get if test is valid + * Get if the test is valid + * * @return bool */ public function isValid(): bool @@ -86,58 +170,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/Unit.php b/src/Unit.php index a87e314..d8a4c15 100755 --- a/src/Unit.php +++ b/src/Unit.php @@ -1,430 +1,412 @@ handler = $handler; - $this->command = $this->handler->getCommand(); - } else { - $this->command = new Command($handler); - } - self::$current = $this; + $this->handler = ($handler === null) ? new CliRenderer(new Command()) : $handler; } - /** - * Skip you can add this if you want to turn of validation of a unit case - * @param bool $skip - * @return $this - */ - public function skip(bool $skip): self + public function getBody(): StreamInterface { - $this->skip = $skip; - return $this; + return $this->handler->getBody(); } /** - * Make script manually callable - * @param string $key + * Will pass a test file name to script used to: + * - Allocate tests + * - Show where tests is executed + * + * @param string $file * @return $this */ - public function manual(string $key): self + public function setFile(string $file): Unit { - if(isset(self::$manual[$key])) { - $file = (string)(self::$headers['file'] ?? "none"); - throw new RuntimeException("The manual key \"$key\" already exists. - Please set a unique key in the " . $file. " file."); - } - self::$manual[$key] = self::$headers['checksum']; - return $this->skip(true); + $this->file = $file; + return $this; } /** - * Access command instance - * @return Command + * Will exit script if errors is thrown + * + * @param bool $failFast + * @return $this */ - public function getCommand(): Command + public function setFailFast(bool $failFast): Unit { - return $this->command; + $this->failFast = $failFast; + return $this; } /** - * Access command instance - * @return StreamInterface + * Will only display error and hide passed tests + * + * @param bool $showErrorsOnly + * @return $this */ - public function getStream(): StreamInterface + public function setShowErrorsOnly(bool $showErrorsOnly): Unit { - return $this->command->getStream(); + $this->showErrorsOnly = $showErrorsOnly; + return $this; } /** - * Disable ANSI - * @param bool $disableAnsi - * @return self + * Display only one test - + * Will accept either file checksum or name form named tests + * + * @param string|null $show + * @return $this */ - public function disableAnsi(bool $disableAnsi): self + public function setShow(?string $show = null): Unit { - $this->command->getAnsi()->disableAnsi($disableAnsi); + $this->show = $show; return $this; } /** - * Print message - * @param string $message - * @return false|string + * Show hidden messages + * + * @param bool $verbose + * @return void */ - public function message(string $message): false|string + public function setVerbose(bool $verbose): void { - return $this->command->message($message); + $this->verbose = $verbose; } /** - * confirm for execute - * @param string $message - * @return bool + * Show file paths even on passed tests + * + * @param bool $alwaysShowFiles + * @return void */ - public function confirm(string $message = "Do you wish to continue?"): bool + public function setAlwaysShowFiles(bool $alwaysShowFiles): void { - return $this->command->confirm($message); + $this->alwaysShowFiles = $alwaysShowFiles; } /** - * DEPRECATED: Name has been changed to case - * @param string $message - * @param Closure $callback - * @return void + * This will help pass over some default for custom Unit instances + * + * @param Unit $inst + * @return $this */ - public function add(string $message, Closure $callback): void + public function inheritConfigs(Unit $inst): Unit { - // Might be trigger in future - //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); - $this->case($message, $callback); + $this->setFile($inst->file); + $this->setShow($inst->show); + $this->setShowErrorsOnly($inst->showErrorsOnly); + $this->setFailFast($inst->failFast); + $this->setVerbose($inst->verbose); + $this->setAlwaysShowFiles($inst->alwaysShowFiles); + return $this; } /** - * Add a test unit/group - * @param string $message - * @param Closure $callback - * @return void + * Check if all executed tests is successful + * + * @return bool */ - public function case(string $message, Closure $callback): void + public static function isSuccessful(): bool { - $testCase = new TestCase($message); - $testCase->bind($callback); - $this->cases[$this->index] = $testCase; - $this->index++; + return (self::$totalPassedTests === self::$totalTests); } - public function performance(Closure $func, ?string $title = null): void + /** + * Get number of executed passed tests + * + * @return int + */ + public static function getPassedTests(): int { - $start = new TestMem(); - $func = $func->bindTo($this); - if(!is_null($func)) { - $func(); - } - $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($line); - $this->command->message( - $this->command->getAnsi()->style(["bold"], "Execution time: ") . - (round($start->getExecutionTime(), 3) . " seconds") - ); - $this->command->message( - $this->command->getAnsi()->style(["bold"], "Memory Usage: ") . - (round($start->getMemoryUsage(), 2) . " KB") - ); - /* - $this->command->message( - $this->command->getAnsi()->style(["bold", "grey"], "Peak Memory Usage: ") . - $this->command->getAnsi()->blue(round($start->getMemoryPeakUsage(), 2) . " KB") - ); - */ - $this->command->message($line); + return self::$totalPassedTests; } /** - * Execute tests suite - * @return bool - * @throws ErrorException + * Get number of executed tests + * + * @return int */ - public function execute(): bool + public static function getTotalTests(): int { - if($this->executed || !$this->validate()) { - return false; - } - - // LOOP through each case - ob_start(); - foreach($this->cases as $row) { - - if(!($row instanceof TestCase)) { - throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); - } - - try { - $tests = $row->dispatchTest(); - } catch (Throwable $e) { - $file = $this->formatFileTitle((string)(self::$headers['file'] ?? ""), 5, false); - throw new RuntimeException($e->getMessage() . ". Error originated from: ". $file, (int)$e->getCode(), $e); - } - - $color = ($row->hasFailed() ? "brightRed" : "brightBlue"); - $flag = $this->command->getAnsi()->style(['blueBg', 'brightWhite'], " PASS "); - if($row->hasFailed()) { - $flag = $this->command->getAnsi()->style(['redBg', 'brightWhite'], " FAIL "); - } - - $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()); - } - } - - self::$totalPassedTests += $row->getCount(); - self::$totalTests += $row->getTotal(); - - $checksum = (string)(self::$headers['checksum'] ?? ""); - $this->command->message(""); - - $this->command->message( - $this->command->getAnsi()->bold("Passed: ") . - $this->command->getAnsi()->style([$color], $row->getCount() . "/" . $row->getTotal()) . - $this->command->getAnsi()->style(["italic", "grey"], " - ". $checksum) - ); - } - $this->output .= ob_get_clean(); - - if($this->output) { - $this->buildNotice("Note:", $this->output, 80); - } - if(!is_null($this->handler)) { - $this->handler->execute(); - } - $this->executed = true; - return true; + return self::$totalTests; } /** - * Will reset the execute and stream if is a seekable stream. - * @return bool + * Get the total number of failed tests + * + * @return int */ - public function resetExecute(): bool + public static function getTotalFailed(): int { - if($this->executed) { - if($this->getStream()->isSeekable()) { - $this->getStream()->rewind(); - } - $this->executed = false; - return true; - } - return false; + return self::$totalTests-self::$totalPassedTests; } /** - * Validate before execute test - * @return bool + * Get the total number of error + * + * NOTE: That an error is a PHP failure or a exception that has been thrown. + * + * @return int */ - private function validate(): bool + public static function getTotalErrors(): int { - $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; + return self::$totalErrors; } /** - * Build the notification stream - * @param string $title - * @param string $output - * @param int $lineLength + * Increment error count + * * @return void */ - public function buildNotice(string $title, string $output, int $lineLength): void + public static function incrementErrors(): 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); + self::$totalErrors++; } /** - * Make file path into a title - * @param string $file - * @param int $length - * @param bool $removeSuffix - * @return string + * Get total duration of all tests + * + * @param int $precision + * @return float */ - private function formatFileTitle(string $file, int $length = 3, bool $removeSuffix = true): string + public static function getDuration(int $precision = 0): float { - $file = explode("/", $file); - if ($removeSuffix) { - $pop = array_pop($file); - $file[] = substr($pop, (int)strpos($pop, 'unitary') + 8); + if($precision > 0) { + return round(self::$duration, $precision); } - - $file = array_chunk(array_reverse($file), $length); - $file = implode("\\", array_reverse($file[0])); - $exp = explode('.', $file); - $file = reset($exp); - return ".." . $file; + return self::$duration; } /** - * Global header information - * @param array $headers + * 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 static function setHeaders(array $headers): void + public function disableAllTest(bool $disable): void { - self::$headers = $headers; + $this->disableAllTests = $disable; } /** - * Get global header - * @param string $key - * @return mixed + * Name has been changed to case + * + * Note: This will become DEPRECATED in the future with exception + * + * @param string $message + * @param Closure $callback + * @return void */ - public static function getArgs(string $key): mixed + public function add(string $message, Closure $callback): void { - return (self::$headers['args'][$key] ?? false); + $this->case($message, $callback); } /** - * Append to global header - * @param string $key - * @param mixed $value + * 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 $expect The closure containing the test case logic. + * @param TestConfig|null $config * @return void */ - public static function appendHeader(string $key, mixed $value): void + public function group(string|TestConfig $message, Closure $expect, ?TestConfig $config = null): void { - self::$headers[$key] = $value; + if ($config !== null && !$config->hasSubject()) { + $addMessage = ($message instanceof TestConfig && $message->hasSubject()) ? $message->message : $message; + $message = $config->withSubject($addMessage); + } + $this->addCase($message, $expect); } /** - * Used to reset current instance + * 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 static function resetUnit(): void + public function case(string|TestConfig $message, Closure $callback): void { - self::$current = null; + $this->addCase($message, $callback, true); } /** - * Used to check if instance is set + * Execute tests suite + * * @return bool + * @throws ErrorException + * @throws BlunderErrorException + * @throws Throwable + */ + public function execute(): bool + { + if ($this->executed || $this->disableAllTests) { + return false; + } + ob_start(); + //$countCases = count($this->cases); + $handler = $this->handler; + if (count($this->cases) === 0) { + return false; + } + $fileChecksum = md5($this->file); + foreach ($this->cases as $index => $row) { + if (!($row instanceof TestCase)) { + throw new RuntimeException("The @cases (object->array) should return a row with instanceof TestCase."); + } + $row->dispatchTest($row); + $tests = $row->runDeferredValidations(); + $checksum = $fileChecksum . $index; + $show = ($row->getConfig()->select === $this->show || $this->show === $checksum); + + if (($this->show !== null) && !$show) { + continue; + } + // Success, no need to try to show errors, continue with the next test + if ($this->showErrorsOnly !== false && !$row->hasFailed()) { + continue; + } + $handler->setCase($row); + $handler->setSuitName($this->file); + $handler->setChecksum($checksum); + $handler->setTests($tests); + $handler->setShow($show); + $handler->setVerbose($this->verbose); + $handler->setAlwaysShowFiles($this->alwaysShowFiles); + $handler->buildBody(); + + if($row->getHasError()) { + self::incrementErrors(); + } + + // Important to add test from skip as successfully count to make sure that + // the total passed tests are correct, and it will not exit with code 1 + self::$totalPassedTests += ($row->getConfig()->skip) ? $row->getTotal() : $row->getCount(); + self::$totalTests += $row->getTotal(); + self::$duration += $row->getDuration(); + } + $out = $handler->outputBuffer(); + if ($out) { + $handler->buildNotes(); + } + $this->executed = true; + return true; + } + + /** + * Validate method that must be called within a group method + * + * @return self + * @throws RuntimeException When called outside a group method */ - public static function hasUnit(): bool + public function validate(): self { - return !is_null(self::$current); + throw new RuntimeException("The validate() method must be called inside a group() method! " . + "Move this validate() call inside your group() callback function."); } /** - * Used to get instance - * @return ?Unit - * @throws Exception + * Validate method that must be called within a group method + * + * @return self + * @throws RuntimeException When called outside a group method */ - public static function getUnit(): ?Unit + public function assert(): self { - if(is_null(self::hasUnit())) { - throw new Exception("Unit has not been set yet. It needs to be set first."); - } - return self::$current; + throw new RuntimeException("The assert() method must be called inside a group() method! " . + "Move this assert() call inside your group() callback function."); } /** - * This will be called when every test has been run by the FileIterator + * Adds a test case to the collection. + * + * @param string|TestConfig $message The description or configuration of the test case. + * @param Closure $expect The closure that defines the test case logic. + * @param bool $bindToClosure Indicates whether the closure should be bound to TestCase. * @return void */ - public static function completed(): void + protected function addCase(string|TestConfig $message, Closure $expect, bool $bindToClosure = false): void { - if(!is_null(self::$current) && is_null(self::$current->handler)) { - $dot = self::$current->command->getAnsi()->middot(); - - self::$current->command->message(""); - self::$current->command->message( - self::$current->command->getAnsi()->style( - ["italic", "grey"], - "Total: " . self::$totalPassedTests . "/" . self::$totalTests . " $dot " . - "Peak memory usage: " . round(memory_get_peak_usage() / 1024, 2) . " KB" - ) - ); - } + $testCase = new TestCase($message); + $testCase->setFailFast($this->failFast); + $testCase->bind($expect, $bindToClosure); + $this->cases[$this->index] = $testCase; + $this->index++; + } + + // Deprecated: Almost same as `disableAllTest`, for older versions + public function skip(bool $disable): self + { + $this->disableAllTests = $disable; + return $this; } /** - * Check if successful - * @return bool + * DEPRECATED: Use TestConfig::setSelect instead + * See documentation for more information + * + * @return void */ - public static function isSuccessful(): bool + public function manual(): void { - return (self::$totalPassedTests !== self::$totalTests); + throw new RuntimeException("Manual method has been deprecated, use TestConfig::setSelect instead. " . + "See documentation for more information."); } /** * DEPRECATED: Not used anymore + * * @return $this */ public function addTitle(): self 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..1332c36 --- /dev/null +++ b/tests/unitary-test-item.php @@ -0,0 +1,73 @@ +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-test.php b/tests/unitary-test.php new file mode 100755 index 0000000..716af1c --- /dev/null +++ b/tests/unitary-test.php @@ -0,0 +1,24 @@ +withName("unitary-test"); + +group("Hello world 0", function(TestCase $case) { + + $case->assert(1 === 2, "wdwdq 2"); + +}, $config); + +group("Hello world 1", function(TestCase $case) { + + $case->validate(1 === 2, function(Expect $expect) { + $expect->isEqualTo(true); + }); + +}, $config); + +group($config->withSubject("Hello world 2"), function(TestCase $case) { + $case->validate(2 === 2, fn() => true); +}); \ No newline at end of file diff --git a/tests/unitary-unitary.php b/tests/unitary-unitary.php index 37e4984..4330ebc 100755 --- a/tests/unitary-unitary.php +++ b/tests/unitary-unitary.php @@ -1,22 +1,258 @@ add("Unitary test", function () { +use MaplePHP\DTO\Traverse; +use MaplePHP\Http\Response; +use MaplePHP\Http\Stream; +use MaplePHP\Unitary\{Config\TestConfig, Expect, Mocker\MethodRegistry, TestCase, Unit}; +use TestLib\Mailer; +use TestLib\UserService; - $this->add("Lorem ipsum dolor", [ - "isString" => [], - "length" => [1,200] +$config = TestConfig::make()->withName("unitary"); + + +group($config->withSubject("Test mocker"), function (TestCase $case) { + throw new Exception("dwddwqdwdwdqdq"); + echo $wdwdw; + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("addFromEmail") + ->withArguments("john.doe@gmail.com", "John Doe") + ->called(2); + }); + + $mail->addFromEmail("john.doe@gmail.com", "John Doe"); +}); + +group("Example of assert in group", function(TestCase $case) { + assert(1 === 2, "This is a error message"); +}); + +group($config->withSubject("Can not mock final or private"), function(TestCase $case) { + + $user = $case->mock(UserService::class, function(MethodRegistry $method) { + $method->method("getUserRole")->willReturn("admin"); + $method->method("getUserType")->willReturn("admin"); + }); + + // You cannot mock final with data (should return a warning) + $case->validate($user->getUserType(), function(Expect $expect) { + $expect->isEqualTo("guest"); + }); + + // You can of course mock regular methods with data + $case->validate($user->getUserRole(), function(Expect $expect) { + $expect->isEqualTo("admin"); + }); + +}); + +group($config->withSubject("Test mocker"), function (TestCase $case) { + + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("addFromEmail") + ->withArguments("john.doe@gmail.com", "John Doe") + ->withArgumentsForCalls(["john.doe@gmail.com", "John Doe"], ["jane.doe@gmail.com", "Jane Doe"]) + ->willThrowOnce(new InvalidArgumentException("Lowrem ipsum")) + ->called(2); + + $method->method("addBCC") + ->isPublic() + ->hasDocComment() + ->hasParams() + ->paramHasType(0) + ->paramIsType(0, "string") + ->paramHasDefault(1, "Daniel") + ->paramIsOptional(1) + ->paramIsReference(2) + ->called(0); + }); - ])->add(92928, [ - "isInt" => [] + $case->validate(fn() => $mail->addFromEmail("john.doe@gmail.com", "John Doe"), function(Expect $inst) { + $inst->isThrowable(InvalidArgumentException::class); + }); - ])->add("Lorem", [ + + $mail->addFromEmail("jane.doe@gmail.com", "Jane Doe"); + + $case->error("Test all exception validations") + ->validate(fn() => throw new ErrorException("Lorem ipsum", 1, 1, "example.php", 22), function(Expect $inst, Traverse $obj) { + $inst->isThrowable(ErrorException::class); + $inst->hasThrowableMessage("Lorem ipsum"); + $inst->hasThrowableSeverity(1); + $inst->hasThrowableCode(1); + $inst->hasThrowableFile("example.php"); + $inst->hasThrowableLine(22); + }); + + $case->validate(fn() => throw new TypeError("Lorem ipsum"), function(Expect $inst, Traverse $obj) { + $inst->isThrowable(TypeError::class); + }); + + + $case->validate(fn() => throw new TypeError("Lorem ipsum"), function(Expect $inst, Traverse $obj) { + $inst->isThrowable(function(Expect $inst) { + $inst->isClass(TypeError::class); + }); + }); +}); + +group($config->withSubject("Mocking response"), function (TestCase $case) { + + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('HelloWorld', 'HelloWorld2') + ->calledAtLeast(1); + }); + $response = new Response($stream); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->hasResponse(); + $inst->isEqualTo('HelloWorld'); + $inst->notIsEqualTo('HelloWorldNot'); + }); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->isEqualTo('HelloWorld2'); + }); +}); + +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"); +}); + +group($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"); + $case->add("HelloWorld", [ + "isEqualTo" => ["HelloWorld"], + ], "Failed to validate"); }); + +group($config->withSubject("Validate partial mock"), function (TestCase $case) { + $mail = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("send")->keepOriginal(); + $method->method("isValidEmail")->keepOriginal(); + $method->method("sendEmail")->keepOriginal(); + }); + + $case->validate(fn() => $mail->send(), function(Expect $inst) { + $inst->hasThrowableMessage("Invalid email"); + }); +}); + +group($config->withSubject("Advanced App Response Test"), function (TestCase $case) { + + + // Quickly mock the Stream class + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('{"test":"test"}') + ->calledAtLeast(1); + + $method->method("fopen")->isPrivate(); + }); + // Mock with configuration + // + // Notice: this will handle TestCase as immutable, and because of this + // the new instance of TestCase must be return to the group callable below + // + // By passing the mocked Stream class to the Response constructor, we + // will actually also test that the argument has the right data type + $case = $case->withMock(Response::class, [$stream]); + + // We can override all "default" mocking values tide to TestCase Instance + $case->getMocker() + ->mockDataType("string", "myCustomMockStringValue") + ->mockDataType("array", ["myCustomMockArrayItem"]) + ->mockDataType("int", 200, "getStatusCode"); + + $response = $case->buildMock(function (MethodRegistry $method) use($stream) { + $method->method("getBody")->willReturn($stream); + }); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->isString(); + $inst->isJson(); + }); + + $case->validate($response->getStatusCode(), function(Expect $inst) { + // Overriding the default making it a 200 integer + $inst->isHttpSuccess(); + }); + + $case->validate($response->getHeader("lorem"), function(Expect $inst) { + // Overriding the default the array would be ['item1', 'item2', 'item3'] + $inst->isInArray("myCustomMockArrayItem"); + }); + + $case->validate($response->getProtocolVersion(), function(Expect $inst) { + // MockedValue is the default value that the mocked class will return + $inst->isEqualTo("MockedValue"); + }); + + $case->validate($response->getBody(), function(Expect $inst) { + $inst->isInstanceOf(Stream::class); + }); + + // You need to return a new instance of TestCase for new mocking settings + return $case; +}); + + +group($config->withSubject("Testing User service"), function (TestCase $case) { + + $mailer = $case->mock(Mailer::class, function (MethodRegistry $method) { + $method->method("addFromEmail") + ->keepOriginal() + ->called(1); + $method->method("getFromEmail") + ->keepOriginal() + ->called(1); + }); + + $service = new UserService($mailer); + $case->validate($service->registerUser("john.doe@gmail.com"), function(Expect $inst) { + $inst->isTrue(); + }); +}); + +group($config->withSubject("Mocking response"), function (TestCase $case) { + + + $stream = $case->mock(Stream::class, function (MethodRegistry $method) { + $method->method("getContents") + ->willReturn('HelloWorld', 'HelloWorld2') + ->calledAtLeast(1); + }); + $response = new Response($stream); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->hasResponse(); + $inst->isEqualTo('HelloWorld'); + $inst->notIsEqualTo('HelloWorldNot'); + }); + + $case->validate($response->getBody()->getContents(), function(Expect $inst) { + $inst->isEqualTo('HelloWorld2'); + }); +}); + +group("Example API Response", function(TestCase $case) { + + $case->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..2a82758 --- /dev/null +++ b/tests/unitary-will-fail.php @@ -0,0 +1,61 @@ +withName("unitary-fail")->withSkip(); +group($config, function (TestCase $case) { + + $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 diff --git a/unitary.config.php b/unitary.config.php new file mode 100644 index 0000000..8718724 --- /dev/null +++ b/unitary.config.php @@ -0,0 +1,20 @@ + 'app/Libraries/Unitary/tests/unitary-test.php', + 'path' => false, // false|string|array + 'smartSearch' => false, // bool + 'errorsOnly' => false, // bool + 'verbose' => false, // bool + 'exclude' => false, // false|string|array + 'discoverPattern' => false, // string|false (paths (`tests/`) and files (`unitary-*.php`).) + 'show' => false, + 'timezone' => 'Europe/Stockholm', + 'local' => 'en_US', + 'alwaysShowFiles' => false, + 'failFast' => false, // bool + //'exit_error_code' => 1, ?? +];