Upstream thephpleague/period state of the touched files before cba0aa9caf
This commit is contained in:
commit
755ecc686d
2 files changed
+571
No files matched your search
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user