From ee3f0dd2cc30e6b1d3f5e77f47775c611ea02ff4 Mon Sep 17 00:00:00 2001 From: = <=> Date: Fri, 30 Jun 2023 15:30:53 -0300 Subject: [PATCH] Executor de formulas --- .github/workflows/php.yml | 44 ++++ .gitignore | 3 + README.md | 26 ++- composer.json | 45 ++++ phpunit.xml | 18 ++ src/Classes/InfixToPostfix.php | 198 ++++++++++++++++++ src/Classes/Node.php | 19 ++ src/Classes/Stack.php | 41 ++++ src/Exception/IncorrectTokenException.php | 11 + .../MalformedExpressionException.php | 11 + src/Exception/OperationException.php | 11 + src/FormulaExecutor.php | 105 ++++++++++ tests/Feature/CalculateExpressionTest.php | 66 ++++++ tests/TestCase.php | 10 + tests/Unit/InfixToPostfixTest.php | 62 ++++++ tests/Unit/StackTest.php | 20 ++ 16 files changed, 689 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/php.yml create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 src/Classes/InfixToPostfix.php create mode 100644 src/Classes/Node.php create mode 100644 src/Classes/Stack.php create mode 100644 src/Exception/IncorrectTokenException.php create mode 100644 src/Exception/MalformedExpressionException.php create mode 100644 src/Exception/OperationException.php create mode 100644 src/FormulaExecutor.php create mode 100644 tests/Feature/CalculateExpressionTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/InfixToPostfixTest.php create mode 100644 tests/Unit/StackTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..1aa73cb --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,44 @@ +name: PHP Composer + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: | + composer install --no-interaction + composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest + + - name: Execute tests + run: vendor/bin/pest --coverage + + # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" + # Docs: https://getcomposer.org/doc/articles/scripts.md + + # - name: Run test suite + # run: composer run-script test diff --git a/.gitignore b/.gitignore index a67d42b..226b1b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ composer.phar +composer.lock /vendor/ +.idea +.phpunit.result.cache # Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file diff --git a/README.md b/README.md index 477fcd8..5ee9361 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,26 @@ # formula-executor -Formula converter in text format for mathematical operation result +Simple math expression calculator + +## Install: +``` +$ composer require nxp/math-executor +``` + +## Support: +* Multiplication +* Division +* Addition +* Subtraction +* Exponentiation +* Parentheses + +## Basic usage: +```php +use Andersonrezende\FormulaExecutor\FormulaExecutor; + +$formula = '(a * (b + c) / d - e)'; +$values = array('a' => 5, 'b' => 3, 'c' => 2, 'd' => 4, 'e' => 6); +$formulaExecutor = new FormulaExecutor($formula, $values); +$resultFormula = $formulaExecutor->execute(); +``` + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..aac3abf --- /dev/null +++ b/composer.json @@ -0,0 +1,45 @@ +{ + "name": "andersonrezende/formula-executor", + "description": "Formula converter in text format for mathematical operation result", + "type": "library", + "license": "MIT", + "autoload": { + "psr-4": { + "Andersonrezende\\FormulaExecutor\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Andersonrezende\\Tests\\": "tests/" + } + }, + "authors": [ + { + "name": "Anderson Rezende", + "email": "andersonrezende17@hotmail.com", + "role": "Developer", + "homepage": "https://github.com/AndersonRezende/" + } + ], + "minimum-stability": "stable", + "keywords": [ + "infix", + "postfix", + "expression", + "formula", + "converter", + "calculator", + "math" + ], + "require": { + "php": ">=7.0" + }, + "require-dev": { + "pestphp/pest": "^2.8" + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..ada83de --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + ./tests + + + + + ./app + ./src + + + diff --git a/src/Classes/InfixToPostfix.php b/src/Classes/InfixToPostfix.php new file mode 100644 index 0000000..452a101 --- /dev/null +++ b/src/Classes/InfixToPostfix.php @@ -0,0 +1,198 @@ +infix = $this->removeWhitespace($expression); + $this->postfix = ''; + $this->tokenize(); + $this->validateTokenizedExpression(); + } + + + /** + * @return void + */ + public function convert(): void + { + $stack = new Stack(); + foreach ($this->tokenizedInfix as $token) { + switch ($token) { + case '+': + case '-': + case '*': + case '/': + case '^': + while (!$stack->isEmpty() && $this->priority($token) <= $this->priority($stack->peek())) { + $this->postfix .= $stack->peek(); + $this->tokenizedPostfix[] = $stack->peek(); + $stack->pop(); + } + $stack->push($token); + break; + case '(': + $stack->push($token); + break; + case ')': + while ($stack->peek() != '(') { + $this->postfix .= $stack->peek(); + $this->tokenizedPostfix[] = $stack->peek(); + $stack->pop(); + } + if ($stack->peek() == '(') { + $stack->pop(); + } + break; + default: + $this->postfix .= $token; + $this->tokenizedPostfix[] = $token; + break; + } + } + while (!$stack->isEmpty()) { + if ($stack->peek() != '(') { + $this->postfix .= $stack->peek(); + $this->tokenizedPostfix[] = $stack->peek(); + } + $stack->pop(); + } + } + + + /** + * @return void + */ + private function tokenize(): void + { + $token = ''; + for ($char = 0; $char < strlen($this->infix); $char++) { + $token .= $this->infix[$char]; + if (($char + 1) < strlen($this->infix)) { + if ($this->isOperatorOrParenthesis($this->infix[$char]) + || $this->isOperatorOrParenthesis($this->infix[$char + 1])) { + $this->tokenizedInfix[] = $token; + $token = ''; + } + } else { + $this->tokenizedInfix[] = $token; + $token = ''; + } + } + } + + /** + * @throws MalformedExpressionException + */ + private function validateTokenizedExpression(): void + { + $tokenizedInfixSize = count($this->tokenizedInfix); + if ($tokenizedInfixSize < 3) { + throw new MalformedExpressionException( + "The expression does not contain a valid minimum number of operands and operators."); + } else { + if($this->isOperator($this->tokenizedInfix[0]) + || $this->isOperator($this->tokenizedInfix[$tokenizedInfixSize - 1]) + || $this->tokenizedInfix[0] == ')' + || $this->tokenizedInfix[$tokenizedInfixSize - 1] == '(') { + throw new MalformedExpressionException( + "Expression does not start or end with a valid symbol."); + } else { + for ($index = 0; $index < $tokenizedInfixSize - 1; $index++) { + if (!$this->isValidOrder($this->tokenizedInfix[$index], $this->tokenizedInfix[$index + 1])) { + $value1 = $this->tokenizedInfix[$index]; + $value2 = $this->tokenizedInfix[$index + 1]; + + throw new MalformedExpressionException( + "The following part of the given expression is incorrect: $value1$value2 ."); + } + } + } + } + } + + public function getStringPostfix(): string + { + return $this->postfix; + } + + public function getTokenizedPostfix(): array + { + return $this->tokenizedPostfix; + } + + + /** + * @param $element + * @return int + */ + private function priority($element): int + { + $priority = 0; + switch ($element) { + case '+': + case '-': + $priority = 1; + break; + case '*': + case '/': + $priority = 2; + break; + case '^': + $priority = 3; + break; + } + return $priority; + } + + + /** + * @param $expression + * @return string + */ + private function removeWhitespace($expression): string + { + return str_replace(' ', '', $expression); + } + + /** + * @param $value + * @return bool + */ + private function isOperatorOrParenthesis($value): bool + { + return in_array($value, $this->operatorsAndParentheses); + } + + private function isOperator($value) + { + return in_array($value, $this->operators); + } + + private function isValidOrder($value1, $value2): bool + { + if ($value1 == '(' && $this->isOperator($value2) + || ($this->isOperator($value1) && $this->isOperator($value2)) + || ($this->isOperator($value1) && $value2 == ')') + || (in_array($value1, $this->parentheses) && in_array($value2, $this->parentheses))) { + return false; + + } + return true; + } +} \ No newline at end of file diff --git a/src/Classes/Node.php b/src/Classes/Node.php new file mode 100644 index 0000000..77c544d --- /dev/null +++ b/src/Classes/Node.php @@ -0,0 +1,19 @@ +element = $element; + $this->next = $next; + } +} \ No newline at end of file diff --git a/src/Classes/Stack.php b/src/Classes/Stack.php new file mode 100644 index 0000000..4881ed6 --- /dev/null +++ b/src/Classes/Stack.php @@ -0,0 +1,41 @@ +top = null; + $this->size = 0; + } + + public function push($element) + { + $this->top = new Node($element, $this->top); + $this->size++; + } + + public function pop() + { + if(!$this->isEmpty()) { + $temp = $this->top; + $this->top = $temp->next; + $this->size--; + } + } + + public function peek() + { + return !is_null($this->top->element) ? $this->top->element : null; + } + + public function isEmpty() + { + return ($this->size <= 0 || is_null($this->peek())); + } + +} \ No newline at end of file diff --git a/src/Exception/IncorrectTokenException.php b/src/Exception/IncorrectTokenException.php new file mode 100644 index 0000000..bdfc93d --- /dev/null +++ b/src/Exception/IncorrectTokenException.php @@ -0,0 +1,11 @@ +expression = $expression; + $this->values = $values; + $itp = new InfixToPostfix($this->expression); + $itp->convert(); + $tokenizedExpression = $itp->getTokenizedPostfix(); + $tokenizedExpression = $this->replaceTokensToValues($tokenizedExpression); + + $result = 0; + $stack = new Stack(); + foreach ($tokenizedExpression as $token) { + if(in_array($token, $this->operands)) { + $operand = $token; + $token2 = $stack->peek(); + $stack->pop(); + $token1 = $stack->peek(); + $stack->pop(); + $result = $this->calculate($token1, $token2, $operand); + $stack->push($result); + } else { + $stack->push($token); + } + } + return $result; + + } + + /** + * @param $token1 + * @param $token2 + * @param $operand + * @return float|int|string + * @throws OperationException + */ + private function calculate($token1, $token2, $operand): float|int|string + { + if (is_numeric($token1) && is_numeric($token2)) { + $result = 0; + switch ($operand) { + case '+': + $result = $token1 + $token2; + break; + case '-': + $result = $token1 - $token2; + break; + case '*': + $result = $token1 * $token2; + break; + case '/': + $result = $token1 / $token2; + break; + case '^': + $result = $token1 ** $token2; + break; + } + } else { + throw new OperationException("The operation must be between two numerical values. + The reported expression was: $token1 $operand $token2"); + } + return $result; + } + + /** + * @param $tokenizedExpression + * @return mixed + * @throws IncorrectTokenException + */ + private function replaceTokensToValues($tokenizedExpression): array + { + foreach ($tokenizedExpression as $key => $value) { + if (!in_array($value, $this->operands) && !is_numeric($value)) { + if(array_key_exists($value, $this->values)) { + $tokenizedExpression[$key] = $this->values[$value]; + } else { + throw new IncorrectTokenException("no value found to replace token $value in expression."); + } + } + } + return $tokenizedExpression; + } +} \ No newline at end of file diff --git a/tests/Feature/CalculateExpressionTest.php b/tests/Feature/CalculateExpressionTest.php new file mode 100644 index 0000000..1eee3e9 --- /dev/null +++ b/tests/Feature/CalculateExpressionTest.php @@ -0,0 +1,66 @@ + 5, 'b' => 3, 'c' => 2, 'd' => 4, 'e' => 6); + $formulaExecutor = new FormulaExecutor(); + $resultFormula = $formulaExecutor->execute($formula, $values); + expect($resultFormula)->toBe(0.25); + } + + public function testCalculateExpressionWithConstantsAndVariables() + { + $formula = 'm * 9.8'; + $values = array('m' => 10); + $formulaExecutor = new FormulaExecutor(); + $resultFormula = $formulaExecutor->execute($formula, $values); + expect($resultFormula)->toBe(98.0); + } + + public function testCalculateExpressionFull() + { + $formula = '(a * (b + c) / d - e ^ f - g)'; + $values = array('a' => 5, 'b' => 3, 'c' => 2, 'd' => 4, 'e' => 6, 'f' => 1, 'g' => 0); + $formulaExecutor = new FormulaExecutor(); + $resultFormula = $formulaExecutor->execute($formula, $values); + expect($resultFormula)->toBe(0.25); + } + + public function testWrongTokens() + { + $this->expectException(OperationException::class); + $formula = 'a + b * c'; + $values = array('a' => 'a', 'b' => 2, 'c' => 3); + $formulaExecutor = new FormulaExecutor(); + $formulaExecutor->execute($formula, $values); + } + + public function testTokenNotFound() + { + $this->expectException(IncorrectTokenException::class); + $formula = 'pi * r ^ 2'; + $values = array('r' => 10); + $formulaExecutor = new FormulaExecutor(); + $formulaExecutor->execute($formula, $values); + } + + public function testExponentiation() + { + $formula = '25^(1/2)'; + $values = array(); + $formulaExecutor = new FormulaExecutor(); + $resultFormula = (int) $formulaExecutor->execute($formula, $values); + expect($resultFormula)->toBe(5); + } + +} \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..cfb05b6 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +convert(); + expect($itp->getTokenizedPostfix())->toMatchArray([ + "a", "b", "c", "+", "*", "d", "g", "-", "*", "h", "*" + ]); + } + + public function testConvertInfixToPostfixTokenized() + { + $infix = '(a + b) ^ b * d'; + $itp = new InfixToPostfix($infix); + $itp->convert(); + expect($itp->getStringPostfix())->toBe('ab+b^d*'); + } + + public function testInvalidInfixExpression(): void + { + $this->expectException(MalformedExpressionException::class); + $infix = 'a * (b + c) * (d - g + ) * h'; + $itp = new InfixToPostfix($infix); + $itp->convert(); + } + + public function testStartsWithInvalidSymbol(): void + { + $this->expectException(MalformedExpressionException::class); + $infix = ')a * (b + c) * (d - g + ) * h'; + $itp = new InfixToPostfix($infix); + $itp->convert(); + } + + public function testEndsWithInvalidSymbol(): void + { + $this->expectException(MalformedExpressionException::class); + $infix = 'a * (b + c) * (d - g + ) * h ('; + $itp = new InfixToPostfix($infix); + $itp->convert(); + } + + public function testInvalidMinimumNumber(): void + { + $this->expectException(MalformedExpressionException::class); + $infix = 'a *'; + $itp = new InfixToPostfix($infix); + $itp->convert(); + } + + +} \ No newline at end of file diff --git a/tests/Unit/StackTest.php b/tests/Unit/StackTest.php new file mode 100644 index 0000000..2099a58 --- /dev/null +++ b/tests/Unit/StackTest.php @@ -0,0 +1,20 @@ +peek())->toBeNull(); + $stack->push(0); + expect($stack->peek())->toBeInt(0); + $stack->push(0.0); + expect($stack->peek())->toBeFloat(0.0); + } + +} \ No newline at end of file