Skip to content

Instantly share code, notes, and snippets.

@gnutix
Last active March 23, 2024 09:05
Show Gist options
  • Save gnutix/d730da3f5e3a9beedd62571418f62194 to your computer and use it in GitHub Desktop.
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)
<?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(),
);
}
}
<?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;
}
<?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);
}
}
<?php
declare(strict_types=1);
namespace Gammadia\DateTimeExtra;
enum InfinityStyle: string
{
case SYMBOL = '-';
}
<?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;
}
<?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