diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6537ca4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..be6b455 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf + +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +CHANGELOG.md export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e20a3ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor +/composer.lock +.phpunit.result.cache +.php_cs.cache +.idea +.vscode +.DS_Store diff --git a/.php_cs.dist b/.php_cs.dist new file mode 100644 index 0000000..4bf9708 --- /dev/null +++ b/.php_cs.dist @@ -0,0 +1,261 @@ +setRiskyAllowed(true) + ->setRules([ + '@PSR1' => true, + '@PSR2' => true, + // Each line of multi-line DocComments must have an asterisk [PSR-5] and must be aligned with the first one. + 'align_multiline_comment' => ['comment_type'=>'all_multiline'], + // Each element of an array must be indented exactly once. + 'array_indentation' => true, + // PHP arrays should be declared using the configured syntax. + 'array_syntax' => ['syntax'=>'short'], + // There MUST be one blank line after the namespace declaration. + 'blank_line_after_namespace' => true, + // Ensure there is no code on the same line as the PHP open tag and it is followed by a blank line. + 'blank_line_after_opening_tag' => true, + // An empty line feed must precede any configured statement. + 'blank_line_before_statement' => true, + // The body of each structure MUST be enclosed by braces. Braces should be properly placed. Body of braces should be properly indented. + 'braces' => ['allow_single_line_closure'=>true], + // A single space or none should be between cast and variable. + 'cast_spaces' => true, + // Class, trait and interface elements must be separated with one blank line. + 'class_attributes_separation' => ['elements'=>['method']], + // Whitespace around the keywords of a class, trait or interfaces definition should be one space. + 'class_definition' => true, + // Using `isset($var) &&` multiple times should be done in one call. + 'combine_consecutive_issets' => true, + // Calling `unset` on multiple items should be done in one call. + 'combine_consecutive_unsets' => true, + // Replace multiple nested calls of `dirname` by only one call with second `$level` parameter. Requires PHP >= 7.0. + 'combine_nested_dirname' => true, + // Remove extra spaces in a nullable typehint. + 'compact_nullable_typehint' => true, + // Concatenation should be spaced according configuration. + 'concat_space' => ['spacing'=>'one'], + // Equal sign in declare statement should be surrounded by spaces or not following configuration. + 'declare_equal_normalize' => true, + // Force strict types declaration in all files. Requires PHP >= 7.0. + 'declare_strict_types' => true, + // Replaces `dirname(__FILE__)` expression with equivalent `__DIR__` constant. + 'dir_constant' => true, + // Add curly braces to indirect variables to make them clear to understand. Requires PHP >= 7.0. + 'explicit_indirect_variable' => true, + // Converts implicit variables into explicit ones in double-quoted strings or heredoc syntax. + 'explicit_string_variable' => true, + // Transforms imported FQCN parameters and return types in function arguments to short version. + 'fully_qualified_strict_types' => true, + // 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, + // Imports or fully qualifies global classes/functions/constants. + 'global_namespace_import' => ['import_classes'=>true,'import_constants'=>true,'import_functions'=>true], + // Include/Require and file path should be divided with a single space. File path should not be placed under brackets. + 'include' => true, + // Pre- or post-increment and decrement operators should be used if possible. + 'increment_style' => ['style'=>'post'], + // Replaces `is_null($var)` expression with `null === $var`. + 'is_null' => ['use_yoda_style'=>false], + // Ensure there is no code on the same line as the PHP open tag. + 'linebreak_after_opening_tag' => true, + // List (`array` destructuring) assignment should be declared using the configured syntax. Requires PHP >= 7.1. + 'list_syntax' => ['syntax'=>'short'], + // Use `&&` and `||` logical operators instead of `and` and `or`. + 'logical_operators' => true, + // Cast should be written in lower case. + 'lowercase_cast' => true, + // Class static references `self`, `static` and `parent` MUST be in lower case. + 'lowercase_static_reference' => true, + // Magic constants should be referred to using the correct casing. + 'magic_constant_casing' => true, + // Magic method definitions and calls must be using the correct casing. + 'magic_method_casing' => true, + // Replace non multibyte-safe functions with corresponding mb function. + 'mb_str_functions' => true, + // In method arguments and method call, there MUST NOT be a space before each comma and there MUST be one space after each comma. Argument lists MAY be split across multiple lines, where each subsequent line is indented once. When doing so, the first item in the list MUST be on the next line, and there MUST be only one argument per line. + 'method_argument_space' => ['on_multiline'=>'ensure_fully_multiline'], + // Method chaining MUST be properly indented. Method chaining with different levels of indentation is not supported. + 'method_chaining_indentation' => true, + // Replaces `intval`, `floatval`, `doubleval`, `strval` and `boolval` function calls with according type casting operator. + 'modernize_types_casting' => true, + // Forbid multi-line whitespace before the closing semicolon or move the semicolon to the new line for chained calls. + 'multiline_whitespace_before_semicolons' => true, + // Function defined by PHP should be called using the correct casing. + 'native_function_casing' => true, + // 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, + // Master functions shall be used instead of aliases. + 'no_alias_functions' => true, + // Replace control structure alternative syntax to use braces. + 'no_alternative_syntax' => true, + // There should be no empty lines after class opening brace. + 'no_blank_lines_after_class_opening' => true, + // There should not be blank lines between docblock and the documented element. + 'no_blank_lines_after_phpdoc' => true, + // There should not be any empty comments. + 'no_empty_comment' => true, + // There should not be empty PHPDoc blocks. + 'no_empty_phpdoc' => true, + // Remove useless semicolon statements. + 'no_empty_statement' => true, + // Removes extra blank lines and/or blank lines following configuration. + 'no_extra_blank_lines' => true, + // Replace accidental usage of homoglyphs (non ascii characters) in names. + 'no_homoglyph_names' => true, + // Remove leading slashes in `use` clauses. + 'no_leading_import_slash' => true, + // The namespace declaration line shouldn't contain leading whitespace. + 'no_leading_namespace_whitespace' => true, + // Either language construct `print` or `echo` should be used. + 'no_mixed_echo_print' => true, + // Operator `=>` should not be surrounded by multi-line whitespaces. + 'no_multiline_whitespace_around_double_arrow' => true, + // Properties MUST not be explicitly initialized with `null` except when they have a type declaration (PHP 7.4). + 'no_null_property_initialization' => true, + // Convert PHP4-style constructors to `__construct`. + 'no_php4_constructor' => true, + // Short cast `bool` using double exclamation mark should not be used. + 'no_short_bool_cast' => true, + // Single-line whitespace before closing semicolon are prohibited. + 'no_singleline_whitespace_before_semicolons' => true, + // There MUST NOT be spaces around offset braces. + 'no_spaces_around_offset' => true, + // Replaces superfluous `elseif` with `if`. + 'no_superfluous_elseif' => true, + // Removes `@param`, `@return` and `@var` tags that don't provide any useful information. + 'no_superfluous_phpdoc_tags' => true, + // Remove trailing commas in list function calls. + 'no_trailing_comma_in_list_call' => true, + // PHP single-line arrays should not have trailing comma. + 'no_trailing_comma_in_singleline_array' => true, + // There MUST be no trailing spaces inside comment or PHPDoc. + '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, + // 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. + 'no_unreachable_default_argument_value' => true, + // Variables must be set `null` instead of using `(unset)` casting. + 'no_unset_cast' => true, + // Unused `use` statements must be removed. + 'no_unused_imports' => true, + // There should not be useless `else` cases. + 'no_useless_else' => true, + // There should not be an empty `return` statement at the end of a function. + 'no_useless_return' => true, + // In array declaration, there MUST NOT be a whitespace before each comma. + 'no_whitespace_before_comma_in_array' => true, + // Remove trailing whitespace at the end of blank lines. + 'no_whitespace_in_blank_line' => true, + // Remove Zero-width space (ZWSP), Non-breaking space (NBSP) and other invisible unicode symbols. + 'non_printable_character' => true, + // Array index should always be written by using square braces. + 'normalize_index_brace' => true, + // Logical NOT operators (`!`) should have one trailing whitespace. + 'not_operator_with_successor_space' => true, + // There should not be space before or after object `T_OBJECT_OPERATOR` `->`. + 'object_operator_without_whitespace' => true, + // Orders the elements of classes/interfaces/traits. + 'ordered_class_elements' => true, + // Ordering `use` statements. + 'ordered_imports' => true, + // Orders the interfaces in an `implements` or `interface extends` clause. + 'ordered_interfaces' => true, + // Docblocks should have the same indentation as the documented subject. + 'phpdoc_indent' => true, + // `@access` annotations should be omitted from PHPDoc. + 'phpdoc_no_access' => true, + // `@package` and `@subpackage` annotations should be omitted from PHPDoc. + 'phpdoc_no_package' => true, + // Classy that does not inherit must not have `@inheritdoc` tags. + 'phpdoc_no_useless_inheritdoc' => true, + // Scalar types should always be written in the same form. `int` not `integer`, `bool` not `boolean`, `float` not `real` or `double`. + 'phpdoc_scalar' => true, + // Single line `@var` PHPDoc should have proper spacing. + 'phpdoc_single_line_var_spacing' => true, + // PHPDoc summary should end in either a full stop, exclamation mark, or question mark. + 'phpdoc_summary' => true, + // Docblocks should only be used on structural elements. + 'phpdoc_to_comment' => true, + // EXPERIMENTAL: Takes `@param` annotations of non-mixed types and adjusts accordingly the function signature. Requires PHP >= 7.0. + 'phpdoc_to_param_type' => true, + // EXPERIMENTAL: Takes `@return` annotation of non-mixed types and adjusts accordingly the function signature. Requires PHP >= 7.0. + 'phpdoc_to_return_type' => true, + // PHPDoc should start and end with content, excluding the very first and last line of the docblocks. + 'phpdoc_trim' => true, + // The correct case must be used for standard PHP types in PHPDoc. + 'phpdoc_types' => true, + // `@var` and `@type` annotations of classy properties should not contain the name. + 'phpdoc_var_without_name' => true, + // Converts `protected` variables and methods to `private` where possible. + 'protected_to_private' => true, + // Class names should match the file name. + 'psr4' => true, + // Local, dynamic and directly referenced variables should not be assigned and directly returned by a function or method. + 'return_assignment' => true, + // There should be one or no space before colon, and one space after it in return type declarations, according to configuration. + 'return_type_declaration' => ['space_before'=>'none'], + // Inside class or interface element `self` should be preferred to the class name itself. + 'self_accessor' => true, + // Instructions must be terminated with a semicolon. + 'semicolon_after_instruction' => true, + // Cast shall be used, not `settype`. + 'set_type_to_cast' => true, + // Cast `(boolean)` and `(integer)` should be written as `(bool)` and `(int)`, `(double)` and `(real)` as `(float)`, `(binary)` as `(string)`. + 'short_scalar_cast' => true, + // A return statement wishing to return `void` should not return `null`. + 'simplified_null_return' => true, + // There should be exactly one blank line before a namespace declaration. + 'single_blank_line_before_namespace' => true, + // Single-line comments and multi-line comments with only one line of actual content should use the `//` syntax. + 'single_line_comment_style' => true, + // Throwing exception must be done in single line. + 'single_line_throw' => true, + // Convert double quotes to single quotes for simple strings. + 'single_quote' => true, + // Each trait `use` must be done as single statement. + 'single_trait_insert_per_statement' => true, + // Fix whitespace after a semicolon. + 'space_after_semicolon' => true, + // Increment and decrement operators should be used if possible. + 'standardize_increment' => true, + // Replace all `<>` with `!=`. + 'standardize_not_equals' => true, + // Comparisons should be strict. + 'strict_comparison' => true, + // Functions should be used with `$strict` param set to `true`. + 'strict_param' => true, + // Standardize spaces around ternary operator. + 'ternary_operator_spaces' => true, + // Use `null` coalescing operator `??` where possible. Requires PHP >= 7.0. + 'ternary_to_null_coalescing' => true, + // PHP multi-line arrays should have a trailing comma. + 'trailing_comma_in_multiline_array' => true, + // Arrays should be formatted like function/method arguments, without leading or trailing single line space. + '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' => ['elements'=>['property','method','const']], + // 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. + 'whitespace_after_comma_in_array' => true, + ]) + ->setFinder(PhpCsFixer\Finder::create() + ->notPath('vendor') + ->in([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->name('*.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true) + ); diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..dd44972 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +*.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..37477fb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.0.1] - 2020-12-22 + +### Added + + - Number immutable value object diff --git a/DOCS.md b/DOCS.md new file mode 100644 index 0000000..c978445 --- /dev/null +++ b/DOCS.md @@ -0,0 +1,82 @@ +# Documentation + +## Instanciation + +`Number::of(any)` accepts any numeric value like floats, integers and even strings or other Number instances + +e.g: `Number::of("1.5")`, `Number::of(1/3)`, `Number::of(1)`, `Number::of(Number::of(12.8))` are valid and correctly handled + +On the other hand, the strings must be sane + +e.g `Number::of('1/3')`, `Number::of('1,2')`, `Number::of('1.5€')` will throw an exception + +Basic instances are already built-in: + +- `Number::zero()` alias for Number::of(0) +- `Number::one()` alias for Number::of(1) +- `Number::ten()` alias for Number::of(10) +- `Number::hundred()` alias for Number::of(100) +- `Number::thousand()` alias for Number::of(1000) + +## Collections + +- `Number::sum(...any)` return a Number which values to the sum of the values in the collection +- `Number::max(...any)` return a Number which values to the max value in the collection +- `Number::min(...any)` return a Number which values to the min value in the collection +- `Number::average(...any)` return a Number which values to the average value of the collection + +Those methods accepts a list of valid values (e.g: `Number::sum("2", 4, 1/2)`) as well as an iterable object (e.g: `Number::sum(["2", 4, 1/2])`) + +## Methods + +### Basic maths methods: + +- `plus(any)` returns a Number which values to the sum between the two Numbers +- `minus(any)` returns a Number which values to the difference between the two Numbers +- `multiply(any)` returns a Number which values to the multiplication between the two Numbers +- `divide(any)` returns a Number which values to the division between the two Numbers +- `power(exponent)` returns a Number which values powered to the exponent +- `square()` alias for power(2) +- `cube()` alias for power(3) +- `absolute()` returns a Number which value is positive +- `negate()` returns a Number which value will be positive if was negative, negative if positive +- `inverse()` returns a Number which values to one over the number +- `round(precision, mode)` returns a Number which values will rounded according to parameters, defaults to 15 digits and up +- `round(precision)` returns a Number which decimals will be truncated according to precision without any rounding + +### Comparison methods: + +- `compare(any)` returns -1 if Number is less, 1 if Number is greater and 0 if Numbers are equal, like strcmp does for strings +- `equals(any)` returns whether the two Numbers are equal. It handles floats epsilon comparison. +- `isDifferent(any)` returns the inverse of equals +- `isGreater(any)` returns the strict superiority comparison result +- `isGreaterOrEqual(any)` returns the superiority comparison result +- `isLess(any)` returns the strict inferiority comparison result +- `isLessOrEqual(any)` returns the inferiority comparison result + +### Other useful methods: + +- `isPositive()` returns whether the Number value is positive or not +- `isNegative()` returns whether the Number value is negative or not +- `isZero()` returns whether the Number value is equal to zero +- `isWhole()` returns whether the Number has a decimal part +- `decimalPlaces()` returns a count of the decimal part digits +- `apply(callback)` returns the callback result, useful for custom functions +- `when(bool, callback)` returns the callback result if the condition is truthy +- `format(locale, decimals, forceDecimals)` returns the display format of the number in the desired locale (not for database storage!) + + +## Accessors +- `get()` returns the raw internal float value (for debug purposes) +- `value()` returns the float value, preferred over value(), because the format will be right (no strange values like -0.0) +- `sign()` returns `+` or `-`. N.B: Zero will be positive. +- `wholePart()` returns the left part of the floating point (e.g 3.52 is 3) +- `decimalPart()` returns the right part of the floating point (e.g 3.52 is 0.52) + +All accessors can be retrieved as properties like `->value` or `->sign`. + + +## Warning + +While floats you be fine for most usages, please read [What Every Programmer Should Know About Floating-Point Arithmetic](https://floating-point-gui.de/), so you will be aware of its limits. Calc tries to overcome it as much as possible (see the `equals()` method implementation), but still some edge cases can occur. +If so, new tests are welcomed (please PR). diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..c42bce0 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Louis-Gabriel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5cf1188 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# Calc + +[![Packagist Version](https://img.shields.io/packagist/v/louisgab/php-calc.svg?style=flat-square)](https://packagist.org/packages/louisgab/php-calc) +[![Packagist Downloads](https://img.shields.io/packagist/dt/louisgab/php-calc.svg?style=flat-square)](https://packagist.org/packages/louisgab/php-calc) +[![GitHub license](https://img.shields.io/github/license/louisgab/php-calc.svg?style=flat-square)](https://github.com/louisgab/php-calc/blob/master/LICENSE) + +💯 Simple fluent float manipulation library. + +Calc aims to provide tools for easy and readable calculations, without any dependency. +It comes with `Number` which is an immutable value object that enables fluent float manipulations. + +## Why + +If you ever worked with a codebase full of that kind of crap: + +```php +$result = round(($b != 0 ? ((1+$a)/$b) : $c)*0.25, 2) +``` + +I'm sure you will enjoy that: + +```php +$result = Number::of($b) + ->when(Number::of($b)->isZero(), + fn($b) => $c + fn($b) => Number::one()->plus($a)->divide($b), + ) + ->multiply(0.25) + ->round(2) + ->value() +``` + +You may think it's like [brick/math](https://github.com/brick/math), which is a really great package, but Calc serves a different purpose. +If floats are good enough for you - and unless you're dealing with sensible data like accounting or science, it should be - then using GMP or bcmath is overkill. + +That's what Calc is made for, still using floats while enjoying nice readability. +Another good point is that it handles floating point problems (e.g `0.1 + 0.2 == 0.3 // false`) as much as possible, so you don't have to think about it each time (and if you are working with junior developers, it will save them from having problems they didn't even know existed!). + +## Install + +Via composer: + +```bash +composer require louisgab/php-calc +``` + +## Usage +Simple as: +```php +use Louisgab\Calc\Number; + +Number::of($anything); +``` + +And good as : +```php +public function carsNeeded(Number $people, Number $placesPerCar): int +{ + return $people->divide($placesPerCar)->round(0)->toInt(); +} +``` + +Please see [DOCS](DOCS.md) + +## Testing + +```bash +composer test +``` + +## Roadmap + +- [x] `Number` +- [ ] `Fraction` +- [ ] `Percentage` + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) + +## Contributing + +Highly welcomed! + +## License + +Please see [The MIT License (MIT)](LICENSE.md). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6a42f77 --- /dev/null +++ b/composer.json @@ -0,0 +1,36 @@ +{ + "name": "louisgab/php-calc", + "description": "Simple fluent float manipulation library", + "type": "library", + "keywords": [ + "php", "number", "float", "fluent" + ], + "homepage": "https://github.com/louisgab/number", + "license": "MIT", + "author": "Louis Gabriel ", + "require": { + "php": "^7.4" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "autoload": { + "psr-4": { + "Louisgab\\Calc\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Louisgab\\Calc\\Tests\\": "tests" + } + }, + "scripts": { + "test": "vendor/bin/phpunit --testdox --colors=always", + "test-coverage": "vendor/bin/phpunit --coverage-html coverage" + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..3ff21ef --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,24 @@ + + + + + src/ + + + + + tests + + + diff --git a/src/Number.php b/src/Number.php new file mode 100755 index 0000000..ff569ef --- /dev/null +++ b/src/Number.php @@ -0,0 +1,450 @@ +value = $value; + } + + public function __toString(): string + { + return (string) $this->value(); + } + + public function __get($name) + { + if (in_array($name, ['value', 'sign', 'wholePart', 'decimalPart'], true)) { + return $this->{$name}(); + } + } + + public static function of($value): self + { + if ($value instanceof self) { + return $value; + } + + if (! is_numeric($value)) { + throw new Exception(sprintf('Value "%s" should be castable to float or a Number instance', (string) $value)); + } + + return new self((float) $value); + } + + // Accessors ----------------------------------------------------------------------------- + + public function get(): float + { + return $this->value; + } + + public function value(int $decimals = 6): float + { + return (float) number_format($this->value, $decimals, '.', ''); + } + + public function sign(): string + { + return $this->isPositive() ? '+' : '-'; + } + + public function wholePart(): int + { + return abs((int) $this->__toString()); + } + + public function decimalPart(): float + { + return abs($this->value) - $this->wholePart(); + } + + // Actions ------------------------------------------------------------------------------- + + public function plus($that): self + { + $that = self::of($that); + + if ($that->isZero()) { + return $this; + } + + return self::of($this->value + $that->value); + } + + public function minus($that): self + { + $that = self::of($that); + + if ($that->isZero()) { + return $this; + } + + return self::of($this->value - $that->value); + } + + public function multiply($that): self + { + $that = self::of($that); + + if ($that->isZero()) { + return $that; + } + + return self::of($this->value * $that->value); + } + + public function divide($that): self + { + $that = self::of($that); + + if ($that->isZero()) { + throw new Exception('Division by zero is not possible'); + } + + return self::of($this->value / $that->value); + } + + public function power(int $exponent): self + { + if ($exponent === 0) { + return self::one(); + } + + if ($exponent === 1) { + return $this; + } + + return self::of($this->value ** $exponent); + } + + public function square(): self + { + return $this->power(2); + } + + public function cube(): self + { + return $this->power(3); + } + + /** + * Float equality isn't trivial. + * @link: https://www.php.net/manual/en/language.types.float.php#language.types.float.comparison + * @see: https://floating-point-gui.de/ + */ + public function equals($that): bool + { + $that = self::of($that); + + if ($this->value === $that->value) { + return true; + } + + $diff = abs($this->value - $that->value); + + if ($diff < PHP_FLOAT_EPSILON * 4) { + return true; + } + + $absA = abs($this->value); + $absB = abs($that->value); + + if ($this->value === 0.0 || $that->value === 0.0 || ($absA + $absB < PHP_FLOAT_MIN)) { + return $diff < (PHP_FLOAT_EPSILON * PHP_FLOAT_MIN); + } + + return $diff / min(($absA + $absB), PHP_FLOAT_MAX) < PHP_FLOAT_EPSILON; + } + + public function isDifferent($that): bool + { + return ! $this->equals($that); + } + + public function isGreater($that): bool + { + return $this->compare($that) > 0; + } + + public function isGreaterOrEqual($that): bool + { + return $this->compare($that) >= 0; + } + + public function isLess($that): bool + { + return $this->compare($that) < 0; + } + + public function isLessOrEqual($that): bool + { + return $this->compare($that) <= 0; + } + + public function isNegative(): bool + { + return $this->isLess(self::zero()); + } + + public function isPositive(): bool + { + return ! $this->isNegative(); + } + + public function isZero(): bool + { + return $this->equals(self::zero()); + } + + public function isWhole(): bool + { + return self::of($this->decimalPart())->isZero(); + } + + public function absolute(): self + { + return $this->isNegative() ? $this->negate() : $this; + } + + public function negate(): self + { + return $this->multiply(-1); + } + + public function inverse(): self + { + return self::one()->divide($this); + } + + public function round(int $precision = PHP_FLOAT_DIG, int $mode = PHP_ROUND_HALF_UP): self + { + return self::of(round($this->value, $precision, $mode)); + } + + public function truncate(int $precision): self + { + return self::of($this->sign() . $this->wholePart() . '.' . mb_substr((string) $this->decimalPart(), 2, $precision)); + } + + public function compare($that): int + { + $that = self::of($that); + + if ($this->equals($that)) { + return 0; + } + + return $this->value <=> $that->value; + } + + public function apply(callable $callback): self + { + return self::of($callback($this)); + } + + public function when(bool $condition, callable $callback, ?callable $default = null): self + { + if ($condition) { + return $this->apply($callback); + } + + if ($default) { + return $this->apply($default); + } + + return $this; + } + + public function format(string $locale = 'en_US', int $decimals = 2, bool $forceDecimals = false): string + { + static $formatter; + + if ($formatter === null) { + $formatter = new NumberFormatter($locale, NumberFormatter::DECIMAL); + } + + if ($forceDecimals) { + $formatter->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, $decimals); + } + + $formatter->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, $decimals); + + return $formatter->format($this->value); + } + + // Config -------------------------------------------------------------------------------- + + public function toInt(): int + { + if (! $this->isWhole()) { + throw new Exception('This number cant be casted to int without precision loss, use round() instead'); + } + + return (int) $this->value(); + } + + public function toFloat(): float + { + return $this->get(); + } + + public function toArray(): array + { + return [ + 'value' => $this->value(), + 'sign' => $this->sign(), + 'format' => $this->format(), + ]; + } + + public function toJson(int $options = 0): string + { + return json_encode($this->jsonSerialize(), $options); + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } + + // Helpers ------------------------------------------------------------------------------- + + public static function zero(): self + { + static $zero; + + if ($zero === null) { + $zero = self::of(0); + } + + return $zero; + } + + public static function one(): self + { + static $one; + + if ($one === null) { + $one = self::of(1); + } + + return $one; + } + + public static function ten(): self + { + static $ten; + + if ($ten === null) { + $ten = self::of(10); + } + + return $ten; + } + + public static function hundred(): self + { + static $hundred; + + if ($hundred === null) { + $hundred = self::of(100); + } + + return $hundred; + } + + public static function thousand(): self + { + static $thousand; + + if ($thousand === null) { + $thousand = self::of(1000); + } + + return $thousand; + } + + // Collections --------------------------------------------------------------------------- + + public static function max(...$collection): self + { + $collection = self::collection(...$collection); + + $max = null; + + foreach ($collection as $item) { + if ($max !== null && ! $item->isGreater($max)) { + continue; + } + + $max = $item; + } + + return $max; + } + + public static function min(...$collection): self + { + $collection = self::collection(...$collection); + + $min = null; + + foreach ($collection as $item) { + if ($min !== null && ! $item->isLess($min)) { + continue; + } + + $min = $item; + } + + return $min; + } + + public static function sum(...$collection): self + { + $collection = self::collection(...$collection); + + $sum = null; + + foreach ($collection as $item) { + $sum = $sum === null ? $item : $sum->plus($item); + } + + return $sum; + } + + public static function average(...$collection): self + { + $collection = self::collection(...$collection); + + $sum = self::sum($collection); + + return $sum->divide(count($collection)); + } + + private static function collection($first, ...$items): array + { + $items = is_iterable($first) ? [...$first, ...$items] : [$first, ...$items]; + + if (empty($items)) { + throw new Exception('Collection expects at least one argument'); + } + + foreach ($items as $key => $item) { + $items[$key] = self::of($item); + } + + return $items; + } +} diff --git a/tests/NumberTest.php b/tests/NumberTest.php new file mode 100644 index 0000000..73a5fc4 --- /dev/null +++ b/tests/NumberTest.php @@ -0,0 +1,544 @@ +assertSame($expected, Number::of($value)->get()); + } + + public function valueProvider(): array + { + return [ + 'int' => [1, 1.0], + 'int string' => ['1', 1.0], + 'int negative' => [-1, -1.0], + 'int negative string' => ['-1', -1.0], + 'float' => [1.1, 1.1], + 'float string' => ['1.1', 1.1], + 'float negative' => [-1.1, -1.1], + 'float negative string' => ['-1.1', -1.1], + 'fraction' => [1/2, 0.5], + 'fraction negative' => [-1/2, -0.5], + ]; + } + + /** + * @dataProvider signProvider + */ + public function testSignIsRight($value, $expected): void + { + $this->assertSame($expected, Number::of($value)->sign); + } + + public function signProvider(): array + { + return [ + 'positive one' => [1, '+'], + 'negative one' => [-1, '-'], + 'positive zero' => [0, '+'], + 'negative zero' => [-0, '+'], + 'positive 1/3' => [1/3, '+'], + 'negative 1/3' => [-1/3, '-'], + ]; + } + + /** + * @dataProvider wholePartProvider + */ + public function testWholePartIsRight($value, $expected): void + { + $this->assertSame($expected, Number::of($value)->wholePart); + } + + public function wholePartProvider(): array + { + return [ + 'zero' => [0, 0], + 'five quarter' => [5/4, 1], + 'minus one half' => [-1/2, 0], + ]; + } + + /** + * @dataProvider decimalPartProvider + */ + public function testDecimalPartIsRight($value, $expected): void + { + $this->assertSame($expected, Number::of($value)->decimalPart); + } + + public function decimalPartProvider(): array + { + return [ + 'zero' => [0, 0.0], + 'five quarter' => [5/4, 0.25], + 'minus one half' => [-1/2, 0.5], + ]; + } + + /** + * @dataProvider plusProvider + */ + public function testPlusIsRight($a, $b, $expected): void + { + $this->assertSame($expected, Number::of($a)->plus($b)->get()); + $this->assertTrue(Number::of($a)->plus($b)->equals($expected)); + } + + public function plusProvider(): array + { + return [ + 'zeros' => [0, 0, 0.0], + '0.3 problem' => [0.1, 0.2, 0.3], + 'inverse' => [3, -3, 0.0], + ]; + } + + /** + * @dataProvider minusProvider + */ + public function testMinusIsRight($a, $b, $expected): void + { + $this->assertSame($expected, Number::of($a)->minus($b)->get()); + $this->assertTrue(Number::of($a)->minus($b)->equals($expected)); + } + + public function minusProvider(): array + { + return [ + 'zeros' => [0, 0, 0.0], + '1.6 problem' => [8, 6.4, 1.6], + 'inverse' => [3, -3, 6.0], + ]; + } + + /** + * @dataProvider multiplyProvider + */ + public function testMultiplyIsRight($a, $b, $expected): void + { + $this->assertSame($expected, Number::of($a)->multiply($b)->get()); + $this->assertTrue(Number::of($a)->multiply($b)->equals($expected)); + } + + public function multiplyProvider(): array + { + return [ + 'zeros' => [0, 0, 0.0], + '410 problem' => [4.1, 100, 410.0], + 'inverse' => [3, 1/3, 1.0], + ]; + } + + /** + * @dataProvider divideProvider + */ + public function testdivideIsRight($a, $b, $expected): void + { + $this->assertSame($expected, Number::of($a)->divide($b)->get()); + $this->assertTrue(Number::of($a)->divide($b)->equals($expected)); + } + + public function divideProvider(): array + { + return [ + '0.041 problem' => [4.1, 100, 0.041], + 'inverse' => [3, 3, 1.0], + ]; + } + + /** + * @dataProvider equalsProvider + */ + public function testEqualsIsRight($a, $b): void + { + $this->assertTrue(Number::of($a)->equals($b)); + } + + public function equalsProvider(): array + { + return [ + 'one third' => [1/3, (2/3) - (1/3)], + '0.3 problem' => [0.1 + 0.2, 0.3], + '0.3 problem variant' => [0.3 - (0.1 + 0.2), 0], + 'zero problem' => [(0.6/0.2) -3, 0], + ]; + } + + /** + * @dataProvider isDifferentProvider + */ + public function testIsDifferentIsRight($a, $b): void + { + $this->assertTrue(Number::of($a)->isDifferent($b)); + } + + public function isDifferentProvider(): array + { + return [ + 'negative one third' => [1/3, -1/3], + 'round one third' => [1/3, 0.33], + 'big diff' => [1000000.0, 1000000.1], + 'negative big numbers' => [-1000000.0, -1000000.1], + 'small diff' => [0.1, 0.1000000001], + 'negative small diff' => [-0.1, -0.1000000001], + ]; + } + + /** + * @dataProvider isGreaterProvider + */ + public function testIsGreaterIsRight($a, $b): void + { + $this->assertTrue(Number::of($a)->isGreater($b)); + } + + public function isGreaterProvider(): array + { + return [ + 'zero' => [1/3, 0], + 'negative one third' => [1/3, -1/3], + 'round one third' => [1/3, 0.33], + ]; + } + + /** + * @dataProvider isLessProvider + */ + public function testIsLessIsRight($a, $b): void + { + $this->assertTrue(Number::of($a)->isLess($b)); + } + + public function isLessProvider(): array + { + return [ + 'zero' => [0, 0.1], + 'negative one third' => [-1/3, 1/3], + 'round one third' => [0.33333333, 1/3], + ]; + } + + /** + * @dataProvider isNegativeProvider + */ + public function testIsNegativeIsRight($value, $expected): void + { + $this->assertSame($expected, Number::of($value)->isNegative()); + } + + public function isNegativeProvider(): array + { + return [ + 'negative number' => [-5, true], + 'positive number' => [5, false], + ]; + } + + /** + * @dataProvider isPositiveProvider + */ + public function testIsPositiveIsRight($value, $expected): void + { + $this->assertSame($expected, Number::of($value)->isPositive()); + } + + public function isPositiveProvider(): array + { + return [ + 'positive number' => [5, true], + 'negative number' => [-5, false], + ]; + } + + /** + * @dataProvider isZeroProvider + */ + public function testIsZeroIsRight($value, $expected): void + { + $this->assertSame($expected, Number::of($value)->isZero()); + } + + public function isZeroProvider(): array + { + return [ + 'int' => [0, true], + 'float' => [0.0, true], + 'string' => ['0', true], + 'calc' => [(-0.6/0.2)+3, true], + 'negative int' => [-0, true], + 'negative float' => [-0.0, true], + 'negative string' => ['-0', true], + 'negative calc' => [(0.6/0.2)-3, true], + ]; + } + + /** + * @dataProvider isWholeProvider + */ + public function testIsWholeIsRight($value, $expected): void + { + $this->assertSame($expected, Number::of($value)->isWhole()); + } + + public function isWholeProvider(): array + { + return [ + 'whole float number' => [1.0, true], + 'whole int number' => [1, true], + 'whole negative number' => [-1, true], + 'decimal number' => [1.1, false], + ]; + } + + /** + * @dataProvider powerProvider + */ + public function testPowerIsRight($a, $b, $expected): void + { + $this->assertSame($expected, Number::of($a)->power($b)->get()); + } + + public function powerProvider(): array + { + return [ + 'zero' => [1234565.987, 0, 1.0], + 'one' => [1234565.987, 1, 1234565.987], + ]; + } + + /** + * @dataProvider squareProvider + */ + public function testSquareIsRight($value, $expected): void + { + $this->assertSame($expected, Number::of($value)->square()->get()); + } + + public function squareProvider(): array + { + return [ + 'three' => [3.0, 9.0], + 'one half' => [1/2, 1/4], + ]; + } + + /** + * @dataProvider absoluteProvider + */ + public function testAbsoluteIsRight($value, $expected): void + { + $this->assertSame($expected, Number::of($value)->absolute()->get()); + $this->assertTrue(Number::of($value)->absolute()->equals($expected)); + } + + public function absoluteProvider(): array + { + return [ + 'zeros' => [0, 0.0], + 'plus one' => [1, 1.0], + 'minus one' => [-1, 1.0], + ]; + } + + /** + * @dataProvider negateProvider + */ + public function testNegateIsRight($value, $expected): void + { + $this->assertSame($expected, Number::of($value)->negate()->get()); + $this->assertTrue(Number::of($value)->negate()->equals($expected)); + } + + public function negateProvider(): array + { + return [ + 'zeros' => [0, 0.0], + 'plus one' => [1, -1.0], + 'minus one' => [-1, 1.0], + ]; + } + + /** + * @dataProvider inverseProvider + */ + public function testInverseIsRight($value, $expected): void + { + $this->assertSame($expected, Number::of($value)->inverse()->get()); + $this->assertTrue(Number::of($value)->inverse()->equals($expected)); + } + + public function inverseProvider(): array + { + return [ + 'plus one' => [1, 1.0], + 'minus one' => [-1, -1.0], + 'one quarter' => [1/4, 4.0], + 'two third' => [2/3, 3/2], + ]; + } + + /** + * @dataProvider roundProvider + */ + public function testRoundIsRight($value, $expected, $expectedtwo): void + { + $this->assertSame($expected, Number::of($value)->round()->get()); + $this->assertTrue(Number::of($value)->round()->equals($expected)); + + $this->assertSame($expectedtwo, Number::of($value)->round(2)->get()); + $this->assertTrue(Number::of($value)->round(2)->equals($expectedtwo)); + } + + public function roundProvider(): array + { + return [ + 'zeros' => [0, 0.0, 0.0], + 'minus one' => [-1, -1.0, -1.0], + 'one quarter' => [1/4, 0.25, 0.25], + 'one third' => [1/3, 0.333333333333333, 0.33], + 'two thirds' => [2/3, 0.666666666666667, 0.67], + ]; + } + + /** + * @dataProvider truncateProvider + */ + public function testTruncateIsRight($value, $expected, $expectedtwo): void + { + $this->assertSame($expected, Number::of($value)->truncate(0)->get()); + $this->assertTrue(Number::of($value)->truncate(0)->equals($expected)); + + $this->assertSame($expectedtwo, Number::of($value)->truncate(2)->get()); + $this->assertTrue(Number::of($value)->truncate(2)->equals($expectedtwo)); + } + + public function truncateProvider(): array + { + return [ + 'zeros' => [0, 0.0, 0.0], + 'minus one' => [-1, -1.0, -1.0], + 'one quarter' => [1/4, 0.0, 0.25], + 'one third' => [1/3, 0.0, 0.33], + 'two thirds' => [2/3, 0.0, 0.66], + ]; + } + + /** + * @dataProvider applyProvider + */ + public function testApplyIsRight($value, $callback, $expected): void + { + $this->assertSame($expected, Number::of($value)->apply($callback)->get()); + } + + public function applyProvider(): array + { + return [ + 'internal' => [3, fn ($number) => $number->square(), 9.0], + 'custom' => [1/3, fn ($number) => ceil($number->value()), ceil(1/3)], + ]; + } + + /** + * @dataProvider whenProvider + */ + public function testWhenIsRight($value, $condition, $callback, $expected): void + { + $this->assertSame($expected, Number::of($value)->when($condition, $callback)->get()); + } + + public function whenProvider(): array + { + return [ + 'true' => [3, true, fn () => Number::one(), 1.0], + 'false' => [3, false, fn () => Number::one(), 3.0], + ]; + } + + /** + * @dataProvider formatProvider + */ + public function testFormatIsRight($value, $expected): void + { + $this->assertSame($expected, Number::of($value)->format()); + } + + public function formatProvider(): array + { + return [ + 'one half' => [1/2, '0.5'], + ]; + } + + /** + * @dataProvider maxProvider + */ + public function testMaxIsRight($values, $expected): void + { + $this->assertSame($expected, Number::max($values)->get()); + } + + public function maxProvider(): array + { + return [ + 'example' => [[-1, 1/2, 0.0, '0.75'], 0.75], + ]; + } + + /** + * @dataProvider minProvider + */ + public function testMinIsRight($values, $expected): void + { + $this->assertSame($expected, Number::min($values)->get()); + } + + public function minProvider(): array + { + return [ + 'example' => [[-1, 1/2, 0.0, '0.75'], -1.0], + ]; + } + + /** + * @dataProvider sumProvider + */ + public function testSumIsRight($values, $expected): void + { + $this->assertSame($expected, Number::sum($values)->get()); + } + + public function sumProvider(): array + { + return [ + 'example' => [[-1, 1/2, 0.0, '0.75'], 1/4], + ]; + } + + /** + * @dataProvider averageProvider + */ + public function testAverageIsRight($values, $expected): void + { + $this->assertSame($expected, Number::average($values)->get()); + } + + public function averageProvider(): array + { + return [ + 'example' => [[-1, 1/2, 0.0, '0.75'], 1/16], + ]; + } +}