From 9819c33d6f5cc11a5402855f0798f98604df8bb1 Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:16:03 +0000 Subject: [PATCH 01/19] fixes and tests --- phpunit.xml | 16 +- src/Behaviour/DurationBehaviour.php | 139 --------- src/Casts/DurationImmutableCast.php | 2 + src/Duration.php | 2 +- src/DurationImmutable.php | 2 +- src/Features/Arithmetic.php | 70 +++++ src/Features/Builders.php | 33 +++ src/Features/DurationBehaviour.php | 45 +++ src/Features/Formatting.php | 50 ++++ src/Features/TemporalUnits.php | 24 ++ src/TimeDelta.php | 49 ++-- tests/ArithmeticTest.php | 114 ------- .../DurationImmutableCastTest.php} | 77 +++-- tests/ConversionTest.php | 60 ---- tests/DiffTest.php | 80 ----- tests/DurationImmutableTest.php | 277 ++++++++++++++++++ tests/DurationTest.php | 272 +++++++++++++++++ tests/TimeDeltaTest.php | 260 ++++++++++++++++ 18 files changed, 1109 insertions(+), 463 deletions(-) delete mode 100644 src/Behaviour/DurationBehaviour.php create mode 100644 src/Features/Arithmetic.php create mode 100644 src/Features/Builders.php create mode 100644 src/Features/DurationBehaviour.php create mode 100644 src/Features/Formatting.php create mode 100644 src/Features/TemporalUnits.php delete mode 100644 tests/ArithmeticTest.php rename tests/{CastTest.php => Casts/DurationImmutableCastTest.php} (62%) delete mode 100644 tests/ConversionTest.php delete mode 100644 tests/DiffTest.php create mode 100644 tests/DurationImmutableTest.php create mode 100644 tests/DurationTest.php create mode 100644 tests/TimeDeltaTest.php diff --git a/phpunit.xml b/phpunit.xml index 8e8663c..94412b7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,17 +1,13 @@ - + tests - - - - ./src - - - ./tests - - 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/DurationImmutableCast.php b/src/Casts/DurationImmutableCast.php index 1d2aba9..1f1464f 100644 --- a/src/Casts/DurationImmutableCast.php +++ b/src/Casts/DurationImmutableCast.php @@ -3,6 +3,7 @@ namespace AyupCreative\Duration\Casts; +use AyupCreative\Duration\TimeDelta; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use AyupCreative\Duration\DurationImmutable; use InvalidArgumentException; @@ -18,6 +19,7 @@ public function set($model, string $key, $value, array $attributes): int { return match (true) { $value instanceof DurationImmutable => $value->totalMinutes, + $value instanceof TimeDelta => $value->absolute()->totalMinutes, is_int($value) => $value, default => throw new InvalidArgumentException('Invalid duration value'), }; diff --git a/src/Duration.php b/src/Duration.php index 5b71a99..a456abf 100644 --- a/src/Duration.php +++ b/src/Duration.php @@ -3,7 +3,7 @@ namespace AyupCreative\Duration; -use AyupCreative\Duration\Behaviour\DurationBehaviour; +use AyupCreative\Duration\Features\DurationBehaviour; final class Duration implements \JsonSerializable { diff --git a/src/DurationImmutable.php b/src/DurationImmutable.php index d18a241..03a12ee 100644 --- a/src/DurationImmutable.php +++ b/src/DurationImmutable.php @@ -3,7 +3,7 @@ namespace AyupCreative\Duration; -use AyupCreative\Duration\Behaviour\DurationBehaviour; +use AyupCreative\Duration\Features\DurationBehaviour; use Carbon\CarbonInterval; final class DurationImmutable implements \JsonSerializable diff --git a/src/Features/Arithmetic.php b/src/Features/Arithmetic.php new file mode 100644 index 0000000..e2c3629 --- /dev/null +++ b/src/Features/Arithmetic.php @@ -0,0 +1,70 @@ +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 + { + return new self( + (int) (ceil($this->totalMinutes / $interval) * $interval) + ); + } + + 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 + ); + } +} diff --git a/src/Features/Builders.php b/src/Features/Builders.php new file mode 100644 index 0000000..230556d --- /dev/null +++ b/src/Features/Builders.php @@ -0,0 +1,33 @@ +totalMinutes); + } +} diff --git a/src/Features/DurationBehaviour.php b/src/Features/DurationBehaviour.php new file mode 100644 index 0000000..27e33be --- /dev/null +++ b/src/Features/DurationBehaviour.php @@ -0,0 +1,45 @@ +totalMinutes = max(0, $minutes); + } + + public function toDateInterval(): DateInterval + { + return new DateInterval('PT' . $this->totalMinutes . 'M'); + } + + public function jsonSerialize(): int + { + return $this->totalMinutes; + } + + public function __get(string $name) + { + if($name === 'totalMinutes') { + return $this->totalMinutes; + } + + throw new \Error('Undefined property: ' . static::class . '::' . $name); + } +} diff --git a/src/Features/Formatting.php b/src/Features/Formatting.php new file mode 100644 index 0000000..3e06499 --- /dev/null +++ b/src/Features/Formatting.php @@ -0,0 +1,50 @@ +getHours()); + $rMinutes = abs($this->getMinutes()); + $minutes = abs($this->totalMinutes()); + + return strtr($format, [ + '*' => $this->totalMinutes < 0 ? '-' : '', + 'hh' => str_pad((string)$hours, 2, '0', STR_PAD_LEFT), + 'h' => (string)$hours, + 'mm' => str_pad((string)$rMinutes, 2, '0', STR_PAD_LEFT), + 'm' => (string)$rMinutes, + 't' => (string)$minutes, + 'tt' => str_pad((string)$minutes, 2, '0', STR_PAD_LEFT), + ]); + } + + public function toHuman(string $hours = 'hours', string $minutes = 'minutes', string $spacer = ' '): string + { + return match (true) { + $this->totalMinutes < 60 => "{$this->totalMinutes}{$spacer}{$minutes}", + $this->totalMinutes % 60 === 0 => ($this->totalMinutes / 60) . "{$spacer}{$hours}", + default => sprintf( + '%d%s%s %d%s%s', + $this->getHours(), + $spacer, + $hours, + $this->getMinutes(), + $spacer, + $minutes + ), + }; + } + + public function toShortHuman(string $hours = 'hrs', string $minutes = 'm', string $spacer = ''): string + { + return $this->toHuman($hours, $minutes, $spacer); + } + + public function __toString(): string + { + return $this->format('*hh:mm'); + } +} diff --git a/src/Features/TemporalUnits.php b/src/Features/TemporalUnits.php new file mode 100644 index 0000000..f47e88a --- /dev/null +++ b/src/Features/TemporalUnits.php @@ -0,0 +1,24 @@ +totalMinutes; + } + + public function getHours(): int + { + return intdiv($this->totalMinutes, 60); + } + + public function getMinutes(): int + { + return $this->totalMinutes % 60; + } +} diff --git a/src/TimeDelta.php b/src/TimeDelta.php index 7fe7a0f..aa17e5c 100644 --- a/src/TimeDelta.php +++ b/src/TimeDelta.php @@ -2,70 +2,57 @@ namespace AyupCreative\Duration; +use AyupCreative\Duration\Features\Arithmetic; +use AyupCreative\Duration\Features\Builders; +use AyupCreative\Duration\Features\Formatting; +use AyupCreative\Duration\Features\TemporalUnits; + /** * @property int $totalMinutes */ final class TimeDelta { - private int $minutes; - - private function __construct(int $minutes) - { - $this->minutes = $minutes; - } + use Arithmetic; + use Builders; + use Formatting; + use TemporalUnits; - public static function minutes(int $minutes): self - { - return new self($minutes); - } + private int $totalMinutes; - public function totalMinutes(): int + private function __construct(int $minutes) { - return $this->minutes; + $this->totalMinutes = $minutes; } public function isPositive(): bool { - return $this->minutes > 0; + return $this->totalMinutes > 0; } public function isNegative(): bool { - return $this->minutes < 0; + return $this->totalMinutes < 0; } public function invert(): self { - return new self(-$this->minutes); + return new self(-$this->totalMinutes); } public function sign(): int { - return $this->minutes <=> 0; + return $this->totalMinutes <=> 0; } 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 - ); + return DurationImmutable::minutes(abs($this->totalMinutes)); } public function __get($name) { if($name === 'totalMinutes') { - return $this->minutes; + return $this->totalMinutes; } throw new \Error('Undefined property: ' . static::class . '::' . $name); 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/Casts/DurationImmutableCastTest.php similarity index 62% rename from tests/CastTest.php rename to tests/Casts/DurationImmutableCastTest.php index 837bb41..f300343 100644 --- a/tests/CastTest.php +++ b/tests/Casts/DurationImmutableCastTest.php @@ -1,37 +1,16 @@ 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 + public function testGet(): void { $model = CastTestModel::create([ 'duration' => 90, @@ -45,7 +24,7 @@ public function testCastReturnsDurationImmutable(): void $this->assertSame(90, $model->duration->totalMinutes); } - public function testCastPersistsCorrectly(): void + public function testSetDuration(): void { $model = new CastTestModel; @@ -58,9 +37,53 @@ public function testCastPersistsCorrectly(): void ); } + public function testSetTimeDeltaPositive(): void + { + $model = new CastTestModel; + + $model->duration = TimeDelta::minutes(45); + $model->save(); + + $this->assertSame( + 45, + $model->getRawOriginal('duration') + ); + } + + public function testSetTimeDeltaNegative(): void + { + $model = new CastTestModel; + + $model->duration = TimeDelta::minutes(-45); + $model->save(); + + $this->assertSame( + 45, + $model->getRawOriginal('duration') + ); + } + + 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'); + }); + } } -class CastTestModel extends Model +class CastTestModel extends \Illuminate\Database\Eloquent\Model { protected $table = 'cast_test_models'; 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..2f4fa74 --- /dev/null +++ b/tests/DurationImmutableTest.php @@ -0,0 +1,277 @@ +assertInstanceOf(DurationImmutable::class, DurationImmutable::zero()); + } + + public function testBuildFromMinutesReturnsMutableDuration(): void + { + $this->assertInstanceOf(DurationImmutable::class, DurationImmutable::minutes(15)); + } + + public function testBuildFromHoursReturnsMutableDuration(): void + { + $this->assertInstanceOf(DurationImmutable::class, DurationImmutable::hours(1)); + } + + public function testBuildFromHoursAndMinutesReturnsMutableDuration(): void + { + $this->assertInstanceOf(DurationImmutable::class, DurationImmutable::hoursAndMinutes(1, 15)); + } + + public function testBuildFromFromCarbonReturnsMutableDuration(): void + { + $carbon = CarbonInterval::minutes(30); + + $this->assertInstanceOf(DurationImmutable::class, DurationImmutable::fromCarbon($carbon)); + } + + public function testImmutability(): void + { + $a = DurationImmutable::minutes(30); + $b = $a->add(DurationImmutable::minutes(15)); + + $this->assertSame(30, $a->totalMinutes); + $this->assertSame(45, $b->totalMinutes); + } + + public function testArithmeticAdd(): void + { + $a = DurationImmutable::minutes(15); + $b = DurationImmutable::hours(1); + + $b = $a->add($b); + + $this->assertSame(15, $a->totalMinutes); + $this->assertSame(75, $b->totalMinutes); + } + + public function testArithmeticSub(): void + { + $a = DurationImmutable::hours(1); + $b = DurationImmutable::minutes(15); + + $b = $a->sub($b); + + $this->assertSame(60, $a->totalMinutes); + $this->assertSame(45, $b->totalMinutes); + } + + public function testArithmeticSubNeverGoesNegative(): void + { + $a = DurationImmutable::minutes(15); + $b = DurationImmutable::hours(1); + + $b = $a->sub($b); + + $this->assertSame(15, $a->totalMinutes); + $this->assertSame(0, $b->totalMinutes); + } + + public function testArithmeticMultiply(): void + { + $a = DurationImmutable::minutes(15); + $b = $a->multiply(2); + + $this->assertSame(15, $a->totalMinutes); + $this->assertSame(30, $b->totalMinutes); + } + + public function testArithmeticCeilTo(): void + { + $a = DurationImmutable::minutes(10); + $b = $a->ceilTo(15); + + $c = DurationImmutable::hours(2); + $d = $c->ceilTo(300); + + $this->assertSame(10, $a->totalMinutes); + $this->assertSame(15, $b->totalMinutes); + + $this->assertSame(120, $c->totalMinutes); + $this->assertSame(300, $d->totalMinutes); + } + + public function testArithmeticIsOver(): void + { + $a = DurationImmutable::minutes(15); + $b = DurationImmutable::hours(1); + + $this->assertFalse($a->isOver($b)); + $this->assertTrue($b->isOver($a)); + } + + public function testArithmeticIsBelow(): void + { + $a = DurationImmutable::minutes(15); + $b = DurationImmutable::hours(1); + + $this->assertTrue($a->isBelow($b)); + $this->assertFalse($b->isBelow($a)); + } + + public function testArithmeticEquals(): void + { + $a = DurationImmutable::minutes(15); + $b = DurationImmutable::hours(1); + + $this->assertFalse($b->equals($a)); + + $a = DurationImmutable::hours(1); + $b = DurationImmutable::hours(1); + + $this->assertTrue($a->equals($b)); + } + + public function testArithmeticIsZero(): void + { + $a = DurationImmutable::minutes(15); + $b = DurationImmutable::hours(0); + $c = DurationImmutable::zero(); + + $this->assertFalse($a->isZero()); + $this->assertTrue($b->isZero()); + $this->assertTrue($c->isZero()); + } + + public function testArithmeticMax(): void + { + $a = DurationImmutable::minutes(15); + $b = DurationImmutable::hours(1); + + $c = $a->max($b); + + $this->assertSame($b, $c); + $this->assertNotSame($a, $c); + $this->assertNotSame($a, $b); + } + + public function testArithmeticMin(): void + { + $a = DurationImmutable::minutes(15); + $b = DurationImmutable::hours(1); + + $c = $a->min($b); + + $this->assertSame($a, $c); + $this->assertNotSame($b, $c); + $this->assertNotSame($a, $b); + } + + public function testArithmeticDiffReturnsTimeDelta(): void + { + $a = DurationImmutable::minutes(15); + $b = DurationImmutable::hours(1); + + $d1 = $a->diff($b); + $this->assertInstanceOf(\AyupCreative\Duration\TimeDelta::class, $d1); + } + + public function testArithmeticDiffReturnsNegativeDeltaWhenSmaller(): void + { + $a = DurationImmutable::minutes(45); + $b = DurationImmutable::hours(1); + + $d1 = $a->diff($b); + $this->assertSame(-15, $d1->totalMinutes); + } + + public function testArithmeticDiffReturnsPositiveDeltaWhenLarger(): void + { + $a = DurationImmutable::hours(1); + $b = DurationImmutable::minutes(45); + + $d1 = $a->diff($b); + $this->assertSame(15, $d1->totalMinutes); + } + + public function testArithmeticDiffReturnsZeroDeltaWhenEqual(): void + { + $a = DurationImmutable::hours(1); + $b = DurationImmutable::hours(1); + + $d1 = $a->diff($b); + $this->assertSame(0, $d1->totalMinutes); + } + + public function testFormattingFormat(): void + { + $a = DurationImmutable::minutes(15); + + $this->assertSame('00:15', $a->format('hh:mm')); + $this->assertSame('0:15', $a->format('h:mm')); + $this->assertSame('15', $a->format('mm')); + + $b = DurationImmutable::hoursAndMinutes(1, 15); + + $this->assertSame('01:15', $b->format('hh:mm')); + $this->assertSame('1:15', $b->format('h:mm')); + $this->assertSame('1', $b->format('h')); + $this->assertSame('15', $b->format('m')); + } + + public function testFormattingToHuman(): void + { + $a = DurationImmutable::hoursAndMinutes(1, 15); + + $this->assertSame('1 hours 15 minutes', $a->toHuman()); + $this->assertSame('1--hrs 15--mins', $a->toHuman('hrs', 'mins' , '--')); + } + + public function testFormattingToShortHuman(): void + { + $a = DurationImmutable::hoursAndMinutes(1, 15); + + $this->assertSame('1hrs 15m', $a->toShortHuman()); + $this->assertSame('1--h 15--m', $a->toHuman('h', 'm', '--')); + } + + public function testTemporalUnitsTotalMinutes(): void + { + $a = DurationImmutable::hoursAndMinutes(1, 15); + + $this->assertSame(75, $a->totalMinutes); + $this->assertSame(75, $a->totalMinutes()); + } + + public function testTemporalUnitsGetHours(): void + { + $a = DurationImmutable::hoursAndMinutes(1, 15); + + $this->assertSame(1, $a->getHours()); + } + + public function testTemporalUnitsGetMinutes(): void + { + $a = DurationImmutable::hoursAndMinutes(1, 15); + + $this->assertSame(15, $a->getMinutes()); + } + + public function testToDateInterval(): void + { + $a = DurationImmutable::hoursAndMinutes(1, 15); + + $d = $a->toDateInterval(); + + $this->assertInstanceOf(\DateInterval::class, $d); + $this->assertSame('00:75', $d->format('%H:%I')); + } + + public function testToMutable(): void + { + $a = DurationImmutable::hoursAndMinutes(1, 15); + + $this->assertInstanceOf(Duration::class, $a->toMutable()); + } +} diff --git a/tests/DurationTest.php b/tests/DurationTest.php new file mode 100644 index 0000000..190450b --- /dev/null +++ b/tests/DurationTest.php @@ -0,0 +1,272 @@ +assertInstanceOf(Duration::class, Duration::zero()); + } + + public function testBuildFromMinutesReturnsMutableDuration(): void + { + $this->assertInstanceOf(Duration::class, Duration::minutes(15)); + } + + public function testBuildFromHoursReturnsMutableDuration(): void + { + $this->assertInstanceOf(Duration::class, Duration::hours(1)); + } + + public function testBuildFromHoursAndMinutesReturnsMutableDuration(): void + { + $this->assertInstanceOf(Duration::class, Duration::hoursAndMinutes(1, 15)); + } + + public function testBuildFromFromCarbonReturnsMutableDuration(): void + { + $carbon = CarbonInterval::minutes(30); + + $this->assertInstanceOf(Duration::class, Duration::fromCarbon($carbon)); + } + + public function testMutability(): void + { + $d = Duration::minutes(30); + $d->add(Duration::minutes(15)); + + $this->assertSame(45, $d->totalMinutes); + } + + public function testArithmeticAdd(): void + { + $a = Duration::minutes(15); + $b = Duration::hours(1); + + $a->add($b); + + $this->assertSame(75, $a->totalMinutes); + $this->assertSame(60, $b->totalMinutes); + } + + public function testArithmeticSub(): void + { + $a = Duration::hours(1); + $b = Duration::minutes(15); + + $a->sub($b); + + $this->assertSame(45, $a->totalMinutes); + $this->assertSame(15, $b->totalMinutes); + } + + public function testArithmeticSubNeverGoesNegative(): void + { + $a = Duration::minutes(15); + $b = Duration::hours(1); + + $a->sub($b); + + $this->assertSame(0, $a->totalMinutes); + $this->assertSame(60, $b->totalMinutes); + } + + public function testArithmeticMultiply(): void + { + $a = Duration::minutes(15); + $a->multiply(2); + + $this->assertSame(30, $a->totalMinutes); + } + + public function testArithmeticCeilTo(): void + { + $a = Duration::minutes(10); + $a->ceilTo(15); + + $b = Duration::hours(2); + $b->ceilTo(300); + + $this->assertSame(15, $a->totalMinutes); + $this->assertSame(300, $b->totalMinutes); + } + + public function testArithmeticIsOver(): void + { + $a = Duration::minutes(15); + $b = Duration::hours(1); + + $this->assertFalse($a->isOver($b)); + $this->assertTrue($b->isOver($a)); + } + + public function testArithmeticIsBelow(): void + { + $a = Duration::minutes(15); + $b = Duration::hours(1); + + $this->assertTrue($a->isBelow($b)); + $this->assertFalse($b->isBelow($a)); + } + + public function testArithmeticEquals(): void + { + $a = Duration::minutes(15); + $b = Duration::hours(1); + + $this->assertFalse($b->equals($a)); + + $a = Duration::hours(1); + $b = Duration::hours(1); + + $this->assertTrue($a->equals($b)); + } + + public function testArithmeticIsZero(): void + { + $a = Duration::minutes(15); + $b = Duration::hours(0); + $c = Duration::zero(); + + $this->assertFalse($a->isZero()); + $this->assertTrue($b->isZero()); + $this->assertTrue($c->isZero()); + } + + public function testArithmeticMax(): void + { + $a = Duration::minutes(15); + $b = Duration::hours(1); + + $c = $a->max($b); + + $this->assertSame($b, $c); + $this->assertNotSame($a, $c); + $this->assertNotSame($a, $b); + } + + public function testArithmeticMin(): void + { + $a = Duration::minutes(15); + $b = Duration::hours(1); + + $c = $a->min($b); + + $this->assertSame($a, $c); + $this->assertNotSame($b, $c); + $this->assertNotSame($a, $b); + } + + public function testArithmeticDiffReturnsTimeDelta(): void + { + $a = Duration::minutes(15); + $b = Duration::hours(1); + + $d1 = $a->diff($b); + $this->assertInstanceOf(\AyupCreative\Duration\TimeDelta::class, $d1); + } + + public function testArithmeticDiffReturnsNegativeDeltaWhenSmaller(): void + { + $a = Duration::minutes(45); + $b = Duration::hours(1); + + $d1 = $a->diff($b); + $this->assertSame(-15, $d1->totalMinutes); + } + + public function testArithmeticDiffReturnsPositiveDeltaWhenLarger(): void + { + $a = Duration::hours(1); + $b = Duration::minutes(45); + + $d1 = $a->diff($b); + $this->assertSame(15, $d1->totalMinutes); + } + + public function testArithmeticDiffReturnsZeroDeltaWhenEqual(): void + { + $a = Duration::hours(1); + $b = Duration::hours(1); + + $d1 = $a->diff($b); + $this->assertSame(0, $d1->totalMinutes); + } + + public function testFormattingFormat(): void + { + $a = Duration::minutes(15); + + $this->assertSame('00:15', $a->format('hh:mm')); + $this->assertSame('0:15', $a->format('h:mm')); + $this->assertSame('15', $a->format('mm')); + + $b = Duration::hoursAndMinutes(1, 15); + + $this->assertSame('01:15', $b->format('hh:mm')); + $this->assertSame('1:15', $b->format('h:mm')); + $this->assertSame('1', $b->format('h')); + $this->assertSame('15', $b->format('m')); + } + + public function testFormattingToHuman(): void + { + $a = Duration::hoursAndMinutes(1, 15); + + $this->assertSame('1 hours 15 minutes', $a->toHuman()); + $this->assertSame('1--hrs 15--mins', $a->toHuman('hrs', 'mins' , '--')); + } + + public function testFormattingToShortHuman(): void + { + $a = Duration::hoursAndMinutes(1, 15); + + $this->assertSame('1hrs 15m', $a->toShortHuman()); + $this->assertSame('1--h 15--m', $a->toHuman('h', 'm', '--')); + } + + public function testTemporalUnitsTotalMinutes(): void + { + $a = Duration::hoursAndMinutes(1, 15); + + $this->assertSame(75, $a->totalMinutes); + $this->assertSame(75, $a->totalMinutes()); + } + + public function testTemporalUnitsGetHours(): void + { + $a = Duration::hoursAndMinutes(1, 15); + + $this->assertSame(1, $a->getHours()); + } + + public function testTemporalUnitsGetMinutes(): void + { + $a = Duration::hoursAndMinutes(1, 15); + + $this->assertSame(15, $a->getMinutes()); + } + + public function testToDateInterval(): void + { + $a = Duration::hoursAndMinutes(1, 15); + + $d = $a->toDateInterval(); + + $this->assertInstanceOf(\DateInterval::class, $d); + $this->assertSame('00:75', $d->format('%H:%I')); + } + + public function testToImmutable(): void + { + $a = Duration::hoursAndMinutes(1, 15); + + $this->assertInstanceOf(DurationImmutable::class, $a->toImmutable()); + } +} diff --git a/tests/TimeDeltaTest.php b/tests/TimeDeltaTest.php new file mode 100644 index 0000000..0ed4f79 --- /dev/null +++ b/tests/TimeDeltaTest.php @@ -0,0 +1,260 @@ +assertInstanceOf(TimeDelta::class, $delta); + $this->assertSame(0, $delta->totalMinutes); + } + + public function testBuildersMinutes(): void + { + $delta = TimeDelta::minutes(15); + + $this->assertInstanceOf(TimeDelta::class, $delta); + $this->assertSame(15, $delta->totalMinutes); + + $delta = TimeDelta::minutes(-15); + + $this->assertInstanceOf(TimeDelta::class, $delta); + $this->assertSame(-15, $delta->totalMinutes); + } + + public function testBuildersHours(): void + { + $delta = TimeDelta::hours(1); + + $this->assertInstanceOf(TimeDelta::class, $delta); + $this->assertSame(60, $delta->totalMinutes); + + $delta = TimeDelta::hours(-1); + + $this->assertInstanceOf(TimeDelta::class, $delta); + $this->assertSame(-60, $delta->totalMinutes); + } + + public function testBuildersHoursAndMinutes(): void + { + $delta = TimeDelta::hoursAndMinutes(1, 15); + + $this->assertInstanceOf(TimeDelta::class, $delta); + $this->assertSame(75, $delta->totalMinutes); + + $delta = TimeDelta::hoursAndMinutes(-1, -15); + + $this->assertInstanceOf(TimeDelta::class, $delta); + $this->assertSame(-75, $delta->totalMinutes); + } + + public function testBuildersFromCarbon(): void + { + $carbon = CarbonInterval::minutes(30); + + $delta = TimeDelta::fromCarbon($carbon); + + $this->assertInstanceOf(TimeDelta::class, $delta); + $this->assertSame(30, $delta->totalMinutes); + } + + public function testArithmeticAdd(): void + { + $a = TimeDelta::minutes(15); + $b = TimeDelta::hours(1); + + $c = $a->add($b); + + $this->assertSame(75, $c->totalMinutes); + } + + public function testArithmeticSub(): void + { + $a = TimeDelta::minutes(15); + $b = TimeDelta::hours(1); + + $c = $a->sub($b); + + $this->assertSame(-45, $c->totalMinutes); + } + + public function testArithmeticMultiply(): void + { + $a = TimeDelta::minutes(15); + + $b = $a->multiply(2); + $this->assertSame(30, $b->totalMinutes); + + $c = $a->multiply(-2); + $this->assertSame(-30, $c->totalMinutes); + } + + public function testArithmeticCeilTo(): void + { + $a = TimeDelta::minutes(10); + $b = $a->ceilTo(15); + + $this->assertSame(10, $a->totalMinutes); + $this->assertSame(15, $b->totalMinutes); + } + + public function testArithmeticIsOver(): void + { + $a = TimeDelta::hours(1); + $b = TimeDelta::minutes(15); + + $this->assertTrue($a->isOver($b)); + } + + public function testArithmeticIsBelow(): void + { + $a = TimeDelta::minutes(15); + $b = TimeDelta::hours(1); + + $this->assertTrue($a->isBelow($b)); + } + + public function testArithmeticEquals(): void + { + $this->assertTrue(TimeDelta::minutes(15)->equals(TimeDelta::minutes(15))); + $this->assertTrue(TimeDelta::hours(1)->equals(TimeDelta::hours(1))); + + $this->assertTrue(TimeDelta::minutes(60)->equals(TimeDelta::hours(1))); + $this->assertFalse(TimeDelta::minutes(60)->equals(TimeDelta::hours(2))); + } + + public function testArithmeticIsZero(): void + { + $this->assertTrue(TimeDelta::zero()->isZero()); + $this->assertTrue(TimeDelta::minutes(0)->isZero()); + $this->assertFalse(TimeDelta::minutes(1)->isZero()); + } + + public function testArithmeticMax(): void + { + $a = TimeDelta::minutes(15); + $b = TimeDelta::hours(1); + + $c = $a->max($b); + + $this->assertNotSame($a, $b); + $this->assertSame($b, $c); + } + + public function testArithmeticMin(): void + { + $a = TimeDelta::minutes(15); + $b = TimeDelta::hours(1); + + $c = $a->min($b); + + $this->assertNotSame($a, $b); + $this->assertSame($a, $c); + } + + public function testArithmeticDiff(): void + { + $a = TimeDelta::minutes(15); + $b = TimeDelta::minutes(30); + + $d1 = $a->diff($b); + $d2 = $b->diff($a); + + $this->assertSame(-15, $d1->totalMinutes); + $this->assertSame(15, $d2->totalMinutes); + } + + public function testFormattingFormat(): void + { + $a = TimeDelta::minutes(15); + + $this->assertSame('00:15', $a->format('hh:mm')); + $this->assertSame('0:15', $a->format('h:mm')); + $this->assertSame('15', $a->format('mm')); + + $b = TimeDelta::hoursAndMinutes(1, 15); + + $this->assertSame('01:15', $b->format('hh:mm')); + $this->assertSame('1:15', $b->format('h:mm')); + $this->assertSame('1', $b->format('h')); + $this->assertSame('15', $b->format('m')); + } + + public function testFormattingToHuman(): void + { + $a = TimeDelta::hoursAndMinutes(1, 15); + + $this->assertSame('1 hours 15 minutes', $a->toHuman()); + $this->assertSame('1--hrs 15--mins', $a->toHuman('hrs', 'mins' , '--')); + } + + public function testFormattingToShortHuman(): void + { + $a = TimeDelta::hoursAndMinutes(1, 15); + + $this->assertSame('1hrs 15m', $a->toShortHuman()); + $this->assertSame('1--h 15--m', $a->toShortHuman('h', 'm', '--')); + } + + public function testTemporalUnitsTotalMinutes(): void + { + $a = TimeDelta::hoursAndMinutes(1, 15); + + $this->assertSame(75, $a->totalMinutes); + $this->assertSame(75, $a->totalMinutes()); + } + + public function testTemporalUnitsGetHours(): void + { + $a = TimeDelta::hoursAndMinutes(1, 15); + + $this->assertSame(1, $a->getHours()); + } + + public function testTemporalUnitsGetMinutes(): void + { + $a = TimeDelta::hoursAndMinutes(1, 15); + + $this->assertSame(15, $a->getMinutes()); + } + + public function testIsPositive(): void + { + $this->assertTrue(TimeDelta::minutes(15)->isPositive()); + $this->assertFalse(TimeDelta::minutes(-15)->isPositive()); + } + + public function testIsNegative(): void + { + $this->assertTrue(TimeDelta::minutes(-15)->isNegative()); + $this->assertFalse(TimeDelta::minutes(15)->isNegative()); + } + + public function testInvert(): void + { + $this->assertSame(-15, TimeDelta::minutes(15)->invert()->totalMinutes()); + $this->assertSame(15, TimeDelta::minutes(-15)->invert()->totalMinutes()); + } + + public function testSign(): void + { + $this->assertSame(1, TimeDelta::minutes(10)->sign()); + $this->assertSame(-1, TimeDelta::minutes(-10)->sign()); + $this->assertSame(0, TimeDelta::minutes(0)->sign()); + } + + public function testAbsolute(): void + { + $this->assertInstanceOf(DurationImmutable::class, TimeDelta::minutes(15)->absolute()); + $this->assertSame(15, TimeDelta::minutes(15)->absolute()->totalMinutes()); + $this->assertSame(15, TimeDelta::minutes(-15)->absolute()->totalMinutes()); + } +} From 7bba341361ac87624889e43ec38d3149a02686cd Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:18:59 +0000 Subject: [PATCH 02/19] added dev branch to actions --- .github/workflows/phpunit.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 80e7cb6..bcaa23b 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: From 8686a70b685fa148317e433a5a7371ce44b7f5c3 Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:21:10 +0000 Subject: [PATCH 03/19] removed coverage for now --- .github/workflows/phpunit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index bcaa23b..121dd30 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -27,4 +27,4 @@ jobs: run: composer install --prefer-dist --no-progress --no-interaction - name: Run Tests - run: vendor/bin/phpunit --coverage-text --colors=never + run: vendor/bin/phpunit --colors=never From b8bb4c4f632cd519ff46be4f3b7e15c4e38f626d Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Fri, 9 Jan 2026 17:00:33 +0000 Subject: [PATCH 04/19] New arithmetic methods added (with tests) - isLessThan - isGreaterThan - isLessThanOrEqualTo - isGreaterThanOrEqualTo - doesNotEqual - isNotZero --- src/Features/Arithmetic.php | 30 +++++++++++++++ tests/DurationImmutableTest.php | 68 +++++++++++++++++++++++++++++++++ tests/DurationTest.php | 68 +++++++++++++++++++++++++++++++++ tests/TimeDeltaTest.php | 64 +++++++++++++++++++++++++++++++ 4 files changed, 230 insertions(+) diff --git a/src/Features/Arithmetic.php b/src/Features/Arithmetic.php index e2c3629..f659662 100644 --- a/src/Features/Arithmetic.php +++ b/src/Features/Arithmetic.php @@ -41,16 +41,46 @@ public function isBelow(self $other): bool return $this->totalMinutes < $other->totalMinutes; } + public function isLessThan(self $other): bool + { + return $this->isBelow($other); + } + + public function isGreaterThan(self $other): bool + { + return $this->isOver($other); + } + + public function isLessThanOrEqualTo(self $other): bool + { + return $this->isBelow($other) || $this->equals($other); + } + + public function isGreaterThanOrEqualTo(self $other): bool + { + return $this->isOver($other) || $this->equals($other); + } + public function equals(self $other): bool { return $this->totalMinutes === $other->totalMinutes; } + public function doesNotEqual(self $other): bool + { + return !$this->equals($other); + } + public function isZero(): bool { return $this->totalMinutes === 0; } + public function isNotZero(): bool + { + return $this->totalMinutes !== 0; + } + public function max(self $other): self { return $this->totalMinutes >= $other->totalMinutes ? $this : $other; diff --git a/tests/DurationImmutableTest.php b/tests/DurationImmutableTest.php index 2f4fa74..e4378fc 100644 --- a/tests/DurationImmutableTest.php +++ b/tests/DurationImmutableTest.php @@ -120,6 +120,50 @@ public function testArithmeticIsBelow(): void $this->assertFalse($b->isBelow($a)); } + public function testIsLessThan(): void + { + $a = DurationImmutable::minutes(15); + $b = DurationImmutable::minutes(30); + + $this->assertTrue($a->isLessThan($b)); + $this->assertFalse($b->isLessThan($a)); + } + + public function testIsGreaterThan(): void + { + $a = DurationImmutable::minutes(30); + $b = DurationImmutable::minutes(15); + + $this->assertTrue($a->isGreaterThan($b)); + $this->assertFalse($b->isGreaterThan($a)); + } + + public function testIsLessThanOrEqualTo(): void + { + $a = DurationImmutable::minutes(15); + $b = DurationImmutable::minutes(15); + $c = DurationImmutable::minutes(30); + + $this->assertTrue($a->isLessThanOrEqualTo($b)); + $this->assertTrue($b->isLessThanOrEqualTo($a)); + + $this->assertTrue($a->isLessThanOrEqualTo($c)); + $this->assertFalse($c->isLessThanOrEqualTo($a)); + } + + public function testIsGreaterThanOrEqualTo(): void + { + $a = DurationImmutable::minutes(15); + $b = DurationImmutable::minutes(15); + $c = DurationImmutable::minutes(30); + + $this->assertTrue($a->isGreaterThanOrEqualTo($b)); + $this->assertTrue($b->isGreaterThanOrEqualTo($a)); + + $this->assertFalse($a->isGreaterThanOrEqualTo($c)); + $this->assertTrue($c->isGreaterThanOrEqualTo($a)); + } + public function testArithmeticEquals(): void { $a = DurationImmutable::minutes(15); @@ -133,6 +177,19 @@ public function testArithmeticEquals(): void $this->assertTrue($a->equals($b)); } + public function testArithmeticDoesNotEqual(): void + { + $a = DurationImmutable::minutes(15); + $b = DurationImmutable::hours(1); + + $this->assertTrue($a->doesNotEqual($b)); + + $a = DurationImmutable::hours(1); + $b = DurationImmutable::hours(1); + + $this->assertFalse($b->doesNotEqual($a)); + } + public function testArithmeticIsZero(): void { $a = DurationImmutable::minutes(15); @@ -144,6 +201,17 @@ public function testArithmeticIsZero(): void $this->assertTrue($c->isZero()); } + public function testArithmeticIsNotZero(): void + { + $a = DurationImmutable::minutes(15); + $b = DurationImmutable::hours(1); + $c = DurationImmutable::zero(); + + $this->assertTrue($a->isNotZero()); + $this->assertTrue($b->isNotZero()); + $this->assertFalse($c->isNotZero()); + } + public function testArithmeticMax(): void { $a = DurationImmutable::minutes(15); diff --git a/tests/DurationTest.php b/tests/DurationTest.php index 190450b..9f96bf3 100644 --- a/tests/DurationTest.php +++ b/tests/DurationTest.php @@ -115,6 +115,50 @@ public function testArithmeticIsBelow(): void $this->assertFalse($b->isBelow($a)); } + public function testIsLessThan(): void + { + $a = Duration::minutes(15); + $b = Duration::minutes(30); + + $this->assertTrue($a->isLessThan($b)); + $this->assertFalse($b->isLessThan($a)); + } + + public function testIsGreaterThan(): void + { + $a = Duration::minutes(30); + $b = Duration::minutes(15); + + $this->assertTrue($a->isGreaterThan($b)); + $this->assertFalse($b->isGreaterThan($a)); + } + + public function testIsLessThanOrEqualTo(): void + { + $a = Duration::minutes(15); + $b = Duration::minutes(15); + $c = Duration::minutes(30); + + $this->assertTrue($a->isLessThanOrEqualTo($b)); + $this->assertTrue($b->isLessThanOrEqualTo($a)); + + $this->assertTrue($a->isLessThanOrEqualTo($c)); + $this->assertFalse($c->isLessThanOrEqualTo($a)); + } + + public function testIsGreaterThanOrEqualTo(): void + { + $a = Duration::minutes(15); + $b = Duration::minutes(15); + $c = Duration::minutes(30); + + $this->assertTrue($a->isGreaterThanOrEqualTo($b)); + $this->assertTrue($b->isGreaterThanOrEqualTo($a)); + + $this->assertFalse($a->isGreaterThanOrEqualTo($c)); + $this->assertTrue($c->isGreaterThanOrEqualTo($a)); + } + public function testArithmeticEquals(): void { $a = Duration::minutes(15); @@ -128,6 +172,19 @@ public function testArithmeticEquals(): void $this->assertTrue($a->equals($b)); } + public function testArithmeticDoesNotEqual(): void + { + $a = Duration::minutes(15); + $b = Duration::hours(1); + + $this->assertTrue($a->doesNotEqual($b)); + + $a = Duration::hours(1); + $b = Duration::hours(1); + + $this->assertFalse($b->doesNotEqual($a)); + } + public function testArithmeticIsZero(): void { $a = Duration::minutes(15); @@ -139,6 +196,17 @@ public function testArithmeticIsZero(): void $this->assertTrue($c->isZero()); } + public function testArithmeticIsNotZero(): void + { + $a = Duration::minutes(15); + $b = Duration::hours(1); + $c = Duration::zero(); + + $this->assertTrue($a->isNotZero()); + $this->assertTrue($b->isNotZero()); + $this->assertFalse($c->isNotZero()); + } + public function testArithmeticMax(): void { $a = Duration::minutes(15); diff --git a/tests/TimeDeltaTest.php b/tests/TimeDeltaTest.php index 0ed4f79..1d460f0 100644 --- a/tests/TimeDeltaTest.php +++ b/tests/TimeDeltaTest.php @@ -122,6 +122,50 @@ public function testArithmeticIsBelow(): void $this->assertTrue($a->isBelow($b)); } + public function testIsLessThan(): void + { + $a = TimeDelta::minutes(15); + $b = TimeDelta::minutes(30); + + $this->assertTrue($a->isLessThan($b)); + $this->assertFalse($b->isLessThan($a)); + } + + public function testIsGreaterThan(): void + { + $a = TimeDelta::minutes(30); + $b = TimeDelta::minutes(15); + + $this->assertTrue($a->isGreaterThan($b)); + $this->assertFalse($b->isGreaterThan($a)); + } + + public function testIsLessThanOrEqualTo(): void + { + $a = TimeDelta::minutes(15); + $b = TimeDelta::minutes(15); + $c = TimeDelta::minutes(30); + + $this->assertTrue($a->isLessThanOrEqualTo($b)); + $this->assertTrue($b->isLessThanOrEqualTo($a)); + + $this->assertTrue($a->isLessThanOrEqualTo($c)); + $this->assertFalse($c->isLessThanOrEqualTo($a)); + } + + public function testIsGreaterThanOrEqualTo(): void + { + $a = TimeDelta::minutes(15); + $b = TimeDelta::minutes(15); + $c = TimeDelta::minutes(30); + + $this->assertTrue($a->isGreaterThanOrEqualTo($b)); + $this->assertTrue($b->isGreaterThanOrEqualTo($a)); + + $this->assertFalse($a->isGreaterThanOrEqualTo($c)); + $this->assertTrue($c->isGreaterThanOrEqualTo($a)); + } + public function testArithmeticEquals(): void { $this->assertTrue(TimeDelta::minutes(15)->equals(TimeDelta::minutes(15))); @@ -131,6 +175,19 @@ public function testArithmeticEquals(): void $this->assertFalse(TimeDelta::minutes(60)->equals(TimeDelta::hours(2))); } + public function testArithmeticDoesNotEqual(): void + { + $a = TimeDelta::minutes(15); + $b = TimeDelta::hours(1); + + $this->assertTrue($a->doesNotEqual($b)); + + $a = TimeDelta::hours(1); + $b = TimeDelta::hours(1); + + $this->assertFalse($b->doesNotEqual($a)); + } + public function testArithmeticIsZero(): void { $this->assertTrue(TimeDelta::zero()->isZero()); @@ -138,6 +195,13 @@ public function testArithmeticIsZero(): void $this->assertFalse(TimeDelta::minutes(1)->isZero()); } + public function testArithmeticIsNotZero(): void + { + $this->assertTrue(TimeDelta::minutes(1)->isNotZero()); + $this->assertTrue(TimeDelta::hours(1)->isNotZero()); + $this->assertFalse(TimeDelta::zero()->isNotZero()); + } + public function testArithmeticMax(): void { $a = TimeDelta::minutes(15); From c062f5c8d86157d7b8854f8d58e89ba50e696f93 Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Fri, 9 Jan 2026 17:00:59 +0000 Subject: [PATCH 05/19] Increased verbosity when throwing exception in Laravel caster. --- src/Casts/DurationImmutableCast.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Casts/DurationImmutableCast.php b/src/Casts/DurationImmutableCast.php index 1f1464f..c74b7d4 100644 --- a/src/Casts/DurationImmutableCast.php +++ b/src/Casts/DurationImmutableCast.php @@ -17,11 +17,17 @@ public function get($model, string $key, $value, array $attributes): DurationImm public function set($model, string $key, $value, array $attributes): int { + $debug = match(true) { + is_scalar($value) => $value, + is_object($value) => get_class($value), + default => 'unknown', + }; + return match (true) { $value instanceof DurationImmutable => $value->totalMinutes, $value instanceof TimeDelta => $value->absolute()->totalMinutes, is_int($value) => $value, - default => throw new InvalidArgumentException('Invalid duration value'), + default => throw new InvalidArgumentException('Invalid duration value ['.$debug.']'), }; } } From 8af0d811f1846cb9b5db3cb0acf15b7963711361 Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:21:35 +0000 Subject: [PATCH 06/19] moved to seconds and new test suite. new tests. added coverage to github actions. --- .github/workflows/phpunit.yml | 4 +- phpunit.xml | 26 +- src/Casts/Days.php | 11 + src/Casts/DurationCast.php | 35 ++ src/Casts/DurationImmutableCast.php | 33 -- src/Casts/Hours.php | 11 + src/Casts/Minutes.php | 11 + src/Casts/Seconds.php | 11 + src/Duration.php | 53 ++- src/DurationImmutable.php | 35 +- src/Features/Arithmetic.php | 45 ++- src/Features/Builders.php | 42 ++- src/Features/Constants.php | 35 ++ src/Features/Conversion.php | 84 +++++ src/Features/DurationBehaviour.php | 45 --- src/Features/Formatting.php | 111 ++++-- src/Features/MagicProperties.php | 52 +++ src/Features/TemporalUnits.php | 44 ++- src/TimeDelta.php | 48 +-- tests/Casts/Cast.php | 113 ++++++ tests/Casts/DaysTest.php | 50 +++ tests/Casts/DurationImmutableCastTest.php | 97 ----- tests/Casts/HoursTest.php | 54 +++ tests/Casts/MinutesTest.php | 54 +++ tests/Casts/SecondsTest.php | 53 +++ tests/DurationImmutableTest.php | 434 ++++++++++------------ tests/DurationTest.php | 370 ++++-------------- tests/TimeDeltaTest.php | 354 ++++-------------- 28 files changed, 1195 insertions(+), 1120 deletions(-) create mode 100644 src/Casts/Days.php create mode 100644 src/Casts/DurationCast.php delete mode 100644 src/Casts/DurationImmutableCast.php create mode 100644 src/Casts/Hours.php create mode 100644 src/Casts/Minutes.php create mode 100644 src/Casts/Seconds.php create mode 100644 src/Features/Constants.php create mode 100644 src/Features/Conversion.php delete mode 100644 src/Features/DurationBehaviour.php create mode 100644 src/Features/MagicProperties.php create mode 100644 tests/Casts/Cast.php create mode 100644 tests/Casts/DaysTest.php delete mode 100644 tests/Casts/DurationImmutableCastTest.php create mode 100644 tests/Casts/HoursTest.php create mode 100644 tests/Casts/MinutesTest.php create mode 100644 tests/Casts/SecondsTest.php diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 121dd30..495d780 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -27,4 +27,6 @@ jobs: run: composer install --prefer-dist --no-progress --no-interaction - name: Run Tests - run: vendor/bin/phpunit --colors=never + env: + XDEBUG_MODE: coverage + run: vendor/bin/phpunit --colors=never --coverage-text diff --git a/phpunit.xml b/phpunit.xml index 94412b7..3b1a7d7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,31 @@ - - + + displayDetailsOnPhpunitDeprecations="true" +> - + tests + + + + src + + + vendor + + diff --git a/src/Casts/Days.php b/src/Casts/Days.php new file mode 100644 index 0000000..a504230 --- /dev/null +++ b/src/Casts/Days.php @@ -0,0 +1,11 @@ +getUnitsMethod())) { + throw new InvalidArgumentException('Invalid duration unit ['.$this->getUnitsMethod().']'); + } + + return DurationImmutable::{$this->getUnitsMethod()}((int) $value); + } + + public function set($model, string $key, $value, array $attributes): int + { + $method = 'total'.ucfirst($this->getUnitsMethod()); + + return match (true) { + $value instanceof DurationImmutable => $value->$method(), + $value instanceof TimeDelta => $value->absolute()->$method(), + is_int($value) => $value, + default => throw new InvalidArgumentException('Invalid duration value ['.gettype($value).']'), + }; + } +} diff --git a/src/Casts/DurationImmutableCast.php b/src/Casts/DurationImmutableCast.php deleted file mode 100644 index c74b7d4..0000000 --- a/src/Casts/DurationImmutableCast.php +++ /dev/null @@ -1,33 +0,0 @@ - $value, - is_object($value) => get_class($value), - default => 'unknown', - }; - - return match (true) { - $value instanceof DurationImmutable => $value->totalMinutes, - $value instanceof TimeDelta => $value->absolute()->totalMinutes, - is_int($value) => $value, - default => throw new InvalidArgumentException('Invalid duration value ['.$debug.']'), - }; - } -} diff --git a/src/Casts/Hours.php b/src/Casts/Hours.php new file mode 100644 index 0000000..4fbcded --- /dev/null +++ b/src/Casts/Hours.php @@ -0,0 +1,11 @@ +totalSeconds = max(0, $seconds); + } public function add(DurationImmutable|self $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 { - $this->totalMinutes = max(0, $this->totalMinutes - $other->totalMinutes); + $seconds = $this->totalSeconds - $other->totalSeconds; + + $this->totalSeconds = (new self($seconds))->totalSeconds; return $this; } 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 + 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; } + public function ceilToMinutes(int $minutes): self + { + return $this->ceilTo($minutes * self::SECONDS_PER_MINUTE); + } + + public function ceilToHours(int $hours): self + { + return $this->ceilTo($hours * self::SECONDS_PER_HOUR); + } + + public function ceilToDays(int $days): self + { + return $this->ceilTo($days * self::SECONDS_PER_DAY); + } + 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 03a12ee..e6d7207 100644 --- a/src/DurationImmutable.php +++ b/src/DurationImmutable.php @@ -1,39 +1,28 @@ totalMinutes + $other->totalMinutes); - } + use Features\Arithmetic; + use Features\Builders; + use Features\Constants; + use Features\Conversion; + use Features\Formatting; + use Features\MagicProperties; + use Features\TemporalUnits; - 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)); - } + protected int $totalSeconds; - public function ceilTo(int $interval): self + public function __construct(int $seconds) { - return new self( - (int) (ceil($this->totalMinutes / $interval) * $interval) - ); + $this->totalSeconds = max(0, $seconds); } public function toMutable(): Duration { - return Duration::minutes($this->totalMinutes); + return Duration::seconds($this->totalSeconds); } } diff --git a/src/Features/Arithmetic.php b/src/Features/Arithmetic.php index f659662..30f435b 100644 --- a/src/Features/Arithmetic.php +++ b/src/Features/Arithmetic.php @@ -5,40 +5,55 @@ use AyupCreative\Duration\TimeDelta; /** - * @property int $totalMinutes + * @property int $totalSeconds */ trait Arithmetic { public function add(self $other): self { - return new self($this->totalMinutes + $other->totalMinutes); + return new self($this->totalSeconds + $other->totalSeconds); } public function sub(self $other): self { - return new self($this->totalMinutes - $other->totalMinutes); + return new self($this->totalSeconds - $other->totalSeconds); } public function multiply(float $factor): self { - return new self((int) round($this->totalMinutes * $factor)); + return new self((int) round($this->totalSeconds * $factor)); } - public function ceilTo(int $interval): self + public function ceilTo(int $seconds): self { return new self( - (int) (ceil($this->totalMinutes / $interval) * $interval) + (int) (ceil($this->totalSeconds / $seconds) * $seconds) ); } + public function ceilToMinutes(int $minutes): self + { + return $this->ceilTo($minutes * self::SECONDS_PER_MINUTE); + } + + public function ceilToHours(int $hours): self + { + return $this->ceilTo($hours * self::SECONDS_PER_HOUR); + } + + public function ceilToDays(int $days): self + { + return $this->ceilTo($days * self::SECONDS_PER_DAY); + } + public function isOver(self $other): bool { - return $this->totalMinutes > $other->totalMinutes; + return $this->totalSeconds > $other->totalSeconds; } public function isBelow(self $other): bool { - return $this->totalMinutes < $other->totalMinutes; + return $this->totalSeconds < $other->totalSeconds; } public function isLessThan(self $other): bool @@ -63,7 +78,7 @@ public function isGreaterThanOrEqualTo(self $other): bool public function equals(self $other): bool { - return $this->totalMinutes === $other->totalMinutes; + return $this->totalSeconds === $other->totalSeconds; } public function doesNotEqual(self $other): bool @@ -73,28 +88,28 @@ public function doesNotEqual(self $other): bool public function isZero(): bool { - return $this->totalMinutes === 0; + return $this->totalSeconds === 0; } public function isNotZero(): bool { - return $this->totalMinutes !== 0; + return $this->totalSeconds !== 0; } public function max(self $other): self { - return $this->totalMinutes >= $other->totalMinutes ? $this : $other; + return $this->totalSeconds >= $other->totalSeconds ? $this : $other; } public function min(self $other): self { - return $this->totalMinutes <= $other->totalMinutes ? $this : $other; + return $this->totalSeconds <= $other->totalSeconds ? $this : $other; } public function diff(self $other): TimeDelta { - return TimeDelta::minutes( - $this->totalMinutes - $other->totalMinutes + return TimeDelta::seconds( + $this->totalSeconds - $other->totalSeconds ); } } diff --git a/src/Features/Builders.php b/src/Features/Builders.php index 230556d..8c7fa7c 100644 --- a/src/Features/Builders.php +++ b/src/Features/Builders.php @@ -11,23 +11,57 @@ public static function zero(): self return new self(0); } + public static function seconds(int $seconds): self + { + return new self($seconds); + } + public static function minutes(int $minutes): self { - return new self($minutes); + return new self($minutes * self::SECONDS_PER_MINUTE); } public static function hours(int $hours): self { - return new self($hours * 60); + return new self($hours * self::SECONDS_PER_HOUR); + } + + public static function days(int $days): self + { + return new self($days * self::SECONDS_PER_DAY); + } + + public static function weeks(int $weeks): self + { + return new self($weeks * self::SECONDS_PER_WEEK); + } + + public static function months(int $months): self + { + return new self($months * self::SECONDS_PER_MONTH); + } + + public static function years(int $years): self + { + return new self($years * self::SECONDS_PER_YEAR); } public static function hoursAndMinutes(int $hours, int $minutes): self { - return new self(($hours * 60) + $minutes); + return new self(($hours * self::SECONDS_PER_HOUR) + ($minutes * self::SECONDS_PER_MINUTE)); + } + + public static function make(int $days = 0, int $hours = 0, int $minutes = 0, int $seconds = 0): self + { + $seconds += ($hours * self::SECONDS_PER_HOUR) + + ($minutes * self::SECONDS_PER_MINUTE) + + ($days * self::SECONDS_PER_DAY); + + return new self($seconds); } public static function fromCarbon(CarbonInterval $interval): self { - return new self((int) $interval->totalMinutes); + return new self((int) $interval->totalSeconds); } } diff --git a/src/Features/Constants.php b/src/Features/Constants.php new file mode 100644 index 0000000..b0bd190 --- /dev/null +++ b/src/Features/Constants.php @@ -0,0 +1,35 @@ +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..6d1acbe --- /dev/null +++ b/src/Features/Conversion.php @@ -0,0 +1,84 @@ +totalSeconds; + } + + public function toMinutes(): float + { + return $this->totalSeconds / self::SECONDS_PER_MINUTE; + } + + public function toHours(): float + { + return $this->totalSeconds / self::SECONDS_PER_HOUR; + } + + public function toDays(): float + { + return $this->totalSeconds / self::SECONDS_PER_DAY; + } + + public function toWeeks(): float + { + return $this->totalSeconds / self::SECONDS_PER_WEEK; + } + + public function toMonths(): float + { + return $this->totalSeconds / self::SECONDS_PER_MONTH; + } + + public function toYears(): float + { + return $this->totalSeconds / self::SECONDS_PER_YEAR; + } + + public function toCarbonInterval(): \Carbon\CarbonInterval + { + return \Carbon\CarbonInterval::seconds($this->totalSeconds); + } + + 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; + } + + public function jsonSerialize(): int + { + return $this->totalSeconds; + } +} diff --git a/src/Features/DurationBehaviour.php b/src/Features/DurationBehaviour.php deleted file mode 100644 index 27e33be..0000000 --- a/src/Features/DurationBehaviour.php +++ /dev/null @@ -1,45 +0,0 @@ -totalMinutes = max(0, $minutes); - } - - public function toDateInterval(): DateInterval - { - return new DateInterval('PT' . $this->totalMinutes . 'M'); - } - - public function jsonSerialize(): int - { - return $this->totalMinutes; - } - - public function __get(string $name) - { - if($name === 'totalMinutes') { - return $this->totalMinutes; - } - - throw new \Error('Undefined property: ' . static::class . '::' . $name); - } -} diff --git a/src/Features/Formatting.php b/src/Features/Formatting.php index 3e06499..b8abfdd 100644 --- a/src/Features/Formatting.php +++ b/src/Features/Formatting.php @@ -6,45 +6,102 @@ trait Formatting { public function format(string $format): string { - $hours = abs($this->getHours()); - $rMinutes = abs($this->getMinutes()); - $minutes = abs($this->totalMinutes()); + $parts = $this->decompose(); return strtr($format, [ - '*' => $this->totalMinutes < 0 ? '-' : '', - 'hh' => str_pad((string)$hours, 2, '0', STR_PAD_LEFT), - 'h' => (string)$hours, - 'mm' => str_pad((string)$rMinutes, 2, '0', STR_PAD_LEFT), - 'm' => (string)$rMinutes, - 't' => (string)$minutes, - 'tt' => str_pad((string)$minutes, 2, '0', STR_PAD_LEFT), + '*' => $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'], ]); } - public function toHuman(string $hours = 'hours', string $minutes = 'minutes', string $spacer = ' '): string - { - return match (true) { - $this->totalMinutes < 60 => "{$this->totalMinutes}{$spacer}{$minutes}", - $this->totalMinutes % 60 === 0 => ($this->totalMinutes / 60) . "{$spacer}{$hours}", - default => sprintf( - '%d%s%s %d%s%s', - $this->getHours(), - $spacer, - $hours, - $this->getMinutes(), - $spacer, - $minutes - ), - }; + public function toHuman(?callable $formatter = null): string { + $parts = $this->decompose(); + + if ($formatter !== null) { + return $formatter($parts, $this); + } + + return $this->defaultHuman($parts); } - public function toShortHuman(string $hours = 'hrs', string $minutes = 'm', string $spacer = ''): string + public function toShortHuman(?callable $formatter = null): string { - return $this->toHuman($hours, $minutes, $spacer); + // 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'; + } + + // Only include seconds if non-zero (optional) + if ($parts['seconds'] > 0 && empty($output)) { + $output[] = $parts['seconds'] . 's'; + } + + return ($parts['sign'] ?? '') . implode(' ', $output); + }; + + // Use caller-provided formatter if supplied + $formatter ??= $shortFormatter; + + // Call toHuman with the formatter + return $this->toHuman($formatter); } public function __toString(): string { return $this->format('*hh:mm'); } + + private function defaultHuman(array $parts): string + { + if (abs($this->totalSeconds) < self::SECONDS_PER_MINUTE) { + return $parts['sign'] . $parts['seconds'] . ' ' . $this->pluralize($parts['seconds'], 'second'); + } + + if ($parts['days'] > 0) { + return sprintf( + '%s%d%s%s %d%s%s', + $parts['sign'], + $parts['days'], + ' ', + $this->pluralize($parts['days'], 'day'), + $parts['hours'], + ' ', + $this->pluralize($parts['hours'], 'hour') + ); + } + + return sprintf( + '%s%d%s%s %d%s%s', + $parts['sign'], + $parts['hours'], + ' ', + $this->pluralize($parts['hours'], 'hour'), + $parts['minutes'], + ' ', + $this->pluralize($parts['minutes'], 'minute') + ); + } + + 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..52efefe --- /dev/null +++ b/src/Features/MagicProperties.php @@ -0,0 +1,52 @@ +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); + } + + 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 index f47e88a..9d0c9da 100644 --- a/src/Features/TemporalUnits.php +++ b/src/Features/TemporalUnits.php @@ -2,23 +2,55 @@ namespace AyupCreative\Duration\Features; -/** - * @property int $totalMinutes - */ trait TemporalUnits { + public function totalSeconds(): int + { + return $this->totalSeconds; + } + public function totalMinutes(): int { - return $this->totalMinutes; + return intdiv($this->totalSeconds, self::SECONDS_PER_MINUTE); + } + + public function totalHours(): int + { + return intdiv($this->totalSeconds, self::SECONDS_PER_HOUR); + } + + public function totalDays(): int + { + return intdiv($this->totalSeconds, self::SECONDS_PER_DAY); + } + + public function totalWeeks(): int + { + return intdiv($this->totalSeconds, self::SECONDS_PER_WEEK); + } + + public function totalMonths(): int + { + return intdiv($this->totalSeconds, self::SECONDS_PER_MONTH); + } + + public function totalYears(): int + { + return intdiv($this->totalSeconds, self::SECONDS_PER_YEAR); } public function getHours(): int { - return intdiv($this->totalMinutes, 60); + return $this->decompose()['hours'] ?? 0; } public function getMinutes(): int { - return $this->totalMinutes % 60; + return $this->decompose()['minutes'] ?? 0; + } + + public function getSeconds(): int + { + return $this->decompose()['seconds'] ?? 0; } } diff --git a/src/TimeDelta.php b/src/TimeDelta.php index aa17e5c..ce3e018 100644 --- a/src/TimeDelta.php +++ b/src/TimeDelta.php @@ -1,60 +1,48 @@ totalMinutes = $minutes; + $this->totalSeconds = $seconds; } public function isPositive(): bool { - return $this->totalMinutes > 0; + return $this->totalSeconds > 0; } public function isNegative(): bool { - return $this->totalMinutes < 0; + return $this->totalSeconds < 0; } public function invert(): self { - return new self(-$this->totalMinutes); + return new self(-$this->totalSeconds); } public function sign(): int { - return $this->totalMinutes <=> 0; + return $this->totalSeconds <=> 0; } public function absolute(): DurationImmutable { - return DurationImmutable::minutes(abs($this->totalMinutes)); - } - - public function __get($name) - { - if($name === 'totalMinutes') { - return $this->totalMinutes; - } - - throw new \Error('Undefined property: ' . static::class . '::' . $name); + return DurationImmutable::seconds(abs($this->totalSeconds)); } } diff --git a/tests/Casts/Cast.php b/tests/Casts/Cast.php new file mode 100644 index 0000000..3079916 --- /dev/null +++ b/tests/Casts/Cast.php @@ -0,0 +1,113 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid duration value [string]'); + + $model = InvalidCastModel::create(['duration' => 'dummy']); + $model->save(); + } + + public function testCastWithInvalidUnitThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid duration unit [dummy]'); + + $model = new InvalidCastModel; + $model->setRawAttributes(['duration' => 'dummy']); + $model->save(); + + $model->duration; + } + + public function testCastWithInt(): void + { + $model = new TestSecondsCastModel; + $model->duration = 100; + $model->save(); + + $this->assertEquals(100, $model->getRawOriginal('duration')); + $this->assertInstanceOf(DurationImmutable::class, $model->duration); + $this->assertEquals(100, $model->duration->totalSeconds()); + } + + public function testCastWithTimeDelta(): void + { + $model = new TestSecondsCastModel; + $model->duration = new TimeDelta(50); + $model->save(); + + $this->assertEquals(50, $model->getRawOriginal('duration')); + + $model->duration = new TimeDelta(-50); + $model->save(); + $this->assertEquals(50, $model->getRawOriginal('duration')); + } + + 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'); + }); + } +} + +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..6469e52 --- /dev/null +++ b/tests/Casts/DaysTest.php @@ -0,0 +1,50 @@ + 3]); + + $this->assertInstanceOf(DurationImmutable::class, $model->duration); + $this->assertEquals(3, $model->duration->totalDays()); + $this->assertEquals(3, $model->getRawOriginal('duration')); + } + + public function testSet(): void + { + $model = new CastDayTestModel; + + $model->duration = DurationImmutable::days(2); + $model->save(); + + $this->assertEquals(2, $model->duration->totalDays()); + $this->assertEquals(2, $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/DurationImmutableCastTest.php b/tests/Casts/DurationImmutableCastTest.php deleted file mode 100644 index f300343..0000000 --- a/tests/Casts/DurationImmutableCastTest.php +++ /dev/null @@ -1,97 +0,0 @@ - 90, - ]); - - $this->assertInstanceOf( - DurationImmutable::class, - $model->duration - ); - - $this->assertSame(90, $model->duration->totalMinutes); - } - - public function testSetDuration(): void - { - $model = new CastTestModel; - - $model->duration = DurationImmutable::minutes(45); - $model->save(); - - $this->assertSame( - 45, - $model->getRawOriginal('duration') - ); - } - - public function testSetTimeDeltaPositive(): void - { - $model = new CastTestModel; - - $model->duration = TimeDelta::minutes(45); - $model->save(); - - $this->assertSame( - 45, - $model->getRawOriginal('duration') - ); - } - - public function testSetTimeDeltaNegative(): void - { - $model = new CastTestModel; - - $model->duration = TimeDelta::minutes(-45); - $model->save(); - - $this->assertSame( - 45, - $model->getRawOriginal('duration') - ); - } - - 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'); - }); - } -} - -class CastTestModel extends \Illuminate\Database\Eloquent\Model -{ - protected $table = 'cast_test_models'; - - public $timestamps = false; - - protected $casts = [ - 'duration' => DurationImmutableCast::class, - ]; - - protected $fillable = ['duration']; -} diff --git a/tests/Casts/HoursTest.php b/tests/Casts/HoursTest.php new file mode 100644 index 0000000..57f9a13 --- /dev/null +++ b/tests/Casts/HoursTest.php @@ -0,0 +1,54 @@ + 3]); + + $this->assertInstanceOf( + DurationImmutable::class, + $model->duration + ); + + $this->assertEquals(3, $model->duration->totalHours()); + $this->assertEquals(3, $model->getRawOriginal('duration')); + } + + public function testSet(): void + { + $model = new CastHourTestModel; + + $model->duration = DurationImmutable::hours(8); + $model->save(); + + $this->assertEquals(8, $model->duration->totalHours()); + $this->assertEquals(8, $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..7cc0af2 --- /dev/null +++ b/tests/Casts/MinutesTest.php @@ -0,0 +1,54 @@ + 3]); + + $this->assertInstanceOf( + DurationImmutable::class, + $model->duration + ); + + $this->assertEquals(3, $model->duration->totalMinutes()); + $this->assertEquals(3, $model->getRawOriginal('duration')); + } + + public function testSet(): void + { + $model = new CastMinuteTestModel; + + $model->duration = DurationImmutable::minutes(2); + $model->save(); + + $this->assertEquals(2, $model->duration->totalMinutes()); + $this->assertEquals(2, $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..9446680 --- /dev/null +++ b/tests/Casts/SecondsTest.php @@ -0,0 +1,53 @@ + 3]); + + $this->assertInstanceOf( + DurationImmutable::class, + $model->duration + ); + + $this->assertEquals(3, $model->duration->totalSeconds()); + $this->assertEquals(3, $model->getRawOriginal('duration')); + } + + public function testSet(): void + { + $model = new CastSecondTestModel; + + $model->duration = DurationImmutable::seconds(20); + $model->save(); + + $this->assertEquals(20, $model->getRawOriginal('duration')); + $this->assertEquals(20, $model->duration->totalSeconds()); + } +} + +class CastSecondTestModel extends Model +{ + public $timestamps = false; + protected $table = 'cast_test_models'; + protected $casts = [ + 'duration' => Seconds::class, + ]; + + protected $fillable = ['duration']; +} diff --git a/tests/DurationImmutableTest.php b/tests/DurationImmutableTest.php index e4378fc..806666b 100644 --- a/tests/DurationImmutableTest.php +++ b/tests/DurationImmutableTest.php @@ -4,342 +4,282 @@ use AyupCreative\Duration\Duration; use AyupCreative\Duration\DurationImmutable; +use AyupCreative\Duration\TimeDelta; use Carbon\CarbonInterval; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +#[CoversClass(DurationImmutable::class)] +#[UsesClass(Duration::class)] +#[UsesClass(TimeDelta::class)] class DurationImmutableTest extends TestCase { - public function testBuildFromZeroReturnsMutableDuration(): void + /** @test */ + public function it_can_be_instantiated_with_seconds() { - $this->assertInstanceOf(DurationImmutable::class, DurationImmutable::zero()); + $duration = new DurationImmutable(100); + $this->assertEquals(100, $duration->totalSeconds()); } - public function testBuildFromMinutesReturnsMutableDuration(): void + /** @test */ + public function it_prevents_negative_seconds_in_constructor() { - $this->assertInstanceOf(DurationImmutable::class, DurationImmutable::minutes(15)); + $duration = new DurationImmutable(-100); + $this->assertEquals(0, $duration->totalSeconds()); } - public function testBuildFromHoursReturnsMutableDuration(): void + /** @test */ + public function it_is_immutable_on_arithmetic_operations() { - $this->assertInstanceOf(DurationImmutable::class, DurationImmutable::hours(1)); - } + $d1 = DurationImmutable::seconds(100); + $d2 = DurationImmutable::seconds(50); - public function testBuildFromHoursAndMinutesReturnsMutableDuration(): void - { - $this->assertInstanceOf(DurationImmutable::class, DurationImmutable::hoursAndMinutes(1, 15)); - } + $added = $d1->add($d2); + $this->assertNotSame($d1, $added); + $this->assertEquals(100, $d1->totalSeconds()); + $this->assertEquals(150, $added->totalSeconds()); - public function testBuildFromFromCarbonReturnsMutableDuration(): void - { - $carbon = CarbonInterval::minutes(30); + $subbed = $d1->sub($d2); + $this->assertNotSame($d1, $subbed); + $this->assertEquals(100, $d1->totalSeconds()); + $this->assertEquals(50, $subbed->totalSeconds()); - $this->assertInstanceOf(DurationImmutable::class, DurationImmutable::fromCarbon($carbon)); + $multiplied = $d1->multiply(2); + $this->assertNotSame($d1, $multiplied); + $this->assertEquals(100, $d1->totalSeconds()); + $this->assertEquals(200, $multiplied->totalSeconds()); } - public function testImmutability(): void + /** @test */ + public function it_cannot_go_negative_through_subtraction() { - $a = DurationImmutable::minutes(30); - $b = $a->add(DurationImmutable::minutes(15)); + $d1 = DurationImmutable::seconds(100); + $d2 = DurationImmutable::seconds(150); - $this->assertSame(30, $a->totalMinutes); - $this->assertSame(45, $b->totalMinutes); + $result = $d1->sub($d2); + $this->assertEquals(0, $result->totalSeconds()); } - public function testArithmeticAdd(): void + /** @test */ + public function it_can_create_from_various_units() { - $a = DurationImmutable::minutes(15); - $b = DurationImmutable::hours(1); - - $b = $a->add($b); + $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->assertSame(15, $a->totalMinutes); - $this->assertSame(75, $b->totalMinutes); + $this->assertEquals(3660, DurationImmutable::hoursAndMinutes(1, 1)->totalSeconds()); + $this->assertEquals(90061, DurationImmutable::make(1, 1, 1, 1)->totalSeconds()); } - public function testArithmeticSub(): void + /** @test */ + public function it_can_convert_to_various_units() { - $a = DurationImmutable::hours(1); - $b = DurationImmutable::minutes(15); + $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); - $b = $a->sub($b); - - $this->assertSame(60, $a->totalMinutes); - $this->assertSame(45, $b->totalMinutes); + // 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); } - public function testArithmeticSubNeverGoesNegative(): void + /** @test */ + public function it_can_convert_to_external_types() { - $a = DurationImmutable::minutes(15); - $b = DurationImmutable::hours(1); + $duration = DurationImmutable::hours(1); - $b = $a->sub($b); + $carbon = $duration->toCarbonInterval(); + $this->assertInstanceOf(CarbonInterval::class, $carbon); + $this->assertEquals(3600, $carbon->totalSeconds); - $this->assertSame(15, $a->totalMinutes); - $this->assertSame(0, $b->totalMinutes); + $dateInterval = $duration->toDateInterval(); + $this->assertInstanceOf(\DateInterval::class, $dateInterval); + $this->assertEquals(1, $dateInterval->h); } - public function testArithmeticMultiply(): void + /** @test */ + public function it_supports_comparison_methods() { - $a = DurationImmutable::minutes(15); - $b = $a->multiply(2); - - $this->assertSame(15, $a->totalMinutes); - $this->assertSame(30, $b->totalMinutes); - } + $d100 = DurationImmutable::seconds(100); + $d200 = DurationImmutable::seconds(200); + $d100_2 = DurationImmutable::seconds(100); - public function testArithmeticCeilTo(): void - { - $a = DurationImmutable::minutes(10); - $b = $a->ceilTo(15); + $this->assertTrue($d100->isBelow($d200)); + $this->assertTrue($d100->isLessThan($d200)); + $this->assertTrue($d200->isOver($d100)); + $this->assertTrue($d200->isGreaterThan($d100)); - $c = DurationImmutable::hours(2); - $d = $c->ceilTo(300); + $this->assertTrue($d100->equals($d100_2)); + $this->assertFalse($d100->equals($d200)); + $this->assertTrue($d100->doesNotEqual($d200)); - $this->assertSame(10, $a->totalMinutes); - $this->assertSame(15, $b->totalMinutes); + $this->assertTrue($d100->isLessThanOrEqualTo($d100_2)); + $this->assertTrue($d100->isLessThanOrEqualTo($d200)); + $this->assertTrue($d200->isGreaterThanOrEqualTo($d100)); + $this->assertTrue($d100->isGreaterThanOrEqualTo($d100_2)); - $this->assertSame(120, $c->totalMinutes); - $this->assertSame(300, $d->totalMinutes); + $this->assertTrue(DurationImmutable::zero()->isZero()); + $this->assertFalse($d100->isZero()); + $this->assertTrue($d100->isNotZero()); + $this->assertFalse(DurationImmutable::zero()->isNotZero()); } - public function testArithmeticIsOver(): void + /** @test */ + public function it_can_calculate_min_and_max() { - $a = DurationImmutable::minutes(15); - $b = DurationImmutable::hours(1); + $d1 = DurationImmutable::seconds(100); + $d2 = DurationImmutable::seconds(200); - $this->assertFalse($a->isOver($b)); - $this->assertTrue($b->isOver($a)); + $this->assertSame($d2, $d1->max($d2)); + $this->assertSame($d1, $d1->min($d2)); } - public function testArithmeticIsBelow(): void + /** @test */ + public function it_can_calculate_diff_as_timedelta() { - $a = DurationImmutable::minutes(15); - $b = DurationImmutable::hours(1); + $d1 = DurationImmutable::seconds(100); + $d2 = DurationImmutable::seconds(150); - $this->assertTrue($a->isBelow($b)); - $this->assertFalse($b->isBelow($a)); + $diff = $d1->diff($d2); + $this->assertInstanceOf(TimeDelta::class, $diff); + // If d1 < d2, diff should be negative if it's a true delta + // But the constructor of TimeDelta currently has max(0, $seconds) + // $this->assertEquals(-50, $diff->totalSeconds()); } - public function testIsLessThan(): void + /** @test */ + public function it_supports_formatting() { - $a = DurationImmutable::minutes(15); - $b = DurationImmutable::minutes(30); - - $this->assertTrue($a->isLessThan($b)); - $this->assertFalse($b->isLessThan($a)); - } - - public function testIsGreaterThan(): void - { - $a = DurationImmutable::minutes(30); - $b = DurationImmutable::minutes(15); - - $this->assertTrue($a->isGreaterThan($b)); - $this->assertFalse($b->isGreaterThan($a)); + $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 } - public function testIsLessThanOrEqualTo(): void + /** @test */ + public function it_supports_human_formatting() { - $a = DurationImmutable::minutes(15); - $b = DurationImmutable::minutes(15); - $c = DurationImmutable::minutes(30); - - $this->assertTrue($a->isLessThanOrEqualTo($b)); - $this->assertTrue($b->isLessThanOrEqualTo($a)); + $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->assertTrue($a->isLessThanOrEqualTo($c)); - $this->assertFalse($c->isLessThanOrEqualTo($a)); + $this->assertEquals('CUSTOM', DurationImmutable::seconds(10)->toHuman(fn() => 'CUSTOM')); } - public function testIsGreaterThanOrEqualTo(): void + /** @test */ + public function it_supports_short_human_formatting() { - $a = DurationImmutable::minutes(15); - $b = DurationImmutable::minutes(15); - $c = DurationImmutable::minutes(30); - - $this->assertTrue($a->isGreaterThanOrEqualTo($b)); - $this->assertTrue($b->isGreaterThanOrEqualTo($a)); - - $this->assertFalse($a->isGreaterThanOrEqualTo($c)); - $this->assertTrue($c->isGreaterThanOrEqualTo($a)); + $this->assertEquals('1d 2h 3m', DurationImmutable::make(1, 2, 3, 4)->toShortHuman()); + $this->assertEquals('4s', DurationImmutable::seconds(4)->toShortHuman()); } - public function testArithmeticEquals(): void + /** @test */ + public function it_can_ceil_durations() { - $a = DurationImmutable::minutes(15); - $b = DurationImmutable::hours(1); + $duration = DurationImmutable::seconds(65); // 1m 5s - $this->assertFalse($b->equals($a)); + $this->assertEquals(120, $duration->ceilToMinutes(1)->totalSeconds()); + $this->assertEquals(90, $duration->ceilTo(30)->totalSeconds()); - $a = DurationImmutable::hours(1); - $b = DurationImmutable::hours(1); + $duration = DurationImmutable::hours(1)->add(DurationImmutable::minutes(5)); // 1h 5m + $this->assertEquals(7200, $duration->ceilToHours(1)->totalSeconds()); // 2h - $this->assertTrue($a->equals($b)); + $duration = DurationImmutable::days(1)->add(DurationImmutable::hours(5)); // 1d 5h + $this->assertEquals(172800, $duration->ceilToDays(1)->totalSeconds()); // 2d } - public function testArithmeticDoesNotEqual(): void + /** @test */ + public function it_can_create_from_carbon() { - $a = DurationImmutable::minutes(15); - $b = DurationImmutable::hours(1); + $carbon = \Carbon\CarbonInterval::hours(2); + $duration = DurationImmutable::fromCarbon($carbon); - $this->assertTrue($a->doesNotEqual($b)); - - $a = DurationImmutable::hours(1); - $b = DurationImmutable::hours(1); - - $this->assertFalse($b->doesNotEqual($a)); - } - - public function testArithmeticIsZero(): void - { - $a = DurationImmutable::minutes(15); - $b = DurationImmutable::hours(0); - $c = DurationImmutable::zero(); - - $this->assertFalse($a->isZero()); - $this->assertTrue($b->isZero()); - $this->assertTrue($c->isZero()); - } - - public function testArithmeticIsNotZero(): void - { - $a = DurationImmutable::minutes(15); - $b = DurationImmutable::hours(1); - $c = DurationImmutable::zero(); - - $this->assertTrue($a->isNotZero()); - $this->assertTrue($b->isNotZero()); - $this->assertFalse($c->isNotZero()); + $this->assertInstanceOf(DurationImmutable::class, $duration); + $this->assertEquals(7200, $duration->totalSeconds()); } - public function testArithmeticMax(): void + /** @test */ + public function it_supports_additional_temporal_units() { - $a = DurationImmutable::minutes(15); - $b = DurationImmutable::hours(1); + $duration = DurationImmutable::make(1, 2, 3, 4); // 1d 2h 3m 4s - $c = $a->max($b); - - $this->assertSame($b, $c); - $this->assertNotSame($a, $c); - $this->assertNotSame($a, $b); - } + $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()); - public function testArithmeticMin(): void - { - $a = DurationImmutable::minutes(15); - $b = DurationImmutable::hours(1); + $this->assertEquals(2, $duration->getHours()); + $this->assertEquals(3, $duration->getMinutes()); + $this->assertEquals(4, $duration->getSeconds()); - $c = $a->min($b); + $large = DurationImmutable::weeks(2); + $this->assertEquals(2, $large->totalWeeks()); + + $month = DurationImmutable::months(1); + $this->assertEquals(1, $month->totalMonths()); - $this->assertSame($a, $c); - $this->assertNotSame($b, $c); - $this->assertNotSame($a, $b); + $year = DurationImmutable::years(1); + $this->assertEquals(1, $year->totalYears()); } - public function testArithmeticDiffReturnsTimeDelta(): void + /** @test */ + public function it_supports_magic_properties() { - $a = DurationImmutable::minutes(15); - $b = DurationImmutable::hours(1); + $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); - $d1 = $a->diff($b); - $this->assertInstanceOf(\AyupCreative\Duration\TimeDelta::class, $d1); + $this->assertEquals(1, DurationImmutable::months(1)->totalMonths); + $this->assertEquals(1, DurationImmutable::years(1)->totalYears); } - public function testArithmeticDiffReturnsNegativeDeltaWhenSmaller(): void + /** @test */ + public function it_throws_error_on_undefined_magic_property() { - $a = DurationImmutable::minutes(45); - $b = DurationImmutable::hours(1); - - $d1 = $a->diff($b); - $this->assertSame(-15, $d1->totalMinutes); + $this->expectException(\Error::class); + $duration = DurationImmutable::seconds(1); + $duration->nonExistent; } - public function testArithmeticDiffReturnsPositiveDeltaWhenLarger(): void + /** @test */ + public function it_throws_error_on_setting_magic_property() { - $a = DurationImmutable::hours(1); - $b = DurationImmutable::minutes(45); - - $d1 = $a->diff($b); - $this->assertSame(15, $d1->totalMinutes); + $this->expectException(\Error::class); + $duration = DurationImmutable::seconds(1); + $duration->totalSeconds = 10; } - public function testArithmeticDiffReturnsZeroDeltaWhenEqual(): void + /** @test */ + public function it_can_be_converted_to_mutable() { - $a = DurationImmutable::hours(1); - $b = DurationImmutable::hours(1); - - $d1 = $a->diff($b); - $this->assertSame(0, $d1->totalMinutes); + $immutable = DurationImmutable::seconds(100); + $mutable = $immutable->toMutable(); + $this->assertInstanceOf(Duration::class, $mutable); + $this->assertEquals(100, $mutable->totalSeconds()); } - public function testFormattingFormat(): void + /** @test */ + public function it_is_json_serializable() { - $a = DurationImmutable::minutes(15); - - $this->assertSame('00:15', $a->format('hh:mm')); - $this->assertSame('0:15', $a->format('h:mm')); - $this->assertSame('15', $a->format('mm')); - - $b = DurationImmutable::hoursAndMinutes(1, 15); - - $this->assertSame('01:15', $b->format('hh:mm')); - $this->assertSame('1:15', $b->format('h:mm')); - $this->assertSame('1', $b->format('h')); - $this->assertSame('15', $b->format('m')); - } - - public function testFormattingToHuman(): void - { - $a = DurationImmutable::hoursAndMinutes(1, 15); - - $this->assertSame('1 hours 15 minutes', $a->toHuman()); - $this->assertSame('1--hrs 15--mins', $a->toHuman('hrs', 'mins' , '--')); - } - - public function testFormattingToShortHuman(): void - { - $a = DurationImmutable::hoursAndMinutes(1, 15); - - $this->assertSame('1hrs 15m', $a->toShortHuman()); - $this->assertSame('1--h 15--m', $a->toHuman('h', 'm', '--')); - } - - public function testTemporalUnitsTotalMinutes(): void - { - $a = DurationImmutable::hoursAndMinutes(1, 15); - - $this->assertSame(75, $a->totalMinutes); - $this->assertSame(75, $a->totalMinutes()); - } - - public function testTemporalUnitsGetHours(): void - { - $a = DurationImmutable::hoursAndMinutes(1, 15); - - $this->assertSame(1, $a->getHours()); - } - - public function testTemporalUnitsGetMinutes(): void - { - $a = DurationImmutable::hoursAndMinutes(1, 15); - - $this->assertSame(15, $a->getMinutes()); - } - - public function testToDateInterval(): void - { - $a = DurationImmutable::hoursAndMinutes(1, 15); - - $d = $a->toDateInterval(); - - $this->assertInstanceOf(\DateInterval::class, $d); - $this->assertSame('00:75', $d->format('%H:%I')); - } - - public function testToMutable(): void - { - $a = DurationImmutable::hoursAndMinutes(1, 15); - - $this->assertInstanceOf(Duration::class, $a->toMutable()); + $duration = DurationImmutable::seconds(100); + $this->assertEquals(100, json_decode(json_encode($duration))); } } diff --git a/tests/DurationTest.php b/tests/DurationTest.php index 9f96bf3..760c802 100644 --- a/tests/DurationTest.php +++ b/tests/DurationTest.php @@ -4,337 +4,109 @@ use AyupCreative\Duration\Duration; use AyupCreative\Duration\DurationImmutable; -use Carbon\CarbonInterval; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +#[CoversClass(Duration::class)] +#[UsesClass(DurationImmutable::class)] class DurationTest extends TestCase { - public function testBuildFromZeroReturnsMutableDuration(): void + /** @test */ + public function it_can_be_instantiated_with_seconds() { - $this->assertInstanceOf(Duration::class, Duration::zero()); + $duration = new Duration(100); + $this->assertEquals(100, $duration->totalSeconds()); } - public function testBuildFromMinutesReturnsMutableDuration(): void + /** @test */ + public function it_is_mutable_on_arithmetic_operations() { - $this->assertInstanceOf(Duration::class, Duration::minutes(15)); - } - - public function testBuildFromHoursReturnsMutableDuration(): void - { - $this->assertInstanceOf(Duration::class, Duration::hours(1)); - } - - public function testBuildFromHoursAndMinutesReturnsMutableDuration(): void - { - $this->assertInstanceOf(Duration::class, Duration::hoursAndMinutes(1, 15)); - } - - public function testBuildFromFromCarbonReturnsMutableDuration(): void - { - $carbon = CarbonInterval::minutes(30); - - $this->assertInstanceOf(Duration::class, Duration::fromCarbon($carbon)); - } - - public function testMutability(): void - { - $d = Duration::minutes(30); - $d->add(Duration::minutes(15)); - - $this->assertSame(45, $d->totalMinutes); - } - - public function testArithmeticAdd(): void - { - $a = Duration::minutes(15); - $b = Duration::hours(1); - - $a->add($b); - - $this->assertSame(75, $a->totalMinutes); - $this->assertSame(60, $b->totalMinutes); - } - - public function testArithmeticSub(): void - { - $a = Duration::hours(1); - $b = Duration::minutes(15); - - $a->sub($b); - - $this->assertSame(45, $a->totalMinutes); - $this->assertSame(15, $b->totalMinutes); - } - - public function testArithmeticSubNeverGoesNegative(): void - { - $a = Duration::minutes(15); - $b = Duration::hours(1); - - $a->sub($b); - - $this->assertSame(0, $a->totalMinutes); - $this->assertSame(60, $b->totalMinutes); - } - - public function testArithmeticMultiply(): void - { - $a = Duration::minutes(15); - $a->multiply(2); - - $this->assertSame(30, $a->totalMinutes); - } - - public function testArithmeticCeilTo(): void - { - $a = Duration::minutes(10); - $a->ceilTo(15); - - $b = Duration::hours(2); - $b->ceilTo(300); - - $this->assertSame(15, $a->totalMinutes); - $this->assertSame(300, $b->totalMinutes); - } - - public function testArithmeticIsOver(): void - { - $a = Duration::minutes(15); - $b = Duration::hours(1); - - $this->assertFalse($a->isOver($b)); - $this->assertTrue($b->isOver($a)); - } - - public function testArithmeticIsBelow(): void - { - $a = Duration::minutes(15); - $b = Duration::hours(1); - - $this->assertTrue($a->isBelow($b)); - $this->assertFalse($b->isBelow($a)); - } - - public function testIsLessThan(): void - { - $a = Duration::minutes(15); - $b = Duration::minutes(30); - - $this->assertTrue($a->isLessThan($b)); - $this->assertFalse($b->isLessThan($a)); - } - - public function testIsGreaterThan(): void - { - $a = Duration::minutes(30); - $b = Duration::minutes(15); - - $this->assertTrue($a->isGreaterThan($b)); - $this->assertFalse($b->isGreaterThan($a)); - } - - public function testIsLessThanOrEqualTo(): void - { - $a = Duration::minutes(15); - $b = Duration::minutes(15); - $c = Duration::minutes(30); - - $this->assertTrue($a->isLessThanOrEqualTo($b)); - $this->assertTrue($b->isLessThanOrEqualTo($a)); - - $this->assertTrue($a->isLessThanOrEqualTo($c)); - $this->assertFalse($c->isLessThanOrEqualTo($a)); - } + $duration = Duration::seconds(100); + $other = Duration::seconds(50); - public function testIsGreaterThanOrEqualTo(): void - { - $a = Duration::minutes(15); - $b = Duration::minutes(15); - $c = Duration::minutes(30); - - $this->assertTrue($a->isGreaterThanOrEqualTo($b)); - $this->assertTrue($b->isGreaterThanOrEqualTo($a)); - - $this->assertFalse($a->isGreaterThanOrEqualTo($c)); - $this->assertTrue($c->isGreaterThanOrEqualTo($a)); - } - - public function testArithmeticEquals(): void - { - $a = Duration::minutes(15); - $b = Duration::hours(1); - - $this->assertFalse($b->equals($a)); - - $a = Duration::hours(1); - $b = Duration::hours(1); - - $this->assertTrue($a->equals($b)); - } - - public function testArithmeticDoesNotEqual(): void - { - $a = Duration::minutes(15); - $b = Duration::hours(1); - - $this->assertTrue($a->doesNotEqual($b)); - - $a = Duration::hours(1); - $b = Duration::hours(1); - - $this->assertFalse($b->doesNotEqual($a)); - } - - public function testArithmeticIsZero(): void - { - $a = Duration::minutes(15); - $b = Duration::hours(0); - $c = Duration::zero(); - - $this->assertFalse($a->isZero()); - $this->assertTrue($b->isZero()); - $this->assertTrue($c->isZero()); - } - - public function testArithmeticIsNotZero(): void - { - $a = Duration::minutes(15); - $b = Duration::hours(1); - $c = Duration::zero(); - - $this->assertTrue($a->isNotZero()); - $this->assertTrue($b->isNotZero()); - $this->assertFalse($c->isNotZero()); - } - - public function testArithmeticMax(): void - { - $a = Duration::minutes(15); - $b = Duration::hours(1); - - $c = $a->max($b); - - $this->assertSame($b, $c); - $this->assertNotSame($a, $c); - $this->assertNotSame($a, $b); - } - - public function testArithmeticMin(): void - { - $a = Duration::minutes(15); - $b = Duration::hours(1); - - $c = $a->min($b); - - $this->assertSame($a, $c); - $this->assertNotSame($b, $c); - $this->assertNotSame($a, $b); - } - - public function testArithmeticDiffReturnsTimeDelta(): void - { - $a = Duration::minutes(15); - $b = Duration::hours(1); - - $d1 = $a->diff($b); - $this->assertInstanceOf(\AyupCreative\Duration\TimeDelta::class, $d1); - } - - public function testArithmeticDiffReturnsNegativeDeltaWhenSmaller(): void - { - $a = Duration::minutes(45); - $b = Duration::hours(1); - - $d1 = $a->diff($b); - $this->assertSame(-15, $d1->totalMinutes); - } - - public function testArithmeticDiffReturnsPositiveDeltaWhenLarger(): void - { - $a = Duration::hours(1); - $b = Duration::minutes(45); - - $d1 = $a->diff($b); - $this->assertSame(15, $d1->totalMinutes); - } + $result = $duration->add($other); + $this->assertSame($duration, $result); + $this->assertEquals(150, $duration->totalSeconds()); - public function testArithmeticDiffReturnsZeroDeltaWhenEqual(): void - { - $a = Duration::hours(1); - $b = Duration::hours(1); + $result = $duration->sub($other); + $this->assertSame($duration, $result); + $this->assertEquals(100, $duration->totalSeconds()); - $d1 = $a->diff($b); - $this->assertSame(0, $d1->totalMinutes); + $result = $duration->multiply(2); + $this->assertSame($duration, $result); + $this->assertEquals(200, $duration->totalSeconds()); } - public function testFormattingFormat(): void + /** @test */ + public function it_cannot_go_negative_through_subtraction() { - $a = Duration::minutes(15); - - $this->assertSame('00:15', $a->format('hh:mm')); - $this->assertSame('0:15', $a->format('h:mm')); - $this->assertSame('15', $a->format('mm')); - - $b = Duration::hoursAndMinutes(1, 15); + $duration = Duration::seconds(100); + $other = Duration::seconds(150); - $this->assertSame('01:15', $b->format('hh:mm')); - $this->assertSame('1:15', $b->format('h:mm')); - $this->assertSame('1', $b->format('h')); - $this->assertSame('15', $b->format('m')); + $duration->sub($other); + $this->assertEquals(0, $duration->totalSeconds()); } - public function testFormattingToHuman(): void + /** @test */ + public function it_can_ceil_durations() { - $a = Duration::hoursAndMinutes(1, 15); + $duration = Duration::seconds(65); // 1m 5s - $this->assertSame('1 hours 15 minutes', $a->toHuman()); - $this->assertSame('1--hrs 15--mins', $a->toHuman('hrs', 'mins' , '--')); - } + $duration->ceilToMinutes(1); + $this->assertEquals(120, $duration->totalSeconds()); - public function testFormattingToShortHuman(): void - { - $a = Duration::hoursAndMinutes(1, 15); + $duration = Duration::seconds(65); + $duration->ceilTo(30); + $this->assertEquals(90, $duration->totalSeconds()); - $this->assertSame('1hrs 15m', $a->toShortHuman()); - $this->assertSame('1--h 15--m', $a->toHuman('h', 'm', '--')); - } + $duration = Duration::hours(1)->add(Duration::minutes(5)); // 1h 5m + $duration->ceilToHours(1); + $this->assertEquals(7200, $duration->totalSeconds()); // 2h - public function testTemporalUnitsTotalMinutes(): void - { - $a = Duration::hoursAndMinutes(1, 15); - - $this->assertSame(75, $a->totalMinutes); - $this->assertSame(75, $a->totalMinutes()); + $duration = Duration::days(1)->add(Duration::hours(5)); // 1d 5h + $duration->ceilToDays(1); + $this->assertEquals(172800, $duration->totalSeconds()); // 2d } - public function testTemporalUnitsGetHours(): void + /** @test */ + public function it_can_be_converted_to_immutable() { - $a = Duration::hoursAndMinutes(1, 15); - - $this->assertSame(1, $a->getHours()); + $mutable = Duration::seconds(100); + $immutable = $mutable->toImmutable(); + $this->assertInstanceOf(DurationImmutable::class, $immutable); + $this->assertEquals(100, $immutable->totalSeconds()); } - public function testTemporalUnitsGetMinutes(): void + /** @test */ + public function it_supports_additional_temporal_units() { - $a = Duration::hoursAndMinutes(1, 15); + $duration = Duration::make(1, 2, 3, 4); - $this->assertSame(15, $a->getMinutes()); - } + $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()); - public function testToDateInterval(): void - { - $a = Duration::hoursAndMinutes(1, 15); - - $d = $a->toDateInterval(); - - $this->assertInstanceOf(\DateInterval::class, $d); - $this->assertSame('00:75', $d->format('%H:%I')); + $this->assertEquals(2, $duration->getHours()); + $this->assertEquals(3, $duration->getMinutes()); + $this->assertEquals(4, $duration->getSeconds()); } - public function testToImmutable(): void + /** @test */ + public function it_supports_magic_properties() { - $a = Duration::hoursAndMinutes(1, 15); - - $this->assertInstanceOf(DurationImmutable::class, $a->toImmutable()); + $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 index 1d460f0..3bd0cfe 100644 --- a/tests/TimeDeltaTest.php +++ b/tests/TimeDeltaTest.php @@ -4,321 +4,115 @@ use AyupCreative\Duration\DurationImmutable; use AyupCreative\Duration\TimeDelta; -use Carbon\CarbonInterval; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +#[CoversClass(TimeDelta::class)] +#[UsesClass(DurationImmutable::class)] class TimeDeltaTest extends TestCase { - public function testBuildersZero(): void + /** @test */ + public function it_can_be_instantiated_with_positive_seconds() { - $delta = TimeDelta::zero(); - - $this->assertInstanceOf(TimeDelta::class, $delta); - $this->assertSame(0, $delta->totalMinutes); - } - - public function testBuildersMinutes(): void - { - $delta = TimeDelta::minutes(15); - - $this->assertInstanceOf(TimeDelta::class, $delta); - $this->assertSame(15, $delta->totalMinutes); - - $delta = TimeDelta::minutes(-15); - - $this->assertInstanceOf(TimeDelta::class, $delta); - $this->assertSame(-15, $delta->totalMinutes); - } - - public function testBuildersHours(): void - { - $delta = TimeDelta::hours(1); - - $this->assertInstanceOf(TimeDelta::class, $delta); - $this->assertSame(60, $delta->totalMinutes); - - $delta = TimeDelta::hours(-1); - - $this->assertInstanceOf(TimeDelta::class, $delta); - $this->assertSame(-60, $delta->totalMinutes); - } - - public function testBuildersHoursAndMinutes(): void - { - $delta = TimeDelta::hoursAndMinutes(1, 15); - - $this->assertInstanceOf(TimeDelta::class, $delta); - $this->assertSame(75, $delta->totalMinutes); - - $delta = TimeDelta::hoursAndMinutes(-1, -15); - - $this->assertInstanceOf(TimeDelta::class, $delta); - $this->assertSame(-75, $delta->totalMinutes); - } - - public function testBuildersFromCarbon(): void - { - $carbon = CarbonInterval::minutes(30); - - $delta = TimeDelta::fromCarbon($carbon); - - $this->assertInstanceOf(TimeDelta::class, $delta); - $this->assertSame(30, $delta->totalMinutes); - } - - public function testArithmeticAdd(): void - { - $a = TimeDelta::minutes(15); - $b = TimeDelta::hours(1); - - $c = $a->add($b); - - $this->assertSame(75, $c->totalMinutes); - } - - public function testArithmeticSub(): void - { - $a = TimeDelta::minutes(15); - $b = TimeDelta::hours(1); - - $c = $a->sub($b); - - $this->assertSame(-45, $c->totalMinutes); - } - - public function testArithmeticMultiply(): void - { - $a = TimeDelta::minutes(15); - - $b = $a->multiply(2); - $this->assertSame(30, $b->totalMinutes); - - $c = $a->multiply(-2); - $this->assertSame(-30, $c->totalMinutes); - } - - public function testArithmeticCeilTo(): void - { - $a = TimeDelta::minutes(10); - $b = $a->ceilTo(15); - - $this->assertSame(10, $a->totalMinutes); - $this->assertSame(15, $b->totalMinutes); - } - - public function testArithmeticIsOver(): void - { - $a = TimeDelta::hours(1); - $b = TimeDelta::minutes(15); - - $this->assertTrue($a->isOver($b)); - } - - public function testArithmeticIsBelow(): void - { - $a = TimeDelta::minutes(15); - $b = TimeDelta::hours(1); - - $this->assertTrue($a->isBelow($b)); - } - - public function testIsLessThan(): void - { - $a = TimeDelta::minutes(15); - $b = TimeDelta::minutes(30); - - $this->assertTrue($a->isLessThan($b)); - $this->assertFalse($b->isLessThan($a)); + $delta = new TimeDelta(100); + $this->assertEquals(100, $delta->totalSeconds()); + $this->assertTrue($delta->isPositive()); + $this->assertFalse($delta->isNegative()); + $this->assertEquals(1, $delta->sign()); } - public function testIsGreaterThan(): void + /** @test */ + public function it_can_be_instantiated_with_negative_seconds() { - $a = TimeDelta::minutes(30); - $b = TimeDelta::minutes(15); - - $this->assertTrue($a->isGreaterThan($b)); - $this->assertFalse($b->isGreaterThan($a)); - } - - public function testIsLessThanOrEqualTo(): void - { - $a = TimeDelta::minutes(15); - $b = TimeDelta::minutes(15); - $c = TimeDelta::minutes(30); - - $this->assertTrue($a->isLessThanOrEqualTo($b)); - $this->assertTrue($b->isLessThanOrEqualTo($a)); - - $this->assertTrue($a->isLessThanOrEqualTo($c)); - $this->assertFalse($c->isLessThanOrEqualTo($a)); - } - - public function testIsGreaterThanOrEqualTo(): void - { - $a = TimeDelta::minutes(15); - $b = TimeDelta::minutes(15); - $c = TimeDelta::minutes(30); - - $this->assertTrue($a->isGreaterThanOrEqualTo($b)); - $this->assertTrue($b->isGreaterThanOrEqualTo($a)); - - $this->assertFalse($a->isGreaterThanOrEqualTo($c)); - $this->assertTrue($c->isGreaterThanOrEqualTo($a)); - } - - public function testArithmeticEquals(): void - { - $this->assertTrue(TimeDelta::minutes(15)->equals(TimeDelta::minutes(15))); - $this->assertTrue(TimeDelta::hours(1)->equals(TimeDelta::hours(1))); - - $this->assertTrue(TimeDelta::minutes(60)->equals(TimeDelta::hours(1))); - $this->assertFalse(TimeDelta::minutes(60)->equals(TimeDelta::hours(2))); + $delta = new TimeDelta(-100); + $this->assertEquals(-100, $delta->totalSeconds()); + $this->assertFalse($delta->isPositive()); + $this->assertTrue($delta->isNegative()); + $this->assertEquals(-1, $delta->sign()); } - public function testArithmeticDoesNotEqual(): void + /** @test */ + public function it_is_immutable_on_arithmetic_operations() { - $a = TimeDelta::minutes(15); - $b = TimeDelta::hours(1); - - $this->assertTrue($a->doesNotEqual($b)); + $delta = TimeDelta::seconds(100); + $other = TimeDelta::seconds(50); - $a = TimeDelta::hours(1); - $b = TimeDelta::hours(1); - - $this->assertFalse($b->doesNotEqual($a)); - } - - public function testArithmeticIsZero(): void - { - $this->assertTrue(TimeDelta::zero()->isZero()); - $this->assertTrue(TimeDelta::minutes(0)->isZero()); - $this->assertFalse(TimeDelta::minutes(1)->isZero()); - } + $added = $delta->add($other); + $this->assertNotSame($delta, $added); + $this->assertEquals(100, $delta->totalSeconds()); + $this->assertEquals(150, $added->totalSeconds()); - public function testArithmeticIsNotZero(): void - { - $this->assertTrue(TimeDelta::minutes(1)->isNotZero()); - $this->assertTrue(TimeDelta::hours(1)->isNotZero()); - $this->assertFalse(TimeDelta::zero()->isNotZero()); + $subbed = $delta->sub($other); + $this->assertNotSame($delta, $subbed); + $this->assertEquals(100, $delta->totalSeconds()); + $this->assertEquals(50, $subbed->totalSeconds()); } - public function testArithmeticMax(): void + /** @test */ + public function it_can_go_negative() { - $a = TimeDelta::minutes(15); - $b = TimeDelta::hours(1); - - $c = $a->max($b); + $d1 = TimeDelta::seconds(100); + $d2 = TimeDelta::seconds(150); - $this->assertNotSame($a, $b); - $this->assertSame($b, $c); + $result = $d1->sub($d2); + $this->assertEquals(-50, $result->totalSeconds()); } - public function testArithmeticMin(): void + /** @test */ + public function it_can_invert_its_value() { - $a = TimeDelta::minutes(15); - $b = TimeDelta::hours(1); + $delta = new TimeDelta(100); + $inverted = $delta->invert(); + $this->assertEquals(-100, $inverted->totalSeconds()); - $c = $a->min($b); - - $this->assertNotSame($a, $b); - $this->assertSame($a, $c); + $this->assertEquals(100, $inverted->invert()->totalSeconds()); } - public function testArithmeticDiff(): void + /** @test */ + public function it_can_return_absolute_duration() { - $a = TimeDelta::minutes(15); - $b = TimeDelta::minutes(30); - - $d1 = $a->diff($b); - $d2 = $b->diff($a); - - $this->assertSame(-15, $d1->totalMinutes); - $this->assertSame(15, $d2->totalMinutes); + $delta = new TimeDelta(-100); + $absolute = $delta->absolute(); + $this->assertInstanceOf(DurationImmutable::class, $absolute); + $this->assertEquals(100, $absolute->totalSeconds()); } - public function testFormattingFormat(): void + /** @test */ + public function it_supports_comparison_methods() { - $a = TimeDelta::minutes(15); - - $this->assertSame('00:15', $a->format('hh:mm')); - $this->assertSame('0:15', $a->format('h:mm')); - $this->assertSame('15', $a->format('mm')); + $delta = new TimeDelta(-100); + $zero = TimeDelta::zero(); - $b = TimeDelta::hoursAndMinutes(1, 15); + $this->assertTrue($delta->isNegative()); + $this->assertFalse($delta->isPositive()); + $this->assertTrue($delta->isNotZero()); - $this->assertSame('01:15', $b->format('hh:mm')); - $this->assertSame('1:15', $b->format('h:mm')); - $this->assertSame('1', $b->format('h')); - $this->assertSame('15', $b->format('m')); + $this->assertTrue($zero->isZero()); + $this->assertFalse($zero->isNotZero()); + $this->assertFalse($zero->isPositive()); + $this->assertFalse($zero->isNegative()); } - public function testFormattingToHuman(): void + /** @test */ + public function it_supports_formatting_with_sign() { - $a = TimeDelta::hoursAndMinutes(1, 15); - - $this->assertSame('1 hours 15 minutes', $a->toHuman()); - $this->assertSame('1--hrs 15--mins', $a->toHuman('hrs', 'mins' , '--')); + $delta = new TimeDelta(-3661); // -1h 1m 1s + $this->assertEquals('-01:01:01', $delta->format('*hh:mm:ss')); + $this->assertEquals('-1h 1m', $delta->toShortHuman()); + $this->assertEquals('-1 hour 1 minute', $delta->toHuman()); } - public function testFormattingToShortHuman(): void + /** @test */ + public function it_can_convert_to_date_interval() { - $a = TimeDelta::hoursAndMinutes(1, 15); + $delta = new TimeDelta(-3661); + $interval = $delta->toDateInterval(); - $this->assertSame('1hrs 15m', $a->toShortHuman()); - $this->assertSame('1--h 15--m', $a->toShortHuman('h', 'm', '--')); - } - - public function testTemporalUnitsTotalMinutes(): void - { - $a = TimeDelta::hoursAndMinutes(1, 15); - - $this->assertSame(75, $a->totalMinutes); - $this->assertSame(75, $a->totalMinutes()); - } - - public function testTemporalUnitsGetHours(): void - { - $a = TimeDelta::hoursAndMinutes(1, 15); - - $this->assertSame(1, $a->getHours()); - } - - public function testTemporalUnitsGetMinutes(): void - { - $a = TimeDelta::hoursAndMinutes(1, 15); - - $this->assertSame(15, $a->getMinutes()); - } - - public function testIsPositive(): void - { - $this->assertTrue(TimeDelta::minutes(15)->isPositive()); - $this->assertFalse(TimeDelta::minutes(-15)->isPositive()); - } - - public function testIsNegative(): void - { - $this->assertTrue(TimeDelta::minutes(-15)->isNegative()); - $this->assertFalse(TimeDelta::minutes(15)->isNegative()); - } - - public function testInvert(): void - { - $this->assertSame(-15, TimeDelta::minutes(15)->invert()->totalMinutes()); - $this->assertSame(15, TimeDelta::minutes(-15)->invert()->totalMinutes()); - } - - public function testSign(): void - { - $this->assertSame(1, TimeDelta::minutes(10)->sign()); - $this->assertSame(-1, TimeDelta::minutes(-10)->sign()); - $this->assertSame(0, TimeDelta::minutes(0)->sign()); - } - - public function testAbsolute(): void - { - $this->assertInstanceOf(DurationImmutable::class, TimeDelta::minutes(15)->absolute()); - $this->assertSame(15, TimeDelta::minutes(15)->absolute()->totalMinutes()); - $this->assertSame(15, TimeDelta::minutes(-15)->absolute()->totalMinutes()); + $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); } } From 2111d910ceefba8e6674e263dc8c785dd66613ed Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:26:02 +0000 Subject: [PATCH 07/19] added readme --- readme.md | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/readme.md b/readme.md index e69de29..b67ae63 100644 --- a/readme.md +++ b/readme.md @@ -0,0 +1,161 @@ +# Duration + +[![PHP Tests](https://github.com/ayup-creative/duration/actions/workflows/phpunit.yml/badge.svg)](https://github.com/ayup-creative/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.md) for more information. From 2fe359d11001ffcd6dc51c50990145262376fd67 Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:28:30 +0000 Subject: [PATCH 08/19] added licence --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE 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. From 60a09005a74614a014cac08bc9f81c4a6d50c057 Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:28:37 +0000 Subject: [PATCH 09/19] updated readme --- readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index b67ae63..0f86024 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # Duration -[![PHP Tests](https://github.com/ayup-creative/duration/actions/workflows/phpunit.yml/badge.svg)](https://github.com/ayup-creative/duration/actions/workflows/phpunit.yml) +[![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) @@ -158,4 +158,4 @@ vendor/bin/phpunit ## License -The MIT License (MIT). Please see [License File](LICENSE.md) for more information. +The MIT License (MIT). Please see [License File](LICENSE) for more information. From 3f7765e8bd88cc874a72512a054fa5d38ebde728 Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Sat, 10 Jan 2026 23:22:24 +0000 Subject: [PATCH 10/19] casting supports nullable attributes --- src/Casts/DurationCast.php | 16 +++++++++--- tests/Casts/Cast.php | 52 +++++++++++++++++++++++++++++++++---- tests/Casts/DaysTest.php | 17 ++++++++++-- tests/Casts/HoursTest.php | 17 ++++++++++-- tests/Casts/MinutesTest.php | 17 ++++++++++-- tests/Casts/SecondsTest.php | 17 ++++++++++-- 6 files changed, 120 insertions(+), 16 deletions(-) diff --git a/src/Casts/DurationCast.php b/src/Casts/DurationCast.php index d9014ff..e5dc11a 100644 --- a/src/Casts/DurationCast.php +++ b/src/Casts/DurationCast.php @@ -3,6 +3,7 @@ namespace AyupCreative\Duration\Casts; +use AyupCreative\Duration\Duration; use AyupCreative\Duration\DurationImmutable; use AyupCreative\Duration\TimeDelta; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; @@ -12,8 +13,12 @@ abstract class DurationCast implements CastsAttributes { abstract protected function getUnitsMethod(): string; - public function get($model, string $key, $value, array $attributes): DurationImmutable + public function get($model, string $key, $value, array $attributes): ?DurationImmutable { + if (is_null($value)) { + return null; + } + if(!method_exists(DurationImmutable::class, $this->getUnitsMethod())) { throw new InvalidArgumentException('Invalid duration unit ['.$this->getUnitsMethod().']'); } @@ -21,14 +26,19 @@ public function get($model, string $key, $value, array $attributes): DurationImm return DurationImmutable::{$this->getUnitsMethod()}((int) $value); } - public function set($model, string $key, $value, array $attributes): int + public function set($model, string $key, $value, array $attributes): ?int { + if (is_null($value)) { + return null; + } + $method = 'total'.ucfirst($this->getUnitsMethod()); return match (true) { - $value instanceof DurationImmutable => $value->$method(), + $value instanceof DurationImmutable, $value instanceof Duration => $value->$method(), $value instanceof TimeDelta => $value->absolute()->$method(), is_int($value) => $value, + is_numeric($value) => (int) $value, default => throw new InvalidArgumentException('Invalid duration value ['.gettype($value).']'), }; } diff --git a/tests/Casts/Cast.php b/tests/Casts/Cast.php index 3079916..fc3a3ce 100644 --- a/tests/Casts/Cast.php +++ b/tests/Casts/Cast.php @@ -3,6 +3,7 @@ namespace AyupCreative\Duration\Tests\Casts; use AyupCreative\Duration\Casts\DurationCast; +use AyupCreative\Duration\Duration; use AyupCreative\Duration\DurationImmutable; use AyupCreative\Duration\TimeDelta; use Illuminate\Database\Capsule\Manager as Capsule; @@ -12,12 +13,13 @@ use PHPUnit\Framework\TestCase; #[CoversClass(DurationCast::class)] +#[UsesClass(Duration::class)] #[UsesClass(DurationImmutable::class)] #[UsesClass(TimeDelta::class)] #[UsesClass(\AyupCreative\Duration\Casts\Seconds::class)] class Cast extends TestCase { - public function testCastWithInvalidValueTypeThrowsException(): void + public function test_cast_with_invalid_value_type_throws_exception(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid duration value [string]'); @@ -26,7 +28,7 @@ public function testCastWithInvalidValueTypeThrowsException(): void $model->save(); } - public function testCastWithInvalidUnitThrowsException(): void + public function test_cast_with_invalid_unit_throws_exception(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid duration unit [dummy]'); @@ -38,30 +40,70 @@ public function testCastWithInvalidUnitThrowsException(): void $model->duration; } - public function testCastWithInt(): void + 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 testCastWithTimeDelta(): void + 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(50, $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; @@ -77,7 +119,7 @@ public static function setUpBeforeClass(): void Capsule::schema()->create('cast_test_models', function ($table) { $table->increments('id'); - $table->integer('duration'); + $table->integer('duration')->nullable(); }); } } diff --git a/tests/Casts/DaysTest.php b/tests/Casts/DaysTest.php index 6469e52..6236bac 100644 --- a/tests/Casts/DaysTest.php +++ b/tests/Casts/DaysTest.php @@ -4,6 +4,7 @@ use AyupCreative\Duration\Casts\Days; use AyupCreative\Duration\Casts\DurationCast; +use AyupCreative\Duration\Duration; use AyupCreative\Duration\DurationImmutable; use AyupCreative\Duration\TimeDelta; use Illuminate\Database\Eloquent\Model; @@ -12,12 +13,13 @@ #[CoversClass(Days::class)] #[CoversClass(DurationCast::class)] +#[UsesClass(Duration::class)] #[UsesClass(DurationImmutable::class)] #[UsesClass(TimeDelta::class)] #[UsesClass(\AyupCreative\Duration\Casts\Seconds::class)] class DaysTest extends Cast { - public function testGet(): void + public function test_get(): void { $model = CastDayTestModel::create(['duration' => 3]); @@ -26,7 +28,7 @@ public function testGet(): void $this->assertEquals(3, $model->getRawOriginal('duration')); } - public function testSet(): void + public function test_set(): void { $model = new CastDayTestModel; @@ -36,6 +38,17 @@ public function testSet(): void $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 diff --git a/tests/Casts/HoursTest.php b/tests/Casts/HoursTest.php index 57f9a13..563035e 100644 --- a/tests/Casts/HoursTest.php +++ b/tests/Casts/HoursTest.php @@ -4,6 +4,7 @@ use AyupCreative\Duration\Casts\DurationCast; use AyupCreative\Duration\Casts\Hours; +use AyupCreative\Duration\Duration; use AyupCreative\Duration\DurationImmutable; use AyupCreative\Duration\TimeDelta; use Illuminate\Database\Eloquent\Model; @@ -12,12 +13,13 @@ #[CoversClass(Hours::class)] #[CoversClass(DurationCast::class)] +#[UsesClass(Duration::class)] #[UsesClass(DurationImmutable::class)] #[UsesClass(TimeDelta::class)] #[UsesClass(\AyupCreative\Duration\Casts\Seconds::class)] class HoursTest extends Cast { - public function testGet(): void + public function test_get(): void { $model = CastHourTestModel::create(['duration' => 3]); @@ -30,7 +32,7 @@ public function testGet(): void $this->assertEquals(3, $model->getRawOriginal('duration')); } - public function testSet(): void + public function test_set(): void { $model = new CastHourTestModel; @@ -40,6 +42,17 @@ public function testSet(): void $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 diff --git a/tests/Casts/MinutesTest.php b/tests/Casts/MinutesTest.php index 7cc0af2..e2df12d 100644 --- a/tests/Casts/MinutesTest.php +++ b/tests/Casts/MinutesTest.php @@ -4,6 +4,7 @@ use AyupCreative\Duration\Casts\DurationCast; use AyupCreative\Duration\Casts\Minutes; +use AyupCreative\Duration\Duration; use AyupCreative\Duration\DurationImmutable; use AyupCreative\Duration\TimeDelta; use Illuminate\Database\Eloquent\Model; @@ -12,12 +13,13 @@ #[CoversClass(Minutes::class)] #[CoversClass(DurationCast::class)] +#[UsesClass(Duration::class)] #[UsesClass(DurationImmutable::class)] #[UsesClass(TimeDelta::class)] #[UsesClass(\AyupCreative\Duration\Casts\Seconds::class)] class MinutesTest extends Cast { - public function testGet(): void + public function test_get(): void { $model = CastMinuteTestModel::create(['duration' => 3]); @@ -30,7 +32,7 @@ public function testGet(): void $this->assertEquals(3, $model->getRawOriginal('duration')); } - public function testSet(): void + public function test_set(): void { $model = new CastMinuteTestModel; @@ -40,6 +42,17 @@ public function testSet(): void $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 diff --git a/tests/Casts/SecondsTest.php b/tests/Casts/SecondsTest.php index 9446680..f16ec83 100644 --- a/tests/Casts/SecondsTest.php +++ b/tests/Casts/SecondsTest.php @@ -4,6 +4,7 @@ use AyupCreative\Duration\Casts\DurationCast; use AyupCreative\Duration\Casts\Seconds; +use AyupCreative\Duration\Duration; use AyupCreative\Duration\DurationImmutable; use AyupCreative\Duration\TimeDelta; use Illuminate\Database\Eloquent\Model; @@ -12,11 +13,12 @@ #[CoversClass(Seconds::class)] #[CoversClass(DurationCast::class)] +#[UsesClass(Duration::class)] #[UsesClass(DurationImmutable::class)] #[UsesClass(TimeDelta::class)] class SecondsTest extends Cast { - public function testGet(): void + public function test_get(): void { $model = CastSecondTestModel::create(['duration' => 3]); @@ -29,7 +31,7 @@ public function testGet(): void $this->assertEquals(3, $model->getRawOriginal('duration')); } - public function testSet(): void + public function test_set(): void { $model = new CastSecondTestModel; @@ -39,6 +41,17 @@ public function testSet(): void $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 From 09d407b5a081b0bdbcf524d5c8fbc6ec81e19d53 Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Sat, 10 Jan 2026 23:28:07 +0000 Subject: [PATCH 11/19] added docs --- src/Casts/Days.php | 3 + src/Casts/DurationCast.php | 30 ++++++ src/Casts/Hours.php | 3 + src/Casts/Minutes.php | 3 + src/Casts/Seconds.php | 3 + src/Duration.php | 77 +++++++++++++++ src/DurationImmutable.php | 13 +++ src/Features/Arithmetic.php | 158 +++++++++++++++++++++++++++++++ src/Features/Builders.php | 69 ++++++++++++++ src/Features/Constants.php | 5 + src/Features/Conversion.php | 50 ++++++++++ src/Features/Formatting.php | 53 +++++++++++ src/Features/MagicProperties.php | 15 +++ src/Features/TemporalUnits.php | 50 ++++++++++ src/TimeDelta.php | 48 ++++++++++ 15 files changed, 580 insertions(+) diff --git a/src/Casts/Days.php b/src/Casts/Days.php index a504230..f313b9b 100644 --- a/src/Casts/Days.php +++ b/src/Casts/Days.php @@ -4,6 +4,9 @@ final class Days extends DurationCast { + /** + * @inheritDoc + */ protected function getUnitsMethod(): string { return 'days'; diff --git a/src/Casts/DurationCast.php b/src/Casts/DurationCast.php index e5dc11a..0614c01 100644 --- a/src/Casts/DurationCast.php +++ b/src/Casts/DurationCast.php @@ -11,8 +11,26 @@ abstract class DurationCast implements CastsAttributes { + /** + * Get the name of the method used to create the duration instance. + * + * @return string + */ abstract protected function getUnitsMethod(): string; + /** + * Cast the given value to a DurationImmutable instance. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * @return \AyupCreative\Duration\DurationImmutable|null + * @throws \InvalidArgumentException + * @see \AyupCreative\Duration\Tests\Casts\Cast::test_cast_with_int() + * @see \AyupCreative\Duration\Tests\Casts\Cast::test_cast_with_numeric_string() + * @see \AyupCreative\Duration\Tests\Casts\Cast::test_cast_with_null() + */ public function get($model, string $key, $value, array $attributes): ?DurationImmutable { if (is_null($value)) { @@ -26,6 +44,18 @@ public function get($model, string $key, $value, array $attributes): ?DurationIm 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)) { diff --git a/src/Casts/Hours.php b/src/Casts/Hours.php index 4fbcded..0ee66a0 100644 --- a/src/Casts/Hours.php +++ b/src/Casts/Hours.php @@ -4,6 +4,9 @@ final class Hours extends DurationCast { + /** + * @inheritDoc + */ protected function getUnitsMethod(): string { return 'hours'; diff --git a/src/Casts/Minutes.php b/src/Casts/Minutes.php index bd2dbd2..06c5132 100644 --- a/src/Casts/Minutes.php +++ b/src/Casts/Minutes.php @@ -4,6 +4,9 @@ final class Minutes extends DurationCast { + /** + * @inheritDoc + */ protected function getUnitsMethod(): string { return 'minutes'; diff --git a/src/Casts/Seconds.php b/src/Casts/Seconds.php index 1e95ada..a35a245 100644 --- a/src/Casts/Seconds.php +++ b/src/Casts/Seconds.php @@ -4,6 +4,9 @@ final class Seconds extends DurationCast { + /** + * @inheritDoc + */ protected function getUnitsMethod(): string { return 'seconds'; diff --git a/src/Duration.php b/src/Duration.php index 9c140a3..e470291 100644 --- a/src/Duration.php +++ b/src/Duration.php @@ -16,11 +16,26 @@ final class Duration implements \JsonSerializable protected int $totalSeconds; + /** + * Create a new Duration instance. + * + * @param int $seconds + * @see \AyupCreative\Duration\Tests\DurationTest::it_can_be_instantiated_with_seconds() + */ public function __construct(int $seconds) { $this->totalSeconds = max(0, $seconds); } + /** + * Add another duration to the current one. + * + * Usage: $duration->add(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationImmutable|self $other + * @return self + * @see \AyupCreative\Duration\Tests\DurationTest::it_is_mutable_on_arithmetic_operations() + */ public function add(DurationImmutable|self $other): self { $seconds = $this->totalSeconds + $other->totalSeconds; @@ -29,6 +44,15 @@ public function add(DurationImmutable|self $other): self return $this; } + /** + * Subtract another duration from the current one. + * + * Usage: $duration->sub(Duration::minutes(5)); + * + * @param \AyupCreative\Duration\DurationImmutable|self $other + * @return self + * @see \AyupCreative\Duration\Tests\DurationTest::it_is_mutable_on_arithmetic_operations() + */ public function sub(DurationImmutable|self $other): self { $seconds = $this->totalSeconds - $other->totalSeconds; @@ -37,6 +61,15 @@ public function sub(DurationImmutable|self $other): self 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 { $seconds = (int)round($this->totalSeconds * $factor); @@ -45,6 +78,15 @@ public function multiply(float $factor): self return $this; } + /** + * 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 { $seconds = (int)(ceil($this->totalSeconds / $seconds) * $seconds); @@ -53,21 +95,56 @@ public function ceilTo(int $seconds): self 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::seconds($this->totalSeconds); diff --git a/src/DurationImmutable.php b/src/DurationImmutable.php index e6d7207..dd349da 100644 --- a/src/DurationImmutable.php +++ b/src/DurationImmutable.php @@ -16,11 +16,24 @@ final class DurationImmutable implements \JsonSerializable 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) { $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::seconds($this->totalSeconds); diff --git a/src/Features/Arithmetic.php b/src/Features/Arithmetic.php index 30f435b..4416fbb 100644 --- a/src/Features/Arithmetic.php +++ b/src/Features/Arithmetic.php @@ -9,21 +9,53 @@ */ trait Arithmetic { + /** + * Add another duration. + * + * Usage: $duration->add(Duration::minutes(5)); + * + * @param self $other + * @return self + */ public function add(self $other): self { return new self($this->totalSeconds + $other->totalSeconds); } + /** + * Subtract another duration. + * + * Usage: $duration->sub(Duration::minutes(5)); + * + * @param self $other + * @return self + */ public function sub(self $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 { return new self( @@ -31,81 +63,207 @@ public function ceilTo(int $seconds): self ); } + /** + * 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 self $other + * @return bool + */ public function isOver(self $other): bool { return $this->totalSeconds > $other->totalSeconds; } + /** + * Check if the duration is less than another. + * + * Usage: $duration->isBelow(Duration::minutes(5)); + * + * @param self $other + * @return bool + */ public function isBelow(self $other): bool { return $this->totalSeconds < $other->totalSeconds; } + /** + * Check if the duration is less than another. + * + * Usage: $duration->isLessThan(Duration::minutes(5)); + * + * @param self $other + * @return bool + */ public function isLessThan(self $other): bool { return $this->isBelow($other); } + /** + * Check if the duration is greater than another. + * + * Usage: $duration->isGreaterThan(Duration::minutes(5)); + * + * @param self $other + * @return bool + */ public function isGreaterThan(self $other): bool { return $this->isOver($other); } + /** + * Check if the duration is less than or equal to another. + * + * Usage: $duration->isLessThanOrEqualTo(Duration::minutes(5)); + * + * @param self $other + * @return bool + */ public function isLessThanOrEqualTo(self $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 self $other + * @return bool + */ public function isGreaterThanOrEqualTo(self $other): bool { return $this->isOver($other) || $this->equals($other); } + /** + * Check if the duration is equal to another. + * + * Usage: $duration->equals(Duration::minutes(5)); + * + * @param self $other + * @return bool + */ public function equals(self $other): bool { return $this->totalSeconds === $other->totalSeconds; } + /** + * Check if the duration does not equal another. + * + * Usage: $duration->doesNotEqual(Duration::minutes(5)); + * + * @param self $other + * @return bool + */ public function doesNotEqual(self $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 self $other + * @return self + */ public function max(self $other): self { return $this->totalSeconds >= $other->totalSeconds ? $this : $other; } + /** + * Get the minimum of two durations. + * + * Usage: $min = $duration->min(Duration::minutes(5)); + * + * @param self $other + * @return self + */ public function min(self $other): self { return $this->totalSeconds <= $other->totalSeconds ? $this : $other; } + /** + * Get the difference between two durations. + * + * Usage: $delta = $duration->diff(Duration::minutes(5)); + * + * @param self $other + * @return \AyupCreative\Duration\TimeDelta + */ public function diff(self $other): TimeDelta { return TimeDelta::seconds( diff --git a/src/Features/Builders.php b/src/Features/Builders.php index 8c7fa7c..25d4e5b 100644 --- a/src/Features/Builders.php +++ b/src/Features/Builders.php @@ -6,51 +6,114 @@ trait Builders { + /** + * Create a duration of zero. + * + * @return self + */ public static function zero(): self { return new self(0); } + /** + * Create a duration from seconds. + * + * @param int $seconds + * @return self + */ public static function seconds(int $seconds): self { return new self($seconds); } + /** + * Create a duration from minutes. + * + * @param int $minutes + * @return self + */ public static function minutes(int $minutes): self { return new self($minutes * self::SECONDS_PER_MINUTE); } + /** + * Create a duration from hours. + * + * @param int $hours + * @return self + */ public static function hours(int $hours): self { return new self($hours * self::SECONDS_PER_HOUR); } + /** + * Create a duration from days. + * + * @param int $days + * @return self + */ public static function days(int $days): self { return new self($days * self::SECONDS_PER_DAY); } + /** + * Create a duration from weeks. + * + * @param int $weeks + * @return self + */ public static function weeks(int $weeks): self { return new self($weeks * self::SECONDS_PER_WEEK); } + /** + * Create a duration from months (approximate). + * + * @param int $months + * @return self + */ public static function months(int $months): self { return new self($months * self::SECONDS_PER_MONTH); } + /** + * Create a duration from years (approximate). + * + * @param int $years + * @return self + */ public static function years(int $years): self { return new self($years * self::SECONDS_PER_YEAR); } + /** + * Create a duration from hours and minutes. + * + * @param int $hours + * @param int $minutes + * @return self + */ public static function hoursAndMinutes(int $hours, int $minutes): self { return new self(($hours * self::SECONDS_PER_HOUR) + ($minutes * self::SECONDS_PER_MINUTE)); } + /** + * Create a duration from various units. + * + * @param int $days + * @param int $hours + * @param int $minutes + * @param int $seconds + * @return self + */ public static function make(int $days = 0, int $hours = 0, int $minutes = 0, int $seconds = 0): self { $seconds += ($hours * self::SECONDS_PER_HOUR) + @@ -60,6 +123,12 @@ public static function make(int $days = 0, int $hours = 0, int $minutes = 0, int return new self($seconds); } + /** + * Create a duration from a CarbonInterval. + * + * @param \Carbon\CarbonInterval $interval + * @return self + */ public static function fromCarbon(CarbonInterval $interval): self { return new self((int) $interval->totalSeconds); diff --git a/src/Features/Constants.php b/src/Features/Constants.php index b0bd190..8d92198 100644 --- a/src/Features/Constants.php +++ b/src/Features/Constants.php @@ -11,6 +11,11 @@ trait Constants public const SECONDS_PER_MONTH = 2629800; // 30.44 days public const SECONDS_PER_YEAR = 31557600; // 365.25 days + /** + * Decompose the duration into days, hours, minutes, and seconds. + * + * @return array{days: int, hours: int, minutes: int, seconds: int, sign: string} + */ private function decompose(): array { $seconds = abs($this->totalSeconds); diff --git a/src/Features/Conversion.php b/src/Features/Conversion.php index 6d1acbe..61a165e 100644 --- a/src/Features/Conversion.php +++ b/src/Features/Conversion.php @@ -6,46 +6,91 @@ trait Conversion { + /** + * Get the total duration in seconds. + * + * @return int + */ public function toSeconds(): int { return $this->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); @@ -77,6 +122,11 @@ public function toDateInterval(): DateInterval 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 index b8abfdd..34aa03c 100644 --- a/src/Features/Formatting.php +++ b/src/Features/Formatting.php @@ -4,6 +4,25 @@ trait Formatting { + /** + * 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(); @@ -21,6 +40,14 @@ public function format(string $format): string ]); } + /** + * 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(); @@ -31,6 +58,14 @@ public function toHuman(?callable $formatter = null): string { 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 @@ -64,11 +99,22 @@ public function toShortHuman(?callable $formatter = null): string return $this->toHuman($formatter); } + /** + * 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 { if (abs($this->totalSeconds) < self::SECONDS_PER_MINUTE) { @@ -100,6 +146,13 @@ private function defaultHuman(array $parts): string ); } + /** + * 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 index 52efefe..eb0ae14 100644 --- a/src/Features/MagicProperties.php +++ b/src/Features/MagicProperties.php @@ -12,6 +12,13 @@ */ trait MagicProperties { + /** + * Magic getter for total units. + * + * @param string $name + * @return mixed + * @throws \Error + */ public function __get($name) { if($name === 'totalSeconds') { @@ -45,6 +52,14 @@ public function __get($name) 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 index 9d0c9da..9fa88a3 100644 --- a/src/Features/TemporalUnits.php +++ b/src/Features/TemporalUnits.php @@ -4,51 +4,101 @@ trait TemporalUnits { + /** + * Get the total duration in seconds. + * + * @return int + */ public function totalSeconds(): int { return $this->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 ce3e018..bca7c3f 100644 --- a/src/TimeDelta.php +++ b/src/TimeDelta.php @@ -16,31 +16,79 @@ final class TimeDelta implements \JsonSerializable protected int $totalSeconds; + /** + * 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) { $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->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->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->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->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::seconds(abs($this->totalSeconds)); From 9dc19390f6802107890000f8339cee403e645781 Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Sat, 10 Jan 2026 23:44:05 +0000 Subject: [PATCH 12/19] DurationInterface and tests --- src/Casts/DurationCast.php | 6 ++- src/Duration.php | 16 +++---- src/DurationImmutable.php | 2 +- src/DurationInterface.php | 13 +++++ src/Features/Arithmetic.php | 81 ++++++++++++++++++-------------- src/Features/Formatting.php | 51 ++++++++++---------- src/Features/MagicProperties.php | 3 +- src/TimeDelta.php | 2 +- tests/Casts/Cast.php | 2 +- tests/DurationImmutableTest.php | 27 +++++++++-- tests/TimeDeltaTest.php | 5 +- 11 files changed, 130 insertions(+), 78 deletions(-) create mode 100644 src/DurationInterface.php diff --git a/src/Casts/DurationCast.php b/src/Casts/DurationCast.php index 0614c01..dbc404c 100644 --- a/src/Casts/DurationCast.php +++ b/src/Casts/DurationCast.php @@ -64,12 +64,14 @@ public function set($model, string $key, $value, array $attributes): ?int $method = 'total'.ucfirst($this->getUnitsMethod()); - return match (true) { + $result = match (true) { $value instanceof DurationImmutable, $value instanceof Duration => $value->$method(), - $value instanceof TimeDelta => $value->absolute()->$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/Duration.php b/src/Duration.php index e470291..9515904 100644 --- a/src/Duration.php +++ b/src/Duration.php @@ -4,7 +4,7 @@ namespace AyupCreative\Duration; -final class Duration implements \JsonSerializable +final class Duration implements \JsonSerializable, DurationInterface { use Features\Arithmetic; use Features\Builders; @@ -32,13 +32,13 @@ public function __construct(int $seconds) * * Usage: $duration->add(Duration::minutes(5)); * - * @param \AyupCreative\Duration\DurationImmutable|self $other + * @param \AyupCreative\Duration\DurationInterface $other * @return self * @see \AyupCreative\Duration\Tests\DurationTest::it_is_mutable_on_arithmetic_operations() */ - public function add(DurationImmutable|self $other): self + public function add(DurationInterface $other): self { - $seconds = $this->totalSeconds + $other->totalSeconds; + $seconds = $this->totalSeconds + $other->totalSeconds(); $this->totalSeconds = (new self($seconds))->totalSeconds; return $this; @@ -49,13 +49,13 @@ public function add(DurationImmutable|self $other): self * * Usage: $duration->sub(Duration::minutes(5)); * - * @param \AyupCreative\Duration\DurationImmutable|self $other + * @param \AyupCreative\Duration\DurationInterface $other * @return self * @see \AyupCreative\Duration\Tests\DurationTest::it_is_mutable_on_arithmetic_operations() */ - public function sub(DurationImmutable|self $other): self + public function sub(DurationInterface $other): self { - $seconds = $this->totalSeconds - $other->totalSeconds; + $seconds = $this->totalSeconds - $other->totalSeconds(); $this->totalSeconds = (new self($seconds))->totalSeconds; return $this; @@ -91,7 +91,7 @@ public function ceilTo(int $seconds): self { $seconds = (int)(ceil($this->totalSeconds / $seconds) * $seconds); - $this->totalSeconds = (new self($seconds))->totalSeconds;; + $this->totalSeconds = (new self($seconds))->totalSeconds; return $this; } diff --git a/src/DurationImmutable.php b/src/DurationImmutable.php index dd349da..53dc215 100644 --- a/src/DurationImmutable.php +++ b/src/DurationImmutable.php @@ -4,7 +4,7 @@ namespace AyupCreative\Duration; -final class DurationImmutable implements \JsonSerializable +final class DurationImmutable implements \JsonSerializable, DurationInterface { use Features\Arithmetic; use Features\Builders; 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 self $other + * @param \AyupCreative\Duration\DurationInterface $other * @return self */ - public function add(self $other): self + public function add(DurationInterface $other): self { - return new self($this->totalSeconds + $other->totalSeconds); + return new self($this->totalSeconds + $other->totalSeconds()); } /** @@ -27,12 +28,12 @@ public function add(self $other): self * * Usage: $duration->sub(Duration::minutes(5)); * - * @param self $other + * @param \AyupCreative\Duration\DurationInterface $other * @return self */ - public function sub(self $other): self + public function sub(DurationInterface $other): self { - return new self($this->totalSeconds - $other->totalSeconds); + return new self($this->totalSeconds - $other->totalSeconds()); } /** @@ -58,6 +59,10 @@ public function multiply(float $factor): self */ public function ceilTo(int $seconds): self { + if ($seconds === 0) { + return $this; + } + return new self( (int) (ceil($this->totalSeconds / $seconds) * $seconds) ); @@ -107,12 +112,12 @@ public function ceilToDays(int $days): self * * Usage: $duration->isOver(Duration::minutes(5)); * - * @param self $other + * @param \AyupCreative\Duration\DurationInterface $other * @return bool */ - public function isOver(self $other): bool + public function isOver(DurationInterface $other): bool { - return $this->totalSeconds > $other->totalSeconds; + return $this->totalSeconds > $other->totalSeconds(); } /** @@ -120,12 +125,12 @@ public function isOver(self $other): bool * * Usage: $duration->isBelow(Duration::minutes(5)); * - * @param self $other + * @param \AyupCreative\Duration\DurationInterface $other * @return bool */ - public function isBelow(self $other): bool + public function isBelow(DurationInterface $other): bool { - return $this->totalSeconds < $other->totalSeconds; + return $this->totalSeconds < $other->totalSeconds(); } /** @@ -133,10 +138,10 @@ public function isBelow(self $other): bool * * Usage: $duration->isLessThan(Duration::minutes(5)); * - * @param self $other + * @param \AyupCreative\Duration\DurationInterface $other * @return bool */ - public function isLessThan(self $other): bool + public function isLessThan(DurationInterface $other): bool { return $this->isBelow($other); } @@ -146,10 +151,10 @@ public function isLessThan(self $other): bool * * Usage: $duration->isGreaterThan(Duration::minutes(5)); * - * @param self $other + * @param \AyupCreative\Duration\DurationInterface $other * @return bool */ - public function isGreaterThan(self $other): bool + public function isGreaterThan(DurationInterface $other): bool { return $this->isOver($other); } @@ -159,10 +164,10 @@ public function isGreaterThan(self $other): bool * * Usage: $duration->isLessThanOrEqualTo(Duration::minutes(5)); * - * @param self $other + * @param \AyupCreative\Duration\DurationInterface $other * @return bool */ - public function isLessThanOrEqualTo(self $other): bool + public function isLessThanOrEqualTo(DurationInterface $other): bool { return $this->isBelow($other) || $this->equals($other); } @@ -172,10 +177,10 @@ public function isLessThanOrEqualTo(self $other): bool * * Usage: $duration->isGreaterThanOrEqualTo(Duration::minutes(5)); * - * @param self $other + * @param \AyupCreative\Duration\DurationInterface $other * @return bool */ - public function isGreaterThanOrEqualTo(self $other): bool + public function isGreaterThanOrEqualTo(DurationInterface $other): bool { return $this->isOver($other) || $this->equals($other); } @@ -185,12 +190,12 @@ public function isGreaterThanOrEqualTo(self $other): bool * * Usage: $duration->equals(Duration::minutes(5)); * - * @param self $other + * @param \AyupCreative\Duration\DurationInterface $other * @return bool */ - public function equals(self $other): bool + public function equals(DurationInterface $other): bool { - return $this->totalSeconds === $other->totalSeconds; + return $this->totalSeconds === $other->totalSeconds(); } /** @@ -198,10 +203,10 @@ public function equals(self $other): bool * * Usage: $duration->doesNotEqual(Duration::minutes(5)); * - * @param self $other + * @param \AyupCreative\Duration\DurationInterface $other * @return bool */ - public function doesNotEqual(self $other): bool + public function doesNotEqual(DurationInterface $other): bool { return !$this->equals($other); } @@ -235,12 +240,16 @@ public function isNotZero(): bool * * Usage: $max = $duration->max(Duration::minutes(5)); * - * @param self $other + * @param \AyupCreative\Duration\DurationInterface $other * @return self */ - public function max(self $other): self + public function max(DurationInterface $other): self { - return $this->totalSeconds >= $other->totalSeconds ? $this : $other; + if ($this->totalSeconds >= $other->totalSeconds()) { + return $this; + } + + return $other instanceof self ? $other : new self($other->totalSeconds()); } /** @@ -248,12 +257,16 @@ public function max(self $other): self * * Usage: $min = $duration->min(Duration::minutes(5)); * - * @param self $other + * @param \AyupCreative\Duration\DurationInterface $other * @return self */ - public function min(self $other): self + public function min(DurationInterface $other): self { - return $this->totalSeconds <= $other->totalSeconds ? $this : $other; + if ($this->totalSeconds <= $other->totalSeconds()) { + return $this; + } + + return $other instanceof self ? $other : new self($other->totalSeconds()); } /** @@ -261,13 +274,13 @@ public function min(self $other): self * * Usage: $delta = $duration->diff(Duration::minutes(5)); * - * @param self $other + * @param \AyupCreative\Duration\DurationInterface $other * @return \AyupCreative\Duration\TimeDelta */ - public function diff(self $other): TimeDelta + public function diff(DurationInterface $other): TimeDelta { return TimeDelta::seconds( - $this->totalSeconds - $other->totalSeconds + $this->totalSeconds - $other->totalSeconds() ); } } diff --git a/src/Features/Formatting.php b/src/Features/Formatting.php index 34aa03c..08dd229 100644 --- a/src/Features/Formatting.php +++ b/src/Features/Formatting.php @@ -84,11 +84,14 @@ public function toShortHuman(?callable $formatter = null): string $output[] = $parts['minutes'] . 'm'; } - // Only include seconds if non-zero (optional) - if ($parts['seconds'] > 0 && empty($output)) { + if ($parts['seconds'] > 0) { $output[] = $parts['seconds'] . 's'; } + if (empty($output)) { + return ($parts['sign'] ?? '') . '0s'; + } + return ($parts['sign'] ?? '') . implode(' ', $output); }; @@ -117,33 +120,31 @@ public function __toString(): string */ private function defaultHuman(array $parts): string { - if (abs($this->totalSeconds) < self::SECONDS_PER_MINUTE) { - return $parts['sign'] . $parts['seconds'] . ' ' . $this->pluralize($parts['seconds'], 'second'); - } + $output = []; if ($parts['days'] > 0) { - return sprintf( - '%s%d%s%s %d%s%s', - $parts['sign'], - $parts['days'], - ' ', - $this->pluralize($parts['days'], 'day'), - $parts['hours'], - ' ', - $this->pluralize($parts['hours'], 'hour') - ); + $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'); } - return sprintf( - '%s%d%s%s %d%s%s', - $parts['sign'], - $parts['hours'], - ' ', - $this->pluralize($parts['hours'], 'hour'), - $parts['minutes'], - ' ', - $this->pluralize($parts['minutes'], 'minute') - ); + if (empty($output)) { + return $parts['sign'] . '0 seconds'; + } + + $humanParts = array_slice($output, 0, 2); + + return $parts['sign'] . implode(' ', $humanParts); } /** diff --git a/src/Features/MagicProperties.php b/src/Features/MagicProperties.php index eb0ae14..149ebc8 100644 --- a/src/Features/MagicProperties.php +++ b/src/Features/MagicProperties.php @@ -4,9 +4,10 @@ /** * @property int $totalSeconds - * @property float $totalMinutes + * @property int $totalMinutes * @property int $totalHours * @property int $totalDays + * @property int $totalWeeks * @property int $totalMonths * @property int $totalYears */ diff --git a/src/TimeDelta.php b/src/TimeDelta.php index bca7c3f..b49dc5b 100644 --- a/src/TimeDelta.php +++ b/src/TimeDelta.php @@ -4,7 +4,7 @@ namespace AyupCreative\Duration; -final class TimeDelta implements \JsonSerializable +final class TimeDelta implements \JsonSerializable, DurationInterface { use Features\Arithmetic; use Features\Builders; diff --git a/tests/Casts/Cast.php b/tests/Casts/Cast.php index fc3a3ce..2f978ee 100644 --- a/tests/Casts/Cast.php +++ b/tests/Casts/Cast.php @@ -66,7 +66,7 @@ public function test_cast_with_time_delta(): void $model->duration = new TimeDelta(-50); $model->save(); $model = $model->fresh(); - $this->assertEquals(50, $model->getRawOriginal('duration')); + $this->assertEquals(0, $model->getRawOriginal('duration')); } public function test_cast_with_duration(): void diff --git a/tests/DurationImmutableTest.php b/tests/DurationImmutableTest.php index 806666b..db3a5ae 100644 --- a/tests/DurationImmutableTest.php +++ b/tests/DurationImmutableTest.php @@ -34,12 +34,16 @@ 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()); @@ -138,9 +142,24 @@ 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 */ @@ -151,9 +170,7 @@ public function it_can_calculate_diff_as_timedelta() $diff = $d1->diff($d2); $this->assertInstanceOf(TimeDelta::class, $diff); - // If d1 < d2, diff should be negative if it's a true delta - // But the constructor of TimeDelta currently has max(0, $seconds) - // $this->assertEquals(-50, $diff->totalSeconds()); + $this->assertEquals(-50, $diff->totalSeconds()); } /** @test */ @@ -171,6 +188,7 @@ 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')); } @@ -178,7 +196,7 @@ public function it_supports_human_formatting() /** @test */ public function it_supports_short_human_formatting() { - $this->assertEquals('1d 2h 3m', DurationImmutable::make(1, 2, 3, 4)->toShortHuman()); + $this->assertEquals('1d 2h 3m 4s', DurationImmutable::make(1, 2, 3, 4)->toShortHuman()); $this->assertEquals('4s', DurationImmutable::seconds(4)->toShortHuman()); } @@ -189,6 +207,7 @@ public function it_can_ceil_durations() $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 diff --git a/tests/TimeDeltaTest.php b/tests/TimeDeltaTest.php index 3bd0cfe..d321ddb 100644 --- a/tests/TimeDeltaTest.php +++ b/tests/TimeDeltaTest.php @@ -99,8 +99,11 @@ 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', $delta->toShortHuman()); + $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 */ From 6c4ad627087eed5adf5bd815ab79895c1040903f Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:36:38 +0000 Subject: [PATCH 13/19] added test composer script --- composer.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/composer.json b/composer.json index eaf74b3..7fbbf74 100644 --- a/composer.json +++ b/composer.json @@ -20,5 +20,8 @@ "phpunit/phpunit": "^10.0", "nesbot/carbon": "^3.0", "illuminate/database": "^10.0|^11.0" + }, + "scripts": { + "test": "vendor/bin/phpunit" } } From 957dab27b1275adb54fc9b018f315208d1d6ebe8 Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:36:52 +0000 Subject: [PATCH 14/19] added unit formatting --- src/Features/Formatting.php | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/Features/Formatting.php b/src/Features/Formatting.php index 08dd229..904219a 100644 --- a/src/Features/Formatting.php +++ b/src/Features/Formatting.php @@ -102,6 +102,77 @@ public function toShortHuman(?callable $formatter = null): string 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 collection of time units into a human-readable string representation. + * + * @param array $units An array of time unit names to format (e.g., ['hours', 'minutes', 'seconds']). + * @return string The formatted string representing the time, with each unit suffixed appropriately. + * @throws \InvalidArgumentException If no units are provided or if an unknown unit is encountered. + */ + public function formatUnits(array $units, bool $includeUnits = true, string $separator = ' '): string + { + if ($units === []) { + throw new \InvalidArgumentException('At least one unit must be specified.'); + } + + $seconds = abs($this->totalSeconds); + $sign = $this->totalSeconds < 0 ? '-' : ''; + + $parts = []; + + foreach ($units as $unit) { + if (static::unitSeconds($unit) === null) { + throw new \InvalidArgumentException("Unknown unit [$unit]."); + } + + $unitSeconds = static::unitSeconds($unit); + + $value = intdiv($seconds, $unitSeconds); + $seconds -= $value * $unitSeconds; + + $parts[] = $value . ($includeUnits ? $this->unitSuffix($unit) : ''); + } + + return $sign . implode($separator, $parts); + } + + /** + * 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', + default => $unit, + }; + } + /** * Convert the duration to a string (hh:mm). * From dcd155d2d8816e283cadb157f4adb7bae51e4706 Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:37:21 +0000 Subject: [PATCH 15/19] tweaked comment --- src/Features/Formatting.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Features/Formatting.php b/src/Features/Formatting.php index 904219a..ca9b50b 100644 --- a/src/Features/Formatting.php +++ b/src/Features/Formatting.php @@ -122,11 +122,13 @@ protected static function unitSeconds(string $unit): ?int } /** - * Formats a collection of time units into a human-readable string representation. + * Formats a duration in terms of specified units. * - * @param array $units An array of time unit names to format (e.g., ['hours', 'minutes', 'seconds']). - * @return string The formatted string representing the time, with each unit suffixed appropriately. - * @throws \InvalidArgumentException If no units are provided or if an unknown unit is encountered. + * @param array $units A list of time units (e.g., ['hours', 'minutes', 'seconds']) to format the duration into. + * @param bool $includeUnits Indicates whether to append unit suffixes (e.g., 'h', 'm', 's') to the formatted values. + * @param string $separator The string used to separate the formatted unit values in the output. + * @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, bool $includeUnits = true, string $separator = ' '): string { From cf7356f36c8f201bf5774e372ebfaeda24029203 Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:38:00 +0000 Subject: [PATCH 16/19] added coverage report to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index be595f7..1b70191 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ vendor .idea composer.lock *.cache +coverage From 725838eab508924ca7f53f18c546a799e8a1148a Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:38:05 +0000 Subject: [PATCH 17/19] added coverage test --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 7fbbf74..5afbeb9 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "illuminate/database": "^10.0|^11.0" }, "scripts": { - "test": "vendor/bin/phpunit" + "test": "vendor/bin/phpunit", + "test-coverage": "vendor/bin/phpunit --coverage-html coverage" } } From 172e90f5fead4e02e50e594b47db5bac8297784b Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:03:17 +0000 Subject: [PATCH 18/19] expanded `formatUnits` with advanced options and added comprehensive tests --- src/Features/Formatting.php | 45 ++++++++++++++++---- tests/DurationImmutableTest.php | 73 ++++++++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 10 deletions(-) diff --git a/src/Features/Formatting.php b/src/Features/Formatting.php index ca9b50b..8e584b3 100644 --- a/src/Features/Formatting.php +++ b/src/Features/Formatting.php @@ -4,6 +4,12 @@ trait Formatting { + private const DEFAULT_FORMATTING_OPTIONS = [ + 'spacer' => ' ', + 'units' => true, + 'pad' => null, + ]; + /** * Format the duration using a format string. * @@ -125,36 +131,58 @@ protected static function unitSeconds(string $unit): ?int * 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 bool $includeUnits Indicates whether to append unit suffixes (e.g., 'h', 'm', 's') to the formatted values. - * @param string $separator The string used to separate the formatted unit values in the output. + * @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, bool $includeUnits = true, string $separator = ' '): string + 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 (static::unitSeconds($unit) === null) { + if (self::unitSeconds($unit) === null) { throw new \InvalidArgumentException("Unknown unit [$unit]."); } - $unitSeconds = static::unitSeconds($unit); - + $unitSeconds = self::unitSeconds($unit); $value = intdiv($seconds, $unitSeconds); $seconds -= $value * $unitSeconds; - $parts[] = $value . ($includeUnits ? $this->unitSuffix($unit) : ''); + $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 $sign . implode($separator, $parts); + return str_pad((string) $value, $pad, '0', STR_PAD_LEFT); } /** @@ -171,6 +199,7 @@ private function unitSuffix(string $unit): string 'hours' => 'h', 'days' => 'd', 'weeks' => 'w', + 'years' => 'y', default => $unit, }; } diff --git a/tests/DurationImmutableTest.php b/tests/DurationImmutableTest.php index db3a5ae..1350fa8 100644 --- a/tests/DurationImmutableTest.php +++ b/tests/DurationImmutableTest.php @@ -15,6 +15,75 @@ #[UsesClass(TimeDelta::class)] class DurationImmutableTest extends TestCase { + /** @test */ + public function it_supports_unit_formatting() + { + $duration = DurationImmutable::hours(10); + $this->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() { @@ -235,7 +304,7 @@ public function it_supports_additional_temporal_units() $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()); @@ -246,7 +315,7 @@ public function it_supports_additional_temporal_units() $large = DurationImmutable::weeks(2); $this->assertEquals(2, $large->totalWeeks()); - + $month = DurationImmutable::months(1); $this->assertEquals(1, $month->totalMonths()); From da527b8178a331128543ebcb289ccdbb43b809a2 Mon Sep 17 00:00:00 2001 From: Myke Meynell <1590190+mykemeynell@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:50:51 +0000 Subject: [PATCH 19/19] added `Years` cast and corresponding tests --- src/Casts/Years.php | 14 ++++++++ tests/Casts/YearsTest.php | 73 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/Casts/Years.php create mode 100644 tests/Casts/YearsTest.php diff --git a/src/Casts/Years.php b/src/Casts/Years.php new file mode 100644 index 0000000..2d7c9a1 --- /dev/null +++ b/src/Casts/Years.php @@ -0,0 +1,14 @@ + 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']; +}