From 06596ca5df13dbd4fa16b0b214b18e570937ffa6 Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 4 Sep 2026 04:29:29 +0200 Subject: [PATCH] Upstream briannesbitt/Carbon state of the touched files before 56c9340e26 --- src/Carbon/Traits/Rounding.php | 226 +++++++++++++++++++++++++++++++++ tests/Carbon/RoundTest.php | 208 ++++++++++++++++++++++++++++++ 2 files changed, 434 insertions(+) create mode 100644 src/Carbon/Traits/Rounding.php create mode 100644 tests/Carbon/RoundTest.php diff --git a/src/Carbon/Traits/Rounding.php b/src/Carbon/Traits/Rounding.php new file mode 100644 index 0000000..4239c66 --- /dev/null +++ b/src/Carbon/Traits/Rounding.php @@ -0,0 +1,226 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Carbon\Traits; + +use Carbon\CarbonInterface; +use Carbon\Exceptions\UnknownUnitException; +use Carbon\WeekDay; +use DateInterval; + +/** + * Trait Rounding. + * + * Round, ceil, floor units. + * + * Depends on the following methods: + * + * @method static copy() + * @method static startOfWeek(int $weekStartsAt = null) + */ +trait Rounding +{ + use IntervalRounding; + + /** + * Round the current instance at the given unit with given precision if specified and the given function. + */ + public function roundUnit( + string $unit, + DateInterval|string|float|int $precision = 1, + callable|string $function = 'round', + ): static { + $metaUnits = [ + // @call roundUnit + 'millennium' => [static::YEARS_PER_MILLENNIUM, 'year'], + // @call roundUnit + 'century' => [static::YEARS_PER_CENTURY, 'year'], + // @call roundUnit + 'decade' => [static::YEARS_PER_DECADE, 'year'], + // @call roundUnit + 'quarter' => [static::MONTHS_PER_QUARTER, 'month'], + // @call roundUnit + 'millisecond' => [1000, 'microsecond'], + ]; + $normalizedUnit = static::singularUnit($unit); + $ranges = array_merge(static::getRangesByUnit($this->daysInMonth), [ + // @call roundUnit + 'microsecond' => [0, 999999], + ]); + $factor = 1; + + if ($normalizedUnit === 'week') { + $normalizedUnit = 'day'; + $precision *= static::DAYS_PER_WEEK; + } + + if (isset($metaUnits[$normalizedUnit])) { + [$factor, $normalizedUnit] = $metaUnits[$normalizedUnit]; + } + + $precision *= $factor; + + if (!isset($ranges[$normalizedUnit])) { + throw new UnknownUnitException($unit); + } + + $found = false; + $fraction = 0; + $arguments = null; + $initialValue = null; + $factor = $this->year < 0 ? -1 : 1; + $changes = []; + $minimumInc = null; + + foreach ($ranges as $unit => [$minimum, $maximum]) { + if ($normalizedUnit === $unit) { + $arguments = [$this->$unit, $minimum]; + $initialValue = $this->$unit; + $fraction = $precision - floor($precision); + $found = true; + + continue; + } + + if ($found) { + $delta = $maximum + 1 - $minimum; + $factor /= $delta; + $fraction *= $delta; + $inc = ($this->$unit - $minimum) * $factor; + + if ($inc !== 0.0) { + $minimumInc = $minimumInc ?? ($arguments[0] / pow(2, 52)); + + // If value is still the same when adding a non-zero increment/decrement, + // it means precision got lost in the addition + if (abs($inc) < $minimumInc) { + $inc = $minimumInc * ($inc < 0 ? -1 : 1); + } + + // If greater than $precision, assume precision loss caused an overflow + if ($function !== 'floor' || abs($arguments[0] + $inc - $initialValue) >= $precision) { + $arguments[0] += $inc; + } + } + + $changes[$unit] = round( + $minimum + ($fraction ? $fraction * $function(($this->$unit - $minimum) / $fraction) : 0), + ); + + // Cannot use modulo as it lose double precision + while ($changes[$unit] >= $delta) { + $changes[$unit] -= $delta; + } + + $fraction -= floor($fraction); + } + } + + [$value, $minimum] = $arguments; + $normalizedValue = floor($function(($value - $minimum) / $precision) * $precision + $minimum); + + /** @var CarbonInterface $result */ + $result = $this; + + foreach ($changes as $unit => $value) { + $result = $result->$unit($value); + } + + return $result->$normalizedUnit($normalizedValue); + } + + /** + * Truncate the current instance at the given unit with given precision if specified. + */ + public function floorUnit(string $unit, DateInterval|string|float|int $precision = 1): static + { + return $this->roundUnit($unit, $precision, 'floor'); + } + + /** + * Ceil the current instance at the given unit with given precision if specified. + */ + public function ceilUnit(string $unit, DateInterval|string|float|int $precision = 1): static + { + return $this->roundUnit($unit, $precision, 'ceil'); + } + + /** + * Round the current instance second with given precision if specified. + */ + public function round(DateInterval|string|float|int $precision = 1, callable|string $function = 'round'): static + { + return $this->roundWith($precision, $function); + } + + /** + * Round the current instance second with given precision if specified. + */ + public function floor(DateInterval|string|float|int $precision = 1): static + { + return $this->round($precision, 'floor'); + } + + /** + * Ceil the current instance second with given precision if specified. + */ + public function ceil(DateInterval|string|float|int $precision = 1): static + { + return $this->round($precision, 'ceil'); + } + + /** + * Round the current instance week. + * + * @param WeekDay|int|null $weekStartsAt optional start allow you to specify the day of week to use to start the week + */ + public function roundWeek(WeekDay|int|null $weekStartsAt = null): static + { + return $this->closest( + $this->avoidMutation()->floorWeek($weekStartsAt), + $this->avoidMutation()->ceilWeek($weekStartsAt), + ); + } + + /** + * Truncate the current instance week. + * + * @param WeekDay|int|null $weekStartsAt optional start allow you to specify the day of week to use to start the week + */ + public function floorWeek(WeekDay|int|null $weekStartsAt = null): static + { + return $this->startOfWeek($weekStartsAt); + } + + /** + * Ceil the current instance week. + * + * @param WeekDay|int|null $weekStartsAt optional start allow you to specify the day of week to use to start the week + */ + public function ceilWeek(WeekDay|int|null $weekStartsAt = null): static + { + if ($this->isMutable()) { + $startOfWeek = $this->avoidMutation()->startOfWeek($weekStartsAt); + + return $startOfWeek != $this ? + $this->startOfWeek($weekStartsAt)->addWeek() : + $this; + } + + $startOfWeek = $this->startOfWeek($weekStartsAt); + + return $startOfWeek != $this ? + $startOfWeek->addWeek() : + $this->avoidMutation(); + } +} diff --git a/tests/Carbon/RoundTest.php b/tests/Carbon/RoundTest.php new file mode 100644 index 0000000..cd661a3 --- /dev/null +++ b/tests/Carbon/RoundTest.php @@ -0,0 +1,208 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Carbon; + +use Carbon\Carbon; +use Carbon\CarbonInterval; +use DateInterval; +use InvalidArgumentException; +use Tests\AbstractTestCase; + +class RoundTest extends AbstractTestCase +{ + public function testRoundWithDefaultUnit() + { + $dt = Carbon::create(2315, 7, 18, 22, 42, 17.643971); + $copy = $dt->copy(); + $ref = $copy->round(); + $this->assertSame($ref, $copy); + $this->assertCarbon($ref, 2315, 7, 18, 22, 42, 18, 0); + + $this->assertCarbon($dt->copy()->round(5), 2315, 7, 18, 22, 42, 20, 0); + $this->assertCarbon($dt->copy()->floor()->round(5), 2315, 7, 18, 22, 42, 15, 0); + $this->assertCarbon($dt->copy()->round(3), 2315, 7, 18, 22, 42, 18, 0); + $this->assertCarbon($dt->copy()->round(4), 2315, 7, 18, 22, 42, 16, 0); + $this->assertCarbon($dt->copy()->round(10), 2315, 7, 18, 22, 42, 20, 0); + $this->assertCarbon($dt->copy()->round(0.5), 2315, 7, 18, 22, 42, 17, 500000); + $this->assertCarbon($dt->copy()->round(0.25), 2315, 7, 18, 22, 42, 17, 750000); + $this->assertCarbon($dt->copy()->round(3.8), 2315, 7, 18, 22, 42, 19, 800000); + + $this->assertCarbon($dt->copy()->floor(5), 2315, 7, 18, 22, 42, 15, 0); + $this->assertCarbon($dt->copy()->floor()->floor(5), 2315, 7, 18, 22, 42, 15, 0); + $this->assertCarbon($dt->copy()->floor(3), 2315, 7, 18, 22, 42, 15, 0); + $this->assertCarbon($dt->copy()->floor(4), 2315, 7, 18, 22, 42, 16, 0); + $this->assertCarbon($dt->copy()->floor(10), 2315, 7, 18, 22, 42, 10, 0); + $this->assertCarbon($dt->copy()->floor(0.5), 2315, 7, 18, 22, 42, 17, 500000); + $this->assertCarbon($dt->copy()->floor(0.25), 2315, 7, 18, 22, 42, 17, 500000); + $this->assertCarbon($dt->copy()->floor(3.8), 2315, 7, 18, 22, 42, 15, 0); + + $this->assertCarbon($dt->copy()->ceil(5), 2315, 7, 18, 22, 42, 20, 0); + $this->assertCarbon($dt->copy()->floor()->ceil(5), 2315, 7, 18, 22, 42, 20, 0); + $this->assertCarbon($dt->copy()->ceil(3), 2315, 7, 18, 22, 42, 18, 0); + $this->assertCarbon($dt->copy()->ceil(4), 2315, 7, 18, 22, 42, 20, 0); + $this->assertCarbon($dt->copy()->ceil(10), 2315, 7, 18, 22, 42, 20, 0); + $this->assertCarbon($dt->copy()->ceil(0.5), 2315, 7, 18, 22, 42, 18, 0); + $this->assertCarbon($dt->copy()->ceil(0.25), 2315, 7, 18, 22, 42, 17, 750000); + $this->assertCarbon($dt->copy()->ceil(3.8), 2315, 7, 18, 22, 42, 19, 800000); + } + + public function testRoundWithStrings() + { + $dt = Carbon::create(2315, 7, 18, 22, 42, 17.643971); + + $this->assertCarbon($dt->copy()->round('minute'), 2315, 7, 18, 22, 42, 0, 0); + $this->assertCarbon($dt->copy()->floor('5 minutes'), 2315, 7, 18, 22, 40, 0, 0); + $this->assertCarbon($dt->copy()->ceil('5 minutes'), 2315, 7, 18, 22, 45, 0, 0); + } + + public function testRoundWithStringsException() + { + $this->expectExceptionObject(new InvalidArgumentException( + 'Rounding is only possible with single unit intervals.', + )); + Carbon::create(2315, 7, 18, 22, 42, 17.643971)->round('2 hours 5 minutes'); + } + + public function testRoundWithInterval() + { + $dt = Carbon::create(2315, 7, 18, 22, 42, 17.643971); + + $this->assertCarbon($dt->copy()->round(CarbonInterval::minute()), 2315, 7, 18, 22, 42, 0, 0); + $this->assertCarbon($dt->copy()->floor(CarbonInterval::minutes(5)), 2315, 7, 18, 22, 40, 0, 0); + $this->assertCarbon($dt->copy()->ceil(new DateInterval('PT5M')), 2315, 7, 18, 22, 45, 0, 0); + } + + public function testRoundWithIntervalException() + { + $this->expectExceptionObject(new InvalidArgumentException( + 'Rounding is only possible with single unit intervals.', + )); + + Carbon::create(2315, 7, 18, 22, 42, 17.643971)->round(CarbonInterval::day()->minutes(5)); + } + + public function testRoundWithBaseUnit() + { + $dt = Carbon::create(2315, 7, 18, 22, 42, 17.643971); + $copy = $dt->copy(); + $ref = $copy->roundSecond(); + $this->assertSame($ref, $copy); + $this->assertCarbon($ref, 2315, 7, 18, 22, 42, 18, 0); + + $this->assertCarbon($dt->copy()->roundDay(), 2315, 7, 19, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->roundDay(5), 2315, 7, 21, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->ceilDay(), 2315, 7, 19, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->floorDay(), 2315, 7, 18, 0, 0, 0, 0); + + $this->assertCarbon($dt->copy()->roundYear(), 2316, 1, 1, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->subMonths(2)->roundYear(), 2315, 1, 1, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->roundYear(2), 2315, 1, 1, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->floorYear(2), 2315, 1, 1, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->ceilYear(2), 2317, 1, 1, 0, 0, 0, 0); + + $this->assertCarbon($dt->copy()->roundMonth(), 2315, 8, 1, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->floorMonth(), 2315, 7, 1, 0, 0, 0, 0); + + for ($i = 1; $i <= Carbon::MONTHS_PER_YEAR; $i++) { + $dt = Carbon::parse("2021-$i-01")->endOfMonth()->floorMonth(); + + $this->assertCarbon($dt, 2021, $i, 1, 0, 0, 0, 0); + } + } + + public function testFloorYear() + { + $date = Carbon::create(2022)->endOfYear()->floorYear(); + $this->assertCarbon($date, 2022, 1, 1, 0, 0, 0, 0); + + $date = Carbon::create(2022)->endOfYear()->floorDay()->floorYear(); + $this->assertCarbon($date, 2022, 1, 1, 0, 0, 0, 0); + + $date = Carbon::create(2022)->endOfYear()->floorYear(); + $this->assertCarbon($date, 2022, 1, 1, 0, 0, 0, 0); + + $date = Carbon::create(2022)->addMonths(6)->floorYear(); + $this->assertCarbon($date, 2022, 1, 1, 0, 0, 0, 0); + } + + public function testCeilYear() + { + $date = Carbon::create(2022)->addMonths(6)->ceilYear(); + $this->assertCarbon($date, 2023, 1, 1, 0, 0, 0, 0); + + $date = Carbon::create(2022)->endOfYear()->ceilYear(); + $this->assertCarbon($date, 2023, 1, 1, 0, 0, 0, 0); + + $date = Carbon::create(2022)->ceilYear(); + $this->assertCarbon($date, 2022, 1, 1, 0, 0, 0, 0); + + $date = Carbon::create(2022)->addMicrosecond()->ceilYear(); + $this->assertCarbon($date, 2023, 1, 1, 0, 0, 0, 0); + } + + public function testRoundWithMetaUnit() + { + $dt = Carbon::create(2315, 7, 18, 22, 42, 17.643971); + $copy = $dt->copy(); + $ref = $copy->roundSecond(); + $this->assertSame($ref, $copy); + $this->assertCarbon($ref, 2315, 7, 18, 22, 42, 18, 0); + + $this->assertCarbon($dt->copy()->roundMillisecond(), 2315, 7, 18, 22, 42, 17, 644000); + $this->assertCarbon($dt->copy()->roundMillennium(), 2001, 1, 1, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->roundQuarter(), 2315, 7, 1, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->roundQuarters(2), 2315, 7, 1, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->subMonth()->floorQuarter(), 2315, 4, 1, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->subMonth()->floorQuarters(2), 2315, 1, 1, 0, 0, 0, 0); + } + + public function testRoundWeek() + { + $dt = Carbon::create(2315, 7, 18, 22, 42, 17.643971); + $copy = $dt->copy(); + $ref = $copy->roundSecond(); + $this->assertSame($ref, $copy); + $this->assertCarbon($ref, 2315, 7, 18, 22, 42, 18, 0); + + $this->assertCarbon($dt->copy()->floorWeek(), 2315, 7, 12, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->ceilWeek(), 2315, 7, 19, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->roundWeek(), 2315, 7, 19, 0, 0, 0, 0); + + $dt = Carbon::create(2315, 7, 19, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->floorWeek(), 2315, 7, 19, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->ceilWeek(), 2315, 7, 19, 0, 0, 0, 0); + $this->assertCarbon($dt->copy()->roundWeek(), 2315, 7, 19, 0, 0, 0, 0); + } + + public function testCeilMonth() + { + $this->assertCarbon(Carbon::parse('2021-01-29')->ceilMonth(), 2021, 2, 1, 0, 0, 0); + $this->assertCarbon(Carbon::parse('2021-01-31')->ceilMonth(), 2021, 2, 1, 0, 0, 0); + $this->assertCarbon(Carbon::parse('2021-12-17')->ceilMonth(), 2022, 1, 1, 0, 0, 0); + } + + public function testFloorMonth() + { + $this->assertCarbon(Carbon::parse('2021-05-31')->floorMonth(3), 2021, 4, 1, 0, 0, 0); + } + + public function testRoundInvalidArgument() + { + $this->expectExceptionObject(new InvalidArgumentException( + 'Unknown unit \'foobar\'.', + )); + + Carbon::now()->roundUnit('foobar'); + } +}