From 755ecc686d0d81517adada9c6f8aff59d1c713ca Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 4 Sep 2026 09:03:39 +0200 Subject: [PATCH] Upstream thephpleague/period state of the touched files before cba0aa9caf --- src/Duration.php | 215 ++++++++++++++++++++++++++ src/DurationTest.php | 356 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 571 insertions(+) create mode 100644 src/Duration.php create mode 100644 src/DurationTest.php diff --git a/src/Duration.php b/src/Duration.php new file mode 100644 index 0000000..a8015cb --- /dev/null +++ b/src/Duration.php @@ -0,0 +1,215 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace League\Period; + +use DateInterval; +use DateTimeImmutable; +use DateTimeInterface; +use Exception; +use InvalidArgumentException; +use Throwable; + +use function preg_match; +use function str_pad; +use function strlen; +use function substr; + +/** + * League Period Duration. + * + * @package League.period + * @author Ignace Nyamagana Butera + * @since 4.2.0 + */ +final class Duration +{ + private const REGEXP_FLOATING_SECONDS_DATE = '@^(?.*)(\.)(?\d{1,6})$@'; + private const REGEXP_FLOATING_SECONDS_INTERVAL = '@^(?.*)(\.)(?\d{1,6})S$@'; + private const REGEXP_FRACTION_DESIGNATOR = '@^P([^T]+)?(T(?=\d+[HMSF])(\d+H)?(\d+M)?(\d+S)?((?\d+)F))?$@'; + private const REGEXP_CHRONOMETER = '@^ + (?\+|-)? # optional sign + ((?\d+):)? # optional hour + ((?\d+):)(?\d+) # required minute and second + (\.(?\d{1,6}))? # optional fraction + $@x'; + + private const REGEXP_TIME_FORMAT = '@^ + (?\+|-)? # optional sign + (?\d+)(:(?\d+)) # required hour and minute + (:(?\d+)(\.(?\d{1,6}))?)? # optional second and fraction + $@x'; + + private function __construct(public readonly DateInterval $dateInterval) + { + } + + /** + * @param array{dateInterval: DateInterval} $properties + */ + public static function __set_state(array $properties): self + { + return new self($properties['dateInterval']); + } + + /** + * Returns a new instance from an interval specification. + * + * @throws Exception + */ + public static function fromIsoString(string $duration): self + { + if (1 === preg_match(self::REGEXP_FLOATING_SECONDS_INTERVAL, $duration, $matches)) { + $interval = new DateInterval($matches['interval'].'S'); + $interval->f = (int) str_pad($matches['fraction'], 6, '0') / 1_000_000; + + return new self($interval); + } + + if (1 === preg_match(self::REGEXP_FLOATING_SECONDS_DATE, $duration, $matches)) { + $interval = new DateInterval($matches['interval']); + $interval->f = (int) str_pad($matches['fraction'], 6, '0') / 1_000_000; + + return new self($interval); + } + + if (1 === preg_match(self::REGEXP_FRACTION_DESIGNATOR, $duration, $matches) + && isset($matches['fraction']) + ) { + $interval = new DateInterval(substr($duration, 0, -strlen($matches['fraction']) - 1)); + $interval->f = (int) $matches['fraction'] / 1_000_000; + + return new self($interval); + } + + return new self(new DateInterval($duration)); + } + + /** + * Returns a new instance from a DateInterval object. + */ + public static function fromDateInterval(DateInterval $duration): self + { + return new self($duration); + } + + /** + * Returns a new instance from a seconds. + * + * @throws InvalidArgumentException + */ + public static function fromSeconds(int $seconds, int $fractions = 0): self + { + if (0 > $fractions) { + throw new InvalidArgumentException('The fraction should be a valid positive integer or zero.'); + } + + $duration = new DateInterval('PT0S'); + $duration->s = $seconds; + $duration->f = $fractions / 1_000_000; + + return new self($duration); + } + + /** + * Creates a new instance from a timer string representation. + * + * @throws InvalidArgumentException + */ + public static function fromChronoString(string $duration): self + { + if (1 !== preg_match(self::REGEXP_CHRONOMETER, $duration, $matches)) { + throw new InvalidArgumentException('Unknown or bad format `'.$duration.'`.'); + } + + return self::fromUnits([ + 'hour' => '' === $matches['hour'] ? '0' : $matches['hour'], + 'minute' => $matches['minute'], + 'second' => $matches['second'], + 'fraction' => $matches['fraction'] ?? null, + 'sign' => $matches['sign'], + ]); + } + + /** + * Creates a new instance from a time string representation. + * + * @throws InvalidArgumentException + */ + public static function fromTimeString(string $duration): self + { + if (1 !== preg_match(self::REGEXP_TIME_FORMAT, $duration, $matches)) { + throw new InvalidArgumentException('Unknown or bad format ('.$duration.')'); + } + + return self::fromUnits([ + 'hour' => $matches['hour'], + 'minute' => $matches['minute'], + 'second' => $matches['second'] ?? '0', + 'fraction' => $matches['fraction'] ?? null, + 'sign' => $matches['sign'], + ]); + } + + /** + * Creates a new instance from a date time string like representation. + * + * @throws InvalidArgumentException + */ + public static function fromDateString(string $duration): self + { + try { + $dateInterval = DateInterval::createFromDateString($duration); + } catch (Throwable $exception) { + throw new InvalidArgumentException('Unknown or bad format `'.$duration.'`.', 0, $exception); + } + + if (false === $dateInterval) { + throw new InvalidArgumentException('Unknown or bad format `'.$duration.'`.'); + } + + return new self($dateInterval); + } + + /** + * @param array{hour: ?string, minute: ?string, second: ?string, fraction: ?string, sign: ?string} $units + */ + private static function fromUnits(array $units): self + { + $units += ['hour' => '0', 'minute' => '0', 'second' => '0', 'fraction' => '0', 'sign' => '+']; + $units['fraction'] = str_pad($units['fraction'] ?? '000000', 6, '0'); + if ('-' === $units['sign']) { + $units['hour'] = '-'.$units['hour']; + } + + return self::fromDateString( + $units['hour'].' hours '.$units['minute'].' minutes '.$units['second'].' seconds '.$units['fraction'].' microseconds' + ); + } + + /** + * Returns a new instance with recalculate properties according to a given datepoint. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the time and date segments recalculate to remove + * carry over points. + */ + public function adjustedTo(DateTimeInterface $date): self + { + if (!$date instanceof DateTimeImmutable) { + $date = DateTimeImmutable::createFromInterface($date); + } + + return new self($date->diff($date->add($this->dateInterval))); + } +} diff --git a/src/DurationTest.php b/src/DurationTest.php new file mode 100644 index 0000000..487204c --- /dev/null +++ b/src/DurationTest.php @@ -0,0 +1,356 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +/** + * League.Period (https://period.thephpleague.com). + * + * (c) Ignace Nyamagana Butera + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\Period; + +use DateInterval; +use DateTime; +use DateTimeInterface; +use DateTimeZone; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Throwable; + +final class DurationTest extends TestCase +{ + private string $timezone; + + protected function setUp(): void + { + $this->timezone = date_default_timezone_get(); + } + + protected function tearDown(): void + { + date_default_timezone_set($this->timezone); + } + + private function formatDuration(Duration $duration): string + { + $interval = $duration->dateInterval; + + $date = ['P']; + if (0 !== $interval->y) { + $date[] = '%yY'; + } + + if (0 !== $interval->m) { + $date[] = '%mM'; + } + + if (0 !== $interval->d) { + $date[] = '%dD'; + } + + $time = ['T']; + if (0 !== $interval->h) { + $time[] = '%hH'; + } + + if (0 !== $interval->i) { + $time[] = '%iM'; + } + + $dateFormat = implode('', $date); + $timeFormat = 1 === count($time) ? '' : implode('', $time); + + if (0.0 !== $interval->f) { + $second = $interval->s + $interval->f; + if (0 > $interval->s) { + $second = $interval->s - $interval->f; + } + + return $interval->format($dateFormat.('' === $timeFormat ? 'T' : $timeFormat)) + .rtrim(sprintf('%f', $second), '0').'S'; + } + + if (0 !== $interval->s) { + return $interval->format($dateFormat.$timeFormat.'%sS'); + } + + if (1 === count($time) && 1 === count($date)) { + return 'PT0S'; + } + + return $interval->format($dateFormat.$timeFormat); + } + + public function testInstantiationFromSetState(): void + { + $duration = Duration::fromDateInterval(new DateInterval('P1D')); + /** @var Duration $generatedDuration */ + $generatedDuration = eval('return '.var_export($duration, true).';'); + + self::assertEquals($duration, $generatedDuration); + } + + public function testCreateFromDateInterval(): void + { + $duration = Duration::fromDateInterval(new DateInterval('P1D')); + + self::assertSame(1, $duration->dateInterval->d); + self::assertFalse($duration->dateInterval->days); + } + + public function testCreateFromDateString(): void + { + $duration = Duration::fromDateString('+1 DAY'); + + self::assertSame(1, $duration->dateInterval->d); + self::assertFalse($duration->dateInterval->days); + } + + /** + * @return iterable> + */ + public static function getDurationCreateFailsProvider(): iterable + { + return [ + 'invalid interval spec 1' => ['PT'], + 'invalid interval spec 2' => ['P'], + 'invalid interval spec 3' => ['PT1'], + 'invalid interval spec 4' => ['P3'], + 'invalid interval spec 5' => ['PT3X'], + 'invalid interval spec 6' => ['PT3s'], + 'invalid string' => ['blablabbla'], + ]; + } + + #[DataProvider('getDurationFromSecondsSuccessfulProvider')] + public function testCreateFromSeconds(int $seconds, int $fraction, string $expected): void + { + self::assertSame($expected, $this->formatDuration(Duration::fromSeconds($seconds, $fraction))); + } + + /** + * @return array + */ + public static function getDurationFromSecondsSuccessfulProvider(): array + { + return [ + 'from an integer' => [ + 'seconds' => 0, + 'fraction' => 0, + 'expected' => 'PT0S', + ], + 'negative seconds' => [ + 'seconds' => -3, + 'fraction' => 2345, + 'expected' => 'PT-3.002345S', + ], + ]; + } + + public function testItFailsToCreateADurationWithANegativeFraction(): void + { + $this->expectException(InvalidArgumentException::class); + + Duration::fromSeconds(32, -1); + } + + #[DataProvider('providesValidIsoString')] + public function testIntervalWithFraction(string $input, string $expected): void + { + self::assertSame($expected, $this->formatDuration(Duration::fromIsoString($input))); + } + + /** + * @return iterable + */ + public static function providesValidIsoString(): iterable + { + return [ + 'IsoString with fraction v1' => [ + 'input' => 'PT3.1S', + 'expected' => 'PT3.1S', + ], + 'IsoString with fraction v2' => [ + 'input' => 'P0000-00-00T00:05:00.023658', + 'expected' => 'PT5M0.023658S', + ], + 'IsoString with fraction v3' => [ + 'input' => 'PT5M23658F', + 'expected' => 'PT5M0.023658S', + ], + ]; + } + + #[DataProvider('fromChronoFailsProvider')] + public function testCreateFromChronoStringFails(string $input): void + { + $this->expectException(InvalidArgumentException::class); + + Duration::fromChronoString($input); + } + + /** + * @return iterable> + */ + public static function fromChronoFailsProvider(): iterable + { + return [ + 'invalid string' => ['foobar'], + 'float like string' => ['-28.5'], + ]; + } + + #[DataProvider('fromChronoProvider')] + public function testCreateFromChronoStringSucceeds(string $chronometer, string $expected): void + { + $duration = Duration::fromChronoString($chronometer); + + self::assertSame($expected, $this->formatDuration($duration)); + } + + /** + * @return iterable + */ + public static function fromChronoProvider(): iterable + { + return [ + 'minute and seconds' => [ + 'chronometer' => '1:2', + 'expected' => 'PT1M2S', + ], + 'hour, minute, seconds' => [ + 'chronometer' => '1:2:3', + 'expected' => 'PT1H2M3S', + ], + 'handling 0 prefix' => [ + 'chronometer' => '00001:00002:000003.0004', + 'expected' => 'PT1H2M3.0004S', + ], + 'negative chrono' => [ + 'chronometer' => '-12:28.5', + 'expected' => 'PT12M28.5S', + ], + ]; + } + + public function testCreateFromTimeStringFails(): void + { + $this->expectException(Throwable::class); + + Duration::fromTimeString('123'); + } + + #[DataProvider('fromTimeStringProvider')] + public function testCreateFromTimeStringSucceeds(string $chronometer, string $expected): void + { + $duration = Duration::fromTimeString($chronometer); + + self::assertSame($expected, $this->formatDuration($duration)); + } + + /** + * @return array + */ + public static function fromTimeStringProvider(): iterable + { + return [ + 'hour and minute' => [ + 'chronometer' => '1:2', + 'expected' => 'PT1H2M', + ], + 'hour, minute, seconds' => [ + 'chronometer' => '1:2:3', + 'expected' => 'PT1H2M3S', + ], + 'handling 0 prefix' => [ + 'chronometer' => '00001:00002:000003.0004', + 'expected' => 'PT1H2M3.0004S', + ], + 'negative chrono' => [ + 'chronometer' => '-12:28', + 'expected' => 'PT-12H28M', + ], + 'negative chrono with seconds' => [ + 'chronometer' => '-00:00:28.5', + 'expected' => 'PT28.5S', + ], + ]; + } + + #[DataProvider('adjustedToDataProvider')] + public function testAdjustedTo(string $input, int|string|DateTimeInterface $reference_date, string $expected): void + { + $duration = Duration::fromIsoString($input); + /** @var DateTimeInterface $date */ + $date = match (true) { + is_int($reference_date) => DatePoint::fromTimestamp($reference_date)->date, + is_string($reference_date) => DatePoint::fromDateString($reference_date)->date, + default => $reference_date, + }; + + self::assertSame($expected, $this->formatDuration($duration->adjustedTo($date))); + } + + /** + * @return iterable + */ + public static function adjustedToDataProvider(): iterable + { + return [ + 'nothing to carry over' => [ + 'input' => 'PT3H', + 'reference_date' => 0, + 'expected' => 'PT3H', + ], + 'hour transformed in days' => [ + 'input' => 'PT24H', + 'reference_date' => 0, + 'expected' => 'P1D', + ], + 'days transformed in months' => [ + 'input' => 'P31D', + 'reference_date' => 0, + 'expected' => 'P1M', + ], + 'months transformed in years' => [ + 'input' => 'P12M', + 'reference_date' => 0, + 'expected' => 'P1Y', + ], + 'leap year' => [ + 'input' => 'P29D', + 'reference_date' => '2020-02-01', + 'expected' => 'P1M', + ], + 'none leap year' => [ + 'input' => 'P29D', + 'reference_date' => '2019-02-01', + 'expected' => 'P1M1D', + ], /* THIS IS FIXED AS OF PHP8.1 + 'dst day' => [ + 'input' => 'PT4H', + 'reference_date' => new DateTime('2019-03-31', new DateTimeZone('Europe/Brussels')), + 'expected' => 'PT3H', + ],*/ + 'non dst day' => [ + 'input' => 'PT4H', + 'reference_date' => new DateTime('2019-04-01', new DateTimeZone('Europe/Brussels')), + 'expected' => 'PT4H', + ], + ]; + } +}