This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
name: PHP Composer
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- 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 --prefer-dist --no-progress
|
||||
|
||||
- name: PHPStan
|
||||
run: composer phpstan
|
||||
|
||||
- name: Code style check
|
||||
run: composer cs-check
|
||||
|
||||
- name: Tests
|
||||
run: composer test
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/vendor/
|
||||
/.php-cs-fixer.cache
|
||||
/.phpunit.result.cache
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PhpCsFixer\Config;
|
||||
use PhpCsFixer\Finder;
|
||||
|
||||
$finder = Finder::create()
|
||||
->in([
|
||||
__DIR__ . '/src',
|
||||
__DIR__ . '/tests',
|
||||
])
|
||||
->name('*.php')
|
||||
->notName('*.blade.php')
|
||||
->ignoreDotFiles(true)
|
||||
->ignoreVCS(true);
|
||||
|
||||
return (new Config())
|
||||
->setRiskyAllowed(true)
|
||||
->setRules([
|
||||
'@PER-CS2.0' => true,
|
||||
'@PHP83Migration' => true,
|
||||
'ordered_imports' => ['sort_algorithm' => 'alpha'],
|
||||
'no_unused_imports' => true,
|
||||
'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false],
|
||||
'single_quote' => true,
|
||||
'explicit_string_variable' => true,
|
||||
'array_syntax' => ['syntax' => 'short'],
|
||||
'trim_array_spaces' => true,
|
||||
'no_whitespace_before_comma_in_array' => true,
|
||||
'whitespace_after_comma_in_array' => ['ensure_single_space' => true],
|
||||
'declare_strict_types' => true,
|
||||
'strict_param' => true,
|
||||
'strict_comparison' => true,
|
||||
'phpdoc_align' => ['align' => 'left'],
|
||||
'phpdoc_order' => true,
|
||||
'phpdoc_trim' => true,
|
||||
'phpdoc_scalar' => true,
|
||||
'no_superfluous_phpdoc_tags' => ['remove_inheritdoc' => true],
|
||||
'yoda_style' => false,
|
||||
'no_useless_else' => true,
|
||||
'no_useless_return' => true,
|
||||
'concat_space' => ['spacing' => 'one'],
|
||||
'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']],
|
||||
'blank_line_before_statement' => ['statements' => ['return', 'throw', 'try', 'if', 'foreach', 'for', 'while']],
|
||||
])
|
||||
->setFinder($finder);
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
FROM php:8.3-cli-alpine
|
||||
|
||||
RUN apk add --no-cache git unzip \
|
||||
&& docker-php-ext-install bcmath
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
RUN mkdir -p /tmp/composer && chown 1000:1000 /tmp/composer
|
||||
|
||||
WORKDIR /app
|
||||
@@ -0,0 +1,29 @@
|
||||
PHP = docker compose run --rm php
|
||||
|
||||
.PHONY: install test phpstan cs-fix cs-check audit shell build
|
||||
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
install:
|
||||
$(PHP) composer install
|
||||
|
||||
test:
|
||||
$(PHP) vendor/bin/phpunit --testsuite Unit --colors=always
|
||||
|
||||
phpstan:
|
||||
$(PHP) vendor/bin/phpstan analyse --no-progress --ansi --memory-limit=512M
|
||||
|
||||
cs-fix:
|
||||
$(PHP) vendor/bin/php-cs-fixer fix --ansi
|
||||
|
||||
cs-check:
|
||||
$(PHP) vendor/bin/php-cs-fixer fix --dry-run --diff --ansi
|
||||
|
||||
audit:
|
||||
$(PHP) composer audit
|
||||
|
||||
ci: audit phpstan cs-check test
|
||||
|
||||
shell:
|
||||
docker compose run --rm php sh
|
||||
@@ -0,0 +1,165 @@
|
||||
# tez-utils-num
|
||||
|
||||
PHP number utilities: formatting, byte sizes, percentage, clamping, and rounding helpers (`Num`), plus arbitrary-precision decimal arithmetic via bcmath (`Bc`). PHP 8.3+, requires `ext-bcmath`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
composer require tez/utils-num
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `Num` | Formatting, byte sizes, percentages, clamping, rounding |
|
||||
| `Bc` | Arbitrary-precision decimal arithmetic wrapping `ext-bcmath` |
|
||||
|
||||
---
|
||||
|
||||
## Num
|
||||
|
||||
Formatting and rounding helpers for everyday number display.
|
||||
|
||||
### format()
|
||||
|
||||
```php
|
||||
use Tez\Utils\Num\Num;
|
||||
|
||||
Num::format(1234567.891); // '1,234,567.89'
|
||||
Num::format(1234567.891, dec: ',', thousands: '.'); // '1.234.567,89'
|
||||
Num::format(1234.5, decimals: 0); // '1,235'
|
||||
Num::format(-1000.0); // '-1,000.00'
|
||||
```
|
||||
|
||||
### bytes()
|
||||
|
||||
Converts a byte count to a human-readable string using binary units (1 KB = 1 024 B).
|
||||
|
||||
```php
|
||||
Num::bytes(0); // '0 B'
|
||||
Num::bytes(1023); // '1,023 B'
|
||||
Num::bytes(1024); // '1.00 KB'
|
||||
Num::bytes(1048576); // '1.00 MB'
|
||||
Num::bytes(1073741824); // '1.00 GB'
|
||||
Num::bytes(1024 ** 4); // '1.00 TB'
|
||||
Num::bytes(1500, precision: 0); // '1 KB'
|
||||
```
|
||||
|
||||
### percent()
|
||||
|
||||
Formats a ratio (`0.0`–`1.0`) as a percentage string.
|
||||
|
||||
```php
|
||||
Num::percent(0.1234); // '12.3%'
|
||||
Num::percent(1.0); // '100.0%'
|
||||
Num::percent(0.0056, decimals: 2); // '0.56%'
|
||||
```
|
||||
|
||||
### clamp()
|
||||
|
||||
Constrains a value to the inclusive range `[$min, $max]`.
|
||||
|
||||
```php
|
||||
Num::clamp(105, 0, 100); // 100
|
||||
Num::clamp(-5, 0, 100); // 0
|
||||
Num::clamp(50, 0, 100); // 50
|
||||
Num::clamp(1.5, 0.0, 0.5); // 0.5
|
||||
```
|
||||
|
||||
### roundTo()
|
||||
|
||||
Rounds a value to the nearest multiple of `$multiple`.
|
||||
|
||||
```php
|
||||
Num::roundTo(17, 5); // 15
|
||||
Num::roundTo(18, 5); // 20
|
||||
Num::roundTo(1.7, 0.5); // 1.5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bc
|
||||
|
||||
Arbitrary-precision decimal arithmetic wrapping PHP's `bcmath` extension. Designed for financial calculations where floating-point imprecision is not acceptable.
|
||||
|
||||
**All inputs must be numeric strings** (e.g. `'1.50000000'`). Passing a non-numeric string throws `\InvalidArgumentException`. Division by zero throws `\DivisionByZeroError`.
|
||||
|
||||
The default scale is **8 decimal places** for all methods.
|
||||
|
||||
### Arithmetic
|
||||
|
||||
```php
|
||||
use Tez\Utils\Num\Bc;
|
||||
|
||||
Bc::add('10.5', '20.3'); // '30.80000000'
|
||||
Bc::sub('20.3', '11.1'); // '9.20000000'
|
||||
Bc::mul('4', '5'); // '20.00000000'
|
||||
Bc::div('1', '3'); // '0.33333333'
|
||||
Bc::div('1', '3', scale: 2); // '0.33'
|
||||
|
||||
// Array operations
|
||||
Bc::sum(['1', '2', '3']); // '6.00000000'
|
||||
Bc::sum([]); // '0.00000000'
|
||||
Bc::product(['2', '3', '4']); // '24.00000000'
|
||||
Bc::product([]); // '1.00000000'
|
||||
Bc::subAll(['10', '3', '2']); // '5.00000000' (10 - 3 - 2)
|
||||
Bc::divAll(['100', '5', '4']); // '5.00000000' (100 / 5 / 4)
|
||||
```
|
||||
|
||||
### Utilities
|
||||
|
||||
```php
|
||||
Bc::abs('-5'); // '5.00000000'
|
||||
Bc::negate('3.14'); // '-3.14000000'
|
||||
Bc::negate('-5'); // '5.00000000'
|
||||
Bc::max('10', '5'); // '10.00000000'
|
||||
Bc::min('10', '3'); // '3.00000000'
|
||||
Bc::scale('1.5'); // '1.50000000' — normalize to 8 decimal places
|
||||
Bc::scale('1.123456789'); // '1.12345678' — truncates, does not round
|
||||
Bc::percent('200', '15'); // '30.00000000' — 15% of 200
|
||||
```
|
||||
|
||||
### Comparisons
|
||||
|
||||
```php
|
||||
Bc::compare('10', '5'); // 1 (greater)
|
||||
Bc::compare('5', '10'); // -1 (less)
|
||||
Bc::compare('5.00', '5'); // 0 (equal)
|
||||
|
||||
Bc::eq('5.00', '5.000'); // true
|
||||
Bc::gt('10', '5'); // true
|
||||
Bc::lt('3', '7'); // true
|
||||
Bc::gte('5', '5'); // true
|
||||
Bc::lte('3', '7'); // true
|
||||
|
||||
Bc::isZero('0.00000000'); // true
|
||||
Bc::isZero('0.00000001'); // false
|
||||
Bc::isPositive('0.00000001'); // true
|
||||
Bc::isNegative('-0.00000001'); // true
|
||||
```
|
||||
|
||||
### Scale behaviour
|
||||
|
||||
`Bc::scale()` truncates (does not round). All arithmetic methods default to scale 8 and accept an explicit `scale` parameter:
|
||||
|
||||
```php
|
||||
Bc::add('10.5', '20.3', scale: 2); // '30.80'
|
||||
Bc::div('1', '3', scale: 4); // '0.3333'
|
||||
|
||||
// Equal at scale 2, different at scale 8
|
||||
Bc::eq('5.001', '5.009', scale: 2); // true
|
||||
Bc::eq('5.001', '5.009'); // false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- PHP 8.3+
|
||||
- `ext-bcmath`
|
||||
- `tez/utils-validate` ^1.0
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "tez/utils-num",
|
||||
"type": "library",
|
||||
"description": "PHP number utilities: formatting, byte sizes, percentage, clamping, and rounding helpers (Num), plus arbitrary-precision decimal arithmetic via bcmath (Bc). PHP 8.3+, requires ext-bcmath.",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Tez\\Utils\\Num\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tez\\Utils\\Tests\\Num\\": "tests/"
|
||||
}
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "git",
|
||||
"url": "https://gitea.root-zone.info/tez/tez-utils-validate.git"
|
||||
},
|
||||
{
|
||||
"type": "git",
|
||||
"url": "https://github.com/tezmanian/tez-utils-validate.git"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.3",
|
||||
"ext-bcmath": "*",
|
||||
"tez/utils-validate": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0",
|
||||
"jetbrains/phpstorm-attributes": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"friendsofphp/php-cs-fixer": "^3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"cs-fix": "php-cs-fixer fix",
|
||||
"cs-check": "php-cs-fixer fix --dry-run --diff",
|
||||
"phpstan": "phpstan analyse",
|
||||
"test": "phpunit --testsuite Unit --colors=always"
|
||||
}
|
||||
}
|
||||
Generated
+4616
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- .:/app
|
||||
- composer-cache:/tmp/composer
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
environment:
|
||||
COMPOSER_HOME: /tmp/composer
|
||||
|
||||
volumes:
|
||||
composer-cache:
|
||||
@@ -0,0 +1,8 @@
|
||||
parameters:
|
||||
level: 9
|
||||
paths:
|
||||
- src
|
||||
- tests
|
||||
treatPhpDocTypesAsCertain: false
|
||||
parallel:
|
||||
maximumNumberOfProcesses: 1
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Num;
|
||||
|
||||
use Tez\Utils\Validate\Guard;
|
||||
|
||||
/**
|
||||
* Arbitrary-precision decimal arithmetic helpers wrapping PHP's bcmath extension.
|
||||
*
|
||||
* All methods default to 8 decimal places, which is appropriate for financial
|
||||
* calculations. Every input must be a numeric string (e.g. "1.50000000").
|
||||
* Passing a non-numeric string to any method throws \InvalidArgumentException.
|
||||
*/
|
||||
final class Bc
|
||||
{
|
||||
/**
|
||||
* Add two arbitrary-precision decimals.
|
||||
*
|
||||
* @param numeric-string $a
|
||||
* @param numeric-string $b
|
||||
* @throws \Throwable
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function add(string $a, string $b, int $scale = 8): string
|
||||
{
|
||||
return bcadd(
|
||||
Guard::isNumeric($a),
|
||||
Guard::isNumeric($b),
|
||||
$scale,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum an array of arbitrary-precision decimals.
|
||||
*
|
||||
* Returns a correctly scaled zero ('0.00000000') for an empty array.
|
||||
*
|
||||
* @param numeric-string[] $operands
|
||||
* @throws \Throwable
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function sum(array $operands, int $scale = 8): string
|
||||
{
|
||||
if ($operands === []) {
|
||||
return self::add('0', '0', $scale);
|
||||
}
|
||||
|
||||
return array_reduce(
|
||||
$operands,
|
||||
static fn(string $carry, string $item) => self::add($carry, (string) $item, $scale),
|
||||
'0',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract $b from $a.
|
||||
*
|
||||
* @param numeric-string $a
|
||||
* @param numeric-string $b
|
||||
* @throws \Throwable
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function sub(string $a, string $b, int $scale = 8): string
|
||||
{
|
||||
return bcsub(
|
||||
Guard::isNumeric($a),
|
||||
Guard::isNumeric($b),
|
||||
$scale,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiply two arbitrary-precision decimals.
|
||||
*
|
||||
* @param numeric-string $a
|
||||
* @param numeric-string $b
|
||||
* @throws \Throwable
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function mul(string $a, string $b, int $scale = 8): string
|
||||
{
|
||||
return bcmul(
|
||||
Guard::isNumeric($a),
|
||||
Guard::isNumeric($b),
|
||||
$scale,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divide $a by $b.
|
||||
*
|
||||
* @param numeric-string $a
|
||||
* @param numeric-string $b
|
||||
* @throws \Throwable
|
||||
* @throws \DivisionByZeroError if $b is zero
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function div(string $a, string $b, int $scale = 8): string
|
||||
{
|
||||
Guard::that(
|
||||
!self::isZero($b, $scale + 1),
|
||||
static fn() => new \DivisionByZeroError('Division by zero.'),
|
||||
);
|
||||
|
||||
return bcdiv(Guard::isNumeric($a), $b, $scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a numeric string to exactly $scale decimal places (truncates, does not round).
|
||||
*
|
||||
* Example: Bc::scale('1.5', 8) → '1.50000000'
|
||||
*
|
||||
* @param numeric-string $value
|
||||
* @throws \Throwable
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function scale(string $value, int $scale = 8): string
|
||||
{
|
||||
return self::add(
|
||||
$value,
|
||||
'0',
|
||||
$scale,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the absolute value.
|
||||
*
|
||||
* @param numeric-string $value
|
||||
* @throws \Throwable
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function abs(string $value, int $scale = 8): string
|
||||
{
|
||||
return self::isNegative($value, $scale)
|
||||
? self::sub('0', $value, $scale)
|
||||
: self::add($value, '0', $scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the larger of two values.
|
||||
*
|
||||
* @param numeric-string $a
|
||||
* @param numeric-string $b
|
||||
* @throws \Throwable
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function max(string $a, string $b, int $scale = 8): string
|
||||
{
|
||||
return self::gte($a, $b, $scale)
|
||||
? self::add($a, '0', $scale)
|
||||
: self::add($b, '0', $scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the smaller of two values.
|
||||
*
|
||||
* @param numeric-string $a
|
||||
* @param numeric-string $b
|
||||
* @throws \Throwable
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function min(string $a, string $b, int $scale = 8): string
|
||||
{
|
||||
return self::lte($a, $b, $scale)
|
||||
? self::add($a, '0', $scale)
|
||||
: self::add($b, '0', $scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two values. Returns -1, 0, or 1.
|
||||
*
|
||||
* @param numeric-string $a
|
||||
* @param numeric-string $b
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public static function compare(string $a, string $b, int $scale = 8): int
|
||||
{
|
||||
return bccomp(
|
||||
Guard::isNumeric($a),
|
||||
Guard::isNumeric($b),
|
||||
$scale,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when the value is exactly zero at the given scale.
|
||||
*
|
||||
* @param numeric-string $value
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public static function isZero(string $value, int $scale = 8): bool
|
||||
{
|
||||
return 0 === self::compare($value, '0', $scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when $a is greater than $b.
|
||||
*
|
||||
* @param numeric-string $a
|
||||
* @param numeric-string $b
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public static function gt(string $a, string $b, int $scale = 8): bool
|
||||
{
|
||||
return 1 === self::compare($a, $b, $scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when $a is less than $b.
|
||||
*
|
||||
* @param numeric-string $a
|
||||
* @param numeric-string $b
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public static function lt(string $a, string $b, int $scale = 8): bool
|
||||
{
|
||||
return -1 === self::compare($a, $b, $scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when $a is greater than or equal to $b.
|
||||
*
|
||||
* @param numeric-string $a
|
||||
* @param numeric-string $b
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public static function gte(string $a, string $b, int $scale = 8): bool
|
||||
{
|
||||
return self::compare($a, $b, $scale) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when $a is less than or equal to $b.
|
||||
*
|
||||
* @param numeric-string $a
|
||||
* @param numeric-string $b
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public static function lte(string $a, string $b, int $scale = 8): bool
|
||||
{
|
||||
return self::compare($a, $b, $scale) <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when $a equals $b at the given scale.
|
||||
*
|
||||
* @param numeric-string $a
|
||||
* @param numeric-string $b
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public static function eq(string $a, string $b, int $scale = 8): bool
|
||||
{
|
||||
return 0 === self::compare($a, $b, $scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when $value is strictly greater than zero.
|
||||
*
|
||||
* @param numeric-string $value
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public static function isPositive(string $value, int $scale = 8): bool
|
||||
{
|
||||
return 1 === self::compare($value, '0', $scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when $value is strictly less than zero.
|
||||
*
|
||||
* @param numeric-string $value
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public static function isNegative(string $value, int $scale = 8): bool
|
||||
{
|
||||
return -1 === self::compare($value, '0', $scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Negate a value (equivalent to multiplying by -1).
|
||||
*
|
||||
* @param numeric-string $value
|
||||
* @throws \Throwable
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function negate(string $value, int $scale = 8): string
|
||||
{
|
||||
return self::sub(
|
||||
'0',
|
||||
$value,
|
||||
$scale,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate $percent % of $value (i.e. value × percent ÷ 100).
|
||||
*
|
||||
* Example: Bc::percent('200', '15') → '30.00000000'
|
||||
*
|
||||
* @param numeric-string $value
|
||||
* @param numeric-string $percent
|
||||
* @throws \Throwable
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function percent(string $value, string $percent, int $scale = 8): string
|
||||
{
|
||||
return self::div(
|
||||
self::mul(
|
||||
$value,
|
||||
$percent,
|
||||
$scale + 2,
|
||||
),
|
||||
'100',
|
||||
$scale,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiply an array of arbitrary-precision decimals together.
|
||||
*
|
||||
* Returns '1.00000000' for an empty array (neutral element of multiplication).
|
||||
*
|
||||
* @param numeric-string[] $operands
|
||||
* @throws \Throwable
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function product(array $operands, int $scale = 8): string
|
||||
{
|
||||
if ($operands === []) {
|
||||
return self::add('1', '0', $scale);
|
||||
}
|
||||
|
||||
return array_reduce(
|
||||
$operands,
|
||||
static fn(string $carry, string $item) => self::mul($carry, (string) $item, $scale),
|
||||
'1',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract all subsequent operands from the first.
|
||||
*
|
||||
* Returns '0.00000000' for an empty array.
|
||||
*
|
||||
* @param numeric-string[] $operands
|
||||
* @throws \Throwable
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function subAll(array $operands, int $scale = 8): string
|
||||
{
|
||||
if ($operands === []) {
|
||||
return self::add('0', '0', $scale);
|
||||
}
|
||||
|
||||
$first = (string) array_shift($operands);
|
||||
|
||||
return array_reduce(
|
||||
$operands,
|
||||
static fn(string $carry, string $item) => self::sub($carry, (string) $item, $scale),
|
||||
$first,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divide the first operand by each subsequent operand in turn.
|
||||
*
|
||||
* Returns '1.00000000' for an empty array (neutral element of division).
|
||||
*
|
||||
* @param numeric-string[] $operands
|
||||
* @throws \Throwable
|
||||
* @return numeric-string
|
||||
*/
|
||||
public static function divAll(array $operands, int $scale = 8): string
|
||||
{
|
||||
if ($operands === []) {
|
||||
return self::add('1', '0', $scale);
|
||||
}
|
||||
|
||||
$first = (string) array_shift($operands);
|
||||
|
||||
return array_reduce(
|
||||
$operands,
|
||||
static fn(string $carry, string $item) => self::div($carry, (string) $item, $scale),
|
||||
$first,
|
||||
);
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Num;
|
||||
|
||||
/**
|
||||
* Number formatting helpers: decimal, byte sizes, percentages, and rounding.
|
||||
*/
|
||||
final class Num
|
||||
{
|
||||
private const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
|
||||
/**
|
||||
* Format a number with grouped thousands and a configurable decimal separator.
|
||||
*/
|
||||
public static function format(
|
||||
int|float $value,
|
||||
int $decimals = 2,
|
||||
string $dec = '.',
|
||||
string $thousands = ',',
|
||||
): string {
|
||||
return number_format((float) $value, $decimals, $dec, $thousands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a byte count as a human-readable string using binary units (1 KB = 1024 B).
|
||||
*
|
||||
* @param int<0, max> $bytes
|
||||
*/
|
||||
public static function bytes(int $bytes, int $precision = 2): string
|
||||
{
|
||||
if ($bytes < 1024) {
|
||||
return self::format($bytes, 0) . ' B';
|
||||
}
|
||||
|
||||
$exp = (int) floor(log($bytes, 1024));
|
||||
$exp = min($exp, count(self::BYTE_UNITS) - 1);
|
||||
$value = $bytes / (1024 ** $exp);
|
||||
$unit = self::BYTE_UNITS[$exp];
|
||||
|
||||
return self::format($value, $precision) . ' ' . $unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a ratio (0.0–1.0) as a percentage string.
|
||||
*
|
||||
* Example: 0.1234 → '12.3%'
|
||||
*/
|
||||
public static function percent(int|float $ratio, int $decimals = 1): string
|
||||
{
|
||||
return self::format((float) $ratio * 100, $decimals) . '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a value to the inclusive range [$min, $max].
|
||||
*/
|
||||
public static function clamp(int|float $value, int|float $min, int|float $max): int|float
|
||||
{
|
||||
if ($value < $min) {
|
||||
return $min;
|
||||
}
|
||||
|
||||
if ($value > $max) {
|
||||
return $max;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a value to the nearest multiple of $multiple.
|
||||
*
|
||||
* Examples: roundTo(17, 5) → 15, roundTo(18, 5) → 20, roundTo(1.7, 0.5) → 1.5
|
||||
*/
|
||||
public static function roundTo(int|float $value, int|float $multiple): int|float
|
||||
{
|
||||
if ($multiple === 0 || $multiple === 0.0) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$rounded = round((float) $value / (float) $multiple) * (float) $multiple;
|
||||
|
||||
if (is_int($value) && is_int($multiple)) {
|
||||
return (int) $rounded;
|
||||
}
|
||||
|
||||
return $rounded;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Num;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Num\Bc;
|
||||
|
||||
final class BcTest extends TestCase
|
||||
{
|
||||
// ── add ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testAddDefaultScale(): void
|
||||
{
|
||||
self::assertSame('30.80000000', Bc::add('10.5', '20.3'));
|
||||
}
|
||||
|
||||
public function testAddCustomScale(): void
|
||||
{
|
||||
self::assertSame('30.80', Bc::add('10.5', '20.3', 2));
|
||||
}
|
||||
|
||||
public function testAddNegativeValues(): void
|
||||
{
|
||||
self::assertSame('-5.00000000', Bc::add('-10', '5'));
|
||||
}
|
||||
|
||||
public function testAddZero(): void
|
||||
{
|
||||
self::assertSame('42.00000000', Bc::add('42', '0'));
|
||||
}
|
||||
|
||||
// ── sub ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testSubDefaultScale(): void
|
||||
{
|
||||
self::assertSame('9.20000000', Bc::sub('20.3', '11.1'));
|
||||
}
|
||||
|
||||
public function testSubResultNegative(): void
|
||||
{
|
||||
self::assertSame('-5.00000000', Bc::sub('5', '10'));
|
||||
}
|
||||
|
||||
public function testSubSameValues(): void
|
||||
{
|
||||
self::assertSame('0.00000000', Bc::sub('100', '100'));
|
||||
}
|
||||
|
||||
// ── mul ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testMulDefaultScale(): void
|
||||
{
|
||||
self::assertSame('20.00000000', Bc::mul('4', '5'));
|
||||
}
|
||||
|
||||
public function testMulDecimalPrecision(): void
|
||||
{
|
||||
// 0.1 × 0.1 = 0.01 exactly at scale 8
|
||||
self::assertSame('0.01000000', Bc::mul('0.1', '0.1'));
|
||||
}
|
||||
|
||||
public function testMulByZero(): void
|
||||
{
|
||||
self::assertSame('0.00000000', Bc::mul('12345.678', '0'));
|
||||
}
|
||||
|
||||
public function testMulNegative(): void
|
||||
{
|
||||
self::assertSame('-6.00000000', Bc::mul('-2', '3'));
|
||||
}
|
||||
|
||||
// ── div ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testDivDefaultScale(): void
|
||||
{
|
||||
self::assertSame('0.33333333', Bc::div('1', '3'));
|
||||
}
|
||||
|
||||
public function testDivExactResult(): void
|
||||
{
|
||||
self::assertSame('2.50000000', Bc::div('5', '2'));
|
||||
}
|
||||
|
||||
public function testDivCustomScale(): void
|
||||
{
|
||||
self::assertSame('0.33', Bc::div('1', '3', 2));
|
||||
}
|
||||
|
||||
public function testDivByZeroThrows(): void
|
||||
{
|
||||
$this->expectException(\DivisionByZeroError::class);
|
||||
Bc::div('1', '0');
|
||||
}
|
||||
|
||||
public function testDivByZeroDecimalThrows(): void
|
||||
{
|
||||
$this->expectException(\DivisionByZeroError::class);
|
||||
Bc::div('1', '0.00000000');
|
||||
}
|
||||
|
||||
// ── scale ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testScalePadsShortDecimals(): void
|
||||
{
|
||||
self::assertSame('1.50000000', Bc::scale('1.5'));
|
||||
}
|
||||
|
||||
public function testScaleTruncatesLongDecimals(): void
|
||||
{
|
||||
self::assertSame('1.12345678', Bc::scale('1.123456789'));
|
||||
}
|
||||
|
||||
public function testScaleIntegerInput(): void
|
||||
{
|
||||
self::assertSame('42.00000000', Bc::scale('42'));
|
||||
}
|
||||
|
||||
public function testScaleCustomScale(): void
|
||||
{
|
||||
self::assertSame('3.14', Bc::scale('3.14159', 2));
|
||||
}
|
||||
|
||||
// ── abs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testAbsNegativeValue(): void
|
||||
{
|
||||
self::assertSame('5.00000000', Bc::abs('-5'));
|
||||
}
|
||||
|
||||
public function testAbsPositiveValueUnchanged(): void
|
||||
{
|
||||
self::assertSame('3.14000000', Bc::abs('3.14'));
|
||||
}
|
||||
|
||||
public function testAbsZero(): void
|
||||
{
|
||||
self::assertSame('0.00000000', Bc::abs('0'));
|
||||
}
|
||||
|
||||
// ── max / min ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function testMaxReturnsLarger(): void
|
||||
{
|
||||
self::assertSame('10.00000000', Bc::max('10', '5'));
|
||||
}
|
||||
|
||||
public function testMaxWhenEqual(): void
|
||||
{
|
||||
self::assertSame('5.00000000', Bc::max('5', '5'));
|
||||
}
|
||||
|
||||
public function testMinReturnsSmaller(): void
|
||||
{
|
||||
self::assertSame('3.00000000', Bc::min('10', '3'));
|
||||
}
|
||||
|
||||
public function testMinNegativeValues(): void
|
||||
{
|
||||
self::assertSame('-10.00000000', Bc::min('-5', '-10'));
|
||||
}
|
||||
|
||||
// ── compare ───────────────────────────────────────────────────────────────
|
||||
|
||||
public function testCompareGreater(): void
|
||||
{
|
||||
self::assertSame(1, Bc::compare('10', '5'));
|
||||
}
|
||||
|
||||
public function testCompareLess(): void
|
||||
{
|
||||
self::assertSame(-1, Bc::compare('5', '10'));
|
||||
}
|
||||
|
||||
public function testCompareEqual(): void
|
||||
{
|
||||
self::assertSame(0, Bc::compare('5.00', '5.000'));
|
||||
}
|
||||
|
||||
// ── isZero ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testIsZeroTrueForZero(): void
|
||||
{
|
||||
self::assertTrue(Bc::isZero('0'));
|
||||
}
|
||||
|
||||
public function testIsZeroTrueForFormattedZero(): void
|
||||
{
|
||||
self::assertTrue(Bc::isZero('0.00000000'));
|
||||
}
|
||||
|
||||
public function testIsZeroFalseForNonZero(): void
|
||||
{
|
||||
self::assertFalse(Bc::isZero('0.00000001'));
|
||||
}
|
||||
|
||||
// ── gt / lt ───────────────────────────────────────────────────────────────
|
||||
|
||||
public function testGtTrueWhenGreater(): void
|
||||
{
|
||||
self::assertTrue(Bc::gt('10', '5'));
|
||||
}
|
||||
|
||||
public function testGtFalseWhenEqual(): void
|
||||
{
|
||||
self::assertFalse(Bc::gt('5', '5'));
|
||||
}
|
||||
|
||||
public function testLtTrueWhenLess(): void
|
||||
{
|
||||
self::assertTrue(Bc::lt('3', '7'));
|
||||
}
|
||||
|
||||
public function testLtFalseWhenGreater(): void
|
||||
{
|
||||
self::assertFalse(Bc::lt('7', '3'));
|
||||
}
|
||||
|
||||
// ── gte ───────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testGteTrueWhenGreater(): void
|
||||
{
|
||||
self::assertTrue(Bc::gte('10', '5'));
|
||||
}
|
||||
|
||||
public function testGteTrueWhenEqual(): void
|
||||
{
|
||||
self::assertTrue(Bc::gte('5', '5'));
|
||||
}
|
||||
|
||||
public function testGteFalseWhenLess(): void
|
||||
{
|
||||
self::assertFalse(Bc::gte('3', '7'));
|
||||
}
|
||||
|
||||
// ── lte ───────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testLteTrueWhenLess(): void
|
||||
{
|
||||
self::assertTrue(Bc::lte('3', '7'));
|
||||
}
|
||||
|
||||
public function testLteTrueWhenEqual(): void
|
||||
{
|
||||
self::assertTrue(Bc::lte('5', '5'));
|
||||
}
|
||||
|
||||
public function testLteFalseWhenGreater(): void
|
||||
{
|
||||
self::assertFalse(Bc::lte('10', '5'));
|
||||
}
|
||||
|
||||
// ── eq ────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testEqTrueWhenEqual(): void
|
||||
{
|
||||
self::assertTrue(Bc::eq('5.00', '5.000'));
|
||||
}
|
||||
|
||||
public function testEqFalseWhenDifferent(): void
|
||||
{
|
||||
self::assertFalse(Bc::eq('5', '5.00000001'));
|
||||
}
|
||||
|
||||
public function testEqRespectsScale(): void
|
||||
{
|
||||
// At scale 2, 5.001 and 5.009 both truncate to 5.00 and are equal
|
||||
self::assertTrue(Bc::eq('5.001', '5.009', 2));
|
||||
}
|
||||
|
||||
// ── isPositive ────────────────────────────────────────────────────────────
|
||||
|
||||
public function testIsPositiveTrueForPositiveValue(): void
|
||||
{
|
||||
self::assertTrue(Bc::isPositive('0.00000001'));
|
||||
}
|
||||
|
||||
public function testIsPositiveFalseForZero(): void
|
||||
{
|
||||
self::assertFalse(Bc::isPositive('0'));
|
||||
}
|
||||
|
||||
public function testIsPositiveFalseForNegative(): void
|
||||
{
|
||||
self::assertFalse(Bc::isPositive('-1'));
|
||||
}
|
||||
|
||||
// ── isNegative ────────────────────────────────────────────────────────────
|
||||
|
||||
public function testIsNegativeTrueForNegativeValue(): void
|
||||
{
|
||||
self::assertTrue(Bc::isNegative('-0.00000001'));
|
||||
}
|
||||
|
||||
public function testIsNegativeFalseForZero(): void
|
||||
{
|
||||
self::assertFalse(Bc::isNegative('0'));
|
||||
}
|
||||
|
||||
public function testIsNegativeFalseForPositive(): void
|
||||
{
|
||||
self::assertFalse(Bc::isNegative('1'));
|
||||
}
|
||||
|
||||
// ── negate ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testNegatePositiveValue(): void
|
||||
{
|
||||
self::assertSame('-5.00000000', Bc::negate('5'));
|
||||
}
|
||||
|
||||
public function testNegateNegativeValue(): void
|
||||
{
|
||||
self::assertSame('5.00000000', Bc::negate('-5'));
|
||||
}
|
||||
|
||||
public function testNegateZeroRemainsZero(): void
|
||||
{
|
||||
self::assertSame('0.00000000', Bc::negate('0'));
|
||||
}
|
||||
|
||||
public function testNegateCustomScale(): void
|
||||
{
|
||||
self::assertSame('-3.14', Bc::negate('3.14', 2));
|
||||
}
|
||||
|
||||
// ── sum ───────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testSumEmptyArrayReturnsZero(): void
|
||||
{
|
||||
self::assertSame('0.00000000', Bc::sum([]));
|
||||
}
|
||||
|
||||
public function testSumSingleElement(): void
|
||||
{
|
||||
self::assertSame('5.00000000', Bc::sum(['5']));
|
||||
}
|
||||
|
||||
public function testSumMultipleElements(): void
|
||||
{
|
||||
self::assertSame('6.00000000', Bc::sum(['1', '2', '3']));
|
||||
}
|
||||
|
||||
public function testSumWithNegativeValues(): void
|
||||
{
|
||||
self::assertSame('-2.00000000', Bc::sum(['-1', '2', '-3']));
|
||||
}
|
||||
|
||||
public function testSumCustomScale(): void
|
||||
{
|
||||
self::assertSame('3.30', Bc::sum(['1.1', '2.2'], 2));
|
||||
}
|
||||
|
||||
public function testSumThrowsOnNonNumericElement(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
/** @phpstan-ignore-next-line */
|
||||
Bc::sum(['1', 'not-a-number', '3']);
|
||||
}
|
||||
|
||||
// ── product ───────────────────────────────────────────────────────────────
|
||||
|
||||
public function testProductEmptyArrayReturnsOne(): void
|
||||
{
|
||||
self::assertSame('1.00000000', Bc::product([]));
|
||||
}
|
||||
|
||||
public function testProductSingleElement(): void
|
||||
{
|
||||
self::assertSame('7.00000000', Bc::product(['7']));
|
||||
}
|
||||
|
||||
public function testProductMultipleElements(): void
|
||||
{
|
||||
self::assertSame('24.00000000', Bc::product(['2', '3', '4']));
|
||||
}
|
||||
|
||||
public function testProductWithDecimalValues(): void
|
||||
{
|
||||
self::assertSame('0.25000000', Bc::product(['0.5', '0.5']));
|
||||
}
|
||||
|
||||
public function testProductWithNegativeValue(): void
|
||||
{
|
||||
self::assertSame('-6.00000000', Bc::product(['-2', '3']));
|
||||
}
|
||||
|
||||
// ── subAll ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testSubAllEmptyArrayReturnsZero(): void
|
||||
{
|
||||
self::assertSame('0.00000000', Bc::subAll([]));
|
||||
}
|
||||
|
||||
public function testSubAllSingleElement(): void
|
||||
{
|
||||
// Single element is returned as-is (no reduce step applies)
|
||||
self::assertSame('5', Bc::subAll(['5']));
|
||||
}
|
||||
|
||||
public function testSubAllMultipleElements(): void
|
||||
{
|
||||
// 10 - 3 - 2 = 5
|
||||
self::assertSame('5.00000000', Bc::subAll(['10', '3', '2']));
|
||||
}
|
||||
|
||||
public function testSubAllResultCanBeNegative(): void
|
||||
{
|
||||
self::assertSame('-5.00000000', Bc::subAll(['5', '10']));
|
||||
}
|
||||
|
||||
// ── divAll ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testDivAllEmptyArrayReturnsOne(): void
|
||||
{
|
||||
self::assertSame('1.00000000', Bc::divAll([]));
|
||||
}
|
||||
|
||||
public function testDivAllSingleElement(): void
|
||||
{
|
||||
// Single element is returned as-is (no reduce step applies)
|
||||
self::assertSame('8', Bc::divAll(['8']));
|
||||
}
|
||||
|
||||
public function testDivAllMultipleElements(): void
|
||||
{
|
||||
// 100 / 5 / 4 = 5
|
||||
self::assertSame('5.00000000', Bc::divAll(['100', '5', '4']));
|
||||
}
|
||||
|
||||
public function testDivAllThrowsOnDivisionByZero(): void
|
||||
{
|
||||
$this->expectException(\DivisionByZeroError::class);
|
||||
Bc::divAll(['10', '0']);
|
||||
}
|
||||
|
||||
// ── percent ───────────────────────────────────────────────────────────────
|
||||
|
||||
public function testPercentBasicCalculation(): void
|
||||
{
|
||||
// 15% of 200 = 30
|
||||
self::assertSame('30.00000000', Bc::percent('200', '15'));
|
||||
}
|
||||
|
||||
public function testPercentFiftyPercent(): void
|
||||
{
|
||||
self::assertSame('50.00000000', Bc::percent('100', '50'));
|
||||
}
|
||||
|
||||
public function testPercentFractionalResult(): void
|
||||
{
|
||||
// 33.33% of 100 = 33.33000000
|
||||
self::assertSame('33.33000000', Bc::percent('100', '33.33'));
|
||||
}
|
||||
|
||||
public function testPercentZeroPercent(): void
|
||||
{
|
||||
self::assertSame('0.00000000', Bc::percent('100', '0'));
|
||||
}
|
||||
|
||||
public function testPercentCustomScale(): void
|
||||
{
|
||||
self::assertSame('30.00', Bc::percent('200', '15', 2));
|
||||
}
|
||||
|
||||
// ── Guard validation ──────────────────────────────────────────────────────
|
||||
|
||||
public function testAddThrowsOnNonNumericInput(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
/** @phpstan-ignore-next-line */
|
||||
Bc::add('not-a-number', '1');
|
||||
}
|
||||
|
||||
public function testSubThrowsOnNonNumericInput(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
/** @phpstan-ignore-next-line */
|
||||
Bc::sub('5', 'abc');
|
||||
}
|
||||
|
||||
public function testMulThrowsOnNonNumericInput(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
/** @phpstan-ignore-next-line */
|
||||
Bc::mul('x', '2');
|
||||
}
|
||||
|
||||
public function testDivThrowsOnNonNumericInput(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
/** @phpstan-ignore-next-line */
|
||||
Bc::div('10', 'y');
|
||||
}
|
||||
|
||||
public function testGtThrowsOnNonNumericInput(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
/** @phpstan-ignore-next-line */
|
||||
Bc::gt('bad', '1');
|
||||
}
|
||||
|
||||
public function testLtThrowsOnNonNumericInput(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
/** @phpstan-ignore-next-line */
|
||||
Bc::lt('1', 'bad');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Num;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Num\Num;
|
||||
|
||||
final class NumTest extends TestCase
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// format()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testFormatDefaultSeparators(): void
|
||||
{
|
||||
self::assertSame('1,234,567.89', Num::format(1234567.891));
|
||||
}
|
||||
|
||||
public function testFormatCustomSeparators(): void
|
||||
{
|
||||
self::assertSame('1.234.567,89', Num::format(1234567.891, dec: ',', thousands: '.'));
|
||||
}
|
||||
|
||||
public function testFormatZeroDecimals(): void
|
||||
{
|
||||
self::assertSame('1,235', Num::format(1234.5, 0));
|
||||
}
|
||||
|
||||
public function testFormatNegative(): void
|
||||
{
|
||||
self::assertSame('-1,000.00', Num::format(-1000.0));
|
||||
}
|
||||
|
||||
public function testFormatInteger(): void
|
||||
{
|
||||
self::assertSame('42.00', Num::format(42));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// bytes()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testBytesZero(): void
|
||||
{
|
||||
self::assertSame('0 B', Num::bytes(0));
|
||||
}
|
||||
|
||||
public function testBytesUnderKilobyte(): void
|
||||
{
|
||||
self::assertSame('1,023 B', Num::bytes(1023));
|
||||
}
|
||||
|
||||
public function testBytesExactlyOneKilobyte(): void
|
||||
{
|
||||
self::assertSame('1.00 KB', Num::bytes(1024));
|
||||
}
|
||||
|
||||
public function testBytesMegabyte(): void
|
||||
{
|
||||
self::assertSame('1.00 MB', Num::bytes(1048576));
|
||||
}
|
||||
|
||||
public function testBytesGigabyte(): void
|
||||
{
|
||||
self::assertSame('1.00 GB', Num::bytes(1073741824));
|
||||
}
|
||||
|
||||
public function testBytesCustomPrecision(): void
|
||||
{
|
||||
self::assertSame('1 KB', Num::bytes(1500, precision: 0));
|
||||
}
|
||||
|
||||
public function testBytesPrecisionTwo(): void
|
||||
{
|
||||
self::assertSame('1.46 KB', Num::bytes(1500));
|
||||
}
|
||||
|
||||
public function testBytesTerabyte(): void
|
||||
{
|
||||
self::assertSame('1.00 TB', Num::bytes(1024 ** 4));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// percent()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testPercentDefaultDecimals(): void
|
||||
{
|
||||
self::assertSame('12.3%', Num::percent(0.1234));
|
||||
}
|
||||
|
||||
public function testPercentFullRatio(): void
|
||||
{
|
||||
self::assertSame('100.0%', Num::percent(1.0));
|
||||
}
|
||||
|
||||
public function testPercentSmallValue(): void
|
||||
{
|
||||
self::assertSame('0.56%', Num::percent(0.0056, decimals: 2));
|
||||
}
|
||||
|
||||
public function testPercentZero(): void
|
||||
{
|
||||
self::assertSame('0.0%', Num::percent(0.0));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// clamp()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testClampAboveMax(): void
|
||||
{
|
||||
self::assertSame(100, Num::clamp(105, 0, 100));
|
||||
}
|
||||
|
||||
public function testClampBelowMin(): void
|
||||
{
|
||||
self::assertSame(0, Num::clamp(-5, 0, 100));
|
||||
}
|
||||
|
||||
public function testClampWithinRange(): void
|
||||
{
|
||||
self::assertSame(50, Num::clamp(50, 0, 100));
|
||||
}
|
||||
|
||||
public function testClampAtExactBoundary(): void
|
||||
{
|
||||
self::assertSame(0, Num::clamp(0, 0, 100));
|
||||
self::assertSame(100, Num::clamp(100, 0, 100));
|
||||
}
|
||||
|
||||
public function testClampFloat(): void
|
||||
{
|
||||
self::assertSame(0.5, Num::clamp(1.5, 0.0, 0.5));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// roundTo()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testRoundToNearestFiveLow(): void
|
||||
{
|
||||
self::assertSame(15, Num::roundTo(17, 5));
|
||||
}
|
||||
|
||||
public function testRoundToNearestFiveHigh(): void
|
||||
{
|
||||
self::assertSame(20, Num::roundTo(18, 5));
|
||||
}
|
||||
|
||||
public function testRoundToHalf(): void
|
||||
{
|
||||
self::assertSame(1.5, Num::roundTo(1.7, 0.5));
|
||||
}
|
||||
|
||||
public function testRoundToExactMultiple(): void
|
||||
{
|
||||
self::assertSame(10, Num::roundTo(10, 5));
|
||||
}
|
||||
|
||||
public function testRoundToOne(): void
|
||||
{
|
||||
self::assertSame(7, Num::roundTo(7, 1));
|
||||
}
|
||||
|
||||
public function testRoundToZeroMultipleReturnsValue(): void
|
||||
{
|
||||
self::assertSame(17, Num::roundTo(17, 0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user