Upstream thephpleague/period state of the touched files before cba0aa9caf

This commit is contained in:
upstream committed 2026-09-04 09:03:39 +02:00
commit 755ecc686d
2 files changed
+571

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
<?php
/**
* League.Period (https://period.thephpleague.com)
*
* (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
*
* 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 <nyamsprod@gmail.com>
* @since 4.2.0
*/
final class Duration
{
private const REGEXP_FLOATING_SECONDS_DATE = '@^(?<interval>.*)(\.)(?<fraction>\d{1,6})$@';
private const REGEXP_FLOATING_SECONDS_INTERVAL = '@^(?<interval>.*)(\.)(?<fraction>\d{1,6})S$@';
private const REGEXP_FRACTION_DESIGNATOR = '@^P([^T]+)?(T(?=\d+[HMSF])(\d+H)?(\d+M)?(\d+S)?((?<fraction>\d+)F))?$@';
private const REGEXP_CHRONOMETER = '@^
(?<sign>\+|-)? # optional sign
((?<hour>\d+):)? # optional hour
((?<minute>\d+):)(?<second>\d+) # required minute and second
(\.(?<fraction>\d{1,6}))? # optional fraction
$@x';
private const REGEXP_TIME_FORMAT = '@^
(?<sign>\+|-)? # optional sign
(?<hour>\d+)(:(?<minute>\d+)) # required hour and minute
(:(?<second>\d+)(\.(?<fraction>\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)));
}
}
+356
View File
@@ -0,0 +1,356 @@
<?php
/**
* League.Period (https://period.thephpleague.com)
*
* (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
*
* 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 <nyamsprod@gmail.com>
*
* 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<string, array<string>>
*/
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<string, array{seconds:int, fraction:int, expected:string}>
*/
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<string, array{input:string, expected:string}>
*/
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<string, array<string>>
*/
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<string, array{chronometer:string, expected:string}>
*/
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<array{chronometer:string, expected:string}>
*/
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<string, array{input:string, reference_date:int|string|DateTimeInterface, expected:string}>
*/
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',
],
];
}
}