diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2db0a46..061ce80 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,15 +8,15 @@ jobs: strategy: fail-fast: false matrix: - php: [8.2, 8.1, 8.0] - dependency-version: [prefer-lowest, prefer-stable] + php: [8.5, 8.4, 8.3, 8.2, 8.1] + dependency-version: [prefer-stable] os: [ubuntu-latest, windows-latest] name: ${{ matrix.os }} - PHP${{ matrix.php }} - ${{ matrix.dependency-version }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -29,7 +29,7 @@ jobs: - name: Install dependencies run: | composer install --no-interaction - composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest + composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction - name: Execute tests run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index 9aedff4..1054073 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ vendor/ .idea/ composer.lock +.phpunit.cache .phpunit.result.cache .vscode .php-cs-fixer.cache diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 534bd1d..baf983a 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -1,6 +1,7 @@ setUnsupportedPhpVersionAllowed(true); $config ->setRiskyAllowed(true) @@ -41,7 +42,7 @@ // Comments with annotation should be docblock when used on structural elements. 'comment_to_phpdoc' => true, // Remove extra spaces in a nullable typehint. - 'compact_nullable_typehint' => true, + 'compact_nullable_type_declaration' => true, // Concatenation should be spaced according configuration. 'concat_space' => ['spacing'=>'one'], // The PHP constants `true`, `false`, and `null` MUST be written using the correct casing. @@ -69,7 +70,7 @@ // Replace core functions calls returning constants with the constants. 'function_to_constant' => true, // Ensure single space between function's argument and its typehint. - 'function_typehint_space' => true, + 'type_declaration_spaces' => true, // Renames PHPDoc tags. 'general_phpdoc_tag_rename' => true, // Function `implode` must be called with 2 arguments in the documented order. @@ -111,9 +112,9 @@ // Add leading `\` before function invocation to speed up resolving. 'native_function_invocation' => ['include'=>['@all','trans']], // Native type hints for functions should use the correct case. - 'native_function_type_declaration_casing' => true, - // All instances created with new keyword must be followed by braces. - 'new_with_braces' => true, + 'native_type_declaration_casing' => true, + // All instances created with new keyword must be followed by parentheses. + 'new_with_parentheses' => true, // Master functions shall be used instead of aliases. 'no_alias_functions' => true, // Master language constructs shall be used instead of aliases. @@ -148,8 +149,6 @@ 'no_short_bool_cast' => true, // When making a method or function call, there MUST NOT be a space between the method or function name and the opening parenthesis. 'no_spaces_after_function_name' => true, - // There MUST NOT be a space after the opening parenthesis. There MUST NOT be a space before the closing parenthesis. - 'no_spaces_inside_parenthesis' => true, // Removes `@param`, `@return` and `@var` tags that don't provide any useful information. 'no_superfluous_phpdoc_tags' => true, // Remove trailing whitespace at the end of non-blank lines. @@ -158,8 +157,8 @@ 'no_trailing_whitespace_in_comment' => true, // Removes unneeded parentheses around control statements. 'no_unneeded_control_parentheses' => true, - // Removes unneeded curly braces that are superfluous and aren't part of a control structure's body. - 'no_unneeded_curly_braces' => true, + // Removes unneeded braces that are superfluous and aren't part of a control structure's body. + 'no_unneeded_braces' => true, // A `final` class must not have `final` methods and `private` methods must not be `final`. 'no_unneeded_final_method' => true, // In function arguments there must not be arguments with default values before non-default ones. @@ -229,7 +228,7 @@ // A PHP file without end tag must always end with a single empty line feed. 'single_blank_line_at_eof' => true, // There should be exactly one blank line before a namespace declaration. - 'single_blank_line_before_namespace' => true, + 'blank_lines_before_namespace' => ['max_line_breaks' => 2, 'min_line_breaks' => 2], // There MUST NOT be more than one property or constant declared per statement. 'single_class_element_per_statement' => true, // There MUST be one use keyword per declaration. @@ -242,6 +241,8 @@ 'single_quote' => true, // Each trait `use` must be done as single statement. 'single_trait_insert_per_statement' => true, + // There MUST NOT be a space after the opening parenthesis. There MUST NOT be a space before the closing parenthesis. + 'spaces_inside_parentheses' => false, // Replace all `<>` with `!=`. 'standardize_not_equals' => true, // Lambdas not (indirect) referencing `$this` must be declared `static`. @@ -264,8 +265,7 @@ 'trim_array_spaces' => true, // Unary operators should be placed adjacent to their operands. 'unary_operator_spaces' => true, - // Visibility MUST be declared on all properties and methods; `abstract` and `final` MUST be declared before the visibility; `static` MUST be declared after the visibility. - 'visibility_required' => true, + 'modifier_keywords' => ['elements' => ['const', 'method', 'property']], // Add `void` return type to functions with missing or empty return statements, but priority is given to `@return` annotations. Requires PHP >= 7.1. 'void_return' => true, // In array declaration, there MUST be a whitespace after each comma. diff --git a/README.md b/README.md index ea390aa..ea2e46d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## Features: * Built in support for +, -, *, /, % and power (^) operators -* Paratheses () and arrays [] are fully supported +* Parentheses () and arrays [] are fully supported * Logical operators (==, !=, <, <, >=, <=, &&, ||, !) * Built in support for most PHP math functions * Support for BCMath Arbitrary Precision Math @@ -16,7 +16,8 @@ * Dynamic variable resolution (delayed computation) * Unlimited variable name lengths * String support, as function parameters or as evaluated as a number by PHP -* Exceptions on divide by zero, or treat as zero +* Exceptions on divide or modulo by zero, or treat as zero +* Custom handling of non-numeric values reaching an arithmetic operator * Unary Plus and Minus (e.g. +3 or -sin(12)) * Pi ($pi) and Euler's number ($e) support to 11 decimal places * Easily extensible @@ -40,21 +41,35 @@ Default functions: * abs * acos (arccos) * acosh +* arccos +* arccosec +* arccot +* arccotan +* arccsc (arccosec) * arcctg (arccot, arccotan) * arcsec -* arccsc (arccosec) +* arcsin +* arctan +* arctg * array * asin (arcsin) * atan (atn, arctan, arctg) * atan2 * atanh +* atn * avg * bindec * ceil * cos +* cosec * cosec (csc) * cosh +* cot +* cotan +* cotg +* csc * ctg (cot, cotan, cotg, ctn) +* ctn * decbin * dechex * decoct @@ -67,6 +82,8 @@ Default functions: * hypot * if * intdiv +* lg +* ln * log (ln) * log10 (lg) * log1p @@ -84,6 +101,8 @@ Default functions: * sqrt * tan (tn, tg) * tanh +* tg +* tn Add custom function to executor: ```php @@ -92,20 +111,20 @@ $executor->addFunction('concat', function($arg1, $arg2) {return $arg1 . $arg2;}) Optional parameters: ```php $executor->addFunction('round', function($num, int $precision = 0) {return round($num, $precision);}); -$executor->calculate('round(17.119)'); // 17 -$executor->calculate('round(17.119, 2)'); // 17.12 +$executor->execute('round(17.119)'); // 17 +$executor->execute('round(17.119, 2)'); // 17.12 ``` Variable number of parameters: ```php -$executor->addFunction('avarage', function(...$args) {return array_sum($args) / count($args);}); -$executor->calculate('avarage(1,3)'); // 2 -$executor->calculate('avarage(1, 3, 4, 8)'); // 4 +$executor->addFunction('average', function(...$args) {return array_sum($args) / count($args);}); +$executor->execute('average(1,3)'); // 2 +$executor->execute('average(1, 3, 4, 8)'); // 4 ``` ## Operators: Default operators: `+ - * / % ^` -Add custom operator to executor: +Add custom float modulo operator to executor: ```php use NXP\Classes\Operator; @@ -114,13 +133,9 @@ $executor->addOperator(new Operator( '%', // Operator sign false, // Is right associated operator 180, // Operator priority - function (&$stack) + function ($op1, $op2) { - $op2 = array_pop($stack); - $op1 = array_pop($stack); - $result = $op1->getValue() % $op2->getValue(); - - return $result; + return fmod($op1, $op2); } )); ``` @@ -180,8 +195,7 @@ $executor->setVarValidationHandler(function (string $name, $variable) { You can dynamically define variables at run time. If a variable has a high computation cost, but might not be used, then you can define an undefined variable handler. It will only get called when the variable is used, rather than having to always set it initially. ```php -$calculator = new MathExecutor(); -$calculator->setVarNotFoundHandler( +$executor->setVarNotFoundHandler( function ($varName) { if ($varName == 'trans') { return transmogrify(); @@ -196,7 +210,7 @@ By default, `MathExecutor` uses PHP floating point math, but if you need a fixed `WARNING`: Functions may return a PHP floating point number. By doing the basic math functions on the results, you will get back a fixed number of decimal points. Use a plus sign in front of any stand alone function to return the proper number of decimal places. ## Division By Zero Support: -Division by zero throws a `\NXP\Exception\DivisionByZeroException` by default +Division and modulo by zero throw a `\NXP\Exception\DivisionByZeroException` by default ```php try { echo $executor->execute('1/0'); @@ -204,13 +218,13 @@ try { echo $e->getMessage(); } ``` -Or call setDivisionByZeroIsZero +Or call setDivisionByZeroIsZero, which covers both `/` and `%` ```php echo $executor->setDivisionByZeroIsZero()->execute('1/0'); ``` If you want another behavior, you can override division operator: ```php -$executor->addOperator("/", false, 180, function($a, $b) { +$executor->addOperator(new Operator("/", false, 180, function($a, $b) { if ($b == 0) { return null; } @@ -219,6 +233,46 @@ $executor->addOperator("/", false, 180, function($a, $b) { echo $executor->execute('1/0'); ``` +## Non-Numeric Value Support: +Arithmetic and ordering operators expect numbers. When a value that is not a number reaches one of them, it is passed to +PHP as-is, which raises a `\TypeError` for the arithmetic operators (`'N/A' / 2`) and compares as a string for the +ordering ones (`'N/A' > 1` is `true`). Call **setNonNumericHandler()** to decide what such a value means instead: + +```php +$executor->setNonNumericHandler( + function ($value, string $operator) { + // 'N/A' ratings count as zero in every calculation + return 0; + } +); +$executor->setVar('rating', 'N/A'); +echo $executor->execute('rating / 2'); // 0 +``` + +The handler receives the offending value and the name of the operator (`'+'`, `'/'`, `'uNeg'`, ...), so it can react +differently per operator, and whatever it returns is used in place of the original value. Throwing from it turns the +`\TypeError` into an error of your own: + +```php +$executor->setNonNumericHandler( + function ($value, string $operator) { + throw new MathExecutorException("Value ({$value}) is not a number, required by operator ({$operator})"); + } +); +``` + +It is called for the operators that require a number (`+`, `-`, `*`, `/`, `%`, `^`, unary `-` and unary `+`, `>`, `>=`, +`<` and `<=`), including the ones redefined by `setDivisionByZeroIsZero()` and `useBCMath()`. Without a handler nothing +changes, which is the default. + +These are never affected: +* Values that are numeric (`'3'` included), `null`, boolean or array. Arrays are a supported variable type, so + `[1, 2] + [3, 4]` keeps its PHP meaning. +* The operators with defined string or boolean semantics: `==`, `!=`, `&&`, `||` and `!`. +* An ordering operator comparing two non-numeric values, which stays a string comparison, consistent with `==` and + `!=`. So `'apple' < 'banana'` is still `true`, while `rating > 1` uses the handler, because the other side is a + number and PHP would otherwise compare that number as a string. + ## String Support: Expressions can contain double or single quoted strings that are evaluated the same way as PHP evaluates strings as numbers. You can also pass strings to functions. diff --git a/composer.json b/composer.json index a0dee1f..c04562b 100644 --- a/composer.json +++ b/composer.json @@ -28,12 +28,12 @@ }, "require": { - "php": ">=8.0" + "php": ">=8.0 <8.6" }, "require-dev": { - "phpunit/phpunit": ">=9.0", - "friendsofphp/php-cs-fixer": "^3.8", - "phpstan/phpstan": "^1.9" + "phpunit/phpunit": ">=10.0", + "friendsofphp/php-cs-fixer": "*", + "phpstan/phpstan": "*" }, "autoload": { "psr-4": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f591521..d76da31 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,20 +1,19 @@ - - - - - - ./tests/ - - + + + + ./tests/ + + diff --git a/src/NXP/Classes/Calculator.php b/src/NXP/Classes/Calculator.php index 80dee36..9ab4f31 100644 --- a/src/NXP/Classes/Calculator.php +++ b/src/NXP/Classes/Calculator.php @@ -1,4 +1,5 @@ allowNegative = false; break; + /** @noinspection PhpMissingBreakStatementInspection */ case 'e' === \strtolower($ch): if (\strlen($this->numberBuffer) && \str_contains($this->numberBuffer, '.')) { @@ -141,6 +143,7 @@ public function tokenize() : self break; } + // no break // Intentionally fall through case $this->isAlpha($ch): @@ -207,6 +210,7 @@ public function tokenize() : self continue 2; } + // could be in exponent, in which case negative should be added to the numberBuffer if ($this->numberBuffer && 'e' == $this->numberBuffer[\strlen($this->numberBuffer) - 1]) { $this->numberBuffer .= $ch; diff --git a/src/NXP/MathExecutor.php b/src/NXP/MathExecutor.php index 84dd3d9..20c605b 100644 --- a/src/NXP/MathExecutor.php +++ b/src/NXP/MathExecutor.php @@ -1,4 +1,5 @@ , >=, < and <=. The operators with defined + * string or boolean semantics (==, !=, &&, || and !) are never affected, and neither is the comparison of two + * non-numeric values by an ordering operator, which stays a string comparison. + * + * Set to null (the default) to keep the standard behavior, where the value is handed to the operator untouched. + * + * @param ?callable $handler callable(mixed $value, string $operator): mixed + * + */ + public function setNonNumericHandler(?callable $handler) : self + { + $this->onNonNumeric = $handler; + + return $this; + } + /** * Remove variable from executor * @@ -281,11 +309,22 @@ public function removeOperator(string $operator) : self } /** - * Set division by zero returns zero instead of throwing DivisionByZeroException + * Set division and modulo by zero to return zero instead of throwing DivisionByZeroException */ public function setDivisionByZeroIsZero() : self { - $this->addOperator(new Operator('/', false, 180, static fn($a, $b) => 0 == $b ? 0 : $a / $b)); + $this->addOperator(new Operator('/', false, 180, function($a, $b) { + $a = $this->normalizeOperand($a, '/'); + $b = $this->normalizeOperand($b, '/'); + + return 0 == $b ? 0 : $a / $b; + })); + $this->addOperator(new Operator('%', false, 180, function($a, $b) { + $a = $this->normalizeOperand($a, '%'); + $b = $this->normalizeOperand($b, '%'); + + return 0 == $b ? 0 : $a % $b; + })); return $this; } @@ -312,20 +351,56 @@ public function clearCache() : self public function useBCMath(int $scale = 2) : self { \bcscale($scale); - $this->addOperator(new Operator('+', false, 170, static fn($a, $b) => \bcadd("{$a}", "{$b}"))); - $this->addOperator(new Operator('-', false, 170, static fn($a, $b) => \bcsub("{$a}", "{$b}"))); - $this->addOperator(new Operator('uNeg', false, 200, static fn($a) => \bcsub('0.0', "{$a}"))); - $this->addOperator(new Operator('*', false, 180, static fn($a, $b) => \bcmul("{$a}", "{$b}"))); - $this->addOperator(new Operator('/', false, 180, static function($a, $b) { + $this->addOperator(new Operator('+', false, 170, function($a, $b) { + $a = $this->normalizeOperand($a, '+'); + $b = $this->normalizeOperand($b, '+'); + + return \bcadd("{$a}", "{$b}"); + })); + $this->addOperator(new Operator('-', false, 170, function($a, $b) { + $a = $this->normalizeOperand($a, '-'); + $b = $this->normalizeOperand($b, '-'); + + return \bcsub("{$a}", "{$b}"); + })); + $this->addOperator(new Operator('uNeg', false, 200, function($a) { + $a = $this->normalizeOperand($a, 'uNeg'); + + return \bcsub('0.0', "{$a}"); + })); + $this->addOperator(new Operator('*', false, 180, function($a, $b) { + $a = $this->normalizeOperand($a, '*'); + $b = $this->normalizeOperand($b, '*'); + + return \bcmul("{$a}", "{$b}"); + })); + $this->addOperator(new Operator('/', false, 180, function($a, $b) { /** @todo PHP8: Use throw as expression -> static fn($a, $b) => 0 == $b ? throw new DivisionByZeroException() : $a / $b */ + $a = $this->normalizeOperand($a, '/'); + $b = $this->normalizeOperand($b, '/'); + if (0 == $b) { throw new DivisionByZeroException(); } return \bcdiv("{$a}", "{$b}"); })); - $this->addOperator(new Operator('^', true, 220, static fn($a, $b) => \bcpow("{$a}", "{$b}"))); - $this->addOperator(new Operator('%', false, 180, static fn($a, $b) => \bcmod("{$a}", "{$b}"))); + $this->addOperator(new Operator('^', true, 220, function($a, $b) { + $a = $this->normalizeOperand($a, '^'); + $b = $this->normalizeOperand($b, '^'); + + return \bcpow("{$a}", "{$b}"); + })); + $this->addOperator(new Operator('%', false, 180, function($a, $b) { + $a = $this->normalizeOperand($a, '%'); + $b = $this->normalizeOperand($b, '%'); + + if (0 == $b) { + throw new DivisionByZeroException(); + } + + return \bcmod("{$a}", "{$b}"); + })); return $this; } @@ -359,16 +434,19 @@ protected function addDefaults() : self protected function defaultOperators() : array { return [ - '+' => [static fn($a, $b) => $a + $b, 170, false], - '-' => [static fn($a, $b) => $a - $b, 170, false], + '+' => [fn($a, $b) => $this->normalizeOperand($a, '+') + $this->normalizeOperand($b, '+'), 170, false], + '-' => [fn($a, $b) => $this->normalizeOperand($a, '-') - $this->normalizeOperand($b, '-'), 170, false], // unary positive token - 'uPos' => [static fn($a) => $a, 200, false], + 'uPos' => [fn($a) => $this->normalizeOperand($a, 'uPos'), 200, false], // unary minus token - 'uNeg' => [static fn($a) => 0 - $a, 200, false], - '*' => [static fn($a, $b) => $a * $b, 180, false], + 'uNeg' => [fn($a) => 0 - $this->normalizeOperand($a, 'uNeg'), 200, false], + '*' => [fn($a, $b) => $this->normalizeOperand($a, '*') * $this->normalizeOperand($b, '*'), 180, false], '/' => [ - static function($a, $b) { + function($a, $b) { /** @todo PHP8: Use throw as expression -> static fn($a, $b) => 0 == $b ? throw new DivisionByZeroException() : $a / $b */ + $a = $this->normalizeOperand($a, '/'); + $b = $this->normalizeOperand($b, '/'); + if (0 == $b) { throw new DivisionByZeroException(); } @@ -378,16 +456,61 @@ static function($a, $b) { 180, false ], - '^' => [static fn($a, $b) => $a ** $b, 220, true], - '%' => [static fn($a, $b) => $a % $b, 180, false], + '^' => [fn($a, $b) => $this->normalizeOperand($a, '^') ** $this->normalizeOperand($b, '^'), 220, true], + '%' => [ + function($a, $b) { + $a = $this->normalizeOperand($a, '%'); + $b = $this->normalizeOperand($b, '%'); + + if (0 == $b) { + throw new DivisionByZeroException(); + } + + return $a % $b; + }, + 180, + false + ], '&&' => [static fn($a, $b) => $a && $b, 100, false], '||' => [static fn($a, $b) => $a || $b, 90, false], - '==' => [static fn($a, $b) => \is_string($a) || \is_string($b) ? 0 == \strcmp($a, $b) : $a == $b, 140, false], - '!=' => [static fn($a, $b) => \is_string($a) || \is_string($b) ? 0 != \strcmp($a, $b) : $a != $b, 140, false], - '>=' => [static fn($a, $b) => $a >= $b, 150, false], - '>' => [static fn($a, $b) => $a > $b, 150, false], - '<=' => [static fn($a, $b) => $a <= $b, 150, false], - '<' => [static fn($a, $b) => $a < $b, 150, false], + '==' => [static fn($a, $b) => \is_string($a) || \is_string($b) ? 0 == \strcmp((string)$a, (string)$b) : $a == $b, 140, false], + '!=' => [static fn($a, $b) => \is_string($a) || \is_string($b) ? 0 != \strcmp((string)$a, (string)$b) : $a != $b, 140, false], + '>=' => [ + function($a, $b) { + [$a, $b] = $this->normalizeComparisonOperands($a, $b, '>='); + + return $a >= $b; + }, + 150, + false + ], + '>' => [ + function($a, $b) { + [$a, $b] = $this->normalizeComparisonOperands($a, $b, '>'); + + return $a > $b; + }, + 150, + false + ], + '<=' => [ + function($a, $b) { + [$a, $b] = $this->normalizeComparisonOperands($a, $b, '<='); + + return $a <= $b; + }, + 150, + false + ], + '<' => [ + function($a, $b) { + [$a, $b] = $this->normalizeComparisonOperands($a, $b, '<'); + + return $a < $b; + }, + 150, + false + ], '!' => [static fn($a) => ! $a, 190, false], ]; } @@ -468,9 +591,9 @@ protected function defaultFunctions() : array return $this->execute($falseval); }, 'intdiv' => static fn($arg1, $arg2) => \intdiv($arg1, $arg2), - 'ln' => static fn($arg) => \log($arg), + 'ln' => static fn($arg1, $arg2 = M_E) => \log($arg1, $arg2), 'lg' => static fn($arg) => \log10($arg), - 'log' => static fn($arg) => \log($arg), + 'log' => static fn($arg1, $arg2 = M_E) => \log($arg1, $arg2), 'log10' => static fn($arg) => \log10($arg), 'log1p' => static fn($arg) => \log1p($arg), 'max' => static function($arg1, ...$args) { @@ -493,7 +616,7 @@ protected function defaultFunctions() : array $count = \count($finalArgs); \sort($finalArgs); - $index = \floor($count / 2); + $index = (int)\floor($count / 2); return ($count & 1) ? $finalArgs[$index] : ($finalArgs[$index - 1] + $finalArgs[$index]) / 2; }, @@ -533,6 +656,39 @@ protected function defaultVars() : array ]; } + /** + * Hands a value that is about to be used by an operator requiring a number to the non-numeric handler, when one + * has been set with setNonNumericHandler and the value is neither numeric, null, boolean nor array. + * + * @return mixed the value returned by the handler, or the original value when no handler applies + */ + protected function normalizeOperand(mixed $value, string $operator) : mixed + { + if (null === $this->onNonNumeric || null === $value || \is_bool($value) || \is_numeric($value) || \is_array($value)) { + return $value; + } + + return \call_user_func($this->onNonNumeric, $value, $operator); + } + + /** + * Applies the non-numeric handler to the operands of an ordering operator. + * + * Comparing two values that are both non-numeric is a string comparison, which is meaningful and consistent + * with == and !=, so it is left untouched. The handler is only applied when the other side is a number, the + * case where PHP would otherwise compare that number as a string. + * + * @return array{mixed, mixed} the operands to compare + */ + protected function normalizeComparisonOperands(mixed $a, mixed $b, string $operator) : array + { + if (null === $this->onNonNumeric || (! \is_numeric($a) && ! \is_numeric($b))) { + return [$a, $b]; + } + + return [$this->normalizeOperand($a, $operator), $this->normalizeOperand($b, $operator)]; + } + /** * Default variable validation, ensures that the value is a scalar or array. * @throws MathExecutorException if the value is not a scalar diff --git a/tests/MathTest.php b/tests/MathTest.php index f8573d3..ecfc6c0 100644 --- a/tests/MathTest.php +++ b/tests/MathTest.php @@ -23,9 +23,7 @@ class MathTest extends TestCase { - /** - * @dataProvider providerExpressions - */ + #[\PHPUnit\Framework\Attributes\DataProvider('providerExpressions')] public function testCalculating(string $expression) : void { $calculator = new MathExecutor(); @@ -51,7 +49,7 @@ public function testCalculating(string $expression) : void * * @return array> */ - public function providerExpressions() + public static function providerExpressions() { return [ ['-5'], @@ -97,6 +95,7 @@ public function providerExpressions() ['hypot(1.5, 3.5)'], ['intdiv(10, 2)'], ['log(1.5)'], + ['log(1.5, 3)'], ['log10(1.5)'], ['log1p(1.5)'], ['max(1.5, 3.5)'], @@ -256,14 +255,19 @@ public function providerExpressions() ['7 % 4'], ['99 % 4'], ['123 % 7'], + ['!(1||0)'], ['!(1&&0)'], + ['!(1)'], + ['!(0)'], + ['! 1'], + ['! 0'], + ['!1'], + ['!0'], ]; } - /** - * @dataProvider bcMathExpressions - */ + #[\PHPUnit\Framework\Attributes\DataProvider('bcMathExpressions')] public function testBCMathCalculating(string $expression, string $expected = '') : void { $calculator = new MathExecutor(); @@ -295,7 +299,7 @@ public function testBCMathCalculating(string $expression, string $expected = '') * * @return array> */ - public function bcMathExpressions() + public static function bcMathExpressions() { return [ ['-5'], @@ -495,14 +499,19 @@ public function bcMathExpressions() ['7 % 4'], ['99 % 4'], ['123 % 7'], + ['!(1||0)'], ['!(1&&0)'], + ['!(1)'], + ['!(0)'], + ['! 1'], + ['! 0'], + ['!1'], + ['!0'], ]; } - /** - * @dataProvider incorrectExpressions - */ + #[\PHPUnit\Framework\Attributes\DataProvider('incorrectExpressions')] public function testIncorrectExpressionException(string $expression) : void { $calculator = new MathExecutor(); @@ -518,7 +527,7 @@ public function testIncorrectExpressionException(string $expression) : void * * @return array> */ - public function incorrectExpressions() + public static function incorrectExpressions() { return [ ['1 * + '], @@ -550,6 +559,22 @@ public function testZeroDivision() : void $calculator = new MathExecutor(); $calculator->setDivisionByZeroIsZero(); $this->assertEquals(0, $calculator->execute('10 / 0')); + $this->assertEquals(0, $calculator->execute('10 % 0')); + } + + public function testZeroModuloException() : void + { + $calculator = new MathExecutor(); + $this->expectException(DivisionByZeroException::class); + $calculator->execute('10 % 0'); + } + + public function testZeroModuloExceptionWithBCMath() : void + { + $calculator = new MathExecutor(); + $calculator->useBCMath(2); + $this->expectException(DivisionByZeroException::class); + $calculator->execute('10 % 0'); } public function testUnaryOperators() : void @@ -582,6 +607,21 @@ public function testVariableIncorrectExpressionException() : void $this->assertEquals(0.0, $calculator->execute('$ + $four')); } + public function testNotVariableOperator() : void + { + $calculator = new MathExecutor(); + $calculator->setVar('one', 1); + $calculator->setVar('zero', 0); + $this->assertEquals(false, $calculator->execute('! $one')); + $this->assertEquals(false, $calculator->execute('!$one')); + $this->assertEquals(false, $calculator->execute('! ($one)')); + $this->assertEquals(false, $calculator->execute('!($one)')); + $this->assertEquals(true, $calculator->execute('! $zero')); + $this->assertEquals(true, $calculator->execute('!$zero')); + $this->assertEquals(true, $calculator->execute('! ($zero)')); + $this->assertEquals(true, $calculator->execute('!($zero)')); + } + public function testExponentiation() : void { $calculator = new MathExecutor(); @@ -946,7 +986,7 @@ public function testNullReturnType() : void public function testGetFunctionsReturnsArray() : void { $calculator = new MathExecutor(); - $this->assertIsArray($calculator->getFunctions()); + $this->assertIsArray($calculator->getFunctions()); // @phpstan-ignore-line } public function testGetFunctionsReturnsFunctions() : void @@ -958,7 +998,7 @@ public function testGetFunctionsReturnsFunctions() : void public function testGetVarsReturnsArray() : void { $calculator = new MathExecutor(); - $this->assertIsArray($calculator->getVars()); + $this->assertIsArray($calculator->getVars()); // @phpstan-ignore-line } public function testGetVarsReturnsCount() : void @@ -1018,6 +1058,7 @@ public function testSetCustomVarValidator() : void if (\is_scalar($variable) || null === $variable) { return; } + // Allow variables of type DateTime, but not others if (! $variable instanceof \DateTime) { throw new MathExecutorException('Invalid variable type'); @@ -1067,9 +1108,7 @@ public function testVarExists() : void $this->assertFalse($calculator->varExists('Lucy')); } - /** - * @dataProvider providerExpressionValues - */ + #[\PHPUnit\Framework\Attributes\DataProvider('providerExpressionValues')] public function testCalculatingValues(string $expression, mixed $value) : void { $calculator = new MathExecutor(); @@ -1091,7 +1130,7 @@ public function testCalculatingValues(string $expression, mixed $value) : void * * @return array> */ - public function providerExpressionValues() + public static function providerExpressionValues() { return [ ['arccos(0.5)', \acos(0.5)], @@ -1118,6 +1157,7 @@ public function providerExpressionValues() ['decbin(10)', \decbin(10)], ['lg(2)', \log10(2)], ['ln(2)', \log(2)], + ['ln(2, 5)', \log(2, 5)], ['sec(4)', 1 / \cos(4)], ['tg(4)', \tan(4)], ]; @@ -1128,7 +1168,7 @@ public function testCache() : void $calculator = new MathExecutor(); $this->assertEquals(256, $calculator->execute('2 ^ 8')); // second arg $cache is true by default - $this->assertIsArray($calculator->getCache()); + $this->assertIsArray($calculator->getCache()); // @phpstan-ignore-line $this->assertCount(1, $calculator->getCache()); $this->assertEquals(512, $calculator->execute('2 ^ 9', true)); @@ -1147,7 +1187,7 @@ public function testCache() : void public function testUnsupportedOperands() : void { - if (\version_compare(PHP_VERSION, '8') >= 0) { + if (\version_compare(PHP_VERSION, '8.0') >= 0) { /** @phpstan-ignore-line */ $calculator = new MathExecutor(); $calculator->setVar('stringVar', 'string'); @@ -1159,4 +1199,265 @@ public function testUnsupportedOperands() : void $this->expectNotToPerformAssertions(); } } + + /** + * Without a handler the operands reach the operator untouched, exactly as before + */ + #[\PHPUnit\Framework\Attributes\DataProvider('nonNumericExpressions')] + public function testNonNumericWithoutHandler(string $expression) : void + { + $calculator = new MathExecutor(); + $calculator->setVar('rating', 'SC'); + $this->expectException(\TypeError::class); + $calculator->execute($expression); + } + + /** + * Arithmetic expressions on a non-numeric value + * + * @return array> + */ + public static function nonNumericExpressions() : array + { + return [ + ['rating + 1'], + ['1 + rating'], + ['rating - 1'], + ['rating * 2'], + ['rating / 2'], + ['rating % 2'], + ['rating ^ 2'], + ['-rating'], + ]; + } + + public function testNonNumericComparisonWithoutHandler() : void + { + $calculator = new MathExecutor(); + $calculator->setVar('rating', 'SC'); + + // PHP compares a non-numeric string with a number as a string, and so does the library + $this->assertEquals(true, $calculator->execute('rating > 1')); + $this->assertEquals(false, $calculator->execute('rating < 1')); + $this->assertEquals('SC', $calculator->execute('+rating')); + } + + public function testNonNumericHandler() : void + { + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'SC'); + $calculator->setVar('blank', ''); + + $this->assertEquals(0, $calculator->execute('rating / 2')); + $this->assertEquals(1, $calculator->execute('1 + rating')); + $this->assertEquals(-1, $calculator->execute('rating - 1')); + $this->assertEquals(0, $calculator->execute('rating * 2')); + $this->assertEquals(0, $calculator->execute('rating % 2')); + $this->assertEquals(0, $calculator->execute('rating ^ 2')); + $this->assertEquals(0, $calculator->execute('-rating')); + $this->assertEquals(0, $calculator->execute('+rating')); + $this->assertEquals(false, $calculator->execute('rating > 1')); + $this->assertEquals(false, $calculator->execute('rating >= 1')); + $this->assertEquals(true, $calculator->execute('rating < 1')); + $this->assertEquals(true, $calculator->execute('rating <= 1')); + $this->assertEquals(true, $calculator->execute('1 > rating')); + $this->assertEquals(false, $calculator->execute('1 < rating')); + $this->assertEquals(1, $calculator->execute('blank + 1')); + } + + public function testNonNumericHandlerReceivesTheOperator() : void + { + $operators = []; + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static function($value, $operator) use (&$operators) { + $operators[] = $operator; + + return 0; + }); + $calculator->setVar('rating', 'SC'); + + foreach (['rating + 1', 'rating - 1', 'rating * 1', 'rating / 1', 'rating % 1', 'rating ^ 1', '-rating', '+rating', 'rating > 1', 'rating >= 1', 'rating < 1', 'rating <= 1'] as $expression) { + $calculator->execute($expression); + } + + $this->assertEquals(['+', '-', '*', '/', '%', '^', 'uNeg', 'uPos', '>', '>=', '<', '<='], $operators); + } + + public function testNonNumericHandlerCanReturnAnyValue() : void + { + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 'SC' === $value ? 10 : 0); + $calculator->setVar('rating', 'SC'); + $calculator->setVar('other', 'DC'); + + $this->assertEquals(11, $calculator->execute('rating + 1')); + $this->assertEquals(1, $calculator->execute('other + 1')); + } + + public function testNonNumericHandlerException() : void + { + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static function($value, $operator) : void { + throw new MathExecutorException("Value ({$value}) is not a number, required by operator ({$operator})"); + }); + $calculator->setVar('rating', 'SC'); + + $this->expectException(MathExecutorException::class); + $this->expectExceptionMessage('Value (SC) is not a number, required by operator (/)'); + $calculator->execute('rating / 2'); + } + + public function testNonNumericHandlerIgnoresNumbers() : void + { + $calls = 0; + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static function($value, $operator) use (&$calls) { + ++$calls; + + return 0; + }); + $calculator->setVar('nothing', null); + $calculator->setVar('yes', true); + + $this->assertEquals(6, $calculator->execute("'3' * 2")); + $this->assertEquals(5.5, $calculator->execute("3 + '2.5'")); + $this->assertEquals(1, $calculator->execute('nothing + 1')); + $this->assertEquals(2, $calculator->execute('yes + 1')); + $this->assertEquals(0, $calls); + } + + public function testNonNumericHandlerDoesNotAffectStringOperators() : void + { + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'SC'); + + $this->assertEquals(true, $calculator->execute("rating == 'SC'")); + $this->assertEquals(false, $calculator->execute("rating != 'SC'")); + $this->assertEquals(true, $calculator->execute("rating != 'DC'")); + $this->assertEquals(true, $calculator->execute('rating && 1')); + $this->assertEquals(true, $calculator->execute('rating || 0')); + $this->assertEquals(false, $calculator->execute('!rating')); + } + + public function testNonNumericHandlerDoesNotAffectStringOrdering() : void + { + $calls = 0; + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static function($value, $operator) use (&$calls) { + ++$calls; + + return 0; + }); + + // Comparing two non-numeric values stays a string comparison, just like == and != + $this->assertEquals(true, $calculator->execute("'apple' < 'banana'")); + $this->assertEquals(false, $calculator->execute("'apple' > 'banana'")); + $this->assertEquals(true, $calculator->execute("'apple' <= 'banana'")); + $this->assertEquals(false, $calculator->execute("'apple' >= 'banana'")); + $this->assertEquals(0, $calls); + } + + public function testNonNumericHandlerDoesNotAffectArrays() : void + { + $calls = 0; + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static function($value, $operator) use (&$calls) { + ++$calls; + + return 0; + }); + $calculator->setVar('first', [1, 2]); + $calculator->setVar('second', [3, 4, 5]); + + // Arrays are a supported variable type, so they keep reaching the operator untouched + $this->assertEquals([1, 2, 5], $calculator->execute('first + second')); + $this->assertEquals(1.5, $calculator->execute('avg(first)')); + $this->assertEquals(0, $calls); + } + + public function testNonNumericHandlerCanBeRemoved() : void + { + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'SC'); + $this->assertEquals(0, $calculator->execute('rating / 2')); + + $calculator->setNonNumericHandler(null); + $this->expectException(\TypeError::class); + $calculator->execute('rating / 2'); + } + + public function testNonNumericHandlerSurvivesClone() : void + { + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + + $clone = clone $calculator; + $clone->setVar('rating', 'SC'); + + $this->assertEquals(0, $clone->execute('rating / 2')); + } + + public function testNonNumericHandlerWithDivisionByZeroIsZero() : void + { + $calculator = new MathExecutor(); + $calculator->setDivisionByZeroIsZero(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'SC'); + + $this->assertEquals(0, $calculator->execute('rating / 2')); + $this->assertEquals(0, $calculator->execute('2 / rating')); + $this->assertEquals(0, $calculator->execute('10 / 0')); + } + + public function testNonNumericHandlerWithBCMath() : void + { + $calculator = new MathExecutor(); + $calculator->useBCMath(2); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'SC'); + + $this->assertEquals('1.00', $calculator->execute('rating + 1')); + $this->assertEquals('-1.00', $calculator->execute('rating - 1')); + $this->assertEquals('0.00', $calculator->execute('rating * 2')); + $this->assertEquals('0.00', $calculator->execute('-rating')); + $this->assertEquals('0.00', $calculator->execute('rating ^ 2')); + $this->assertEquals('0.00', $calculator->execute('rating % 2')); + $this->assertEquals('0.00', $calculator->execute('rating / 2')); + } + + public function testNonNumericHandlerModuloByNonNumeric() : void + { + $calculator = new MathExecutor(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'SC'); + + // A handler that turns the divisor into zero reaches the library's own exception, as division does + $this->expectException(DivisionByZeroException::class); + $calculator->execute('2 % rating'); + } + + public function testNonNumericHandlerModuloByNonNumericIsZero() : void + { + $calculator = new MathExecutor(); + $calculator->setDivisionByZeroIsZero(); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'SC'); + + $this->assertEquals(0, $calculator->execute('2 % rating')); + $this->assertEquals(0, $calculator->execute('2 / rating')); + } + + public function testNonNumericHandlerWithBCMathDivisionByNonNumeric() : void + { + $calculator = new MathExecutor(); + $calculator->useBCMath(2); + $calculator->setNonNumericHandler(static fn($value, $operator) => 0); + $calculator->setVar('rating', 'SC'); + + $this->expectException(DivisionByZeroException::class); + $calculator->execute('2 / rating'); + } }