-
-
Save gnutix/d730da3f5e3a9beedd62571418f62194 to your computer and use it in GitHub Desktop.
LocalDateTime interval class with exclusive end (along with its dependencies and unit tests)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace Gammadia\DateTimeExtra; | |
use Brick\DateTime\Duration; | |
use Brick\DateTime\Instant; | |
use Brick\DateTime\LocalDate; | |
use Brick\DateTime\LocalDateTime; | |
use Brick\DateTime\LocalTime; | |
use Brick\DateTime\Period; | |
use Brick\DateTime\Year; | |
use Brick\DateTime\YearMonth; | |
use Brick\DateTime\YearWeek; | |
use DateTimeImmutable; | |
use DateTimeZone; | |
use Doctrine\ORM\Mapping as ORM; | |
use Gammadia\DateTimeExtra\Exceptions\IntervalParseException; | |
use InvalidArgumentException; | |
use JsonSerializable; | |
use RuntimeException; | |
use Stringable; | |
use Traversable; | |
use WeakMap; | |
use Webmozart\Assert\Assert; | |
use function count; | |
use function Gammadia\Collections\Functional\contains; | |
use function Gammadia\Collections\Functional\filter; | |
use function Gammadia\Collections\Functional\map; | |
use function Gammadia\Collections\Functional\sort; | |
use function is_string; | |
#[ORM\Embeddable] | |
final class LocalDateTimeInterval implements JsonSerializable, Stringable | |
{ | |
private ?string $iso = null; | |
private ?Duration $duration = null; | |
private function __construct( | |
#[ORM\Column(type: 'local_datetime')] | |
private readonly ?LocalDateTime $start, | |
#[ORM\Column(type: 'local_datetime')] | |
private readonly ?LocalDateTime $end, | |
bool $validateStartAfterEnd = false, | |
) { | |
/** | |
* Using Assert here would have a huge performance cost because of the {@see LocalDateTime::__toString()} calls. | |
*/ | |
if ($validateStartAfterEnd && null !== $start && null !== $end && $start->isAfter($end)) { | |
throw new InvalidArgumentException(sprintf('Start after end: %s / %s', $start, $end)); | |
} | |
} | |
public function __toString(): string | |
{ | |
return $this->toISOString(); | |
} | |
public function toISOString(): string | |
{ | |
return $this->iso ??= ($this->start ?? InfinityStyle::SYMBOL) . '/' . ($this->end ?? InfinityStyle::SYMBOL); | |
} | |
public function jsonSerialize(): string | |
{ | |
return $this->toISOString(); | |
} | |
/** | |
* Creates a finite half-open interval between given time points (inclusive start, exclusive end). | |
*/ | |
public static function between(?LocalDateTime $start, ?LocalDateTime $end): self | |
{ | |
return new self($start, $end, validateStartAfterEnd: true); | |
} | |
/** | |
* Creates an empty interval at the given timepoint. | |
*/ | |
public static function empty(LocalDateTime $timepoint): self | |
{ | |
return new self($timepoint, $timepoint); | |
} | |
/** | |
* Creates an infinite half-open interval since given start (inclusive). | |
*/ | |
public static function since(LocalDateTime $timepoint): self | |
{ | |
return new self($timepoint, null); | |
} | |
/** | |
* Creates an infinite open interval until given end (exclusive). | |
*/ | |
public static function until(LocalDateTime $timepoint): self | |
{ | |
return new self(null, $timepoint); | |
} | |
/** | |
* Creates an infinite interval. | |
*/ | |
public static function forever(): self | |
{ | |
static $forever; | |
return $forever ??= new self(null, null); | |
} | |
public static function day(LocalDate|LocalDateTime $input): self | |
{ | |
$startOfDay = $input instanceof LocalDateTime | |
? $input->withTime(LocalTime::min()) | |
: $input->atTime(LocalTime::min()); | |
return new self($startOfDay, $startOfDay->plusDays(1)); | |
} | |
/** | |
* If the type of the input argument is known at call-site, usage of the following explicit methods is preferred : | |
* - {@see self::day()} for LocalDate | |
* - {@see self::empty()} for LocalDateTime | |
* | |
* @return ($temporal is null ? null : self) | |
*/ | |
public static function cast(null|string|self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): ?self | |
{ | |
if (null === $temporal) { | |
return null; | |
} | |
if (is_string($temporal)) { | |
return self::parse($temporal); | |
} | |
if ($temporal instanceof self) { | |
return $temporal; | |
} | |
if ($temporal instanceof LocalDateInterval) { | |
// Not sure why but the following line is required even though the cache is initialized after LocalDateInterval's class definition | |
/** @phpstan-ignore-next-line https://github.com/phpstan/phpstan/issues/8390 */ | |
LocalDateInterval::$cache ??= new WeakMap(); | |
/** @var self */ | |
return LocalDateInterval::$cache[$temporal] ??= new self( | |
start: $temporal->getStart()?->atTime(LocalTime::min()), | |
end: $temporal->getEnd()?->atTime(LocalTime::min())->plusDays(1), | |
); | |
} | |
return match (true) { | |
$temporal instanceof LocalDate => self::day($temporal), | |
$temporal instanceof LocalDateTime => self::empty($temporal), | |
$temporal instanceof YearWeek => new self( | |
start: $temporal->getFirstDay()->atTime(LocalTime::min()), | |
end: $temporal->getLastDay()->atTime(LocalTime::min())->plusDays(1), | |
), | |
$temporal instanceof YearMonth => new self( | |
start: $temporal->getFirstDay()->atTime(LocalTime::min()), | |
end: $temporal->getLastDay()->atTime(LocalTime::min())->plusDays(1), | |
), | |
$temporal instanceof Year => new self( | |
start: $temporal->atMonth(1)->getFirstDay()->atTime(LocalTime::min()), | |
end: $temporal->atMonth(12)->getLastDay()->atTime(LocalTime::min())->plusDays(1), | |
), | |
}; | |
} | |
/** | |
* Creates an interval that contains (encompasses) every provided intervals | |
* | |
* Returns new timestamp interval or null if the input is empty | |
*/ | |
public static function containerOf(null|self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year ...$temporals): ?self | |
{ | |
$starts = $ends = []; | |
foreach ($temporals as $temporal) { | |
switch (true) { | |
case null === $temporal: | |
continue 2; | |
case $temporal instanceof LocalDate: | |
$start = $temporal->atTime(LocalTime::min()); | |
$starts[] = $start; | |
$ends[] = $start->plusDays(1); | |
break; | |
case $temporal instanceof LocalDateTime: | |
$starts[] = $temporal; | |
$ends[] = $temporal; | |
break; | |
case $temporal instanceof LocalDateInterval: | |
$starts[] = $temporal->getStart()?->atTime(LocalTime::min()); | |
$ends[] = $temporal->getEnd()?->atTime(LocalTime::min())->plusDays(1); | |
break; | |
case $temporal instanceof YearWeek: | |
$starts[] = $temporal->getFirstDay()->atTime(LocalTime::min()); | |
$ends[] = $temporal->getLastDay()->atTime(LocalTime::min())->plusDays(1); | |
break; | |
case $temporal instanceof YearMonth: | |
$starts[] = $temporal->getFirstDay()->atTime(LocalTime::min()); | |
$ends[] = $temporal->getLastDay()->atTime(LocalTime::min())->plusDays(1); | |
break; | |
case $temporal instanceof Year: | |
$starts[] = $temporal->atMonth(1)->getFirstDay()->atTime(LocalTime::min()); | |
$ends[] = $temporal->atMonth(12)->getLastDay()->atTime(LocalTime::min())->plusDays(1); | |
break; | |
default: | |
$starts[] = $temporal->start; | |
$ends[] = $temporal->end; | |
break; | |
} | |
} | |
return match (count($starts)) { | |
0 => null, | |
1 => new self($starts[0], $ends[0]), | |
default => new self( | |
start: contains($starts, value: null) ? null : LocalDateTime::minOf(...$starts), | |
end: contains($ends, value: null) ? null : LocalDateTime::maxOf(...$ends), | |
), | |
}; | |
} | |
/** | |
* @todo Remove once a PHPStan extension has been written and containerOf() does not need Asserts anymore | |
*/ | |
public static function unsafeContainerOf(null|self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year ...$temporals): self | |
{ | |
$container = self::containerOf(...$temporals); | |
Assert::notNull($container, sprintf('You cannot give an empty array to %s.', __METHOD__)); | |
return $container; | |
} | |
/** | |
* @return list<self> | |
*/ | |
public static function disjointContainersOf(null|self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year ...$temporals): array | |
{ | |
$temporals = filter($temporals); | |
if ([] === $temporals) { | |
return []; | |
} | |
$timeRanges = sort( | |
map($temporals, static fn (self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): self | |
=> self::unsafeContainerOf($temporal), | |
), | |
static fn (self $a, self $b): int => $a->compareTo($b), | |
); | |
/** @var array{start: LocalDateTime|null, end: LocalDateTime|null}|null $nextContainer */ | |
$nextContainer = null; | |
$containers = []; | |
foreach ($timeRanges as $timeRange) { | |
if (null === $nextContainer) { | |
$nextContainer = ['start' => $timeRange->start, 'end' => $timeRange->end]; | |
} elseif (null !== $nextContainer['end'] | |
&& null !== $timeRange->start | |
&& $nextContainer['end']->isBefore($timeRange->start) | |
) { | |
$containers[] = new self($nextContainer['start'], $nextContainer['end']); | |
$nextContainer = ['start' => $timeRange->start, 'end' => $timeRange->end]; | |
} elseif (null === $nextContainer['end'] || null === $timeRange->end) { | |
$containers[] = new self($nextContainer['start'], null); | |
return $containers; | |
} elseif ($timeRange->end->isAfter($nextContainer['end'])) { | |
$nextContainer['end'] = $timeRange->end; | |
} | |
} | |
Assert::notNull($nextContainer); | |
$containers[] = new self($nextContainer['start'], $nextContainer['end']); | |
return $containers; | |
} | |
/** | |
* Converts this instance to a timestamp interval with | |
* dates from midnight to midnight. | |
*/ | |
public function toFullDays(): self | |
{ | |
return new self( | |
start: $this->start?->withTime(LocalTime::min()), | |
end: null === $this->end ? null : (!$this->isEmpty() && $this->end->getTime()->isEqualTo(LocalTime::min()) | |
? $this->end | |
: $this->end->plusDays(1)->withTime(LocalTime::min()) | |
), | |
); | |
} | |
public function isFullDays(): bool | |
{ | |
return $this->equals($this->toFullDays()); | |
} | |
/** | |
* Returns the nullable start time point. | |
*/ | |
public function getStart(): ?LocalDateTime | |
{ | |
return $this->start; | |
} | |
/** | |
* Returns the nullable end time point. | |
*/ | |
public function getEnd(): ?LocalDateTime | |
{ | |
return $this->end; | |
} | |
/** | |
* Yields the start time point if not null. | |
*/ | |
public function getFiniteStart(): LocalDateTime | |
{ | |
return $this->start ?? throw new RuntimeException(sprintf('The interval "%s" does not have a finite start.', $this)); | |
} | |
/** | |
* Yields the end time point if not null. | |
*/ | |
public function getFiniteEnd(): LocalDateTime | |
{ | |
return $this->end ?? throw new RuntimeException(sprintf('The interval "%s" does not have a finite end.', $this)); | |
} | |
/** | |
* Yields a copy of this interval with given start time. | |
*/ | |
public function withStart(?LocalDateTime $timepoint): self | |
{ | |
return new self($timepoint, $this->end, validateStartAfterEnd: true); | |
} | |
/** | |
* Yields a copy of this interval with given end time. | |
*/ | |
public function withEnd(?LocalDateTime $timepoint): self | |
{ | |
return new self($this->start, $timepoint, validateStartAfterEnd: true); | |
} | |
/** | |
* Interpretes given ISO-conforming text as interval. | |
* | |
* @return ($text is null ? null : self) | |
*/ | |
public static function parse(?string $text): ?self | |
{ | |
if (null === $text) { | |
return null; | |
} | |
if (!str_contains($text, '/')) { | |
throw IntervalParseException::notAnInterval($text); | |
} | |
[$startStr, $endStr] = explode('/', trim($text), 2); | |
$startsWithPeriod = str_starts_with($startStr, 'P'); | |
$startsWithInfinity = InfinityStyle::SYMBOL === $startStr; | |
$endsWithPeriod = str_starts_with($endStr, 'P'); | |
$endsWithInfinity = InfinityStyle::SYMBOL === $endStr; | |
if ($startsWithPeriod && $endsWithPeriod) { | |
throw IntervalParseException::uniqueDuration($text); | |
} | |
if (($startsWithPeriod && $endsWithInfinity) || ($startsWithInfinity && $endsWithPeriod)) { | |
throw IntervalParseException::durationIncompatibleWithInfinity($text); | |
} | |
// START | |
if ($startsWithInfinity) { | |
$ldt1 = null; | |
} elseif ($startsWithPeriod) { | |
$ldt2 = LocalDateTime::parse($endStr); | |
$ldt1 = str_contains($startStr, 'T') | |
? $ldt2->minusDuration(Duration::parse($startStr)) | |
: $ldt2->minusPeriod(Period::parse($startStr)); | |
return new self($ldt1, $ldt2, validateStartAfterEnd: true); | |
} else { | |
$ldt1 = LocalDateTime::parse($startStr); | |
} | |
// END | |
if ($endsWithInfinity) { | |
$ldt2 = null; | |
} elseif ($endsWithPeriod) { | |
if (null === $ldt1) { | |
throw new RuntimeException('Cannot process end period without start.'); | |
} | |
$ldt2 = str_contains($endStr, 'T') | |
? $ldt1->plusDuration(Duration::parse($endStr)) | |
: $ldt1->plusPeriod(Period::parse($endStr)); | |
} else { | |
$ldt2 = LocalDateTime::parse($endStr); | |
} | |
return new self($ldt1, $ldt2, validateStartAfterEnd: true); | |
} | |
/** | |
* Moves this interval along the POSIX-axis by the given duration or period. | |
*/ | |
public function move(Duration|Period $input): self | |
{ | |
return $input instanceof Period | |
? new self($this->start?->plusPeriod($input), $this->end?->plusPeriod($input)) | |
: new self($this->start?->plusDuration($input), $this->end?->plusDuration($input)); | |
} | |
/** | |
* Return the duration of this interval. | |
*/ | |
public function getDuration(): Duration | |
{ | |
if (null === $this->start || null === $this->end) { | |
throw new RuntimeException('Returning the duration with infinite boundary is not possible.'); | |
} | |
return $this->duration ??= Duration::between( | |
startInclusive: $this->getUTCInstant($this->start), | |
endExclusive: $this->getUTCInstant($this->end), | |
); | |
} | |
/** | |
* Iterates through every moments which are the result of adding the given duration or period | |
* to the start until the end of this interval is reached. | |
* | |
* @return Traversable<LocalDateTime> | |
*/ | |
public function iterate(Duration|Period $input): Traversable | |
{ | |
if (null === $this->start || null === $this->end) { | |
throw new RuntimeException('Iterate is not supported for infinite intervals.'); | |
} | |
for ( | |
$start = $this->start; | |
$start->isBefore($this->end); | |
) { | |
yield $start; | |
$start = $input instanceof Period | |
? $start->plusPeriod($input) | |
: $start->plusDuration($input); | |
} | |
} | |
/** | |
* Returns slices of this interval. | |
* | |
* Each slice is at most as long as the given period or duration. The last slice might be shorter. | |
* | |
* @return Traversable<self> | |
*/ | |
public function slice(Duration|Period $input): Traversable | |
{ | |
foreach ($this->iterate($input) as $start) { | |
$end = $input instanceof Period | |
? $start->plusPeriod($input) | |
: $start->plusDuration($input); | |
$end = null !== $this->end | |
? LocalDateTime::minOf($end, $this->end) | |
: $end; | |
yield new self($start, $end); | |
} | |
} | |
/** | |
* Determines if this interval is empty. An interval is empty when the "end" is equal to the "start" boundary. | |
*/ | |
public function isEmpty(): bool | |
{ | |
return null !== $this->start | |
&& null !== $this->end | |
&& $this->start->isEqualTo($this->end); | |
} | |
/** | |
* ALLEN-relation: Does this interval precede the other one such that | |
* there is a gap between? | |
*/ | |
public function precedes(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
return null !== $this->end && null !== $temporal->start && $this->end->isBefore($temporal->start); | |
} | |
/** | |
* ALLEN-relation: Equivalent to $temporal->precedes($this). | |
*/ | |
public function precededBy(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
return $temporal->precedes($this); | |
} | |
/** | |
* ALLEN-relation: Does this interval precede the other one such that | |
* there is no gap between? | |
*/ | |
public function meets(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
return null !== $this->end && null !== $temporal->start && $this->end->isEqualTo($temporal->start) | |
&& (null === $this->start || $this->start->isBefore($temporal->start)); | |
} | |
/** | |
* ALLEN-relation: Equivalent to $temporal->meets($this). | |
*/ | |
public function metBy(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
return $temporal->meets($this); | |
} | |
/** | |
* ALLEN-relation: Does this interval finish the other one such that | |
* both end time points are equal and the start of this interval is after | |
* the start of the other one? | |
*/ | |
public function finishes(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
return null !== $this->start && (null === $temporal->start || $this->start->isAfter($temporal->start)) | |
&& (null === $this->end | |
? null === $temporal->end | |
: null !== $temporal->end && $this->end->isEqualTo($temporal->end) && !$this->start->isEqualTo($this->end) | |
); | |
} | |
/** | |
* ALLEN-relation: Equivalent to $temporal->finishes($this). | |
*/ | |
public function finishedBy(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
return $temporal->finishes($this); | |
} | |
/** | |
* ALLEN-relation: Does this interval start the other one such that both | |
* start time points are equal and the end of this interval is before the | |
* end of the other one? | |
*/ | |
public function starts(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
return null !== $this->end && (null === $temporal->end || $this->end->isBefore($temporal->end)) | |
&& (null === $this->start | |
? null === $temporal->start | |
: null !== $temporal->start && $this->start->isEqualTo($temporal->start) | |
); | |
} | |
/** | |
* ALLEN-relation: Equivalent to $temporal->starts($this). | |
*/ | |
public function startedBy(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
return $temporal->starts($this); | |
} | |
/** | |
* ALLEN-relation ("contains"): Does this interval enclose the other one such that | |
* this start is before the start of the other one and this end is after | |
* the end of the other one? | |
*/ | |
public function encloses(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
return null !== $temporal->start && (null === $this->start || $this->start->isBefore($temporal->start)) | |
&& null !== $temporal->end && (null === $this->end || $this->end->isAfter($temporal->end)); | |
} | |
/** | |
* ALLEN-relation ("during"): Equivalent to $temporal->encloses($this). | |
*/ | |
public function enclosedBy(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
return $temporal->encloses($this); | |
} | |
/** | |
* ALLEN-relation: Does this interval overlaps the other one such that | |
* the start of this interval is before the start of the other one and | |
* the end of this interval is after the start of the other one but still | |
* before the end of the other one? | |
*/ | |
public function overlaps(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
return null !== $temporal->start && (null === $this->start || $this->start->isBefore($temporal->start)) | |
&& null !== $this->end && (null === $temporal->end || $this->end->isBefore($temporal->end)) | |
&& $this->end->isAfter($temporal->start); | |
} | |
/** | |
* ALLEN-relation: Equivalent to $temporal->overlaps($this). | |
*/ | |
public function overlappedBy(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
return $temporal->overlaps($this); | |
} | |
/** | |
* ALLEN-relation: Find out which Allen relation applies | |
* | |
* @return Relation::ALLEN_* | |
*/ | |
public function relationWith(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): int | |
{ | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
return match (null === $this->start ? (null === $temporal->start ? 0 : -1) : (null === $temporal->start ? 1 : $this->start->compareTo($temporal->start))) { | |
-1 => match (null === $this->end || null === $temporal->start ? 1 : $this->end->compareTo($temporal->start)) { | |
-1 => Relation::ALLEN_PRECEDES, | |
0 => Relation::ALLEN_MEETS, | |
1 => match (null === $this->end ? (null === $temporal->end ? 0 : 1) : (null === $temporal->end ? -1 : $this->end->compareTo($temporal->end))) { | |
-1 => Relation::ALLEN_OVERLAPS, | |
0 => Relation::ALLEN_FINISHED_BY, | |
1 => Relation::ALLEN_ENCLOSES, | |
}, | |
}, | |
0 => match (null === $this->end ? (null === $temporal->end ? 0 : 1) : (null === $temporal->end ? -1 : $this->end->compareTo($temporal->end))) { | |
-1 => Relation::ALLEN_STARTS, | |
0 => Relation::ALLEN_EQUALS, | |
1 => Relation::ALLEN_STARTED_BY, | |
}, | |
1 => match (null === $this->start || null === $temporal->end ? -1 : $this->start->compareTo($temporal->end)) { | |
-1 => match (null === $this->end ? (null === $temporal->end ? 0 : 1) : (null === $temporal->end ? -1 : $this->end->compareTo($temporal->end))) { | |
-1 => Relation::ALLEN_ENCLOSED_BY, | |
0 => Relation::ALLEN_FINISHES, | |
1 => Relation::ALLEN_OVERLAPPED_BY, | |
}, | |
0 => Relation::ALLEN_MET_BY, | |
1 => Relation::ALLEN_PRECEDED_BY, | |
}, | |
}; | |
} | |
/** | |
* Is the finite end of this interval before or equal to the given interval's start? | |
*/ | |
public function isBefore(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if (null === $this->end) { | |
return false; | |
} | |
return $this->precedes($temporal) || $this->meets($temporal); | |
} | |
/** | |
* Is the finite start of this interval after or equal to the given interval's end? | |
*/ | |
public function isAfter(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if (null === $this->start) { | |
return false; | |
} | |
return $this->precededBy($temporal) || $this->metBy($temporal); | |
} | |
/** | |
* Queries whether an interval contains another interval. | |
* One interval contains another if it stays within its bounds. | |
* An empty interval never contains anything. | |
*/ | |
public function contains(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if ($this->isEmpty()) { | |
return false; | |
} | |
return (bool) ($this->relationWith($temporal) & Relation::CONTAINS); | |
} | |
/** | |
* Queries whether an interval intersects another interval. | |
* An interval intersects if its neither before nor after the other. | |
* | |
* This method is commutative (A intersects B if and only if B intersects A). | |
*/ | |
public function intersects(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
return (bool) ($this->relationWith($temporal) & Relation::INTERSECTS); | |
} | |
/** | |
* Obtains the intersection of this interval and other one if present. | |
* | |
* Returns a wrapper around the found intersection (which can be empty) or null. | |
*/ | |
public function findIntersection(null|self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): ?self | |
{ | |
if (null === $temporal) { | |
return null; | |
} | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
if (!$this->intersects($temporal)) { | |
return null; | |
} | |
if (null === $this->start && null === $temporal->start) { | |
$start = null; | |
} elseif (null === $this->start) { | |
$start = $temporal->start; | |
} elseif (null === $temporal->start) { | |
$start = $this->start; | |
} else { | |
$start = LocalDateTime::maxOf($this->start, $temporal->start); | |
} | |
if (null === $this->end && null === $temporal->end) { | |
$end = null; | |
} elseif (null === $this->end) { | |
$end = $temporal->end; | |
} elseif (null === $temporal->end) { | |
$end = $this->end; | |
} else { | |
$end = LocalDateTime::minOf($this->end, $temporal->end); | |
} | |
return new self($start, $end); | |
} | |
/** | |
* @return list<self> | |
*/ | |
public function subtract(null|self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): array | |
{ | |
if (null === $temporal) { | |
return [$this]; | |
} | |
$temporal = self::cast($temporal); | |
return match ($this->relationWith($temporal)) { | |
Relation::ALLEN_EQUALS, Relation::ALLEN_STARTS, Relation::ALLEN_FINISHES, Relation::ALLEN_ENCLOSED_BY | |
=> [], | |
Relation::ALLEN_PRECEDES, Relation::ALLEN_PRECEDED_BY, Relation::ALLEN_MEETS, Relation::ALLEN_MET_BY | |
=> [$this], | |
Relation::ALLEN_OVERLAPPED_BY, Relation::ALLEN_STARTED_BY | |
=> [$this->withStart($temporal->getEnd())], | |
Relation::ALLEN_OVERLAPS, Relation::ALLEN_FINISHED_BY | |
=> [$this->withEnd($temporal->getStart())], | |
Relation::ALLEN_ENCLOSES | |
=> [$this->withEnd($temporal->getStart()), $this->withStart($temporal->getEnd())], | |
}; | |
} | |
/** | |
* Compares the boundaries (start and end) of this and the other interval. | |
*/ | |
public function equals(null|self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): bool | |
{ | |
if (null === $temporal) { | |
return false; | |
} | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
return (null === $this->start ? null === $temporal->start : (null !== $temporal->start && $this->start->isEqualTo($temporal->start))) | |
&& (null === $this->end ? null === $temporal->end : (null !== $temporal->end && $this->end->isEqualTo($temporal->end))); | |
} | |
/** | |
* @return -1|0|1 | |
*/ | |
public function compareTo(self|LocalDate|LocalDateTime|LocalDateInterval|YearWeek|YearMonth|Year $temporal): int | |
{ | |
if (!$temporal instanceof self) { | |
$temporal = self::cast($temporal); | |
} | |
return (null === $this->start ? (null === $temporal->start ? 0 : -1) : (null === $temporal->start ? 1 : $this->start->compareTo($temporal->start))) | |
?: (null === $this->end ? (null === $temporal->end ? 0 : 1) : (null === $temporal->end ? -1 : $this->end->compareTo($temporal->end))); | |
} | |
/** | |
* Determines if this interval has finite boundaries. | |
*/ | |
public function isFinite(): bool | |
{ | |
return null !== $this->start && null !== $this->end; | |
} | |
/** | |
* Determines if this interval has infinite start boundary. | |
*/ | |
public function hasInfiniteStart(): bool | |
{ | |
return null === $this->start; | |
} | |
/** | |
* Determines if this interval has infinite end boundary. | |
*/ | |
public function hasInfiniteEnd(): bool | |
{ | |
return null === $this->end; | |
} | |
private function getUTCInstant(LocalDateTime $timepoint): Instant | |
{ | |
static $utc; | |
$utc ??= new DateTimeZone('UTC'); | |
return Instant::of( | |
epochSecond: (new DateTimeImmutable((string) $timepoint->withNano(0), $utc))->getTimestamp(), | |
nanoAdjustment: $timepoint->getNano(), | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace Gammadia\DateTimeExtra; | |
interface Relation | |
{ | |
public const ALLEN_PRECEDES = 0b1000000000000; | |
public const ALLEN_PRECEDED_BY = 0b0100000000000; | |
public const ALLEN_MEETS = 0b0010000000000; | |
public const ALLEN_MET_BY = 0b0001000000000; | |
public const ALLEN_OVERLAPS = 0b0000100000000; | |
public const ALLEN_OVERLAPPED_BY = 0b0000010000000; | |
public const ALLEN_STARTS = 0b0000001000000; | |
public const ALLEN_STARTED_BY = 0b0000000100000; | |
public const ALLEN_ENCLOSES = 0b0000000010000; | |
public const ALLEN_ENCLOSED_BY = 0b0000000001000; | |
public const ALLEN_FINISHES = 0b0000000000100; | |
public const ALLEN_FINISHED_BY = 0b0000000000010; | |
public const ALLEN_EQUALS = 0b0000000000001; | |
public const INTERSECTS = ~(self::ALLEN_PRECEDES | self::ALLEN_PRECEDED_BY | self::ALLEN_MEETS | self::ALLEN_MET_BY); | |
public const BEFORE = self::ALLEN_PRECEDES | self::ALLEN_MEETS; | |
public const AFTER = self::ALLEN_PRECEDED_BY | self::ALLEN_MET_BY; | |
/** | |
* @internal This relation has no condition regarding empty intervals and does not have the exact same semantic as | |
* {@see LocalDateTimeInterval::contains()}. If you must use it, check for empty intervals separately. | |
*/ | |
public const CONTAINS = self::ALLEN_ENCLOSES | self::ALLEN_EQUALS | self::ALLEN_STARTED_BY | self::ALLEN_FINISHED_BY; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace Gammadia\DateTimeExtra\Exceptions; | |
use Brick\DateTime\Parser\DateTimeParseException; | |
use Throwable; | |
final class IntervalParseException extends DateTimeParseException | |
{ | |
public static function notAnInterval(string $textToParse): self | |
{ | |
return new self('Text cannot be parsed to an interval: ' . $textToParse); | |
} | |
public static function uniqueDuration(string $textToParse): self | |
{ | |
return new self('Text cannot be parsed to a Duration/Duration format: ' . $textToParse); | |
} | |
public static function durationIncompatibleWithInfinity(string $textToParse): self | |
{ | |
return new self('Text cannot be parsed to a Period/- or -/Duration format: ' . $textToParse); | |
} | |
public static function localTimeInterval(string $textToParse, ?Throwable $throwable = null): self | |
{ | |
return new self('Text cannot be parsed to a LocalTime/Duration format: ' . $textToParse, 0, $throwable); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace Gammadia\DateTimeExtra; | |
enum InfinityStyle: string | |
{ | |
case SYMBOL = '-'; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace Gammadia\Collections\Functional; | |
use function is_object; | |
use function sort as phpSort; | |
function equals(mixed $a, mixed $b): bool | |
{ | |
if (is_object($a) && is_object($b) && $a::class === $b::class) { | |
return match (true) { | |
// Gammadia | |
method_exists($a, 'equals') => $a->equals($b), | |
// Brick DateTime | |
method_exists($a, 'isEqualTo') => $a->isEqualTo($b), | |
// Carbon | |
method_exists($a, 'equalTo') => $a->equalTo($b), | |
// Symfony string | |
method_exists($a, 'equalsTo') => $a->equalsTo($b), | |
default => $a === $b, | |
}; | |
} | |
return $a === $b; | |
} | |
function contains(array $array, mixed $value): bool | |
{ | |
foreach ($array as $item) { | |
if (equals($item, $value)) { | |
return true; | |
} | |
} | |
return false; | |
} | |
function filter(array $array, ?callable $predicate = null, int $mode = ARRAY_FILTER_USE_BOTH): array | |
{ | |
// We cannot call array_filter with "null" as the callback, otherwise it results in this error : | |
// TypeError: array_filter() expects parameter 2 to be a valid callback, no array or string given | |
return null !== $predicate | |
? array_filter($array, $predicate, $mode) | |
: array_filter($array); | |
} | |
function map(array $array, callable $fn): array | |
{ | |
$keys = array_keys($array); | |
return array_combine($keys, array_map($fn, $array, $keys)); | |
} | |
function sort(array $array, ?callable $comparator = null, bool $preserveKeys = false, int $flags = SORT_REGULAR): array | |
{ | |
if (null === $comparator) { | |
if ($preserveKeys) { | |
asort($array, $flags); | |
} else { | |
phpSort($array, $flags); | |
} | |
} else { | |
if ($preserveKeys) { | |
uasort($array, $comparator); | |
} else { | |
usort($array, $comparator); | |
} | |
} | |
return $array; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace Gammadia\DateTimeExtra\Test\Unit; | |
use Brick\DateTime\Duration; | |
use Brick\DateTime\LocalDate; | |
use Brick\DateTime\LocalDateTime; | |
use Brick\DateTime\Parser\DateTimeParseException; | |
use Brick\DateTime\Period; | |
use Brick\DateTime\Year; | |
use Brick\DateTime\YearMonth; | |
use Brick\DateTime\YearWeek; | |
use Gammadia\DateTimeExtra\Helper\IntervalHelper; | |
use Gammadia\DateTimeExtra\LocalDateInterval; | |
use Gammadia\DateTimeExtra\LocalDateTimeInterval; | |
use Gammadia\DateTimeExtra\Relation; | |
use InvalidArgumentException; | |
use PHPUnit\Framework\Attributes\DataProvider; | |
use PHPUnit\Framework\TestCase; | |
use RuntimeException; | |
use function count; | |
use function Gammadia\Collections\Functional\map; | |
use function Gammadia\Collections\Functional\scount; | |
use function Gammadia\Collections\Functional\slist; | |
use function Gammadia\Collections\Functional\smap; | |
use function is_string; | |
final class LocalDateTimeIntervalTest extends TestCase | |
{ | |
private const RELATION_NAME = [ | |
Relation::ALLEN_PRECEDES => 'PRECEDES', | |
Relation::ALLEN_PRECEDED_BY => 'PRECEDED_BY', | |
Relation::ALLEN_MEETS => 'MEETS', | |
Relation::ALLEN_MET_BY => 'MET_BY', | |
Relation::ALLEN_OVERLAPS => 'OVERLAPS', | |
Relation::ALLEN_OVERLAPPED_BY => 'OVERLAPPED_BY', | |
Relation::ALLEN_STARTS => 'STARTS', | |
Relation::ALLEN_STARTED_BY => 'STARTED_BY', | |
Relation::ALLEN_ENCLOSES => 'ENCLOSES', | |
Relation::ALLEN_ENCLOSED_BY => 'ENCLOSED_BY', | |
Relation::ALLEN_FINISHES => 'FINISHES', | |
Relation::ALLEN_FINISHED_BY => 'FINISHED_BY', | |
Relation::ALLEN_EQUALS => 'EQUALS', | |
]; | |
private const RELATION_INVERSE = [ | |
Relation::ALLEN_PRECEDES => Relation::ALLEN_PRECEDED_BY, | |
Relation::ALLEN_PRECEDED_BY => Relation::ALLEN_PRECEDES, | |
Relation::ALLEN_MEETS => Relation::ALLEN_MET_BY, | |
Relation::ALLEN_MET_BY => Relation::ALLEN_MEETS, | |
Relation::ALLEN_OVERLAPS => Relation::ALLEN_OVERLAPPED_BY, | |
Relation::ALLEN_OVERLAPPED_BY => Relation::ALLEN_OVERLAPS, | |
Relation::ALLEN_STARTS => Relation::ALLEN_STARTED_BY, | |
Relation::ALLEN_STARTED_BY => Relation::ALLEN_STARTS, | |
Relation::ALLEN_ENCLOSES => Relation::ALLEN_ENCLOSED_BY, | |
Relation::ALLEN_ENCLOSED_BY => Relation::ALLEN_ENCLOSES, | |
Relation::ALLEN_FINISHES => Relation::ALLEN_FINISHED_BY, | |
Relation::ALLEN_FINISHED_BY => Relation::ALLEN_FINISHES, | |
Relation::ALLEN_EQUALS => Relation::ALLEN_EQUALS, | |
]; | |
public function testConstructThrowsInvalidArgumentExceptionInversedRanges(): void | |
{ | |
$this->expectException(InvalidArgumentException::class); | |
$this->expectExceptionMessage('Start after end: 2021-01-01T12:00 / 2021-01-01T11:30:24'); | |
LocalDateTimeInterval::between(LocalDateTime::parse('2021-01-01T12:00'), LocalDateTime::parse('2021-01-01T11:30:24')); | |
} | |
#[DataProvider('getFiniteStart')] | |
public function testGetFiniteStart(string $iso, ?string $expected = null): void | |
{ | |
$timeRange = LocalDateTimeInterval::parse($iso); | |
if (null === $expected) { | |
self::assertNull($timeRange->getStart()); | |
$this->expectException(RuntimeException::class); | |
} | |
self::assertSame($expected, (string) $timeRange->getFiniteStart()); | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function getFiniteStart(): iterable | |
{ | |
yield 'Infinite start => throws exception' => ['-/2020-01-01T00:00']; | |
yield 'Infinite => throws exception' => ['-/-']; | |
yield 'Finite start => works' => ['2020-01-01T00:00/-', '2020-01-01T00:00']; | |
yield 'Finite range => works' => ['2020-01-01T00:00/2020-01-02T00:00', '2020-01-01T00:00']; | |
} | |
#[DataProvider('getFiniteEnd')] | |
public function testGetFiniteEnd(string $iso, ?string $expected = null): void | |
{ | |
$timeRange = LocalDateTimeInterval::parse($iso); | |
if (null === $expected) { | |
self::assertNull($timeRange->getEnd()); | |
$this->expectException(RuntimeException::class); | |
} | |
self::assertSame($expected, (string) $timeRange->getFiniteEnd()); | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function getFiniteEnd(): iterable | |
{ | |
yield 'Infinite end => throws exception' => ['2020-01-01T00:00/-']; | |
yield 'Infinite => throws exception' => ['-/-']; | |
yield 'Finite end => works' => ['-/2020-01-02T00:00', '2020-01-02T00:00']; | |
yield 'Finite range => works' => ['2020-01-01T00:00/2020-01-02T00:00', '2020-01-02T00:00']; | |
} | |
#[DataProvider('toStringProvider')] | |
public function testToString(LocalDateTimeInterval $timeRange, string $expected): void | |
{ | |
self::assertSame($expected, (string) $timeRange); | |
self::assertSame($expected, $timeRange->toISOString()); | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function toStringProvider(): iterable | |
{ | |
$iso1 = '2007-01-01T10:15:30'; | |
$iso2 = '2007-02-14T10:15:30'; | |
$timepoint1 = LocalDateTime::parse($iso1); | |
$timepoint2 = LocalDateTime::parse($iso2); | |
yield 'Forever' => [LocalDateTimeInterval::forever(), '-/-']; | |
yield 'Since' => [LocalDateTimeInterval::since($timepoint1), $iso1 . '/-']; | |
yield 'Until' => [LocalDateTimeInterval::until($timepoint1), '-/' . $iso1]; | |
yield 'Between' => [LocalDateTimeInterval::between($timepoint1, $timepoint2), $iso1 . '/' . $iso2]; | |
} | |
#[DataProvider('day')] | |
public function testDay(LocalDate|LocalDateTime $input, string $expected): void | |
{ | |
self::assertSame($expected, (string) LocalDateTimeInterval::day($input)); | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function day(): iterable | |
{ | |
$day = '2020-01-01T00:00/2020-01-02T00:00'; | |
yield 'From a date' => [LocalDate::parse('2020-01-01'), $day]; | |
yield 'At midnight' => [LocalDateTime::parse('2020-01-01T00:00:00'), $day]; | |
yield 'At midday' => [LocalDateTime::parse('2020-01-01T12:00:00'), $day]; | |
yield 'Just before midnight the next day' => [LocalDateTime::parse('2020-01-01T23:59:59.999999'), $day]; | |
yield 'At midnight the next day' => [LocalDateTime::parse('2020-01-02T00:00'), '2020-01-02T00:00/2020-01-03T00:00']; | |
} | |
public function testParseLocalDateTimeAndPeriod(): void | |
{ | |
$iso = '2012-04-01T14:15/2012-04-05T16:00'; | |
self::assertNull(LocalDateTimeInterval::parse(null)); | |
self::assertSame($iso, (string) LocalDateTimeInterval::parse('2012-04-01T14:15/P4DT1H45M')); | |
self::assertSame($iso, (string) LocalDateTimeInterval::parse('P4DT1H45M/2012-04-05T16:00')); | |
} | |
#[DataProvider('providerParseInvalidIntervalsThrowsIntervalParseException')] | |
public function testParseInvalidStringThrowsIntervalParseException(string $text): void | |
{ | |
$this->expectException(DateTimeParseException::class); | |
LocalDateTimeInterval::parse($text); | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function providerParseInvalidIntervalsThrowsIntervalParseException(): iterable | |
{ | |
yield ['P4DT1H45M/P2DT1H45M']; | |
yield ['-/P2DT1H45M']; | |
yield ['P4DT1H45M/-']; | |
} | |
#[DataProvider('providerParseInvalidIntervalsThrowsException')] | |
public function testParseInvalidStringThrowsException(string $text): void | |
{ | |
$this->expectException(DateTimeParseException::class); | |
LocalDateTimeInterval::parse($text); | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function providerParseInvalidIntervalsThrowsException(): iterable | |
{ | |
yield ['2012-04-30']; | |
yield ['2012-04-30T14:15']; | |
yield ['2012-04-30T14:15/T16:00']; | |
yield ['2012-04-30T14:15/T24:00']; | |
yield ['2012-04-30T14:15/16:00']; | |
yield ['2012-092T14:15/096T16:00']; | |
yield ['2012-W13-7T14:15/W14-4T16:00']; | |
yield ['2012092T1415/096T1600']; | |
yield ['2012W137T1415/W144T1600']; | |
yield ['2012-092T14:15/2012-096T16:00']; | |
yield ['2012-W13-7T14:15/2012-W14-4T16:00']; | |
yield ['2012092T1415/2012096T1600']; | |
yield ['2012W137T1415/2012W144T1600']; | |
yield ['2012-04-01T14:15/P2M4DT1H45M']; | |
yield ['2012-092T14:15/P4DT1H45M']; | |
yield ['2012-W13-7T14:15/P0000-00-04T01:45']; | |
yield ['P4DT1H45M/2012-096T16:00']; | |
yield ['P0000-00-04T01:45/2012-W14-4T16:00']; | |
yield ['2015-01-01T08:45/+∞']; | |
yield ['-∞/2015-01-01T08:45']; | |
yield ['2015-01-01T08:45/+999999999-12-31T23:59:59,999999999']; | |
yield ['-∞/+∞']; | |
} | |
public function testParseInfinity(): void | |
{ | |
$timepoint = '2015-01-01T08:45'; | |
self::assertSame('2015-01-01T08:45/-', (string) LocalDateTimeInterval::parse($timepoint . '/-')); | |
self::assertSame('-/2015-01-01T08:45', (string) LocalDateTimeInterval::parse('-/' . $timepoint)); | |
self::assertSame('-/-', (string) LocalDateTimeInterval::forever()); | |
} | |
public function testGetDurationOfLocalDateTimeIntervalWithZonalCorrection(): void | |
{ | |
self::assertSame( | |
(string) Duration::parse('P29DT9H15M'), | |
(string) LocalDateTimeInterval::parse('2014-01-01T21:45/2014-01-31T07:00')->getDuration(), | |
); | |
} | |
#[DataProvider('move')] | |
public function testMove(string $iso, Duration|Period $input, string $expected): void | |
{ | |
self::assertSame($expected, (string) LocalDateTimeInterval::parse($iso)->move($input)); | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function move(): iterable | |
{ | |
yield ['2012-04-10T00:00:00/2012-12-31T23:59:59', Duration::parse('+PT1S'), '2012-04-10T00:00:01/2013-01-01T00:00']; | |
yield ['2012-04-10T00:00:01/2013-01-01T00:00', Duration::parse('-PT1S'), '2012-04-10T00:00/2012-12-31T23:59:59']; | |
yield ['2012-01-01T01:50/2012-01-01T23:50', Duration::parse('PT20M'), '2012-01-01T02:10/2012-01-02T00:10']; | |
yield ['2012-01-01T10:00/2012-01-01T20:00', Duration::parse('PT20H'), '2012-01-02T06:00/2012-01-02T16:00']; | |
yield ['2012-01-01T00:00/2012-01-02T00:00', Period::parse('P1D'), '2012-01-02T00:00/2012-01-03T00:00']; | |
yield ['2012-01-01T00:00/2012-01-02T00:00', Period::parse('P1W1D'), '2012-01-09T00:00/2012-01-10T00:00']; | |
yield ['2012-01-01T00:00/2012-01-02T00:00', Period::parse('P1M1W1D'), '2012-02-09T00:00/2012-02-10T00:00']; | |
yield ['2012-02-09T00:00/2012-02-10T00:00', Period::parse('-P1M1W1D'), '2012-01-01T00:00/2012-01-02T00:00']; | |
// Leap year | |
yield ['2016-02-29T00:00/2016-06-01T00:00', Period::parse('P1Y'), '2017-02-28T00:00/2017-06-01T00:00']; | |
yield ['2017-02-28T00:00/2017-06-01T00:00', Period::parse('-P1Y'), '2016-02-28T00:00/2016-06-01T00:00']; | |
yield ['2016-02-29T00:00/2016-06-01T00:00', Period::parse('-P1Y'), '2015-02-28T00:00/2015-06-01T00:00']; | |
} | |
#[DataProvider('iterateProvider')] | |
public function testIterate(int $iterations, Period|Duration $input): void | |
{ | |
self::assertSame($iterations, scount(LocalDateTimeInterval::parse('2016-01-01T00:00/2016-01-31T00:00')->iterate($input))); | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function iterateProvider(): iterable | |
{ | |
yield [30, Period::parse('P1D')]; | |
yield [30, Duration::parse('P1D')]; | |
yield [720, Duration::parse('PT1H')]; | |
yield [29, Duration::parse('PT25H')]; | |
yield [20, Duration::parse('P1DT12H')]; | |
yield [30, Duration::parse('P1D')]; | |
yield [30, Duration::parse('PT24H')]; | |
yield [30, Period::parse('P1D')]; | |
yield [5, Period::parse('P7D')]; | |
yield [5, Period::parse('P1W')]; | |
yield [1, Period::parse('P30D')]; | |
yield [1, Period::parse('P1M')]; | |
yield [1, Period::parse('P1M1D')]; | |
yield [1, Period::parse('P2M')]; | |
} | |
/** | |
* @param string[] $expected | |
*/ | |
#[DataProvider('slice')] | |
public function testSlice(Duration|Period $input, string $interval, array $expected): void | |
{ | |
self::assertSame($expected, slist(smap( | |
LocalDateTimeInterval::parse($interval)->slice($input), | |
static fn (LocalDateTimeInterval $slice): string => (string) $slice, | |
))); | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function slice(): iterable | |
{ | |
yield [ | |
Period::parse('P1D'), | |
'2019-02-01T00:00:00/2019-02-03T00:00:00', | |
[ | |
'2019-02-01T00:00/2019-02-02T00:00', | |
'2019-02-02T00:00/2019-02-03T00:00', | |
], | |
]; | |
yield [ | |
Period::parse('P1D'), | |
'2019-03-31T00:00:00/P3D', | |
[ | |
'2019-03-31T00:00/2019-04-01T00:00', | |
'2019-04-01T00:00/2019-04-02T00:00', | |
'2019-04-02T00:00/2019-04-03T00:00', | |
], | |
]; | |
yield [ | |
Duration::parse('PT23H'), | |
'2019-03-31T00:00:00/PT24H', | |
[ | |
'2019-03-31T00:00/2019-03-31T23:00', | |
'2019-03-31T23:00/2019-04-01T00:00', | |
], | |
]; | |
yield [ | |
Duration::parse('PT3H'), | |
'2019-03-31T01:00:00/2019-04-01T00:00:00', | |
[ | |
'2019-03-31T01:00/2019-03-31T04:00', | |
'2019-03-31T04:00/2019-03-31T07:00', | |
'2019-03-31T07:00/2019-03-31T10:00', | |
'2019-03-31T10:00/2019-03-31T13:00', | |
'2019-03-31T13:00/2019-03-31T16:00', | |
'2019-03-31T16:00/2019-03-31T19:00', | |
'2019-03-31T19:00/2019-03-31T22:00', | |
'2019-03-31T22:00/2019-04-01T00:00', | |
], | |
]; | |
} | |
public function testWithStart(): void | |
{ | |
$start = '2014-01-01T00:00'; | |
$end = '2014-01-20T00:00'; | |
self::assertSame( | |
$start . '/' . $end, | |
(string) LocalDateTimeInterval::parse('2014-01-10T00:00/' . $end)->withStart(LocalDateTime::parse($start)), | |
); | |
} | |
public function testWithEnd(): void | |
{ | |
$start = '2014-01-01T00:00'; | |
$end = '2014-01-20T00:00'; | |
self::assertSame( | |
$start . '/' . $end, | |
(string) LocalDateTimeInterval::parse($start . '/2014-01-10T00:00')->withEnd(LocalDateTime::parse($end)), | |
); | |
} | |
public function testSince(): void | |
{ | |
self::assertSame('2016-02-28T13:20/-', (string) LocalDateTimeInterval::since(LocalDateTime::parse('2016-02-28T13:20'))); | |
} | |
public function testUntil(): void | |
{ | |
self::assertSame('-/2016-02-28T13:20', (string) LocalDateTimeInterval::until(LocalDateTime::parse('2016-02-28T13:20'))); | |
} | |
#[DataProvider('empty')] | |
public function testIsEmpty(string $iso, bool $expected): void | |
{ | |
self::assertSame($expected, LocalDateTimeInterval::parse($iso)->isEmpty()); | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function empty(): iterable | |
{ | |
yield 'Midnight' => ['2020-01-01T00:00/2020-01-01T00:00', true]; | |
yield 'Specific time' => ['2020-01-01T12:00/2020-01-01T12:00', true]; | |
yield 'Specific time with maximum precision' => ['2020-01-01T12:34:56.789/2020-01-01T12:34:56.789', true]; | |
yield 'Infinite start' => ['-/2020-01-01T00:00', false]; | |
yield 'Infinite end' => ['2020-01-01T00:00/-', false]; | |
yield 'Forever' => ['-/-', false]; | |
} | |
/** | |
* @param array<int, string|null> $input | |
*/ | |
#[DataProvider('containerOf')] | |
public function testContainerOf(array $input, ?string $expected): void | |
{ | |
$input = map($input, static fn (?string $iso): LocalDate|LocalDateTime|LocalDateInterval|LocalDateTimeInterval|YearWeek|YearMonth|Year|null | |
=> IntervalHelper::parse($iso), | |
); | |
self::assertSame((string) $expected, (string) LocalDateTimeInterval::containerOf(...$input)); | |
// Using cast() should be the same as using (unsafe)ContainerOf with one element | |
if (1 === count($input)) { | |
self::assertSame((string) $expected, (string) LocalDateTimeInterval::cast(...$input)); | |
} | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function containerOf(): iterable | |
{ | |
$singleRange = static fn (string $iso): array => [[$iso], $iso]; | |
yield 'With empty array' => [[], null]; | |
yield 'With null values' => [[null], null]; | |
$iso = '2020-01-01T00:00/2020-01-03T00:00'; | |
yield 'Nulls mixed with ranges are skipped' => [[null, $iso, null], $iso]; | |
yield 'With one empty range' => $singleRange('2020-01-01T00:00/2020-01-01T00:00'); | |
yield 'With one non-empty range' => $singleRange('2020-01-01T08:00/2020-01-01T12:00'); | |
yield 'With one multi-days range' => $singleRange('2020-01-01T12:00/2020-01-04T12:00'); | |
yield 'With one infinite start range' => $singleRange('-/2020-01-04T12:00'); | |
yield 'With one infinite end range' => $singleRange('2020-01-01T12:00/-'); | |
yield 'With one infinite range' => $singleRange('-/-'); | |
yield 'Consecutive time ranges' => [ | |
[ | |
'2020-01-01T00:00/2020-01-02T00:00', | |
'2020-01-02T00:00/2020-01-03T00:00', | |
], | |
'2020-01-01T00:00/2020-01-03T00:00', | |
]; | |
yield 'With blanks' => [ | |
[ | |
'2020-01-01T12:00/2020-01-01T18:00', | |
'2020-01-03T08:00/2020-01-04T00:00', | |
], | |
'2020-01-01T12:00/2020-01-04T00:00', | |
]; | |
yield 'With overlaps' => [ | |
[ | |
'2020-01-01T12:00/2020-01-01T18:00', | |
'2020-01-01T14:00/2020-01-04T00:00', | |
], | |
'2020-01-01T12:00/2020-01-04T00:00', | |
]; | |
yield 'With all of the above combined' => [ | |
[ | |
'2020-01-01T00:00/2020-01-02T00:00', | |
'2020-01-02T00:00/2020-01-03T00:00', | |
'2020-01-02T12:00/2020-01-03T12:00', | |
'2020-01-04T00:00/2020-01-04T00:00', | |
], | |
'2020-01-01T00:00/2020-01-04T00:00', | |
]; | |
yield 'With elements in a non-chronological order' => [ | |
[ | |
'2020-01-03T08:00/2020-01-04T00:00', | |
'2020-01-01T12:00/2020-01-01T18:00', | |
], | |
'2020-01-01T12:00/2020-01-04T00:00', | |
]; | |
yield 'With various types as input (1)' => [ | |
[ | |
'2020-01-01T12:00/2020-01-01T18:00', | |
'2020-01-03T08:00', | |
'2020-01-04', | |
'2020-01-06/2020-01-08', | |
], | |
'2020-01-01T12:00/2020-01-09T00:00', | |
]; | |
yield 'With various types as input (2)' => [ | |
['2022-02', '2022-W02'], | |
'2022-01-10T00:00/2022-03-01T00:00', | |
]; | |
yield 'With various types as input (3)' => [ | |
['2022-02', '2023'], | |
'2022-02-01T00:00/2024-01-01T00:00', | |
]; | |
} | |
/** | |
* @param array<string|null> $temporals | |
* @param array<string> $expected | |
*/ | |
#[DataProvider('disjointContainersOf')] | |
public function testDisjointContainersOf(array $temporals, array $expected): void | |
{ | |
self::assertSame($expected, map( | |
LocalDateTimeInterval::disjointContainersOf(...map( | |
$temporals, | |
static fn (?string $iso): null|LocalDate|LocalDateTime|LocalDateInterval|LocalDateTimeInterval|YearWeek|YearMonth|Year | |
=> IntervalHelper::parse($iso), | |
)), | |
static fn (LocalDateTimeInterval $timeRange): string => (string) $timeRange, | |
)); | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function disjointContainersOf(): iterable | |
{ | |
yield 'Empty array' => [[], []]; | |
yield 'Null in array' => [[null], []]; | |
yield 'One time range' => [ | |
['2022-06-30T12:00/2022-06-30T18:00'], | |
['2022-06-30T12:00/2022-06-30T18:00'], | |
]; | |
yield 'Two identical time ranges: merge' => [ | |
['2022-06-30T12:00/2022-06-30T18:00', '2022-06-30T12:00/2022-06-30T18:00'], | |
['2022-06-30T12:00/2022-06-30T18:00'], | |
]; | |
yield 'Two consecutive time ranges: merge' => [ | |
['2022-06-30T12:00/2022-06-30T18:00', '2022-06-30T18:00/2022-06-30T22:00'], | |
['2022-06-30T12:00/2022-06-30T22:00'], | |
]; | |
yield 'Two consecutive time ranges (wrong order): merge' => [ | |
['2022-06-30T18:00/2022-06-30T22:00', '2022-06-30T12:00/2022-06-30T18:00'], | |
['2022-06-30T12:00/2022-06-30T22:00'], | |
]; | |
yield 'Two non-consecutive time ranges: split' => [ | |
['2022-06-30T12:00/2022-06-30T18:00', '2022-06-30T20:00/2022-06-30T22:00'], | |
['2022-06-30T12:00/2022-06-30T18:00', '2022-06-30T20:00/2022-06-30T22:00'], | |
]; | |
yield 'Complex scenario' => [ | |
['2022-06-24T00:00/2022-06-27T00:00', '2022-06-23T00:00/2022-07-01T12:00', '2022-06-27T00:00/2022-06-27T00:00', '2022-06-29T00:00/2022-07-04T00:00'], | |
['2022-06-23T00:00/2022-07-04T00:00'], | |
]; | |
yield 'Infinite start' => [['2022-06-24T00:00', '-/2022-07-01T00:00'], ['-/2022-07-01T00:00']]; | |
yield 'Infinite end' => [['2022-06-24T00:00/2022-06-29T00:00', '2022-06-30T00:00/2022-07-01T00:00', '2022-06-26T00:00/-'], ['2022-06-24T00:00/-']]; | |
yield 'Infinite from overlapping until+since' => [['-/2022-07-01T00:00', '2022-06-10T00:00/-'], ['-/-']]; | |
yield 'Infinite from abuting until+since' => [['-/2022-07-01T00:00', '2022-07-01T00:00/-'], ['-/-']]; | |
yield 'Infinite on both sides with blank in-between' => [ | |
['-/2022-06-24T00:00', '2022-06-29T00:00/-'], | |
['-/2022-06-24T00:00', '2022-06-29T00:00/-'], | |
]; | |
yield 'Infinite' => [['-/-'], ['-/-']]; | |
yield 'With various types as input' => [ | |
[ | |
'2020-01-01/2020-01-02', | |
'2020-01-03T08:00', | |
'2020-01-04', | |
'2020-01-06T12:00/2020-01-09T00:00', | |
'2022-02', | |
'2022-W02', | |
'2023', | |
], | |
[ | |
'2020-01-01T00:00/2020-01-03T00:00', | |
'2020-01-03T08:00/2020-01-03T08:00', | |
'2020-01-04T00:00/2020-01-05T00:00', | |
'2020-01-06T12:00/2020-01-09T00:00', | |
'2022-01-10T00:00/2022-01-17T00:00', | |
'2022-02-01T00:00/2022-03-01T00:00', | |
'2023-01-01T00:00/2024-01-01T00:00', | |
], | |
]; | |
} | |
#[DataProvider('toFullDays')] | |
public function testToFullDays(string $input, string $expected): void | |
{ | |
self::assertSame($expected, (string) LocalDateTimeInterval::parse($input)->toFullDays()); | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function toFullDays(): iterable | |
{ | |
// Empty set | |
yield 'Empty at midnight' => ['2020-01-01T00:00/2020-01-01T00:00', '2020-01-01T00:00/2020-01-02T00:00']; | |
yield 'Empty at noon' => ['2020-01-01T12:00/2020-01-01T12:00', '2020-01-01T00:00/2020-01-02T00:00']; | |
// Same day | |
yield 'Midnight to midnight interval' => ['2020-01-01T00:00/2020-01-02T00:00', '2020-01-01T00:00/2020-01-02T00:00']; | |
yield 'Hours to midnight interval' => ['2020-01-01T01:00/2020-01-02T00:00', '2020-01-01T00:00/2020-01-02T00:00']; | |
yield 'Midnight to hours interval' => ['2020-01-01T00:00/2020-01-01T12:00', '2020-01-01T00:00/2020-01-02T00:00']; | |
yield 'Hours to hours interval' => ['2020-01-01T08:00/2020-01-01T12:00', '2020-01-01T00:00/2020-01-02T00:00']; | |
// Over multiple days | |
yield 'Midnight to midnight next day interval' => ['2020-01-01T00:00/2020-01-03T00:00', '2020-01-01T00:00/2020-01-03T00:00']; | |
yield 'Hours to midnight next day interval' => ['2020-01-01T01:00/2020-01-03T00:00', '2020-01-01T00:00/2020-01-03T00:00']; | |
yield 'Midnight to hours next day interval' => ['2020-01-01T00:00/2020-01-02T12:00', '2020-01-01T00:00/2020-01-03T00:00']; | |
yield 'Hours to hours next day interval' => ['2020-01-01T08:00/2020-01-02T12:00', '2020-01-01T00:00/2020-01-03T00:00']; | |
// Infinites | |
yield 'Infinite start ending at midnight' => ['-/2020-01-01T00:00', '-/2020-01-01T00:00']; | |
yield 'Infinite start ending at hours' => ['-/2020-01-01T12:00', '-/2020-01-02T00:00']; | |
yield 'Infinite end starting at midnight' => ['2020-01-01T00:00/-', '2020-01-01T00:00/-']; | |
yield 'Infinite end starting at hours' => ['2020-01-01T12:00/-', '2020-01-01T00:00/-']; | |
yield 'Forever' => ['-/-', '-/-']; | |
} | |
#[DataProvider('isFullDays')] | |
public function testIsFullDays(string $input, bool $expected): void | |
{ | |
self::assertSame($expected, LocalDateTimeInterval::parse($input)->isFullDays()); | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function isFullDays(): iterable | |
{ | |
yield ['2020-01-01T00:00/2020-01-02T00:00', true]; | |
yield ['2020-01-01T00:00/2020-01-04T00:00', true]; | |
yield ['2020-01-01T00:00/2020-01-04T01:00', false]; | |
yield ['2020-01-01T01:00/2020-01-04T00:00', false]; | |
yield ['2020-01-01T01:00/2020-01-04T01:00', false]; | |
} | |
/** | |
* @param array<string>|string $expected | |
*/ | |
#[DataProvider('subtract')] | |
public function testSubtract(string $a, string $b, array|string $expected): void | |
{ | |
$timeRangeA = LocalDateTimeInterval::cast(IntervalHelper::parse($a)); | |
$timeRangeB = LocalDateTimeInterval::cast(IntervalHelper::parse($b)); | |
self::assertSame( | |
(array) $expected, | |
map($timeRangeA->subtract($timeRangeB), static fn (LocalDateTimeInterval $timeRange) => (string) $timeRange), | |
); | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function subtract(): iterable | |
{ | |
$ref = '2022-04-07T13:00/2022-04-07T18:00'; | |
$infiniteStartRef = '-/2022-04-07T18:00'; | |
$infiniteEndRef = '2022-04-07T13:00/-'; | |
$empty = static fn (string $timepoint): string => $timepoint . '/' . $timepoint; | |
// ALLEN relationships | |
yield 'precedes' => [$ref, '2022-04-07T08:00/2022-04-07T12:00', $ref]; | |
yield 'precededBy' => [$ref, '2022-04-07T20:00/2022-04-07T22:00', $ref]; | |
yield 'meets' => [$ref, '2022-04-07T08:00/2022-04-07T13:00', $ref]; | |
yield 'meetBy' => [$ref, '2022-04-07T18:00/2022-04-07T20:00', $ref]; | |
yield 'overlaps' => [$ref, '2022-04-07T08:00/2022-04-07T14:00', '2022-04-07T14:00/2022-04-07T18:00']; | |
yield 'overlappedBy' => [$ref, '2022-04-07T14:00/2022-04-07T20:00', '2022-04-07T13:00/2022-04-07T14:00']; | |
yield 'starts' => [$ref, '2022-04-07T13:00/2022-04-07T14:00', '2022-04-07T14:00/2022-04-07T18:00']; | |
yield 'startedBy' => [$ref, '2022-04-07T13:00/2022-04-07T20:00', []]; | |
yield 'encloses' => [$ref, '2022-04-07T14:00/2022-04-07T15:00', [ | |
'2022-04-07T13:00/2022-04-07T14:00', | |
'2022-04-07T15:00/2022-04-07T18:00', | |
]]; | |
yield 'enclosedBy' => [$ref, '2022-04-07T12:00/2022-04-07T20:00', []]; | |
yield 'finishes' => [$ref, '2022-04-07T14:00/2022-04-07T18:00', '2022-04-07T13:00/2022-04-07T14:00']; | |
yield 'finishedBy' => [$ref, '2022-04-07T12:00/2022-04-07T18:00', []]; | |
yield 'equalTo' => [$ref, $ref, []]; | |
// Empty ranges | |
yield 'equalTo (with empty ranges)' => [$empty('2022-04-07T20:00'), $empty('2022-04-07T20:00'), []]; | |
yield 'Subtract empty range from non-empty range: before' => [$ref, $empty('2022-04-07T08:00'), $ref]; | |
yield 'Subtract non-empty range from empty range: before' => [$empty('2022-04-07T20:00'), $ref, $empty('2022-04-07T20:00')]; | |
yield 'Subtract empty range from non-empty range: at start' => [$ref, $empty('2022-04-07T13:00'), $ref]; | |
yield 'Subtract non-empty range from empty range: at start' => [$empty('2022-04-07T13:00'), $ref, []]; | |
yield 'Subtract empty range from non-empty range: inside' => [$ref, $empty('2022-04-07T14:00'), [ | |
'2022-04-07T13:00/2022-04-07T14:00', | |
'2022-04-07T14:00/2022-04-07T18:00', | |
]]; | |
yield 'Subtract empty range from non-empty range: at end' => [$ref, $empty('2022-04-07T18:00'), $ref]; | |
yield 'Subtract non-empty range from empty range: at end' => [$empty('2022-04-07T18:00'), $ref, $empty('2022-04-07T18:00')]; | |
yield 'Subtract empty range from non-empty range: after' => [$ref, $empty('2022-04-07T20:00'), $ref]; | |
yield 'Subtract non-empty range from empty range: after' => [$empty('2022-04-07T08:00'), $ref, $empty('2022-04-07T08:00')]; | |
// Infinite start | |
yield 'Subtract infinite start range from range: ending before' => [$ref, '-/2022-04-07T08:00', $ref]; | |
yield 'Subtract range from infinite start range: ending before' => ['-/2022-04-07T08:00', $ref, '-/2022-04-07T08:00']; | |
yield 'Subtract infinite start range from range: ending at start' => [$ref, '-/2022-04-07T13:00', $ref]; | |
yield 'Subtract range from infinite start range: ending at start' => ['-/2022-04-07T13:00', $ref, '-/2022-04-07T13:00']; | |
yield 'Subtract infinite start range from range: ending inside' => [$ref, '-/2022-04-07T15:00', '2022-04-07T15:00/2022-04-07T18:00']; | |
yield 'Subtract range from infinite start range: ending inside' => ['-/2022-04-07T15:00', $ref, '-/2022-04-07T13:00']; | |
yield 'Subtract infinite start range from range: ending at end' => [$ref, '-/2022-04-07T18:00', []]; | |
yield 'Subtract range from infinite start range: ending at end' => ['-/2022-04-07T18:00', $ref, '-/2022-04-07T13:00']; | |
yield 'Subtract infinite start range from range: ending after' => [$ref, '-/2022-04-07T20:00', []]; | |
yield 'Subtract range from infinite start range: ending after' => ['-/2022-04-07T20:00', $ref, [ | |
'-/2022-04-07T13:00', | |
'2022-04-07T18:00/2022-04-07T20:00', | |
]]; | |
// Infinite end | |
yield 'Subtract infinite end range from range: starting before' => [$ref, '2022-04-07T08:00/-', []]; | |
yield 'Subtract range from infinite end range: starting before' => ['2022-04-07T08:00/-', $ref, [ | |
'2022-04-07T08:00/2022-04-07T13:00', | |
'2022-04-07T18:00/-', | |
]]; | |
yield 'Subtract infinite end range from range: starting at start' => [$ref, '2022-04-07T13:00/-', []]; | |
yield 'Subtract range from infinite end range: starting at start' => ['2022-04-07T13:00/-', $ref, '2022-04-07T18:00/-']; | |
yield 'Subtract infinite end range from range: starting inside' => [$ref, '2022-04-07T15:00/-', '2022-04-07T13:00/2022-04-07T15:00']; | |
yield 'Subtract range from infinite end range: starting inside' => ['2022-04-07T15:00/-', $ref, '2022-04-07T18:00/-']; | |
yield 'Subtract infinite end range from range: starting at end' => [$ref, '2022-04-07T18:00/-', $ref]; | |
yield 'Subtract range from infinite end range: starting at end' => ['2022-04-07T18:00/-', $ref, '2022-04-07T18:00/-']; | |
yield 'Subtract infinite end range from range: starting after' => [$ref, '2022-04-07T20:00/-', $ref]; | |
yield 'Subtract range from infinite end range: starting after' => ['2022-04-07T20:00/-', $ref, '2022-04-07T20:00/-']; | |
// Infinite | |
yield 'Subtract infinite range from range' => [$ref, '-/-', []]; | |
yield 'Subtract range from infinite range' => ['-/-', $ref, [ | |
'-/2022-04-07T13:00', | |
'2022-04-07T18:00/-', | |
]]; | |
yield 'Subtract infinite start range from infinite range' => ['-/-', '2022-04-07T08:00/-', '-/2022-04-07T08:00']; | |
yield 'Subtract infinite range from infinite start range' => ['2022-04-07T08:00/-', '-/-', []]; | |
yield 'Subtract infinite end range from infinite range' => ['-/-', '-/2022-04-07T08:00', '2022-04-07T08:00/-']; | |
yield 'Subtract infinite range from infinite end range' => ['-/2022-04-07T08:00', '-/-', []]; | |
yield 'Subtract infinite range from infinite range' => ['-/-', '-/-', []]; | |
// Infinites + empty ranges | |
yield 'Subtract empty range from infinite range' => ['-/-', $empty('2022-04-07T08:00'), [ | |
'-/2022-04-07T08:00', | |
'2022-04-07T08:00/-', | |
]]; | |
yield 'Subtract infinite range from empty range' => [$empty('2022-04-07T08:00'), '-/-', []]; | |
yield 'Subtract empty range from infinite start range: inside' => [$infiniteStartRef, $empty('2022-04-07T14:00'), [ | |
'-/2022-04-07T14:00', | |
'2022-04-07T14:00/2022-04-07T18:00', | |
]]; | |
yield 'Subtract infinite start range from empty range: overlap' => [$empty('2022-04-07T14:00'), $infiniteStartRef, []]; | |
yield 'Subtract empty range from infinite start range: at end' => [$infiniteStartRef, $empty('2022-04-07T18:00'), $infiniteStartRef]; | |
yield 'Subtract infinite start range from empty range: at end' => [$empty('2022-04-07T18:00'), $infiniteStartRef, $empty('2022-04-07T18:00')]; | |
yield 'Subtract empty range from infinite start range: after' => [$infiniteStartRef, $empty('2022-04-07T20:00'), $infiniteStartRef]; | |
yield 'Subtract infinite start range from empty range: after' => [$empty('2022-04-07T20:00'), $infiniteStartRef, $empty('2022-04-07T20:00')]; | |
yield 'Subtract empty range from infinite end range: before' => [$infiniteEndRef, $empty('2022-04-07T12:00'), $infiniteEndRef]; | |
yield 'Subtract infinite end range from empty range: before' => [$empty('2022-04-07T12:00'), $infiniteEndRef, $empty('2022-04-07T12:00')]; | |
yield 'Subtract empty range from infinite end range: at start' => [$infiniteEndRef, $empty('2022-04-07T13:00'), $infiniteEndRef]; | |
yield 'Subtract infinite end range from empty range: at start' => [$empty('2022-04-07T13:00'), $infiniteEndRef, []]; | |
yield 'Subtract empty range from infinite end range: inside' => [$infiniteEndRef, $empty('2022-04-07T17:00'), [ | |
'2022-04-07T13:00/2022-04-07T17:00', | |
'2022-04-07T17:00/-', | |
]]; | |
yield 'Subtract infinite end range from empty range: inside' => [$empty('2022-04-07T17:00'), $infiniteEndRef, []]; | |
// Types support | |
yield 'With LocalDate: with intersection' => [$ref, '2022-04-07', []]; | |
yield 'With LocalDate: without intersection' => [$ref, '2022-04-08', $ref]; | |
yield 'With LocalDateTime: with intersection' => [$ref, '2022-04-07T14:00', [ | |
'2022-04-07T13:00/2022-04-07T14:00', | |
'2022-04-07T14:00/2022-04-07T18:00', | |
]]; | |
yield 'With LocalDateTime: without intersection' => [$ref, '2022-04-07T20:00', $ref]; | |
yield 'With LocalDateInterval: with intersection' => [$ref, '2022-04-06/2022-04-10', []]; | |
yield 'With LocalDateInterval: without intersection' => [$ref, '2022-04-08/2022-04-10', $ref]; | |
} | |
#[DataProvider('castProvider')] | |
public function testCast(null|string|LocalDate|LocalDateTime|LocalDateInterval|LocalDateTimeInterval|YearWeek|YearMonth|Year $input, ?string $expected): void | |
{ | |
self::assertSame($expected, LocalDateTimeInterval::cast($input)?->toISOString()); | |
// Using cast() should be the same as using (unsafe)ContainerOf with one element | |
$inputAsObject = is_string($input) ? IntervalHelper::parse($input) : $input; | |
self::assertSame($expected, LocalDateTimeInterval::containerOf($inputAsObject)?->toISOString()); | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function castProvider(): iterable | |
{ | |
yield 'null' => [null, null]; | |
yield 'ISO string' => ['2020-10-28T00:00/2020-10-29T00:00', '2020-10-28T00:00/2020-10-29T00:00']; | |
yield 'LocalDate => whole day' => [IntervalHelper::parse('2020-10-28'), '2020-10-28T00:00/2020-10-29T00:00']; | |
yield 'LocalDateTime => empty range' => [IntervalHelper::parse('2020-10-28T00:00'), '2020-10-28T00:00/2020-10-28T00:00']; | |
yield 'LocalDateInterval => days range' => [IntervalHelper::parse('2020-10-28/2020-10-30'), '2020-10-28T00:00/2020-10-31T00:00']; | |
yield 'LocalDateInterval with infinite start' => [IntervalHelper::parse('-/2020-10-30'), '-/2020-10-31T00:00']; | |
yield 'LocalDateInterval with infinite end' => [IntervalHelper::parse('2020-10-28/-'), '2020-10-28T00:00/-']; | |
yield 'LocalDateInterval with infinite' => [LocalDateInterval::forever(), '-/-']; | |
yield 'LocalDateTimeInterval' => [IntervalHelper::parse('2020-10-28T00:00/2020-10-31T00:00'), '2020-10-28T00:00/2020-10-31T00:00']; | |
yield 'LocalDateTimeInterval with infinite start' => [IntervalHelper::parse('-/2020-10-31T00:00'), '-/2020-10-31T00:00']; | |
yield 'LocalDateTimeInterval with infinite end' => [IntervalHelper::parse('2020-10-28T00:00/-'), '2020-10-28T00:00/-']; | |
yield 'LocalDateTimeInterval with infinite' => [LocalDateTimeInterval::forever(), '-/-']; | |
yield 'YearWeek' => [YearWeek::of(2022, 48), '2022-11-28T00:00/2022-12-05T00:00']; | |
yield 'YearMonth' => [YearMonth::of(2022, 5), '2022-05-01T00:00/2022-06-01T00:00']; | |
yield 'Year' => [Year::of(2022), '2022-01-01T00:00/2023-01-01T00:00']; | |
} | |
/** | |
* @param Relation::ALLEN_* $expectedRelation | |
*/ | |
#[DataProvider('relations')] | |
public function testRelationWith(string $a, string $b, int $expectedRelation, ?string $expectedIntersection): void | |
{ | |
$parse = static function (string $interval): LocalDateTimeInterval { | |
[$start, $end] = explode('/', $interval); | |
return LocalDateTimeInterval::between( | |
'-' === $start ? null : LocalDateTime::parse('2020-10-28T' . $start), | |
'-' === $end ? null : LocalDateTime::parse('2020-10-28T' . $end), | |
); | |
}; | |
$a = $parse($a); | |
$b = $parse($b); | |
$expectedIntersection = null !== $expectedIntersection ? (string) $parse($expectedIntersection) : null; | |
foreach ([ | |
[$expectedRelation, $a, $b], | |
[self::RELATION_INVERSE[$expectedRelation], $b, $a], | |
] as [$relation, $a, $b]) { | |
$actual = $a->relationWith($b); | |
$relationName = self::RELATION_NAME[$relation]; | |
// Test "find my Allen relation" | |
self::assertTrue( | |
$relation === $actual, | |
sprintf('Expected [%s] %s %s [%s], got %s', $a, str_ends_with($relationName, '_BY') ? 'to be' : 'to', $relationName, $b, self::RELATION_NAME[$actual]), | |
); | |
// Individual Allen relations | |
self::assertSame(Relation::ALLEN_PRECEDES === $relation, $a->precedes($b), sprintf('[%s] precedes [%s]', $a, $b)); | |
self::assertSame(Relation::ALLEN_PRECEDED_BY === $relation, $a->precededBy($b), sprintf('[%s] preceded by [%s]', $a, $b)); | |
self::assertSame(Relation::ALLEN_MEETS === $relation, $a->meets($b), sprintf('[%s] meets [%s]', $a, $b)); | |
self::assertSame(Relation::ALLEN_MET_BY === $relation, $a->metBy($b), sprintf('[%s] met by [%s]', $a, $b)); | |
self::assertSame(Relation::ALLEN_OVERLAPS === $relation, $a->overlaps($b), sprintf('[%s] overlaps [%s]', $a, $b)); | |
self::assertSame(Relation::ALLEN_OVERLAPPED_BY === $relation, $a->overlappedBy($b), sprintf('[%s] overlapped by [%s]', $a, $b)); | |
self::assertSame(Relation::ALLEN_STARTS === $relation, $a->starts($b), sprintf('[%s] starts [%s]', $a, $b)); | |
self::assertSame(Relation::ALLEN_STARTED_BY === $relation, $a->startedBy($b), sprintf('[%s] started by [%s]', $a, $b)); | |
self::assertSame(Relation::ALLEN_ENCLOSES === $relation, $a->encloses($b), sprintf('[%s] encloses [%s]', $a, $b)); | |
self::assertSame(Relation::ALLEN_ENCLOSED_BY === $relation, $a->enclosedBy($b), sprintf('[%s] enclosed by [%s]', $a, $b)); | |
self::assertSame(Relation::ALLEN_FINISHES === $relation, $a->finishes($b), sprintf('[%s] finishes [%s]', $a, $b)); | |
self::assertSame(Relation::ALLEN_FINISHED_BY === $relation, $a->finishedBy($b), sprintf('[%s] finished by [%s]', $a, $b)); | |
self::assertSame(Relation::ALLEN_EQUALS === $relation, $a->equals($b), sprintf('[%s] equals [%s]', $a, $b)); | |
// FOR THE TESTS BELOW, BE CAREFUL TO ALWAYS HAVE PAIRS OF INVERSE RELATIONS IF WE NEED TO TEST FOR COMMUTATIVITY | |
// CompareTo | |
$expectedCompareTo = match ($relation) { | |
Relation::ALLEN_PRECEDES, Relation::ALLEN_MEETS, Relation::ALLEN_OVERLAPS, Relation::ALLEN_FINISHED_BY, Relation::ALLEN_ENCLOSES, Relation::ALLEN_STARTS | |
=> -1, | |
Relation::ALLEN_EQUALS | |
=> 0, | |
Relation::ALLEN_STARTED_BY, Relation::ALLEN_ENCLOSED_BY, Relation::ALLEN_FINISHES, Relation::ALLEN_OVERLAPPED_BY, Relation::ALLEN_MET_BY, Relation::ALLEN_PRECEDED_BY | |
=> 1, | |
}; | |
self::assertSame($expectedCompareTo, $a->compareTo($b), sprintf('[%s] compareTo [%s]', $a, $b)); | |
// Is before if (and only if) precedes or meets | |
$before = (bool) ($relation & (Relation::ALLEN_PRECEDES | Relation::ALLEN_MEETS)); | |
self::assertSame($before, $a->isBefore($b), sprintf('[%s] isBefore [%s]', $a, $b)); | |
// Is after if (and only if) preceded by or metBy | |
$after = (bool) ($relation & (Relation::ALLEN_PRECEDED_BY | Relation::ALLEN_MET_BY)); | |
self::assertSame($after, $a->isAfter($b), sprintf('[%s] isAfter [%s]', $a, $b)); | |
// Contains if (and only if) not empty and encloses, equals, startedBy or finishedBy | |
$contained = !$a->isEmpty() && ($relation & (Relation::ALLEN_ENCLOSES | Relation::ALLEN_EQUALS | Relation::ALLEN_STARTED_BY | Relation::ALLEN_FINISHED_BY)); | |
self::assertSame($contained, $a->contains($b), sprintf('[%s] contains [%s]', $a, $b)); | |
// Intersects if (and only if) not precedes, precededBy, meets or metBy | |
$intersected = (bool) ($relation & ~(Relation::ALLEN_PRECEDES | Relation::ALLEN_PRECEDED_BY | Relation::ALLEN_MEETS | Relation::ALLEN_MET_BY)); | |
self::assertSame($intersected, $a->intersects($b), sprintf('[%s] intersects [%s]', $a, $b)); | |
// Sanity checks | |
self::assertTrue(!($before && $after), sprintf('[%s] and [%s] cannot both be before and after at the same time', $a, $b)); | |
self::assertTrue(($before || $after) !== $intersected, sprintf('[%s] and [%s] should be either before/after or intersects, not both or neither', $a, $b)); | |
self::assertSame($intersected, null !== $a->findIntersection($b), sprintf('[%s] and [%s] should have an intersection if intersected', $a, $b)); | |
if ($contained) { | |
self::assertTrue($intersected, sprintf('[%s] and [%s] should be intersects if contained', $a, $b)); | |
} | |
// This ensures the method is commutative | |
self::assertSame($expectedIntersection, $a->findIntersection($b)?->toISOString(), sprintf('%s intersection with %s is %s', $a, $b, $expectedIntersection)); | |
self::assertSame($expectedIntersection, $b->findIntersection($a)?->toISOString(), sprintf('%s intersection with %s is %s', $b, $a, $expectedIntersection)); | |
} | |
} | |
/** | |
* @return iterable<mixed> | |
*/ | |
public static function relations(): iterable | |
{ | |
// Precedes / Preceded by | |
yield ['08:00/10:00', '12:00/18:00', Relation::ALLEN_PRECEDES, null]; | |
yield ['08:00/10:00', '12:00/12:00', Relation::ALLEN_PRECEDES, null]; | |
yield ['08:00/08:00', '12:00/18:00', Relation::ALLEN_PRECEDES, null]; | |
yield ['08:00/08:00', '12:00/12:00', Relation::ALLEN_PRECEDES, null]; | |
yield ['-/10:00', '12:00/18:00', Relation::ALLEN_PRECEDES, null]; | |
yield ['-/10:00', '12:00/12:00', Relation::ALLEN_PRECEDES, null]; | |
yield ['08:00/10:00', '12:00/-', Relation::ALLEN_PRECEDES, null]; | |
yield ['08:00/08:00', '12:00/-', Relation::ALLEN_PRECEDES, null]; | |
yield ['-/10:00', '12:00/-', Relation::ALLEN_PRECEDES, null]; | |
// Meets / Met by | |
yield ['08:00/12:00', '12:00/18:00', Relation::ALLEN_MEETS, null]; | |
yield ['08:00/12:00', '12:00/12:00', Relation::ALLEN_MEETS, null]; | |
yield ['-/12:00', '12:00/18:00', Relation::ALLEN_MEETS, null]; | |
yield ['-/12:00', '12:00/12:00', Relation::ALLEN_MEETS, null]; | |
yield ['08:00/12:00', '12:00/-', Relation::ALLEN_MEETS, null]; | |
yield ['-/12:00', '12:00/-', Relation::ALLEN_MEETS, null]; | |
// Overlaps / Overlapped by | |
yield ['08:00/14:00', '12:00/18:00', Relation::ALLEN_OVERLAPS, '12:00/14:00']; | |
yield ['-/14:00', '12:00/18:00', Relation::ALLEN_OVERLAPS, '12:00/14:00']; | |
yield ['08:00/14:00', '12:00/-', Relation::ALLEN_OVERLAPS, '12:00/14:00']; | |
yield ['-/14:00', '12:00/-', Relation::ALLEN_OVERLAPS, '12:00/14:00']; | |
// Starts / Started by | |
yield ['12:00/14:00', '12:00/18:00', Relation::ALLEN_STARTS, '12:00/14:00']; | |
yield ['12:00/14:00', '12:00/-', Relation::ALLEN_STARTS, '12:00/14:00']; | |
yield ['12:00/12:00', '12:00/18:00', Relation::ALLEN_STARTS, '12:00/12:00']; | |
yield ['12:00/12:00', '12:00/-', Relation::ALLEN_STARTS, '12:00/12:00']; | |
yield ['-/12:00', '-/18:00', Relation::ALLEN_STARTS, '-/12:00']; | |
yield ['-/12:00', '-/-', Relation::ALLEN_STARTS, '-/12:00']; | |
// Encloses / Enclosed by | |
yield ['08:00/18:00', '12:00/16:00', Relation::ALLEN_ENCLOSES, '12:00/16:00']; | |
yield ['-/18:00', '12:00/16:00', Relation::ALLEN_ENCLOSES, '12:00/16:00']; | |
yield ['08:00/-', '12:00/16:00', Relation::ALLEN_ENCLOSES, '12:00/16:00']; | |
yield ['-/-', '12:00/16:00', Relation::ALLEN_ENCLOSES, '12:00/16:00']; | |
yield ['08:00/18:00', '12:00/12:00', Relation::ALLEN_ENCLOSES, '12:00/12:00']; | |
yield ['-/18:00', '12:00/12:00', Relation::ALLEN_ENCLOSES, '12:00/12:00']; | |
yield ['08:00/-', '12:00/12:00', Relation::ALLEN_ENCLOSES, '12:00/12:00']; | |
yield ['-/-', '12:00/12:00', Relation::ALLEN_ENCLOSES, '12:00/12:00']; | |
// Finishes / Finished by | |
yield ['16:00/18:00', '12:00/18:00', Relation::ALLEN_FINISHES, '16:00/18:00']; | |
yield ['16:00/18:00', '-/18:00', Relation::ALLEN_FINISHES, '16:00/18:00']; | |
yield ['16:00/-', '12:00/-', Relation::ALLEN_FINISHES, '16:00/-']; | |
yield ['16:00/-', '-/-', Relation::ALLEN_FINISHES, '16:00/-']; | |
// Equals | |
yield ['12:00/18:00', '12:00/18:00', Relation::ALLEN_EQUALS, '12:00/18:00']; | |
yield ['12:00/12:00', '12:00/12:00', Relation::ALLEN_EQUALS, '12:00/12:00']; | |
yield ['-/18:00', '-/18:00', Relation::ALLEN_EQUALS, '-/18:00']; | |
yield ['12:00/-', '12:00/-', Relation::ALLEN_EQUALS, '12:00/-']; | |
yield ['-/-', '-/-', Relation::ALLEN_EQUALS, '-/-']; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment