diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 80e7cb6..495d780 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -2,9 +2,9 @@ name: PHP Tests on: push: - branches: [ main ] + branches: [ main, dev ] pull_request: - branches: [ main ] + branches: [ main, dev ] jobs: tests: @@ -27,4 +27,6 @@ jobs: run: composer install --prefer-dist --no-progress --no-interaction - name: Run Tests - run: vendor/bin/phpunit --coverage-text --colors=never + env: + XDEBUG_MODE: coverage + run: vendor/bin/phpunit --colors=never --coverage-text diff --git a/.gitignore b/.gitignore index be595f7..1b70191 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ vendor .idea composer.lock *.cache +coverage diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6bf85ff --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ayup Creative + +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/composer.json b/composer.json index eaf74b3..5afbeb9 100644 --- a/composer.json +++ b/composer.json @@ -20,5 +20,9 @@ "phpunit/phpunit": "^10.0", "nesbot/carbon": "^3.0", "illuminate/database": "^10.0|^11.0" + }, + "scripts": { + "test": "vendor/bin/phpunit", + "test-coverage": "vendor/bin/phpunit --coverage-html coverage" } } diff --git a/phpunit.xml b/phpunit.xml index 8e8663c..3b1a7d7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,17 +1,31 @@ - - + + - + tests - + - ./src + src - ./tests + vendor - + diff --git a/readme.md b/readme.md index e69de29..0f86024 100644 --- a/readme.md +++ b/readme.md @@ -0,0 +1,161 @@ +# Duration + +[![PHP Tests](https://github.com/Ayup-Creative/php-duration/actions/workflows/phpunit.yml/badge.svg)](https://github.com/Ayup-Creative/php-duration/actions/workflows/phpunit.yml) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/ayup-creative/duration.svg?style=flat-square)](https://packagist.org/packages/ayup-creative/duration) +[![Total Downloads](https://img.shields.io/packagist/dt/ayup-creative/duration.svg?style=flat-square)](https://packagist.org/packages/ayup-creative/duration) +[![License](https://img.shields.io/packagist/l/ayup-creative/duration.svg?style=flat-square)](https://packagist.org/packages/ayup-creative/duration) + +Carbon-style mutable and immutable Duration value objects with Laravel support. This package provides a simple way to handle durations of time (hours, minutes, seconds) without the complexity of dates. + +## Installation + +You can install the package via composer: + +```bash +composer require ayup-creative/duration +``` + +## Usage + +The package provides two main classes: `Duration` (mutable) and `DurationImmutable` (immutable). + +### Creation + +```php +use AyupCreative\Duration\DurationImmutable; + +// From various units +$duration = DurationImmutable::seconds(100); +$duration = DurationImmutable::minutes(5); +$duration = DurationImmutable::hours(2); +$duration = DurationImmutable::days(1); +$duration = DurationImmutable::weeks(1); +$duration = DurationImmutable::months(1); // 30.44 days +$duration = DurationImmutable::years(1); // 365.25 days + +// Combined +$duration = DurationImmutable::make(days: 1, hours: 2, minutes: 3, seconds: 4); +$duration = DurationImmutable::hoursAndMinutes(1, 30); + +// From Carbon +$duration = DurationImmutable::fromCarbon(\Carbon\CarbonInterval::hours(2)); +``` + +### Accessing Units + +You can access the total time in various units: + +```php +$duration = DurationImmutable::hours(2); + +$duration->totalSeconds(); // 7200 +$duration->totalMinutes(); // 120 (int) +$duration->toMinutes(); // 120.0 (float) +$duration->totalHours(); // 2 + +// Or via magic properties +$duration->totalSeconds; // 7200 +$duration->totalMinutes; // 120 +``` + +You can also get the individual parts of a decomposed duration: + +```php +$duration = DurationImmutable::make(hours: 1, minutes: 30, seconds: 15); + +$duration->getHours(); // 1 +$duration->getMinutes(); // 30 +$duration->getSeconds(); // 15 +``` + +### Arithmetic + +```php +$d1 = DurationImmutable::minutes(30); +$d2 = DurationImmutable::minutes(15); + +$result = $d1->add($d2); // 45 minutes +$result = $d1->sub($d2); // 15 minutes +$result = $d1->multiply(2); // 60 minutes + +// Ceiling operations +$duration = DurationImmutable::seconds(65); +$duration->ceilToMinutes(1); // 120 seconds +$duration->ceilTo(30); // 90 seconds +``` + +### Comparisons + +```php +$d1 = DurationImmutable::minutes(30); +$d2 = DurationImmutable::minutes(60); + +$d1->isLessThan($d2); // true +$d1->isGreaterThan($d2); // false +$d1->equals($d2); // false +$d1->isZero(); // false +$d1->max($d2); // returns $d2 +``` + +### Formatting & Humanization + +```php +$duration = DurationImmutable::make(days: 1, hours: 2, minutes: 3, seconds: 4); + +// Custom format +$duration->format('dd:hh:mm:ss'); // "01:02:03:04" +$duration->format('d:h:m:s'); // "1:2:3:4" + +// Human readable +$duration->toHuman(); // "1 day 2 hours" +$duration->toShortHuman(); // "1d 2h 3m" + +// String conversion +(string) $duration; // "02:03" (hh:mm) +``` + +### TimeDelta (Negative Durations) + +While `Duration` and `DurationImmutable` are always positive (clamped to 0), `TimeDelta` allows for negative durations, perfect for representing differences. + +```php +use AyupCreative\Duration\TimeDelta; + +$delta = new TimeDelta(-3600); // -1 hour + +$delta->isNegative(); // true +$delta->absolute(); // Returns DurationImmutable of 1 hour +``` + +### Laravel Support + +The package includes Eloquent casts for easy integration with your models. + +```php +use AyupCreative\Duration\Casts\Seconds; +use AyupCreative\Duration\Casts\Minutes; +use AyupCreative\Duration\Casts\Hours; +use AyupCreative\Duration\Casts\Days; +use Illuminate\Database\Eloquent\Model; + +class Task extends Model +{ + protected $casts = [ + 'duration_in_seconds' => Seconds::class, + 'estimate_in_hours' => Hours::class, + ]; +} + +$task = Task::find(1); +$task->duration_in_seconds; // Returns DurationImmutable instance +``` + +## Testing + +```bash +vendor/bin/phpunit +``` + +## License + +The MIT License (MIT). Please see [License File](LICENSE) for more information. diff --git a/src/Behaviour/DurationBehaviour.php b/src/Behaviour/DurationBehaviour.php deleted file mode 100644 index ab9d1e6..0000000 --- a/src/Behaviour/DurationBehaviour.php +++ /dev/null @@ -1,139 +0,0 @@ -totalMinutes = max(0, $minutes); - } - - public static function minutes(int $minutes): self - { - return new self($minutes); - } - - public static function hours(int $hours): self - { - return new self($hours * 60); - } - - public static function hoursAndMinutes(int $hours, int $minutes): self - { - return new self(($hours * 60) + $minutes); - } - - public static function zero(): self - { - return new self(0); - } - - public static function fromCarbon(CarbonInterval $interval): self - { - return new self((int) $interval->totalMinutes); - } - - public function getHours(): int - { - return intdiv($this->totalMinutes, 60); - } - - public function remainderMinutes(): int - { - return $this->totalMinutes % 60; - } - - public function isOver(self $other): bool - { - return $this->totalMinutes > $other->totalMinutes; - } - - public function isBelow(self $other): bool - { - return $this->totalMinutes < $other->totalMinutes; - } - - public function equals(self $other): bool - { - return $this->totalMinutes === $other->totalMinutes; - } - - public function isZero(): bool - { - return $this->totalMinutes === 0; - } - - public function max(self $other): self - { - return $this->totalMinutes >= $other->totalMinutes ? $this : $other; - } - - public function min(self $other): self - { - return $this->totalMinutes <= $other->totalMinutes ? $this : $other; - } - - public function diff(self $other): TimeDelta - { - return TimeDelta::minutes( - $this->totalMinutes - $other->totalMinutes - ); - } - - public function toDateInterval(): DateInterval - { - return new DateInterval('PT' . $this->totalMinutes . 'M'); - } - - public function toHuman(): string - { - return match (true) { - $this->totalMinutes < 60 => "{$this->totalMinutes} minutes", - $this->totalMinutes % 60 === 0 => ($this->totalMinutes / 60) . " hours", - default => sprintf( - '%d hours %d minutes', - $this->getHours(), - $this->remainderMinutes() - ), - }; - } - - - public function jsonSerialize(): int - { - return $this->totalMinutes; - } - - public function format(string $format): string - { - return sprintf($format, $this->getHours(), $this->remainderMinutes()); - } - - - public function __toString(): string - { - return $this->format($this->format); - } - - public function __get(string $name) - { - if($name === 'totalMinutes') { - return $this->totalMinutes; - } - - throw new \Error('Undefined property: ' . static::class . '::' . $name); - } -} diff --git a/src/Casts/Days.php b/src/Casts/Days.php new file mode 100644 index 0000000..f313b9b --- /dev/null +++ b/src/Casts/Days.php @@ -0,0 +1,14 @@ +getUnitsMethod())) { + throw new InvalidArgumentException('Invalid duration unit ['.$this->getUnitsMethod().']'); + } + + return DurationImmutable::{$this->getUnitsMethod()}((int) $value); + } + + /** + * Prepare the given value for storage. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * @return int|null + * @throws \InvalidArgumentException + * @see \AyupCreative\Duration\Tests\Casts\Cast::test_cast_with_duration() + * @see \AyupCreative\Duration\Tests\Casts\Cast::test_cast_with_time_delta() + */ + public function set($model, string $key, $value, array $attributes): ?int + { + if (is_null($value)) { + return null; + } + + $method = 'total'.ucfirst($this->getUnitsMethod()); + + $result = match (true) { + $value instanceof DurationImmutable, $value instanceof Duration => $value->$method(), + $value instanceof TimeDelta => $value->$method(), + is_int($value) => $value, + is_numeric($value) => (int) $value, + default => throw new InvalidArgumentException('Invalid duration value ['.gettype($value).']'), + }; + + return max(0, $result); + } +} diff --git a/src/Casts/DurationImmutableCast.php b/src/Casts/DurationImmutableCast.php deleted file mode 100644 index 1d2aba9..0000000 --- a/src/Casts/DurationImmutableCast.php +++ /dev/null @@ -1,25 +0,0 @@ - $value->totalMinutes, - is_int($value) => $value, - default => throw new InvalidArgumentException('Invalid duration value'), - }; - } -} diff --git a/src/Casts/Hours.php b/src/Casts/Hours.php new file mode 100644 index 0000000..0ee66a0 --- /dev/null +++ b/src/Casts/Hours.php @@ -0,0 +1,14 @@ +totalSeconds = max(0, $seconds); + } - public function add(DurationImmutable|self $other): self + /** + * Add another duration to the current one. + * + * Usage: $duration->add(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationInterface $other + * @return self + * @see \AyupCreative\Duration\Tests\DurationTest::it_is_mutable_on_arithmetic_operations() + */ + public function add(DurationInterface $other): self { - $this->totalMinutes += $other->totalMinutes; + $seconds = $this->totalSeconds + $other->totalSeconds(); + + $this->totalSeconds = (new self($seconds))->totalSeconds; return $this; } - public function sub(DurationImmutable|self $other): self + /** + * Subtract another duration from the current one. + * + * Usage: $duration->sub(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationInterface $other + * @return self + * @see \AyupCreative\Duration\Tests\DurationTest::it_is_mutable_on_arithmetic_operations() + */ + public function sub(DurationInterface $other): self { - $this->totalMinutes = max(0, $this->totalMinutes - $other->totalMinutes); + $seconds = $this->totalSeconds - $other->totalSeconds(); + + $this->totalSeconds = (new self($seconds))->totalSeconds; return $this; } + /** + * Multiply the duration by a factor. + * + * Usage: $duration->multiply(1.5); + * + * @param float $factor + * @return self + * @see \AyupCreative\Duration\Tests\DurationTest::it_is_mutable_on_arithmetic_operations() + */ public function multiply(float $factor): self { - $this->totalMinutes = (int) round($this->totalMinutes * $factor); + $seconds = (int)round($this->totalSeconds * $factor); + + $this->totalSeconds = (new self($seconds))->totalSeconds; return $this; } - public function ceilTo(int $interval): self + /** + * Ceil the duration to the nearest multiple of the given seconds. + * + * Usage: $duration->ceilTo(30); + * + * @param int $seconds + * @return self + * @see \AyupCreative\Duration\Tests\DurationTest::it_can_ceil_durations() + */ + public function ceilTo(int $seconds): self { - $this->totalMinutes = (int) (ceil($this->totalMinutes / $interval) * $interval); + $seconds = (int)(ceil($this->totalSeconds / $seconds) * $seconds); + + $this->totalSeconds = (new self($seconds))->totalSeconds; return $this; } + /** + * Ceil the duration to the nearest multiple of the given minutes. + * + * Usage: $duration->ceilToMinutes(15); + * + * @param int $minutes + * @return self + * @see \AyupCreative\Duration\Tests\DurationTest::it_can_ceil_durations() + */ + public function ceilToMinutes(int $minutes): self + { + return $this->ceilTo($minutes * self::SECONDS_PER_MINUTE); + } + + /** + * Ceil the duration to the nearest multiple of the given hours. + * + * Usage: $duration->ceilToHours(1); + * + * @param int $hours + * @return self + * @see \AyupCreative\Duration\Tests\DurationTest::it_can_ceil_durations() + */ + public function ceilToHours(int $hours): self + { + return $this->ceilTo($hours * self::SECONDS_PER_HOUR); + } + + /** + * Ceil the duration to the nearest multiple of the given days. + * + * Usage: $duration->ceilToDays(1); + * + * @param int $days + * @return self + * @see \AyupCreative\Duration\Tests\DurationTest::it_can_ceil_durations() + */ + public function ceilToDays(int $days): self + { + return $this->ceilTo($days * self::SECONDS_PER_DAY); + } + + /** + * Convert the duration to an immutable instance. + * + * Usage: $immutable = $duration->toImmutable(); + * + * @return \AyupCreative\Duration\DurationImmutable + * @see \AyupCreative\Duration\Tests\DurationTest::it_can_be_converted_to_immutable() + */ public function toImmutable(): DurationImmutable { - return DurationImmutable::minutes($this->totalMinutes); + return DurationImmutable::seconds($this->totalSeconds); } } diff --git a/src/DurationImmutable.php b/src/DurationImmutable.php index d18a241..53dc215 100644 --- a/src/DurationImmutable.php +++ b/src/DurationImmutable.php @@ -1,39 +1,41 @@ totalMinutes + $other->totalMinutes); - } - - public function sub(self $other): self - { - return new self($this->totalMinutes - $other->totalMinutes); - } - - public function multiply(float $factor): self - { - return new self((int) round($this->totalMinutes * $factor)); - } - - public function ceilTo(int $interval): self + use Features\Arithmetic; + use Features\Builders; + use Features\Constants; + use Features\Conversion; + use Features\Formatting; + use Features\MagicProperties; + use Features\TemporalUnits; + + protected int $totalSeconds; + + /** + * Create a new DurationImmutable instance. + * + * @param int $seconds + * @see \AyupCreative\Duration\Tests\DurationImmutableTest::it_can_be_instantiated_with_seconds() + */ + public function __construct(int $seconds) { - return new self( - (int) (ceil($this->totalMinutes / $interval) * $interval) - ); + $this->totalSeconds = max(0, $seconds); } + /** + * Convert the duration to a mutable instance. + * + * Usage: $mutable = $duration->toMutable(); + * + * @return \AyupCreative\Duration\Duration + */ public function toMutable(): Duration { - return Duration::minutes($this->totalMinutes); + return Duration::seconds($this->totalSeconds); } } diff --git a/src/DurationInterface.php b/src/DurationInterface.php new file mode 100644 index 0000000..4a5fdb2 --- /dev/null +++ b/src/DurationInterface.php @@ -0,0 +1,13 @@ +add(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationInterface $other + * @return self + */ + public function add(DurationInterface $other): self + { + return new self($this->totalSeconds + $other->totalSeconds()); + } + + /** + * Subtract another duration. + * + * Usage: $duration->sub(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationInterface $other + * @return self + */ + public function sub(DurationInterface $other): self + { + return new self($this->totalSeconds - $other->totalSeconds()); + } + + /** + * Multiply the duration by a factor. + * + * Usage: $duration->multiply(1.5); + * + * @param float $factor + * @return self + */ + public function multiply(float $factor): self + { + return new self((int) round($this->totalSeconds * $factor)); + } + + /** + * Ceil the duration to the nearest multiple of the given seconds. + * + * Usage: $duration->ceilTo(30); + * + * @param int $seconds + * @return self + */ + public function ceilTo(int $seconds): self + { + if ($seconds === 0) { + return $this; + } + + return new self( + (int) (ceil($this->totalSeconds / $seconds) * $seconds) + ); + } + + /** + * Ceil the duration to the nearest multiple of the given minutes. + * + * Usage: $duration->ceilToMinutes(15); + * + * @param int $minutes + * @return self + */ + public function ceilToMinutes(int $minutes): self + { + return $this->ceilTo($minutes * self::SECONDS_PER_MINUTE); + } + + /** + * Ceil the duration to the nearest multiple of the given hours. + * + * Usage: $duration->ceilToHours(1); + * + * @param int $hours + * @return self + */ + public function ceilToHours(int $hours): self + { + return $this->ceilTo($hours * self::SECONDS_PER_HOUR); + } + + /** + * Ceil the duration to the nearest multiple of the given days. + * + * Usage: $duration->ceilToDays(1); + * + * @param int $days + * @return self + */ + public function ceilToDays(int $days): self + { + return $this->ceilTo($days * self::SECONDS_PER_DAY); + } + + /** + * Check if the duration is greater than another. + * + * Usage: $duration->isOver(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationInterface $other + * @return bool + */ + public function isOver(DurationInterface $other): bool + { + return $this->totalSeconds > $other->totalSeconds(); + } + + /** + * Check if the duration is less than another. + * + * Usage: $duration->isBelow(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationInterface $other + * @return bool + */ + public function isBelow(DurationInterface $other): bool + { + return $this->totalSeconds < $other->totalSeconds(); + } + + /** + * Check if the duration is less than another. + * + * Usage: $duration->isLessThan(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationInterface $other + * @return bool + */ + public function isLessThan(DurationInterface $other): bool + { + return $this->isBelow($other); + } + + /** + * Check if the duration is greater than another. + * + * Usage: $duration->isGreaterThan(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationInterface $other + * @return bool + */ + public function isGreaterThan(DurationInterface $other): bool + { + return $this->isOver($other); + } + + /** + * Check if the duration is less than or equal to another. + * + * Usage: $duration->isLessThanOrEqualTo(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationInterface $other + * @return bool + */ + public function isLessThanOrEqualTo(DurationInterface $other): bool + { + return $this->isBelow($other) || $this->equals($other); + } + + /** + * Check if the duration is greater than or equal to another. + * + * Usage: $duration->isGreaterThanOrEqualTo(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationInterface $other + * @return bool + */ + public function isGreaterThanOrEqualTo(DurationInterface $other): bool + { + return $this->isOver($other) || $this->equals($other); + } + + /** + * Check if the duration is equal to another. + * + * Usage: $duration->equals(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationInterface $other + * @return bool + */ + public function equals(DurationInterface $other): bool + { + return $this->totalSeconds === $other->totalSeconds(); + } + + /** + * Check if the duration does not equal another. + * + * Usage: $duration->doesNotEqual(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationInterface $other + * @return bool + */ + public function doesNotEqual(DurationInterface $other): bool + { + return !$this->equals($other); + } + + /** + * Check if the duration is zero. + * + * Usage: $duration->isZero(); + * + * @return bool + */ + public function isZero(): bool + { + return $this->totalSeconds === 0; + } + + /** + * Check if the duration is not zero. + * + * Usage: $duration->isNotZero(); + * + * @return bool + */ + public function isNotZero(): bool + { + return $this->totalSeconds !== 0; + } + + /** + * Get the maximum of two durations. + * + * Usage: $max = $duration->max(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationInterface $other + * @return self + */ + public function max(DurationInterface $other): self + { + if ($this->totalSeconds >= $other->totalSeconds()) { + return $this; + } + + return $other instanceof self ? $other : new self($other->totalSeconds()); + } + + /** + * Get the minimum of two durations. + * + * Usage: $min = $duration->min(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationInterface $other + * @return self + */ + public function min(DurationInterface $other): self + { + if ($this->totalSeconds <= $other->totalSeconds()) { + return $this; + } + + return $other instanceof self ? $other : new self($other->totalSeconds()); + } + + /** + * Get the difference between two durations. + * + * Usage: $delta = $duration->diff(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationInterface $other + * @return \AyupCreative\Duration\TimeDelta + */ + public function diff(DurationInterface $other): TimeDelta + { + return TimeDelta::seconds( + $this->totalSeconds - $other->totalSeconds() + ); + } +} diff --git a/src/Features/Builders.php b/src/Features/Builders.php new file mode 100644 index 0000000..25d4e5b --- /dev/null +++ b/src/Features/Builders.php @@ -0,0 +1,136 @@ +totalSeconds); + } +} diff --git a/src/Features/Constants.php b/src/Features/Constants.php new file mode 100644 index 0000000..8d92198 --- /dev/null +++ b/src/Features/Constants.php @@ -0,0 +1,40 @@ +totalSeconds); + + $days = intdiv($seconds, self::SECONDS_PER_DAY); + $seconds %= self::SECONDS_PER_DAY; + + $hours = intdiv($seconds, self::SECONDS_PER_HOUR); + $seconds %= self::SECONDS_PER_HOUR; + + $minutes = intdiv($seconds, self::SECONDS_PER_MINUTE); + $seconds %= self::SECONDS_PER_MINUTE; + + return [ + 'days' => $days, + 'hours' => $hours, + 'minutes' => $minutes, + 'seconds' => $seconds, + 'sign' => $this->totalSeconds < 0 ? '-' : '' + ]; + } +} diff --git a/src/Features/Conversion.php b/src/Features/Conversion.php new file mode 100644 index 0000000..61a165e --- /dev/null +++ b/src/Features/Conversion.php @@ -0,0 +1,134 @@ +totalSeconds; + } + + /** + * Get the total duration in minutes. + * + * @return float + */ + public function toMinutes(): float + { + return $this->totalSeconds / self::SECONDS_PER_MINUTE; + } + + /** + * Get the total duration in hours. + * + * @return float + */ + public function toHours(): float + { + return $this->totalSeconds / self::SECONDS_PER_HOUR; + } + + /** + * Get the total duration in days. + * + * @return float + */ + public function toDays(): float + { + return $this->totalSeconds / self::SECONDS_PER_DAY; + } + + /** + * Get the total duration in weeks. + * + * @return float + */ + public function toWeeks(): float + { + return $this->totalSeconds / self::SECONDS_PER_WEEK; + } + + /** + * Get the total duration in months (approximate). + * + * @return float + */ + public function toMonths(): float + { + return $this->totalSeconds / self::SECONDS_PER_MONTH; + } + + /** + * Get the total duration in years (approximate). + * + * @return float + */ + public function toYears(): float + { + return $this->totalSeconds / self::SECONDS_PER_YEAR; + } + + /** + * Convert the duration to a CarbonInterval instance. + * + * @return \Carbon\CarbonInterval + */ + public function toCarbonInterval(): \Carbon\CarbonInterval + { + return \Carbon\CarbonInterval::seconds($this->totalSeconds); + } + + /** + * Convert the duration to a DateInterval instance. + * + * @return \DateInterval + */ + public function toDateInterval(): DateInterval + { + $seconds = abs($this->totalSeconds); + + $days = intdiv($seconds, self::SECONDS_PER_DAY); + $seconds %= self::SECONDS_PER_DAY; + + $hours = intdiv($seconds, self::SECONDS_PER_HOUR); + $seconds %= self::SECONDS_PER_HOUR; + + $minutes = intdiv($seconds, self::SECONDS_PER_MINUTE); + $seconds %= self::SECONDS_PER_MINUTE; + + $intervalSpec = sprintf( + 'P%dDT%dH%dM%dS', + $days, + $hours, + $minutes, + $seconds + ); + + $interval = new \DateInterval($intervalSpec); + + // Preserve negative durations using invert + if ($this->totalSeconds < 0) { + $interval->invert = 1; + } + + return $interval; + } + + /** + * Serialize the duration to JSON. + * + * @return int + */ + public function jsonSerialize(): int + { + return $this->totalSeconds; + } +} diff --git a/src/Features/Formatting.php b/src/Features/Formatting.php new file mode 100644 index 0000000..8e584b3 --- /dev/null +++ b/src/Features/Formatting.php @@ -0,0 +1,263 @@ + ' ', + 'units' => true, + 'pad' => null, + ]; + + /** + * Format the duration using a format string. + * + * Available tokens: + * * : Sign (+ or -) + * dd : Days (padded to 2 digits) + * d : Days + * hh : Hours (padded to 2 digits) + * h : Hours + * mm : Minutes (padded to 2 digits) + * m : Minutes + * ss : Seconds (padded to 2 digits) + * s : Seconds + * + * Usage: $duration->format('dd:hh:mm:ss'); + * + * @param string $format + * @return string + */ + public function format(string $format): string + { + $parts = $this->decompose(); + + return strtr($format, [ + '*' => $this->totalSeconds < 0 ? '-' : '', + 'dd' => str_pad((string) $parts['days'], 2, '0', STR_PAD_LEFT), + 'd' => (string) $parts['days'], + 'hh' => str_pad((string) $parts['hours'], 2, '0', STR_PAD_LEFT), + 'h' => (string) $parts['hours'], + 'mm' => str_pad((string) $parts['minutes'], 2, '0', STR_PAD_LEFT), + 'm' => (string) $parts['minutes'], + 'ss' => str_pad((string) $parts['seconds'], 2, '0', STR_PAD_LEFT), + 's' => (string) $parts['seconds'], + ]); + } + + /** + * Get a human-readable representation of the duration. + * + * Usage: $duration->toHuman(); // "1 day 2 hours" + * + * @param callable|null $formatter Custom formatter function + * @return string + */ + public function toHuman(?callable $formatter = null): string { + $parts = $this->decompose(); + + if ($formatter !== null) { + return $formatter($parts, $this); + } + + return $this->defaultHuman($parts); + } + + /** + * Get a short human-readable representation of the duration. + * + * Usage: $duration->toShortHuman(); // "1d 2h 3m" + * + * @param callable|null $formatter Custom formatter function + * @return string + */ + public function toShortHuman(?callable $formatter = null): string + { + // Default short formatter + $shortFormatter = function (array $parts) { + $output = []; + + if ($parts['days'] > 0) { + $output[] = $parts['days'] . 'd'; + } + + if ($parts['hours'] > 0) { + $output[] = $parts['hours'] . 'h'; + } + + if ($parts['minutes'] > 0) { + $output[] = $parts['minutes'] . 'm'; + } + + if ($parts['seconds'] > 0) { + $output[] = $parts['seconds'] . 's'; + } + + if (empty($output)) { + return ($parts['sign'] ?? '') . '0s'; + } + + return ($parts['sign'] ?? '') . implode(' ', $output); + }; + + // Use caller-provided formatter if supplied + $formatter ??= $shortFormatter; + + // Call toHuman with the formatter + return $this->toHuman($formatter); + } + + /** + * Converts time units into their respective values in seconds. + * + * @return null|int Number of seconds per unit. + */ + protected static function unitSeconds(string $unit): ?int + { + $units = [ + 'seconds' => 1, + 'minutes' => self::SECONDS_PER_MINUTE, + 'hours' => self::SECONDS_PER_HOUR, + 'days' => self::SECONDS_PER_DAY, + 'weeks' => self::SECONDS_PER_WEEK, + 'years' => self::SECONDS_PER_YEAR, + ]; + + return $units[$unit] ?? null; + } + + /** + * Formats a duration in terms of specified units. + * + * @param array $units A list of time units (e.g., ['hours', 'minutes', 'seconds']) to format the duration into. + * @param array $options Additional formatting options. + * @return string A string representation of the duration, formatted according to the specified units and options. + * @throws \InvalidArgumentException If the units array is empty or contains an unrecognized unit. + */ + public function formatUnits(array $units, array $options = []): string + { + if ($units === []) { + throw new \InvalidArgumentException('At least one unit must be specified.'); + } + + $options = array_replace(self::DEFAULT_FORMATTING_OPTIONS, $options); + + $seconds = abs($this->totalSeconds); + $sign = $this->totalSeconds < 0 ? '-' : ''; + + $parts = []; + + foreach ($units as $unit) { + if (self::unitSeconds($unit) === null) { + throw new \InvalidArgumentException("Unknown unit [$unit]."); + } + + $unitSeconds = self::unitSeconds($unit); + $value = intdiv($seconds, $unitSeconds); + $seconds -= $value * $unitSeconds; + + $valueStr = $this->formatValue($value, $options['pad']); + + if ($options['units']) { + $valueStr .= $this->unitSuffix($unit); + } + + $parts[] = $valueStr; + } + + return $sign . implode($options['spacer'], $parts); + } + + /** + * Formats an integer value as a string, optionally padding it with leading zeros. + * + * @param int $value The integer value to format. + * @param int|null $pad The total width of the resulting string. If null, no padding is applied. + * @return string The formatted string with optional padding. + */ + private function formatValue(int $value, ?int $pad): string + { + if ($pad === null) { + return (string) $value; + } + + return str_pad((string) $value, $pad, '0', STR_PAD_LEFT); + } + + /** + * Converts a time unit to its corresponding suffix. + * + * @param string $unit The name of the time unit (e.g., 'seconds', 'minutes', etc.). + * @return string The shortened suffix for the provided time unit. If no match is found, the original unit string is returned. + */ + private function unitSuffix(string $unit): string + { + return match ($unit) { + 'seconds' => 's', + 'minutes' => 'm', + 'hours' => 'h', + 'days' => 'd', + 'weeks' => 'w', + 'years' => 'y', + default => $unit, + }; + } + + /** + * Convert the duration to a string (hh:mm). + * + * @return string + */ + public function __toString(): string + { + return $this->format('*hh:mm'); + } + + /** + * Default human-readable formatter. + * + * @param array $parts + * @return string + */ + private function defaultHuman(array $parts): string + { + $output = []; + + if ($parts['days'] > 0) { + $output[] = $parts['days'] . ' ' . $this->pluralize($parts['days'], 'day'); + } + + if ($parts['hours'] > 0) { + $output[] = $parts['hours'] . ' ' . $this->pluralize($parts['hours'], 'hour'); + } + + if ($parts['minutes'] > 0) { + $output[] = $parts['minutes'] . ' ' . $this->pluralize($parts['minutes'], 'minute'); + } + + if ($parts['seconds'] > 0) { + $output[] = $parts['seconds'] . ' ' . $this->pluralize($parts['seconds'], 'second'); + } + + if (empty($output)) { + return $parts['sign'] . '0 seconds'; + } + + $humanParts = array_slice($output, 0, 2); + + return $parts['sign'] . implode(' ', $humanParts); + } + + /** + * Pluralize a label based on a value. + * + * @param int $value + * @param string $label + * @return string + */ + private function pluralize(int $value, string $label): string + { + return $value === 1 ? $label : $label . 's'; + } +} diff --git a/src/Features/MagicProperties.php b/src/Features/MagicProperties.php new file mode 100644 index 0000000..149ebc8 --- /dev/null +++ b/src/Features/MagicProperties.php @@ -0,0 +1,68 @@ +totalSeconds(); + } + + if($name === 'totalMinutes') { + return $this->totalMinutes(); + } + + if($name === 'totalHours') { + return $this->totalHours(); + } + + if($name === 'totalDays') { + return $this->totalDays(); + } + + if($name === 'totalWeeks') { + return $this->totalWeeks(); + } + + if($name === 'totalMonths') { + return $this->totalMonths(); + } + + if($name === 'totalYears') { + return $this->totalYears(); + } + + throw new \Error('Undefined property: ' . self::class . '::' . $name); + } + + /** + * Magic setter to prevent setting properties. + * + * @param string $name + * @param mixed $value + * @return void + * @throws \Error + */ + public function __set($name, $value) + { + throw new \Error('Cannot set property: ' . self::class . '::' . $name); + } +} diff --git a/src/Features/TemporalUnits.php b/src/Features/TemporalUnits.php new file mode 100644 index 0000000..9fa88a3 --- /dev/null +++ b/src/Features/TemporalUnits.php @@ -0,0 +1,106 @@ +totalSeconds; + } + + /** + * Get the total duration in whole minutes. + * + * @return int + */ + public function totalMinutes(): int + { + return intdiv($this->totalSeconds, self::SECONDS_PER_MINUTE); + } + + /** + * Get the total duration in whole hours. + * + * @return int + */ + public function totalHours(): int + { + return intdiv($this->totalSeconds, self::SECONDS_PER_HOUR); + } + + /** + * Get the total duration in whole days. + * + * @return int + */ + public function totalDays(): int + { + return intdiv($this->totalSeconds, self::SECONDS_PER_DAY); + } + + /** + * Get the total duration in whole weeks. + * + * @return int + */ + public function totalWeeks(): int + { + return intdiv($this->totalSeconds, self::SECONDS_PER_WEEK); + } + + /** + * Get the total duration in whole months (approximate). + * + * @return int + */ + public function totalMonths(): int + { + return intdiv($this->totalSeconds, self::SECONDS_PER_MONTH); + } + + /** + * Get the total duration in whole years (approximate). + * + * @return int + */ + public function totalYears(): int + { + return intdiv($this->totalSeconds, self::SECONDS_PER_YEAR); + } + + /** + * Get the hours part of the decomposed duration. + * + * @return int + */ + public function getHours(): int + { + return $this->decompose()['hours'] ?? 0; + } + + /** + * Get the minutes part of the decomposed duration. + * + * @return int + */ + public function getMinutes(): int + { + return $this->decompose()['minutes'] ?? 0; + } + + /** + * Get the seconds part of the decomposed duration. + * + * @return int + */ + public function getSeconds(): int + { + return $this->decompose()['seconds'] ?? 0; + } +} diff --git a/src/TimeDelta.php b/src/TimeDelta.php index 7fe7a0f..b49dc5b 100644 --- a/src/TimeDelta.php +++ b/src/TimeDelta.php @@ -1,73 +1,96 @@ minutes = $minutes; - } + protected int $totalSeconds; - public static function minutes(int $minutes): self + /** + * Create a new TimeDelta instance. + * + * @param int $seconds + * @see \AyupCreative\Duration\Tests\TimeDeltaTest::it_can_be_instantiated_with_positive_seconds() + * @see \AyupCreative\Duration\Tests\TimeDeltaTest::it_can_be_instantiated_with_negative_seconds() + */ + public function __construct(int $seconds) { - return new self($minutes); - } - - public function totalMinutes(): int - { - return $this->minutes; + $this->totalSeconds = $seconds; } + /** + * Check if the duration is positive. + * + * Usage: $isPositive = $delta->isPositive(); + * + * @return bool + * @see \AyupCreative\Duration\Tests\TimeDeltaTest::it_can_be_instantiated_with_positive_seconds() + */ public function isPositive(): bool { - return $this->minutes > 0; + return $this->totalSeconds > 0; } + /** + * Check if the duration is negative. + * + * Usage: $isNegative = $delta->isNegative(); + * + * @return bool + * @see \AyupCreative\Duration\Tests\TimeDeltaTest::it_can_be_instantiated_with_negative_seconds() + */ public function isNegative(): bool { - return $this->minutes < 0; + return $this->totalSeconds < 0; } + /** + * Invert the sign of the duration. + * + * Usage: $inverted = $delta->invert(); + * + * @return self + * @see \AyupCreative\Duration\Tests\TimeDeltaTest::it_can_invert_its_value() + */ public function invert(): self { - return new self(-$this->minutes); + return new self(-$this->totalSeconds); } + /** + * Get the sign of the duration (-1, 0, or 1). + * + * Usage: $sign = $delta->sign(); + * + * @return int + * @see \AyupCreative\Duration\Tests\TimeDeltaTest::it_can_be_instantiated_with_positive_seconds() + * @see \AyupCreative\Duration\Tests\TimeDeltaTest::it_can_be_instantiated_with_negative_seconds() + */ public function sign(): int { - return $this->minutes <=> 0; + return $this->totalSeconds <=> 0; } + /** + * Get the absolute duration as a DurationImmutable instance. + * + * Usage: $abs = $delta->absolute(); + * + * @return \AyupCreative\Duration\DurationImmutable + * @see \AyupCreative\Duration\Tests\TimeDeltaTest::it_can_return_absolute_duration() + */ public function absolute(): DurationImmutable { - return DurationImmutable::minutes(abs($this->minutes)); - } - - public function __toString(): string - { - $sign = $this->minutes < 0 ? '-' : ''; - $m = abs($this->minutes); - - return sprintf( - '%s%d:%02d', - $sign, - intdiv($m, 60), - $m % 60 - ); - } - - public function __get($name) - { - if($name === 'totalMinutes') { - return $this->minutes; - } - - throw new \Error('Undefined property: ' . static::class . '::' . $name); + return DurationImmutable::seconds(abs($this->totalSeconds)); } } diff --git a/tests/ArithmeticTest.php b/tests/ArithmeticTest.php deleted file mode 100644 index 3be5bf5..0000000 --- a/tests/ArithmeticTest.php +++ /dev/null @@ -1,114 +0,0 @@ -add($b); - - $this->assertSame(60, $a->totalMinutes); - $this->assertSame(90, $c->totalMinutes); - } - - public function testMutableArithmetic(): void - { - $d = Duration::minutes(60); - - $d->add(DurationImmutable::minutes(15)) - ->add(DurationImmutable::minutes(15)); - - $this->assertSame(90, $d->totalMinutes); - } - - public function testIsZero(): void - { - $d = Duration::minutes(0); - - $this->assertTrue($d->isZero()); - } - - public function testMaxAndMin(): void - { - $a = DurationImmutable::minutes(30); - $b = DurationImmutable::minutes(45); - - $this->assertSame(45, $a->max($b)->totalMinutes); - $this->assertSame(30, $a->min($b)->totalMinutes); - } - - public function testHoursAndMinutes(): void - { - $d = DurationImmutable::hoursAndMinutes(1, 75); - $this->assertSame(135, $d->totalMinutes); - } - - public function testZeroDuration(): void - { - $this->assertTrue(DurationImmutable::zero()->isZero()); - } - - public function testImmutableMultiply(): void - { - $a = DurationImmutable::minutes(30); - $b = $a->multiply(2); - - $this->assertSame(30, $a->totalMinutes);; - $this->assertSame(60, $b->totalMinutes);; - } - - public function testMutableMultiply(): void - { - $a = Duration::minutes(15); - $b = $a->multiply(2); - - $this->assertSame(30, $a->totalMinutes); - $this->assertSame(30, $b->totalMinutes); - } - - public function testImmutableCeilTo(): void - { - $a = DurationImmutable::minutes(61); - $b = $a->ceilTo(15); - - $this->assertSame(61, $a->totalMinutes); - $this->assertSame(75, $b->totalMinutes); - } - - public function testMutableCeilTo(): void - { - $a = Duration::minutes(61); - $b = $a->ceilTo(15); - - $this->assertSame(75, $a->totalMinutes); - $this->assertSame(75, $b->totalMinutes); - } - - public function testDurationNeverNegative(): void - { - $d = DurationImmutable::minutes(30) - ->sub(DurationImmutable::minutes(90)); - - $this->assertSame(0, $d->totalMinutes); - } - - public function testImmutableNeverMutates(): void - { - $a = DurationImmutable::minutes(30); - $b = $a->add(DurationImmutable::minutes(15)); - - $this->assertSame(30, $a->totalMinutes); - $this->assertSame(45, $b->totalMinutes); - } -} diff --git a/tests/CastTest.php b/tests/CastTest.php deleted file mode 100644 index 837bb41..0000000 --- a/tests/CastTest.php +++ /dev/null @@ -1,74 +0,0 @@ -addConnection([ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - - $capsule->setAsGlobal(); - $capsule->bootEloquent(); - - Capsule::schema()->create('cast_test_models', function ($table) { - $table->increments('id'); - $table->integer('duration'); - }); - } - - public function testCastReturnsDurationImmutable(): void - { - $model = CastTestModel::create([ - 'duration' => 90, - ]); - - $this->assertInstanceOf( - DurationImmutable::class, - $model->duration - ); - - $this->assertSame(90, $model->duration->totalMinutes); - } - - public function testCastPersistsCorrectly(): void - { - $model = new CastTestModel; - - $model->duration = DurationImmutable::minutes(45); - $model->save(); - - $this->assertSame( - 45, - $model->getRawOriginal('duration') - ); - } - -} - -class CastTestModel extends Model -{ - protected $table = 'cast_test_models'; - - public $timestamps = false; - - protected $casts = [ - 'duration' => DurationImmutableCast::class, - ]; - - protected $fillable = ['duration']; -} diff --git a/tests/Casts/Cast.php b/tests/Casts/Cast.php new file mode 100644 index 0000000..2f978ee --- /dev/null +++ b/tests/Casts/Cast.php @@ -0,0 +1,155 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid duration value [string]'); + + $model = InvalidCastModel::create(['duration' => 'dummy']); + $model->save(); + } + + public function test_cast_with_invalid_unit_throws_exception(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid duration unit [dummy]'); + + $model = new InvalidCastModel; + $model->setRawAttributes(['duration' => 'dummy']); + $model->save(); + + $model->duration; + } + + public function test_cast_with_int(): void + { + $model = new TestSecondsCastModel; + $model->duration = 100; + $model->save(); + + $model = $model->fresh(); + $this->assertEquals(100, $model->getRawOriginal('duration')); + $this->assertInstanceOf(DurationImmutable::class, $model->duration); + $this->assertEquals(100, $model->duration->totalSeconds()); + } + + public function test_cast_with_time_delta(): void + { + $model = new TestSecondsCastModel; + $model->duration = new TimeDelta(50); + $model->save(); + + $model = $model->fresh(); + $this->assertEquals(50, $model->getRawOriginal('duration')); + $this->assertInstanceOf(DurationImmutable::class, $model->duration); + $this->assertEquals(50, $model->duration->totalSeconds()); + + $model->duration = new TimeDelta(-50); + $model->save(); + $model = $model->fresh(); + $this->assertEquals(0, $model->getRawOriginal('duration')); + } + + public function test_cast_with_duration(): void + { + $model = new TestSecondsCastModel; + $model->duration = Duration::seconds(100); + $model->save(); + + $model = $model->fresh(); + $this->assertEquals(100, $model->getRawOriginal('duration')); + $this->assertInstanceOf(DurationImmutable::class, $model->duration); + $this->assertEquals(100, $model->duration->totalSeconds()); + } + + public function test_cast_with_null(): void + { + $model = new TestSecondsCastModel; + $model->duration = null; + $model->save(); + + $model = $model->fresh(); + $this->assertNull($model->getRawOriginal('duration')); + $this->assertNull($model->duration); + } + + public function test_cast_with_numeric_string(): void + { + $model = new TestSecondsCastModel; + $model->duration = '123'; + $model->save(); + + $model = $model->fresh(); + $this->assertEquals(123, $model->getRawOriginal('duration')); + $this->assertInstanceOf(DurationImmutable::class, $model->duration); + $this->assertEquals(123, $model->duration->totalSeconds()); + } + + public static function setUpBeforeClass(): void + { + $capsule = new Capsule; + + $capsule->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + Capsule::schema()->create('cast_test_models', function ($table) { + $table->increments('id'); + $table->integer('duration')->nullable(); + }); + } +} + +class InvalidCastModel extends Model +{ + public $timestamps = false; + protected $table = 'cast_test_models'; + protected $casts = [ + 'duration' => InvalidUnitCast::class, + ]; + + protected $fillable = ['duration']; +} + +class TestSecondsCastModel extends Model +{ + public $timestamps = false; + protected $table = 'cast_test_models'; + protected $casts = [ + 'duration' => \AyupCreative\Duration\Casts\Seconds::class, + ]; + + protected $fillable = ['duration']; +} + +class InvalidUnitCast extends DurationCast +{ + protected function getUnitsMethod(): string + { + return 'dummy'; + } +} diff --git a/tests/Casts/DaysTest.php b/tests/Casts/DaysTest.php new file mode 100644 index 0000000..6236bac --- /dev/null +++ b/tests/Casts/DaysTest.php @@ -0,0 +1,63 @@ + 3]); + + $this->assertInstanceOf(DurationImmutable::class, $model->duration); + $this->assertEquals(3, $model->duration->totalDays()); + $this->assertEquals(3, $model->getRawOriginal('duration')); + } + + public function test_set(): void + { + $model = new CastDayTestModel; + + $model->duration = DurationImmutable::days(2); + $model->save(); + + $this->assertEquals(2, $model->duration->totalDays()); + $this->assertEquals(2, $model->getRawOriginal('duration')); + } + + public function test_null(): void + { + $model = new CastDayTestModel; + $model->duration = null; + $model->save(); + + $model = $model->fresh(); + $this->assertNull($model->duration); + $this->assertNull($model->getRawOriginal('duration')); + } +} + +class CastDayTestModel extends Model +{ + public $timestamps = false; + protected $table = 'cast_test_models'; + protected $casts = [ + 'duration' => Days::class, + ]; + + protected $fillable = ['duration']; +} diff --git a/tests/Casts/HoursTest.php b/tests/Casts/HoursTest.php new file mode 100644 index 0000000..563035e --- /dev/null +++ b/tests/Casts/HoursTest.php @@ -0,0 +1,67 @@ + 3]); + + $this->assertInstanceOf( + DurationImmutable::class, + $model->duration + ); + + $this->assertEquals(3, $model->duration->totalHours()); + $this->assertEquals(3, $model->getRawOriginal('duration')); + } + + public function test_set(): void + { + $model = new CastHourTestModel; + + $model->duration = DurationImmutable::hours(8); + $model->save(); + + $this->assertEquals(8, $model->duration->totalHours()); + $this->assertEquals(8, $model->getRawOriginal('duration')); + } + + public function test_null(): void + { + $model = new CastHourTestModel; + $model->duration = null; + $model->save(); + + $model = $model->fresh(); + $this->assertNull($model->duration); + $this->assertNull($model->getRawOriginal('duration')); + } +} + +class CastHourTestModel extends Model +{ + public $timestamps = false; + protected $table = 'cast_test_models'; + protected $casts = [ + 'duration' => Hours::class, + ]; + + protected $fillable = ['duration']; +} diff --git a/tests/Casts/MinutesTest.php b/tests/Casts/MinutesTest.php new file mode 100644 index 0000000..e2df12d --- /dev/null +++ b/tests/Casts/MinutesTest.php @@ -0,0 +1,67 @@ + 3]); + + $this->assertInstanceOf( + DurationImmutable::class, + $model->duration + ); + + $this->assertEquals(3, $model->duration->totalMinutes()); + $this->assertEquals(3, $model->getRawOriginal('duration')); + } + + public function test_set(): void + { + $model = new CastMinuteTestModel; + + $model->duration = DurationImmutable::minutes(2); + $model->save(); + + $this->assertEquals(2, $model->duration->totalMinutes()); + $this->assertEquals(2, $model->getRawOriginal('duration')); + } + + public function test_null(): void + { + $model = new CastMinuteTestModel; + $model->duration = null; + $model->save(); + + $model = $model->fresh(); + $this->assertNull($model->duration); + $this->assertNull($model->getRawOriginal('duration')); + } +} + +class CastMinuteTestModel extends Model +{ + public $timestamps = false; + protected $table = 'cast_test_models'; + protected $casts = [ + 'duration' => Minutes::class, + ]; + + protected $fillable = ['duration']; +} diff --git a/tests/Casts/SecondsTest.php b/tests/Casts/SecondsTest.php new file mode 100644 index 0000000..f16ec83 --- /dev/null +++ b/tests/Casts/SecondsTest.php @@ -0,0 +1,66 @@ + 3]); + + $this->assertInstanceOf( + DurationImmutable::class, + $model->duration + ); + + $this->assertEquals(3, $model->duration->totalSeconds()); + $this->assertEquals(3, $model->getRawOriginal('duration')); + } + + public function test_set(): void + { + $model = new CastSecondTestModel; + + $model->duration = DurationImmutable::seconds(20); + $model->save(); + + $this->assertEquals(20, $model->getRawOriginal('duration')); + $this->assertEquals(20, $model->duration->totalSeconds()); + } + + public function test_null(): void + { + $model = new CastSecondTestModel; + $model->duration = null; + $model->save(); + + $model = $model->fresh(); + $this->assertNull($model->duration); + $this->assertNull($model->getRawOriginal('duration')); + } +} + +class CastSecondTestModel extends Model +{ + public $timestamps = false; + protected $table = 'cast_test_models'; + protected $casts = [ + 'duration' => Seconds::class, + ]; + + protected $fillable = ['duration']; +} diff --git a/tests/Casts/YearsTest.php b/tests/Casts/YearsTest.php new file mode 100644 index 0000000..bac81df --- /dev/null +++ b/tests/Casts/YearsTest.php @@ -0,0 +1,73 @@ + 3]); + + $this->assertInstanceOf(DurationImmutable::class, $model->duration); + $this->assertEquals(3, $model->duration->totalYears()); + $this->assertEquals(3, $model->getRawOriginal('duration')); + } + + public function test_set(): void + { + $model = new CastYearTestModel; + + $model->duration = DurationImmutable::years(2); + $model->save(); + + $this->assertEquals(2, $model->duration->totalYears()); + $this->assertEquals(2, $model->getRawOriginal('duration')); + + $model->duration = 5; + $model->save(); + $this->assertEquals(5, $model->duration->totalYears()); + $this->assertEquals(5, $model->getRawOriginal('duration')); + + $model->duration = new TimeDelta(5 * DurationImmutable::SECONDS_PER_YEAR); // 5 years + $model->save(); + $this->assertEquals(5, $model->duration->totalYears()); + $this->assertEquals(5, $model->getRawOriginal('duration')); + } + + public function test_null(): void + { + $model = new CastYearTestModel; + $model->duration = null; + $model->save(); + + $model = $model->fresh(); + $this->assertNull($model->duration); + $this->assertNull($model->getRawOriginal('duration')); + } +} + +class CastYearTestModel extends Model +{ + public $timestamps = false; + protected $table = 'cast_test_models'; + protected $casts = [ + 'duration' => Years::class, + ]; + + protected $fillable = ['duration']; +} diff --git a/tests/ConversionTest.php b/tests/ConversionTest.php deleted file mode 100644 index 5dde4a0..0000000 --- a/tests/ConversionTest.php +++ /dev/null @@ -1,60 +0,0 @@ -toMutable(); - - $mutable->sub(DurationImmutable::minutes(30)); - - $this->assertSame(120, $immutable->totalMinutes); - $this->assertSame(90, $mutable->totalMinutes); - } - - public function testToString(): void - { - $d = DurationImmutable::minutes(75); - $this->assertSame('1:15', (string) $d); - } - - public function testFormatting(): void - { - $d = DurationImmutable::minutes(75); - $this->assertSame('1 h 15 m', $d->format('%h h %d m')); - - $d = DurationImmutable::minutes(63); - $this->assertSame('1 h 3 m', $d->format('%h h %d m')); - - $d = DurationImmutable::minutes(65); - $this->assertSame('1 h 05 m', $d->format('%h h %02d m')); - } - - public function testCarbonInterop(): void - { - $carbon = CarbonInterval::minutes(45); - $d = DurationImmutable::fromCarbon($carbon); - - $this->assertSame(45, $d->totalMinutes); - } - - public function testJsonSerialisation(): void - { - $d = DurationImmutable::minutes(30); - $this->assertSame('30', json_encode($d)); - } - - public function testToHuman(): void - { - $this->assertSame('1 hours 15 minutes', DurationImmutable::minutes(75)->toHuman()); - } -} diff --git a/tests/DiffTest.php b/tests/DiffTest.php deleted file mode 100644 index 41450a1..0000000 --- a/tests/DiffTest.php +++ /dev/null @@ -1,80 +0,0 @@ -diff($b); - - $this->assertInstanceOf(TimeDelta::class, $delta); - $this->assertSame(30, $delta->totalMinutes); - $this->assertTrue($delta->isPositive()); - $this->assertFalse($delta->isNegative()); - } - - public function testDiffReturnsNegativeDeltaWhenSmaller(): void - { - $a = DurationImmutable::minutes(45); - $b = DurationImmutable::minutes(60); - - $delta = $a->diff($b); - - $this->assertSame(-15, $delta->totalMinutes); - $this->assertTrue($delta->isNegative()); - $this->assertFalse($delta->isPositive()); - } - - public function testDiffReturnsZeroDeltaWhenEqual(): void - { - $a = DurationImmutable::minutes(60); - $b = DurationImmutable::minutes(60); - - $delta = $a->diff($b); - - $this->assertSame(0, $delta->totalMinutes); - $this->assertFalse($delta->isPositive()); - $this->assertFalse($delta->isNegative()); - } - - public function testDeltaAbsoluteReturnsDuration(): void - { - $delta = TimeDelta::minutes(-75); - - $absolute = $delta->absolute(); - - $this->assertInstanceOf(DurationImmutable::class, $absolute); - $this->assertSame(75, $absolute->totalMinutes); - } - - public function testDeltaStringFormatting(): void - { - $positive = TimeDelta::minutes(75); - $negative = TimeDelta::minutes(-75); - - $this->assertSame('1:15', (string) $positive); - $this->assertSame('-1:15', (string) $negative); - } - - public function testDeltaInvert(): void - { - $d = TimeDelta::minutes(15)->invert(); - $this->assertSame(-15, $d->totalMinutes); - } - - public function testDeltaSign(): void - { - $this->assertSame(1, TimeDelta::minutes(10)->sign()); - $this->assertSame(-1, TimeDelta::minutes(-10)->sign()); - $this->assertSame(0, TimeDelta::minutes(0)->sign()); - } -} diff --git a/tests/DurationImmutableTest.php b/tests/DurationImmutableTest.php new file mode 100644 index 0000000..1350fa8 --- /dev/null +++ b/tests/DurationImmutableTest.php @@ -0,0 +1,373 @@ +assertEquals('10h', $duration->formatUnits(['hours'])); + $this->assertEquals('10h 0m', $duration->formatUnits(['hours', 'minutes'])); + $this->assertEquals('10h 0m 0s', $duration->formatUnits(['hours', 'minutes', 'seconds'])); + + $this->assertEquals('0d 10h', $duration->formatUnits(['days', 'hours'])); + $this->assertEquals('0w 0d 10h', $duration->formatUnits(['weeks', 'days', 'hours'])); + $this->assertEquals('0y 0d 10h', $duration->formatUnits(['years', 'days', 'hours'])); + } + + /** @test */ + public function it_supports_forced_unit_formatting() + { + $duration = DurationImmutable::hours(100); + + $this->assertEquals('100h', $duration->formatUnits(['hours'])); + } + + /** @test */ + public function it_throws_exception_on_invalid_unit() + { + $this->expectException(\InvalidArgumentException::class); + DurationImmutable::hours(100)->formatUnits(['invalid']); + } + + /** @test */ + public function it_supports_formatting_without_units() + { + $duration = DurationImmutable::hours(100); + + $this->assertEquals('100', $duration->formatUnits(['hours'], ['units' => false])); + $this->assertEquals('4 4', $duration->formatUnits(['days', 'hours'], ['units' => false])); + } + + /** @test */ + public function it_supports_formatting_with_custom_spacer() + { + $duration = DurationImmutable::hours(100); + + $this->assertEquals('4d and 4h', $duration->formatUnits(['days', 'hours'], ['spacer' => ' and '])); + } + + /** @test */ + public function it_supports_formatting_with_padding() + { + $duration = DurationImmutable::hours(4); + + $this->assertEquals('004h', $duration->formatUnits(['hours'], ['pad' => 3])); + $this->assertEquals('04h', $duration->formatUnits(['hours'], ['pad' => 2])); + } + + /** @test */ + public function it_supports_combined_formatting() + { + $duration = DurationImmutable::hours(100); + + $this->assertEquals('04:04', $duration->formatUnits(['days', 'hours'], ['spacer' => ':', 'pad' => 2, 'units' => false])); + } + + /** @test */ + public function it_throws_exception_when_no_units_passed_to_formatUnits() + { + $this->expectException(\InvalidArgumentException::class); + DurationImmutable::hours(100)->formatUnits([]); + } + + /** @test */ + public function it_can_be_instantiated_with_seconds() + { + $duration = new DurationImmutable(100); + $this->assertEquals(100, $duration->totalSeconds()); + } + + /** @test */ + public function it_prevents_negative_seconds_in_constructor() + { + $duration = new DurationImmutable(-100); + $this->assertEquals(0, $duration->totalSeconds()); + } + + /** @test */ + public function it_is_immutable_on_arithmetic_operations() + { + $d1 = DurationImmutable::seconds(100); + $d2 = DurationImmutable::seconds(50); + $mutable = Duration::seconds(25); + + $added = $d1->add($d2); + $this->assertNotSame($d1, $added); + $this->assertEquals(100, $d1->totalSeconds()); + $this->assertEquals(150, $added->totalSeconds()); + + $addedMutable = $d1->add($mutable); + $this->assertEquals(125, $addedMutable->totalSeconds()); + + $subbed = $d1->sub($d2); + $this->assertNotSame($d1, $subbed); + $this->assertEquals(100, $d1->totalSeconds()); + $this->assertEquals(50, $subbed->totalSeconds()); + + $multiplied = $d1->multiply(2); + $this->assertNotSame($d1, $multiplied); + $this->assertEquals(100, $d1->totalSeconds()); + $this->assertEquals(200, $multiplied->totalSeconds()); + } + + /** @test */ + public function it_cannot_go_negative_through_subtraction() + { + $d1 = DurationImmutable::seconds(100); + $d2 = DurationImmutable::seconds(150); + + $result = $d1->sub($d2); + $this->assertEquals(0, $result->totalSeconds()); + } + + /** @test */ + public function it_can_create_from_various_units() + { + $this->assertEquals(0, DurationImmutable::zero()->totalSeconds()); + $this->assertEquals(1, DurationImmutable::seconds(1)->totalSeconds()); + $this->assertEquals(60, DurationImmutable::minutes(1)->totalSeconds()); + $this->assertEquals(3600, DurationImmutable::hours(1)->totalSeconds()); + $this->assertEquals(86400, DurationImmutable::days(1)->totalSeconds()); + $this->assertEquals(604800, DurationImmutable::weeks(1)->totalSeconds()); + $this->assertEquals(2629800, DurationImmutable::months(1)->totalSeconds()); + $this->assertEquals(31557600, DurationImmutable::years(1)->totalSeconds()); + + $this->assertEquals(3660, DurationImmutable::hoursAndMinutes(1, 1)->totalSeconds()); + $this->assertEquals(90061, DurationImmutable::make(1, 1, 1, 1)->totalSeconds()); + } + + /** @test */ + public function it_can_convert_to_various_units() + { + $duration = DurationImmutable::hours(1); + $this->assertEquals(3600, $duration->toSeconds()); + $this->assertEquals(60, $duration->toMinutes()); + $this->assertEquals(1, $duration->toHours()); + $this->assertEquals(1/24, $duration->toDays()); + $this->assertEquals(1/168, $duration->toWeeks(), '', 0.00001); + + // This test might fail due to bug in Conversion trait + $this->assertEquals(3600 / 2629800, $duration->toMonths(), '', 0.00001); + $this->assertEquals(3600 / 31557600, $duration->toYears(), '', 0.00001); + } + + /** @test */ + public function it_can_convert_to_external_types() + { + $duration = DurationImmutable::hours(1); + + $carbon = $duration->toCarbonInterval(); + $this->assertInstanceOf(CarbonInterval::class, $carbon); + $this->assertEquals(3600, $carbon->totalSeconds); + + $dateInterval = $duration->toDateInterval(); + $this->assertInstanceOf(\DateInterval::class, $dateInterval); + $this->assertEquals(1, $dateInterval->h); + } + + /** @test */ + public function it_supports_comparison_methods() + { + $d100 = DurationImmutable::seconds(100); + $d200 = DurationImmutable::seconds(200); + $d100_2 = DurationImmutable::seconds(100); + + $this->assertTrue($d100->isBelow($d200)); + $this->assertTrue($d100->isLessThan($d200)); + $this->assertTrue($d200->isOver($d100)); + $this->assertTrue($d200->isGreaterThan($d100)); + + $this->assertTrue($d100->equals($d100_2)); + $this->assertFalse($d100->equals($d200)); + $this->assertTrue($d100->doesNotEqual($d200)); + + $this->assertTrue($d100->isLessThanOrEqualTo($d100_2)); + $this->assertTrue($d100->isLessThanOrEqualTo($d200)); + $this->assertTrue($d200->isGreaterThanOrEqualTo($d100)); + $this->assertTrue($d100->isGreaterThanOrEqualTo($d100_2)); + + $this->assertTrue(DurationImmutable::zero()->isZero()); + $this->assertFalse($d100->isZero()); + $this->assertTrue($d100->isNotZero()); + $this->assertFalse(DurationImmutable::zero()->isNotZero()); + } + + /** @test */ + public function it_can_calculate_min_and_max() + { + $d1 = DurationImmutable::seconds(100); + $d2 = DurationImmutable::seconds(200); + $mutable = Duration::seconds(300); + + $this->assertSame($d2, $d1->max($d2)); + $this->assertSame($d1, $d1->max($d1)); // Cover return $this + $this->assertEquals(300, $d1->max($mutable)->totalSeconds()); // Cover new self + $this->assertInstanceOf(DurationImmutable::class, $d1->max($mutable)); + + $this->assertSame($d1, $d1->min($d2)); + $this->assertSame($d2, $d2->min($d2)); // Cover return $this + $this->assertEquals(100, $d2->min($d1)->totalSeconds()); + } + + /** @test */ + public function it_supports_formatting_zeros() + { + $zero = DurationImmutable::zero(); + $this->assertEquals('0 seconds', $zero->toHuman()); + $this->assertEquals('0s', $zero->toShortHuman()); + } + + /** @test */ + public function it_can_calculate_diff_as_timedelta() + { + $d1 = DurationImmutable::seconds(100); + $d2 = DurationImmutable::seconds(150); + + $diff = $d1->diff($d2); + $this->assertInstanceOf(TimeDelta::class, $diff); + $this->assertEquals(-50, $diff->totalSeconds()); + } + + /** @test */ + public function it_supports_formatting() + { + $duration = DurationImmutable::make(1, 2, 3, 4); // 1d 2h 3m 4s + $this->assertEquals('01:02:03:04', $duration->format('dd:hh:mm:ss')); + $this->assertEquals('1:2:3:4', $duration->format('d:h:m:s')); + $this->assertEquals('02:03', (string)$duration); // *hh:mm + } + + /** @test */ + public function it_supports_human_formatting() + { + $this->assertEquals('1 day 2 hours', DurationImmutable::make(1, 2, 3, 4)->toHuman()); + $this->assertEquals('2 hours 3 minutes', DurationImmutable::make(0, 2, 3, 4)->toHuman()); + $this->assertEquals('4 seconds', DurationImmutable::seconds(4)->toHuman()); + $this->assertEquals('1 day 1 minute', DurationImmutable::make(1, 0, 1, 0)->toHuman()); + + $this->assertEquals('CUSTOM', DurationImmutable::seconds(10)->toHuman(fn() => 'CUSTOM')); + } + + /** @test */ + public function it_supports_short_human_formatting() + { + $this->assertEquals('1d 2h 3m 4s', DurationImmutable::make(1, 2, 3, 4)->toShortHuman()); + $this->assertEquals('4s', DurationImmutable::seconds(4)->toShortHuman()); + } + + /** @test */ + public function it_can_ceil_durations() + { + $duration = DurationImmutable::seconds(65); // 1m 5s + + $this->assertEquals(120, $duration->ceilToMinutes(1)->totalSeconds()); + $this->assertEquals(90, $duration->ceilTo(30)->totalSeconds()); + $this->assertSame($duration, $duration->ceilTo(0)); + + $duration = DurationImmutable::hours(1)->add(DurationImmutable::minutes(5)); // 1h 5m + $this->assertEquals(7200, $duration->ceilToHours(1)->totalSeconds()); // 2h + + $duration = DurationImmutable::days(1)->add(DurationImmutable::hours(5)); // 1d 5h + $this->assertEquals(172800, $duration->ceilToDays(1)->totalSeconds()); // 2d + } + + /** @test */ + public function it_can_create_from_carbon() + { + $carbon = \Carbon\CarbonInterval::hours(2); + $duration = DurationImmutable::fromCarbon($carbon); + + $this->assertInstanceOf(DurationImmutable::class, $duration); + $this->assertEquals(7200, $duration->totalSeconds()); + } + + /** @test */ + public function it_supports_additional_temporal_units() + { + $duration = DurationImmutable::make(1, 2, 3, 4); // 1d 2h 3m 4s + + $this->assertEquals(1, $duration->totalDays()); + $this->assertEquals(26, $duration->totalHours()); + $this->assertEquals(1563, $duration->totalMinutes()); + $this->assertEquals(93784, $duration->totalSeconds()); + + $this->assertEquals(0, $duration->totalWeeks()); + $this->assertEquals(0, $duration->totalMonths()); + $this->assertEquals(0, $duration->totalYears()); + + $this->assertEquals(2, $duration->getHours()); + $this->assertEquals(3, $duration->getMinutes()); + $this->assertEquals(4, $duration->getSeconds()); + + $large = DurationImmutable::weeks(2); + $this->assertEquals(2, $large->totalWeeks()); + + $month = DurationImmutable::months(1); + $this->assertEquals(1, $month->totalMonths()); + + $year = DurationImmutable::years(1); + $this->assertEquals(1, $year->totalYears()); + } + + /** @test */ + public function it_supports_magic_properties() + { + $duration = DurationImmutable::hours(2); + $this->assertEquals(7200, $duration->totalSeconds); + $this->assertEquals(120, $duration->totalMinutes); + $this->assertEquals(2, $duration->totalHours); + $this->assertEquals(0, $duration->totalDays); + $this->assertEquals(0, $duration->totalWeeks); + $this->assertEquals(0, $duration->totalMonths); + $this->assertEquals(0, $duration->totalYears); + + $this->assertEquals(1, DurationImmutable::months(1)->totalMonths); + $this->assertEquals(1, DurationImmutable::years(1)->totalYears); + } + + /** @test */ + public function it_throws_error_on_undefined_magic_property() + { + $this->expectException(\Error::class); + $duration = DurationImmutable::seconds(1); + $duration->nonExistent; + } + + /** @test */ + public function it_throws_error_on_setting_magic_property() + { + $this->expectException(\Error::class); + $duration = DurationImmutable::seconds(1); + $duration->totalSeconds = 10; + } + + /** @test */ + public function it_can_be_converted_to_mutable() + { + $immutable = DurationImmutable::seconds(100); + $mutable = $immutable->toMutable(); + $this->assertInstanceOf(Duration::class, $mutable); + $this->assertEquals(100, $mutable->totalSeconds()); + } + + /** @test */ + public function it_is_json_serializable() + { + $duration = DurationImmutable::seconds(100); + $this->assertEquals(100, json_decode(json_encode($duration))); + } +} diff --git a/tests/DurationTest.php b/tests/DurationTest.php new file mode 100644 index 0000000..760c802 --- /dev/null +++ b/tests/DurationTest.php @@ -0,0 +1,112 @@ +assertEquals(100, $duration->totalSeconds()); + } + + /** @test */ + public function it_is_mutable_on_arithmetic_operations() + { + $duration = Duration::seconds(100); + $other = Duration::seconds(50); + + $result = $duration->add($other); + $this->assertSame($duration, $result); + $this->assertEquals(150, $duration->totalSeconds()); + + $result = $duration->sub($other); + $this->assertSame($duration, $result); + $this->assertEquals(100, $duration->totalSeconds()); + + $result = $duration->multiply(2); + $this->assertSame($duration, $result); + $this->assertEquals(200, $duration->totalSeconds()); + } + + /** @test */ + public function it_cannot_go_negative_through_subtraction() + { + $duration = Duration::seconds(100); + $other = Duration::seconds(150); + + $duration->sub($other); + $this->assertEquals(0, $duration->totalSeconds()); + } + + /** @test */ + public function it_can_ceil_durations() + { + $duration = Duration::seconds(65); // 1m 5s + + $duration->ceilToMinutes(1); + $this->assertEquals(120, $duration->totalSeconds()); + + $duration = Duration::seconds(65); + $duration->ceilTo(30); + $this->assertEquals(90, $duration->totalSeconds()); + + $duration = Duration::hours(1)->add(Duration::minutes(5)); // 1h 5m + $duration->ceilToHours(1); + $this->assertEquals(7200, $duration->totalSeconds()); // 2h + + $duration = Duration::days(1)->add(Duration::hours(5)); // 1d 5h + $duration->ceilToDays(1); + $this->assertEquals(172800, $duration->totalSeconds()); // 2d + } + + /** @test */ + public function it_can_be_converted_to_immutable() + { + $mutable = Duration::seconds(100); + $immutable = $mutable->toImmutable(); + $this->assertInstanceOf(DurationImmutable::class, $immutable); + $this->assertEquals(100, $immutable->totalSeconds()); + } + + /** @test */ + public function it_supports_additional_temporal_units() + { + $duration = Duration::make(1, 2, 3, 4); + + $this->assertEquals(1, $duration->totalDays()); + $this->assertEquals(26, $duration->totalHours()); + $this->assertEquals(1563, $duration->totalMinutes()); + $this->assertEquals(93784, $duration->totalSeconds()); + + $this->assertEquals(0, $duration->totalWeeks()); + $this->assertEquals(0, $duration->totalMonths()); + $this->assertEquals(0, $duration->totalYears()); + + $this->assertEquals(2, $duration->getHours()); + $this->assertEquals(3, $duration->getMinutes()); + $this->assertEquals(4, $duration->getSeconds()); + } + + /** @test */ + public function it_supports_magic_properties() + { + $duration = Duration::hours(2); + $this->assertEquals(7200, $duration->totalSeconds); + $this->assertEquals(120, $duration->totalMinutes); + $this->assertEquals(2, $duration->totalHours); + $this->assertEquals(0, $duration->totalDays); + $this->assertEquals(0, $duration->totalWeeks); + $this->assertEquals(0, $duration->totalMonths); + $this->assertEquals(0, $duration->totalYears); + } +} diff --git a/tests/TimeDeltaTest.php b/tests/TimeDeltaTest.php new file mode 100644 index 0000000..d321ddb --- /dev/null +++ b/tests/TimeDeltaTest.php @@ -0,0 +1,121 @@ +assertEquals(100, $delta->totalSeconds()); + $this->assertTrue($delta->isPositive()); + $this->assertFalse($delta->isNegative()); + $this->assertEquals(1, $delta->sign()); + } + + /** @test */ + public function it_can_be_instantiated_with_negative_seconds() + { + $delta = new TimeDelta(-100); + $this->assertEquals(-100, $delta->totalSeconds()); + $this->assertFalse($delta->isPositive()); + $this->assertTrue($delta->isNegative()); + $this->assertEquals(-1, $delta->sign()); + } + + /** @test */ + public function it_is_immutable_on_arithmetic_operations() + { + $delta = TimeDelta::seconds(100); + $other = TimeDelta::seconds(50); + + $added = $delta->add($other); + $this->assertNotSame($delta, $added); + $this->assertEquals(100, $delta->totalSeconds()); + $this->assertEquals(150, $added->totalSeconds()); + + $subbed = $delta->sub($other); + $this->assertNotSame($delta, $subbed); + $this->assertEquals(100, $delta->totalSeconds()); + $this->assertEquals(50, $subbed->totalSeconds()); + } + + /** @test */ + public function it_can_go_negative() + { + $d1 = TimeDelta::seconds(100); + $d2 = TimeDelta::seconds(150); + + $result = $d1->sub($d2); + $this->assertEquals(-50, $result->totalSeconds()); + } + + /** @test */ + public function it_can_invert_its_value() + { + $delta = new TimeDelta(100); + $inverted = $delta->invert(); + $this->assertEquals(-100, $inverted->totalSeconds()); + + $this->assertEquals(100, $inverted->invert()->totalSeconds()); + } + + /** @test */ + public function it_can_return_absolute_duration() + { + $delta = new TimeDelta(-100); + $absolute = $delta->absolute(); + $this->assertInstanceOf(DurationImmutable::class, $absolute); + $this->assertEquals(100, $absolute->totalSeconds()); + } + + /** @test */ + public function it_supports_comparison_methods() + { + $delta = new TimeDelta(-100); + $zero = TimeDelta::zero(); + + $this->assertTrue($delta->isNegative()); + $this->assertFalse($delta->isPositive()); + $this->assertTrue($delta->isNotZero()); + + $this->assertTrue($zero->isZero()); + $this->assertFalse($zero->isNotZero()); + $this->assertFalse($zero->isPositive()); + $this->assertFalse($zero->isNegative()); + } + + /** @test */ + public function it_supports_formatting_with_sign() + { + $delta = new TimeDelta(-3661); // -1h 1m 1s + $this->assertEquals('-01:01:01', $delta->format('*hh:mm:ss')); + $this->assertEquals('-1h 1m 1s', $delta->toShortHuman()); + $this->assertEquals('-1 hour 1 minute', $delta->toHuman()); + + $delta2 = new TimeDelta(-60); // -1 minute + $this->assertEquals('-1 minute', $delta2->toHuman()); + } + + /** @test */ + public function it_can_convert_to_date_interval() + { + $delta = new TimeDelta(-3661); + $interval = $delta->toDateInterval(); + + $this->assertInstanceOf(\DateInterval::class, $interval); + $this->assertEquals(1, $interval->h); + $this->assertEquals(1, $interval->i); + $this->assertEquals(1, $interval->s); + $this->assertEquals(1, $interval->invert); + } +}