From d37085b46a6491f31471a3901c09a572a76b337e Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 4 Sep 2026 08:53:18 +0200 Subject: [PATCH] Upstream brick/date-time state of the touched files before 9dd1445216 --- src/Instant.php | 377 ++++++++++++ src/ZonedDateTime.php | 761 +++++++++++++++++++++++ tests/InstantTest.php | 667 ++++++++++++++++++++ tests/IntervalTest.php | 303 ++++++++++ tests/ZonedDateTimeTest.php | 1141 +++++++++++++++++++++++++++++++++++ 5 files changed, 3249 insertions(+) create mode 100644 src/Instant.php create mode 100644 src/ZonedDateTime.php create mode 100644 tests/InstantTest.php create mode 100644 tests/IntervalTest.php create mode 100644 tests/ZonedDateTimeTest.php diff --git a/src/Instant.php b/src/Instant.php new file mode 100644 index 0000000..76c5afc --- /dev/null +++ b/src/Instant.php @@ -0,0 +1,377 @@ +getTime(); + } + + /** + * Returns the minimum supported instant. + * + * This could be used by an application as a "far past" instant. + */ + public static function min(): Instant + { + /** @var Instant|null $min */ + static $min = null; + + return $min ??= new Instant(PHP_INT_MIN, 0); + } + + /** + * Returns the maximum supported instant. + * + * This could be used by an application as a "far future" instant. + */ + public static function max(): Instant + { + /** @var Instant|null $max */ + static $max = null; + + return $max ??= new Instant(PHP_INT_MAX, 999_999_999); + } + + public function plus(Duration $duration): Instant + { + if ($duration->isZero()) { + return $this; + } + + $seconds = $this->epochSecond + $duration->getSeconds(); + $nanos = $this->nano + $duration->getNanos(); + + return Instant::of($seconds, $nanos); + } + + public function minus(Duration $duration): Instant + { + if ($duration->isZero()) { + return $this; + } + + return $this->plus($duration->negated()); + } + + public function plusSeconds(int $seconds): Instant + { + if ($seconds === 0) { + return $this; + } + + return new Instant($this->epochSecond + $seconds, $this->nano); + } + + public function minusSeconds(int $seconds): Instant + { + return $this->plusSeconds(-$seconds); + } + + public function plusMinutes(int $minutes): Instant + { + return $this->plusSeconds($minutes * LocalTime::SECONDS_PER_MINUTE); + } + + public function minusMinutes(int $minutes): Instant + { + return $this->plusMinutes(-$minutes); + } + + public function plusHours(int $hours): Instant + { + return $this->plusSeconds($hours * LocalTime::SECONDS_PER_HOUR); + } + + public function minusHours(int $hours): Instant + { + return $this->plusHours(-$hours); + } + + public function plusDays(int $days): Instant + { + return $this->plusSeconds($days * LocalTime::SECONDS_PER_DAY); + } + + /** + * Returns a copy of this Instant with the epoch second altered. + */ + public function withEpochSecond(int $epochSecond): Instant + { + if ($epochSecond === $this->epochSecond) { + return $this; + } + + return new Instant($epochSecond, $this->nano); + } + + /** + * Returns a copy of this Instant with the nano-of-second altered. + * + * @throws DateTimeException If the nano-of-second if not valid. + */ + public function withNano(int $nano): Instant + { + if ($nano === $this->nano) { + return $this; + } + + Field\NanoOfSecond::check($nano); + + return new Instant($this->epochSecond, $nano); + } + + public function minusDays(int $days): Instant + { + return $this->plusDays(-$days); + } + + public function getEpochSecond(): int + { + return $this->epochSecond; + } + + public function getNano(): int + { + return $this->nano; + } + + /** + * Compares this instant with another. + * + * @return -1|0|1 If this instant is before, on, or after the given instant. + */ + public function compareTo(Instant $that): int + { + $seconds = $this->getEpochSecond() - $that->getEpochSecond(); + + if ($seconds !== 0) { + return $seconds > 0 ? 1 : -1; + } + + $nanos = $this->getNano() - $that->getNano(); + + if ($nanos !== 0) { + return $nanos > 0 ? 1 : -1; + } + + return 0; + } + + /** + * Returns whether this instant equals another. + */ + public function isEqualTo(Instant $that): bool + { + return $this->compareTo($that) === 0; + } + + /** + * Returns whether this instant is after another. + */ + public function isAfter(Instant $that): bool + { + return $this->compareTo($that) === 1; + } + + /** + * Returns whether this instant is after or equal to another. + */ + public function isAfterOrEqualTo(Instant $that): bool + { + return $this->compareTo($that) >= 0; + } + + /** + * Returns whether this instant is before another. + */ + public function isBefore(Instant $that): bool + { + return $this->compareTo($that) === -1; + } + + /** + * Returns whether this instant is before or equal to another. + */ + public function isBeforeOrEqualTo(Instant $that): bool + { + return $this->compareTo($that) <= 0; + } + + public function isBetweenInclusive(Instant $from, Instant $to): bool + { + return $this->isAfterOrEqualTo($from) && $this->isBeforeOrEqualTo($to); + } + + public function isBetweenExclusive(Instant $from, Instant $to): bool + { + return $this->isAfter($from) && $this->isBefore($to); + } + + /** + * Returns whether this instant is in the future, according to the given clock. + * + * If no clock is provided, the system clock is used. + */ + public function isFuture(?Clock $clock = null): bool + { + return $this->isAfter(Instant::now($clock)); + } + + /** + * Returns whether this instant is in the past, according to the given clock. + * + * If no clock is provided, the system clock is used. + */ + public function isPast(?Clock $clock = null): bool + { + return $this->isBefore(Instant::now($clock)); + } + + /** + * Returns a ZonedDateTime formed from this instant and the specified time-zone. + */ + public function atTimeZone(TimeZone $timeZone): ZonedDateTime + { + return ZonedDateTime::ofInstant($this, $timeZone); + } + + /** + * Returns an Interval from this Instant (inclusive) to the given one (exclusive). + * + * @throws DateTimeException If the given Instant is before this Instant. + */ + public function getIntervalTo(Instant $that): Interval + { + return Interval::of($this, $that); + } + + /** + * Returns a decimal representation of the timestamp represented by this instant. + * + * The output does not have trailing decimal zeros. + * + * Examples: `123456789`, `123456789.5`, `123456789.000000001`. + */ + public function toDecimal(): string + { + $result = (string) $this->epochSecond; + + if ($this->nano !== 0) { + $nano = (string) $this->nano; + $nano = str_pad($nano, 9, '0', STR_PAD_LEFT); + $nano = rtrim($nano, '0'); + + $result .= '.' . $nano; + } + + return $result; + } + + /** + * Serializes as a string using {@see Instant::toISOString()}. + * + * @return non-empty-string + */ + #[Override] + public function jsonSerialize(): string + { + return $this->toISOString(); + } + + /** + * Returns the ISO 8601 representation of this instant. + * + * @return non-empty-string + */ + public function toISOString(): string + { + return (string) ZonedDateTime::ofInstant($this, TimeZone::utc()); + } + + /** + * {@see Instant::toISOString()}. + * + * @return non-empty-string + */ + #[Override] + public function __toString(): string + { + return $this->toISOString(); + } +} diff --git a/src/ZonedDateTime.php b/src/ZonedDateTime.php new file mode 100644 index 0000000..099375a --- /dev/null +++ b/src/ZonedDateTime.php @@ -0,0 +1,761 @@ +toNativeDateTimeZone(); + $dt = new DateTime((string) $dateTime->withNano(0), $dtz); + + $instant = Instant::of($dt->getTimestamp(), $dateTime->getNano()); + + if ($timeZone instanceof TimeZoneOffset) { + $timeZoneOffset = $timeZone; + } else { + $timeZoneOffset = TimeZoneOffset::ofTotalSeconds($dt->getOffset()); + } + + // The time can be affected if the date-time is not valid for the given time-zone due to a DST transition, + // so we have to re-compute the local date-time from the DateTime object. + // DateTime does not support nanos of seconds, so we just copy the nanos back from the original date-time. + $dateTime = LocalDateTime::parse($dt->format('Y-m-d\TH:i:s'))->withNano($dateTime->getNano()); + + return new ZonedDateTime($dateTime, $timeZoneOffset, $timeZone, $instant); + } + + /** + * Creates a ZonedDateTime from an instant and a time zone. + * + * This resolves the instant to a date and time without ambiguity. + */ + public static function ofInstant(Instant $instant, TimeZone $timeZone): ZonedDateTime + { + $dateTimeZone = $timeZone->toNativeDateTimeZone(); + + // We need to pass a DateTimeZone to avoid a PHP warning... + $dateTime = new DateTime('@' . $instant->getEpochSecond(), $dateTimeZone); + + // ... but this DateTimeZone is ignored because of the timestamp, so we set it again. + $dateTime->setTimezone($dateTimeZone); + + $localDateTime = LocalDateTime::parse($dateTime->format('Y-m-d\TH:i:s')); + $localDateTime = $localDateTime->withNano($instant->getNano()); + + if ($timeZone instanceof TimeZoneOffset) { + $timeZoneOffset = $timeZone; + } else { + $timeZoneOffset = TimeZoneOffset::ofTotalSeconds($dateTime->getOffset()); + } + + return new ZonedDateTime($localDateTime, $timeZoneOffset, $timeZone, $instant); + } + + /** + * Returns the current date-time in the given time-zone, according to the given clock. + * + * If no clock is provided, the system clock is used. + */ + public static function now(TimeZone $timeZone, ?Clock $clock = null): ZonedDateTime + { + return ZonedDateTime::ofInstant(Instant::now($clock), $timeZone); + } + + /** + * Obtains an instance of `ZonedDateTime` from a set of date-time fields. + * + * This method is only useful to parsers. + * + * @throws DateTimeException If the zoned date-time is not valid. + * @throws DateTimeParseException If required fields are missing from the result. + */ + public static function from(DateTimeParseResult $result): ZonedDateTime + { + $localDateTime = LocalDateTime::from($result); + + $timeZoneOffset = TimeZoneOffset::from($result); + + if ($result->hasField(Field\TimeZoneRegion::NAME)) { + $timeZone = TimeZoneRegion::from($result); + } else { + $timeZone = $timeZoneOffset; + } + + return ZonedDateTime::of( + $localDateTime, + $timeZone, + ); + } + + /** + * Obtains an instance of `ZonedDateTime` from a text string. + * + * Valid examples: + * - `2007-12-03T10:15:30:45Z` + * - `2007-12-03T10:15:30+01:00` + * - `2007-12-03T10:15:30+01:00[Europe/Paris]` + * + * @param string $text The text to parse. + * @param DateTimeParser|null $parser The parser to use, defaults to the ISO 8601 parser. + * + * @throws DateTimeException If the date is not valid. + * @throws DateTimeParseException If the text string does not follow the expected format. + */ + public static function parse(string $text, ?DateTimeParser $parser = null): ZonedDateTime + { + if ($parser === null) { + $parser = IsoParsers::zonedDateTime(); + } + + return ZonedDateTime::from($parser->parse($text)); + } + + /** + * Creates a ZonedDateTime from a native DateTime or DateTimeImmutable object. + * + * @throws DateTimeException If the DateTime object has no timezone. + */ + public static function fromNativeDateTime(DateTimeInterface $dateTime): ZonedDateTime + { + $localDateTime = LocalDateTime::fromNativeDateTime($dateTime); + + $dateTimeZone = $dateTime->getTimezone(); + + if ($dateTimeZone === false) { // @phpstan-ignore identical.alwaysFalse + // @codeCoverageIgnoreStart + throw new DateTimeException('This DateTime object has no timezone.'); + // @codeCoverageIgnoreEnd + } + + $timeZone = TimeZone::fromNativeDateTimeZone($dateTimeZone); + + if ($timeZone instanceof TimeZoneOffset) { + $timeZoneOffset = $timeZone; + } else { + $timeZoneOffset = TimeZoneOffset::ofTotalSeconds($dateTime->getOffset()); + } + + $instant = Instant::of($dateTime->getTimestamp(), $localDateTime->getNano()); + + return new ZonedDateTime($localDateTime, $timeZoneOffset, $timeZone, $instant); + } + + /** + * Returns the `LocalDateTime` part of this `ZonedDateTime`. + */ + public function getDateTime(): LocalDateTime + { + return $this->localDateTime; + } + + /** + * Returns the `LocalDate` part of this `ZonedDateTime`. + */ + public function getDate(): LocalDate + { + return $this->localDateTime->getDate(); + } + + /** + * Returns the `LocalTime` part of this `ZonedDateTime`. + */ + public function getTime(): LocalTime + { + return $this->localDateTime->getTime(); + } + + public function getYear(): int + { + return $this->localDateTime->getYear(); + } + + /** + * Returns the month-of-year as a Month enum. + */ + public function getMonth(): Month + { + return $this->localDateTime->getMonth(); + } + + /** + * Returns the month-of-year value from 1 to 12. + * + * @return int<1, 12> + */ + public function getMonthValue(): int + { + return $this->localDateTime->getMonthValue(); + } + + /** + * @return int<1, 31> + */ + public function getDayOfMonth(): int + { + return $this->localDateTime->getDayOfMonth(); + } + + public function getDayOfWeek(): DayOfWeek + { + return $this->localDateTime->getDayOfWeek(); + } + + /** + * @return int<1, 366> + */ + public function getDayOfYear(): int + { + return $this->localDateTime->getDayOfYear(); + } + + public function getHour(): int + { + return $this->localDateTime->getHour(); + } + + public function getMinute(): int + { + return $this->localDateTime->getMinute(); + } + + public function getSecond(): int + { + return $this->localDateTime->getSecond(); + } + + public function getEpochSecond(): int + { + return $this->instant->getEpochSecond(); + } + + public function getNano(): int + { + return $this->instant->getNano(); + } + + /** + * Returns the time-zone, region or offset. + */ + public function getTimeZone(): TimeZone + { + return $this->timeZone; + } + + /** + * Returns the time-zone offset. + */ + public function getTimeZoneOffset(): TimeZoneOffset + { + return $this->timeZoneOffset; + } + + public function getInstant(): Instant + { + return $this->instant; + } + + /** + * Returns a copy of this ZonedDateTime with a different date. + */ + public function withDate(LocalDate $date): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime->withDate($date), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with a different time. + */ + public function withTime(LocalTime $time): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime->withTime($time), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with the year altered. + */ + public function withYear(int $year): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime->withYear($year), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with the month-of-year altered. + */ + public function withMonth(int|Month $month): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime->withMonth($month), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with the day-of-month altered. + */ + public function withDay(int $day): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime->withDay($day), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with the hour-of-day altered. + */ + public function withHour(int $hour): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime->withHour($hour), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with the minute-of-hour altered. + */ + public function withMinute(int $minute): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime->withMinute($minute), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with the second-of-minute altered. + */ + public function withSecond(int $second): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime->withSecond($second), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with the nano-of-second altered. + */ + public function withNano(int $nano): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime->withNano($nano), $this->timeZone); + } + + /** + * Returns a copy of this `ZonedDateTime` with a different time-zone, + * retaining the local date-time if possible. + */ + public function withTimeZoneSameLocal(TimeZone $timeZone): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime, $timeZone); + } + + /** + * Returns a copy of this date-time with a different time-zone, retaining the instant. + */ + public function withTimeZoneSameInstant(TimeZone $timeZone): ZonedDateTime + { + return ZonedDateTime::ofInstant($this->instant, $timeZone); + } + + /** + * Returns a copy of this date-time with the time-zone set to the offset. + * + * This returns a zoned date-time where the time-zone is the same as `getOffset()`. + * The local date-time, offset and instant of the result will be the same as in this date-time. + * + * Setting the date-time to a fixed single offset means that any future + * calculations, such as addition or subtraction, have no complex edge cases + * due to time-zone rules. + * This might also be useful when sending a zoned date-time across a network, + * as most protocols, such as ISO-8601, only handle offsets, and not region-based time zones. + */ + public function withFixedOffsetTimeZone(): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime, $this->timeZoneOffset); + } + + /** + * Returns a copy of this ZonedDateTime with the specified Period added. + */ + public function plusPeriod(Period $period): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime->plusPeriod($period), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with the specified Duration added. + */ + public function plusDuration(Duration $duration): ZonedDateTime + { + return ZonedDateTime::ofInstant($this->instant->plus($duration), $this->timeZone); + } + + /** + * Returns an Interval from this ZonedDateTime (inclusive) to the given one (exclusive). + * + * @throws DateTimeException If the given ZonedDateTime is before this ZonedDateTime. + */ + public function getIntervalTo(ZonedDateTime $that): Interval + { + return $this->getInstant()->getIntervalTo($that->getInstant()); + } + + /** + * Returns a Duration representing the time elapsed between this ZonedDateTime and the given one. + * This method will return a negative duration if the given ZonedDateTime is before the current one. + */ + public function getDurationTo(ZonedDateTime $that): Duration + { + return Duration::between($this->getInstant(), $that->getInstant()); + } + + /** + * Returns a copy of this ZonedDateTime with the specified period in years added. + */ + public function plusYears(int $years): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime->plusYears($years), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with the specified period in months added. + */ + public function plusMonths(int $months): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime->plusMonths($months), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with the specified period in weeks added. + */ + public function plusWeeks(int $weeks): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime->plusWeeks($weeks), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with the specified period in days added. + */ + public function plusDays(int $days): ZonedDateTime + { + return ZonedDateTime::of($this->localDateTime->plusDays($days), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with the specified period in hours added. + */ + public function plusHours(int $hours): ZonedDateTime + { + return ZonedDateTime::ofInstant($this->instant->plusHours($hours), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with the specified period in minutes added. + */ + public function plusMinutes(int $minutes): ZonedDateTime + { + return ZonedDateTime::ofInstant($this->instant->plusMinutes($minutes), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with the specified period in seconds added. + */ + public function plusSeconds(int $seconds): ZonedDateTime + { + return ZonedDateTime::ofInstant($this->instant->plusSeconds($seconds), $this->timeZone); + } + + /** + * Returns a copy of this ZonedDateTime with the specified Period subtracted. + */ + public function minusPeriod(Period $period): ZonedDateTime + { + return $this->plusPeriod($period->negated()); + } + + /** + * Returns a copy of this ZonedDateTime with the specified Duration subtracted. + */ + public function minusDuration(Duration $duration): ZonedDateTime + { + return $this->plusDuration($duration->negated()); + } + + /** + * Returns a copy of this ZonedDateTime with the specified period in years subtracted. + */ + public function minusYears(int $years): ZonedDateTime + { + return $this->plusYears(-$years); + } + + /** + * Returns a copy of this ZonedDateTime with the specified period in months subtracted. + */ + public function minusMonths(int $months): ZonedDateTime + { + return $this->plusMonths(-$months); + } + + /** + * Returns a copy of this ZonedDateTime with the specified period in weeks subtracted. + */ + public function minusWeeks(int $weeks): ZonedDateTime + { + return $this->plusWeeks(-$weeks); + } + + /** + * Returns a copy of this ZonedDateTime with the specified period in days subtracted. + */ + public function minusDays(int $days): ZonedDateTime + { + return $this->plusDays(-$days); + } + + /** + * Returns a copy of this ZonedDateTime with the specified period in hours subtracted. + */ + public function minusHours(int $hours): ZonedDateTime + { + return $this->plusHours(-$hours); + } + + /** + * Returns a copy of this ZonedDateTime with the specified period in minutes subtracted. + */ + public function minusMinutes(int $minutes): ZonedDateTime + { + return $this->plusMinutes(-$minutes); + } + + /** + * Returns a copy of this ZonedDateTime with the specified period in seconds subtracted. + */ + public function minusSeconds(int $seconds): ZonedDateTime + { + return $this->plusSeconds(-$seconds); + } + + /** + * Compares this ZonedDateTime with another. + * + * The comparison is performed on the instant. + * + * @return -1|0|1 If this zoned date-time is before, on, or after the given one. + */ + public function compareTo(ZonedDateTime $that): int + { + return $this->instant->compareTo($that->instant); + } + + /** + * Returns whether this ZonedDateTime equals another. + * + * The comparison is performed on the instant. + */ + public function isEqualTo(ZonedDateTime $that): bool + { + return $this->compareTo($that) === 0; + } + + /** + * Returns whether this ZonedDateTime is after another. + * + * The comparison is performed on the instant. + */ + public function isAfter(ZonedDateTime $that): bool + { + return $this->compareTo($that) === 1; + } + + /** + * Returns whether this ZonedDateTime is after or equal to another. + * + * The comparison is performed on the instant. + */ + public function isAfterOrEqualTo(ZonedDateTime $that): bool + { + return $this->compareTo($that) >= 0; + } + + /** + * Returns whether this ZonedDateTime is before another. + * + * The comparison is performed on the instant. + */ + public function isBefore(ZonedDateTime $that): bool + { + return $this->compareTo($that) === -1; + } + + /** + * Returns whether this ZonedDateTime is before or equal to another. + * + * The comparison is performed on the instant. + */ + public function isBeforeOrEqualTo(ZonedDateTime $that): bool + { + return $this->compareTo($that) <= 0; + } + + public function isBetweenInclusive(ZonedDateTime $from, ZonedDateTime $to): bool + { + return $this->isAfterOrEqualTo($from) && $this->isBeforeOrEqualTo($to); + } + + public function isBetweenExclusive(ZonedDateTime $from, ZonedDateTime $to): bool + { + return $this->isAfter($from) && $this->isBefore($to); + } + + /** + * Returns whether this ZonedDateTime is in the future, according to the given clock. + * + * If no clock is provided, the system clock is used. + */ + public function isFuture(?Clock $clock = null): bool + { + return $this->instant->isFuture($clock); + } + + /** + * Returns whether this ZonedDateTime is in the past, according to the given clock. + * + * If no clock is provided, the system clock is used. + */ + public function isPast(?Clock $clock = null): bool + { + return $this->instant->isPast($clock); + } + + /** + * Converts this ZonedDateTime to a native DateTime object. + * + * Note that the native DateTime object supports a precision up to the microsecond, + * so the nanoseconds are rounded down to the nearest microsecond. + */ + public function toNativeDateTime(): DateTime + { + $second = $this->localDateTime->getSecond(); + + // round down to the microsecond + $nano = $this->localDateTime->getNano(); + $nano = 1000 * intdiv($nano, 1000); + + $dateTime = (string) $this->localDateTime->withNano($nano); + $dateTimeZone = $this->timeZone->toNativeDateTimeZone(); + + $format = 'Y-m-d\TH:i'; + + if ($second !== 0 || $nano !== 0) { + $format .= ':s'; + + if ($nano !== 0) { + $format .= '.u'; + } + } + + $nativeDateTime = DateTime::createFromFormat($format, $dateTime, $dateTimeZone); + + assert($nativeDateTime !== false); + + return $nativeDateTime; + } + + public function toNativeDateTimeImmutable(): DateTimeImmutable + { + return DateTimeImmutable::createFromMutable($this->toNativeDateTime()); + } + + /** + * Serializes as a string using {@see ZonedDateTime::toISOString()}. + * + * @return non-empty-string + */ + #[Override] + public function jsonSerialize(): string + { + return $this->toISOString(); + } + + /** + * Returns the ISO 8601 representation of this zoned date time. + * + * @return non-empty-string + */ + public function toISOString(): string + { + $string = $this->localDateTime . $this->timeZoneOffset; + + if ($this->timeZone instanceof TimeZoneRegion) { + $string .= '[' . $this->timeZone . ']'; + } + + return $string; + } + + /** + * {@see ZonedDateTime::toISOString()}. + * + * @return non-empty-string + */ + #[Override] + public function __toString(): string + { + return $this->toISOString(); + } +} diff --git a/tests/InstantTest.php b/tests/InstantTest.php new file mode 100644 index 0000000..1e3a042 --- /dev/null +++ b/tests/InstantTest.php @@ -0,0 +1,667 @@ +getEpochSecond()); + self::assertSame($expectedNanos, $duration->getNano()); + } + + public static function providerOf(): array + { + return [ + [3, 1, 3, 1], + [4, -999999999, 3, 1], + [2, 1000000001, 3, 1], + [-3, 1, -3, 1], + [-4, 1000000001, -3, 1], + [-2, -999999999, -3, 1], + [1, -1000000001, -1, 999999999], + [-1, -1000000001, -3, 999999999], + ]; + } + + public function testEpoch(): void + { + $epoch = Instant::epoch(); + + self::assertInstantIs(0, 0, $epoch); + self::assertSame($epoch, Instant::epoch()); + } + + public function testNow(): void + { + $clock = new FixedClock(Instant::of(123456789, 987654321)); + self::assertInstantIs(123456789, 987654321, Instant::now($clock)); + } + + public function testMin(): void + { + $min = Instant::min(); + + self::assertInstantIs(PHP_INT_MIN, 0, $min); + self::assertSame($min, Instant::min()); + } + + public function testMax(): void + { + $max = Instant::max(); + + self::assertInstantIs(PHP_INT_MAX, 999999999, $max); + self::assertSame($max, Instant::max()); + } + + /** + * @param int $second The base second. + * @param int $nano The base nano-of-second. + * @param int $plusSeconds The seconds of the duration to add. + * @param int $plusNanos The nanos of the duration to add. + * @param int $expectedSecond The expected second of the result. + * @param int $expectedNano The expected nano of the result. + */ + #[DataProvider('providerPlus')] + public function testPlus(int $second, int $nano, int $plusSeconds, int $plusNanos, int $expectedSecond, int $expectedNano): void + { + $result = Instant::of($second, $nano)->plus(Duration::ofSeconds($plusSeconds, $plusNanos)); + self::assertInstantIs($expectedSecond, $expectedNano, $result); + } + + /** + * @param int $second The base second. + * @param int $nano The base nano-of-second. + * @param int $plusSeconds The seconds of the duration to add. + * @param int $plusNanos The nanos of the duration to add. + * @param int $expectedSecond The expected second of the result. + * @param int $expectedNano The expected nano of the result. + */ + #[DataProvider('providerPlus')] + public function testMinus(int $second, int $nano, int $plusSeconds, int $plusNanos, int $expectedSecond, int $expectedNano): void + { + $result = Instant::of($second, $nano)->minus(Duration::ofSeconds(-$plusSeconds, -$plusNanos)); + self::assertInstantIs($expectedSecond, $expectedNano, $result); + } + + public static function providerPlus(): array + { + return [ + [123456, 789, 0, 0, 123456, 789], + [123456, 789, 123, 456789, 123579, 457578], + [123456, 789, -123, -456789, 123332, 999544000], + [123456789, 999999999, 1, 1, 123456791, 0], + [123456789, 0, -1, -1, 123456787, 999999999], + ]; + } + + /** + * @param int $second The base second. + * @param int $nano The base nano-of-second. + * @param int $plusSeconds The number of seconds to add. + * @param int $expectedSecond The expected second of the result. + */ + #[DataProvider('providerPlusSeconds')] + public function testPlusSeconds(int $second, int $nano, int $plusSeconds, int $expectedSecond): void + { + $result = Instant::of($second, $nano)->plusSeconds($plusSeconds); + self::assertInstantIs($expectedSecond, $nano, $result); + } + + /** + * @param int $second The base second. + * @param int $nano The base nano-of-second. + * @param int $plusSeconds The number of seconds to add. + * @param int $expectedSecond The expected second of the result. + */ + #[DataProvider('providerPlusSeconds')] + public function testMinusSeconds(int $second, int $nano, int $plusSeconds, int $expectedSecond): void + { + $result = Instant::of($second, $nano)->minusSeconds(-$plusSeconds); + self::assertInstantIs($expectedSecond, $nano, $result); + } + + public static function providerPlusSeconds(): array + { + return [ + [123456, 789, 0, 123456, 789], + [123456, 789, 1000, 124456], + [123456, 789, -1000, 122456], + [123456, 123456789, 456, 123912], + [123456, 123456789, -456, 123000], + [123456, 987, 1000000, 1123456], + [123456, 987, -1000000, -876544], + ]; + } + + /** + * @param int $second The base second. + * @param int $nano The base nano-of-second. + * @param int $plusMinutes The number of minutes to add. + * @param int $expectedSecond The expected second of the result. + */ + #[DataProvider('providerPlusMinutes')] + public function testPlusMinutes(int $second, int $nano, int $plusMinutes, int $expectedSecond): void + { + $result = Instant::of($second, $nano)->plusMinutes($plusMinutes); + self::assertInstantIs($expectedSecond, $nano, $result); + } + + /** + * @param int $second The base second. + * @param int $nano The base nano-of-second. + * @param int $plusMinutes The number of minutes to add. + * @param int $expectedSecond The expected second of the result. + */ + #[DataProvider('providerPlusMinutes')] + public function testMinusMinutes(int $second, int $nano, int $plusMinutes, int $expectedSecond): void + { + $result = Instant::of($second, $nano)->minusMinutes(-$plusMinutes); + self::assertInstantIs($expectedSecond, $nano, $result); + } + + public static function providerPlusMinutes(): array + { + return [ + [123456, 789, 0, 123456, 789], + [123456, 789, 1000, 183456], + [123456, 789, -1000, 63456], + [123456, 123456789, 456, 150816], + [123456, 123456789, -456, 96096], + [123456, 987, 1000000, 60123456], + [123456, 987, -1000000, -59876544], + ]; + } + + /** + * @param int $second The base second. + * @param int $nano The base nano-of-second. + * @param int $plusHours The number of hours to add. + * @param int $expectedSecond The expected second of the result. + */ + #[DataProvider('providerPlusHours')] + public function testPlusHours(int $second, int $nano, int $plusHours, int $expectedSecond): void + { + $result = Instant::of($second, $nano)->plusHours($plusHours); + self::assertInstantIs($expectedSecond, $nano, $result); + } + + /** + * @param int $second The base second. + * @param int $nano The base nano-of-second. + * @param int $plusHours The number of hours to add. + * @param int $expectedSecond The expected second of the result. + */ + #[DataProvider('providerPlusHours')] + public function testMinusHours(int $second, int $nano, int $plusHours, int $expectedSecond): void + { + $result = Instant::of($second, $nano)->minusHours(-$plusHours); + self::assertInstantIs($expectedSecond, $nano, $result); + } + + public static function providerPlusHours(): array + { + return [ + [123456, 789, 0, 123456, 789], + [123456, 789, 1000, 3723456], + [123456, 789, -1000, -3476544], + [123456, 123456789, 456, 1765056], + [123456, 123456789, -456, -1518144], + [123456, 987, 1, 127056], + [123456, 987, -1, 119856], + ]; + } + + /** + * @param int $second The base second. + * @param int $nano The base nano-of-second. + * @param int $plusDays The number of days to add. + * @param int $expectedSecond The expected second of the result. + */ + #[DataProvider('providerPlusDays')] + public function testPlusDays(int $second, int $nano, int $plusDays, int $expectedSecond): void + { + $result = Instant::of($second, $nano)->plusDays($plusDays); + self::assertInstantIs($expectedSecond, $nano, $result); + } + + /** + * @param int $second The base second. + * @param int $nano The base nano-of-second. + * @param int $plusDays The number of days to add. + * @param int $expectedSecond The expected second of the result. + */ + #[DataProvider('providerPlusDays')] + public function testMinusDays(int $second, int $nano, int $plusDays, int $expectedSecond): void + { + $result = Instant::of($second, $nano)->minusDays(-$plusDays); + self::assertInstantIs($expectedSecond, $nano, $result); + } + + public static function providerPlusDays(): array + { + return [ + [123456, 789, 0, 123456, 789], + [123456, 789, 1000, 86523456], + [123456, 789, -1000, -86276544], + [123456, 123456789, 456, 39521856], + [123456, 123456789, -456, -39274944], + [123456, 987, 1, 209856], + [123456, 987, -1, 37056], + ]; + } + + public function testWithEpochSecond(): void + { + $instant = Instant::of(1234567890, 987654321); + self::assertInstantIs(2345678901, 987654321, $instant->withEpochSecond(2345678901)); + } + + public function testWithNano(): void + { + $instant = Instant::of(1234567890, 987654321); + self::assertInstantIs(1234567890, 123456789, $instant->withNano(123456789)); + } + + #[DataProvider('providerWithInvalidNanoThrowsException')] + public function testWithInvalidNanoThrowsException(int $nano): void + { + $instant = Instant::of(1234567890, 987654321); + + $this->expectException(DateTimeException::class); + $instant->withNano($nano); + } + + public static function providerWithInvalidNanoThrowsException(): array + { + return [ + [-1], + [1000000000], + ]; + } + + /** + * @param int $s1 The epoch second of the 1st instant. + * @param int $n1 The nanosecond adjustment of the 1st instant. + * @param int $s2 The epoch second of the 2nd instant. + * @param int $n2 The nanosecond adjustment of the 2nd instant. + * @param int $cmp The expected comparison value. + */ + #[DataProvider('providerCompareTo')] + public function testCompareTo(int $s1, int $n1, int $s2, int $n2, int $cmp): void + { + self::assertSame($cmp, Instant::of($s1, $n1)->compareTo(Instant::of($s2, $n2))); + } + + /** + * @param int $s1 The epoch second of the 1st instant. + * @param int $n1 The nanosecond adjustment of the 1st instant. + * @param int $s2 The epoch second of the 2nd instant. + * @param int $n2 The nanosecond adjustment of the 2nd instant. + * @param int $cmp The comparison value. + */ + #[DataProvider('providerCompareTo')] + public function testIsEqualTo(int $s1, int $n1, int $s2, int $n2, int $cmp): void + { + self::assertSame($cmp === 0, Instant::of($s1, $n1)->isEqualTo(Instant::of($s2, $n2))); + } + + /** + * @param int $s1 The epoch second of the 1st instant. + * @param int $n1 The nanosecond adjustment of the 1st instant. + * @param int $s2 The epoch second of the 2nd instant. + * @param int $n2 The nanosecond adjustment of the 2nd instant. + * @param int $cmp The comparison value. + */ + #[DataProvider('providerCompareTo')] + public function testIsAfter(int $s1, int $n1, int $s2, int $n2, int $cmp): void + { + self::assertSame($cmp === 1, Instant::of($s1, $n1)->isAfter(Instant::of($s2, $n2))); + } + + /** + * @param int $s1 The epoch second of the 1st instant. + * @param int $n1 The nanosecond adjustment of the 1st instant. + * @param int $s2 The epoch second of the 2nd instant. + * @param int $n2 The nanosecond adjustment of the 2nd instant. + * @param int $cmp The comparison value. + */ + #[DataProvider('providerCompareTo')] + public function testIsAfterOrEqualTo(int $s1, int $n1, int $s2, int $n2, int $cmp): void + { + self::assertSame($cmp >= 0, Instant::of($s1, $n1)->isAfterOrEqualTo(Instant::of($s2, $n2))); + } + + /** + * @param int $s1 The epoch second of the 1st instant. + * @param int $n1 The nanosecond adjustment of the 1st instant. + * @param int $s2 The epoch second of the 2nd instant. + * @param int $n2 The nanosecond adjustment of the 2nd instant. + * @param int $cmp The comparison value. + */ + #[DataProvider('providerCompareTo')] + public function testIsBefore(int $s1, int $n1, int $s2, int $n2, int $cmp): void + { + self::assertSame($cmp === -1, Instant::of($s1, $n1)->isBefore(Instant::of($s2, $n2))); + } + + /** + * @param int $s1 The epoch second of the 1st instant. + * @param int $n1 The nanosecond adjustment of the 1st instant. + * @param int $s2 The epoch second of the 2nd instant. + * @param int $n2 The nanosecond adjustment of the 2nd instant. + * @param int $cmp The comparison value. + */ + #[DataProvider('providerCompareTo')] + public function testIsBeforeOrEqualTo(int $s1, int $n1, int $s2, int $n2, int $cmp): void + { + self::assertSame($cmp <= 0, Instant::of($s1, $n1)->isBeforeOrEqualTo(Instant::of($s2, $n2))); + } + + /** + * @param int $testSecond The second of the test instant. + * @param int $testNano The nanosecond adjustment to the test instant. + * @param int $nowSecond The second of the current time. + * @param int $nowNano The nanosecond adjustment to the current time. + * @param int $cmp The comparison value. + */ + #[DataProvider('providerCompareTo')] + public function testIsFuture(int $testSecond, int $testNano, int $nowSecond, int $nowNano, int $cmp): void + { + $clock = new FixedClock(Instant::of($nowSecond, $nowNano)); + self::assertSame($cmp === 1, Instant::of($testSecond, $testNano)->isFuture($clock)); + } + + /** + * @param int $testSecond The second of the test instant. + * @param int $testNano The nanosecond adjustment to the test instant. + * @param int $nowSecond The second of the current time. + * @param int $nowNano The nanosecond adjustment to the current time. + * @param int $cmp The comparison value. + */ + #[DataProvider('providerCompareTo')] + public function testIsPast(int $testSecond, int $testNano, int $nowSecond, int $nowNano, int $cmp): void + { + $clock = new FixedClock(Instant::of($nowSecond, $nowNano)); + self::assertSame($cmp === -1, Instant::of($testSecond, $testNano)->isPast($clock)); + } + + public static function providerCompareTo(): array + { + return [ + [-1, -1, -1, -1, 0], + [-1, -1, -1, 0, -1], + [-1, -1, -1, 1, -1], + [-1, -1, 0, -1, -1], + [-1, -1, 0, 0, -1], + [-1, -1, 0, 1, -1], + [-1, -1, 1, -1, -1], + [-1, -1, 1, 0, -1], + [-1, -1, 1, 1, -1], + [-1, 0, -1, -1, 1], + [-1, 0, -1, 0, 0], + [-1, 0, -1, 1, -1], + [-1, 0, 0, -1, -1], + [-1, 0, 0, 0, -1], + [-1, 0, 0, 1, -1], + [-1, 0, 1, -1, -1], + [-1, 0, 1, 0, -1], + [-1, 0, 1, 1, -1], + [-1, 1, -1, -1, 1], + [-1, 1, -1, 0, 1], + [-1, 1, -1, 1, 0], + [-1, 1, 0, -1, -1], + [-1, 1, 0, 0, -1], + [-1, 1, 0, 1, -1], + [-1, 1, 1, -1, -1], + [-1, 1, 1, 0, -1], + [-1, 1, 1, 1, -1], + [0, -1, -1, -1, 1], + [0, -1, -1, 0, 1], + [0, -1, -1, 1, 1], + [0, -1, 0, -1, 0], + [0, -1, 0, 0, -1], + [0, -1, 0, 1, -1], + [0, -1, 1, -1, -1], + [0, -1, 1, 0, -1], + [0, -1, 1, 1, -1], + [0, 0, -1, -1, 1], + [0, 0, -1, 0, 1], + [0, 0, -1, 1, 1], + [0, 0, 0, -1, 1], + [0, 0, 0, 0, 0], + [0, 0, 0, 1, -1], + [0, 0, 1, -1, -1], + [0, 0, 1, 0, -1], + [0, 0, 1, 1, -1], + [0, 1, -1, -1, 1], + [0, 1, -1, 0, 1], + [0, 1, -1, 1, 1], + [0, 1, 0, -1, 1], + [0, 1, 0, 0, 1], + [0, 1, 0, 1, 0], + [0, 1, 1, -1, -1], + [0, 1, 1, 0, -1], + [0, 1, 1, 1, -1], + [1, -1, -1, -1, 1], + [1, -1, -1, 0, 1], + [1, -1, -1, 1, 1], + [1, -1, 0, -1, 1], + [1, -1, 0, 0, 1], + [1, -1, 0, 1, 1], + [1, -1, 1, -1, 0], + [1, -1, 1, 0, -1], + [1, -1, 1, 1, -1], + [1, 0, -1, -1, 1], + [1, 0, -1, 0, 1], + [1, 0, -1, 1, 1], + [1, 0, 0, -1, 1], + [1, 0, 0, 0, 1], + [1, 0, 0, 1, 1], + [1, 0, 1, -1, 1], + [1, 0, 1, 0, 0], + [1, 0, 1, 1, -1], + [1, 1, -1, -1, 1], + [1, 1, -1, 0, 1], + [1, 1, -1, 1, 1], + [1, 1, 0, -1, 1], + [1, 1, 0, 0, 1], + [1, 1, 0, 1, 1], + [1, 1, 1, -1, 1], + [1, 1, 1, 0, 1], + [1, 1, 1, 1, 0], + ]; + } + + /** + * @param int $seconds The seconds value. + * @param int $nanos The nano seconds value. + * @param bool $isBetween Check the secs and nanos are between. + */ + #[DataProvider('providerIsBetweenInclusive')] + public function testIsBetweenInclusive(int $seconds, int $nanos, bool $isBetween): void + { + self::assertSame($isBetween, Instant::of($seconds, $nanos)->isBetweenInclusive( + Instant::of(-1, -1), + Instant::of(1, 1), + )); + } + + /** + * @param int $seconds The seconds value. + * @param int $nanos The nano seconds value. + * @param bool $isBetween Check the secs and nanos are between. + */ + #[DataProvider('providerIsBetweenExclusive')] + public function testIsBetweenExclusive(int $seconds, int $nanos, bool $isBetween): void + { + self::assertSame($isBetween, Instant::of($seconds, $nanos)->isBetweenExclusive( + Instant::of(-1, -1), + Instant::of(1, 1), + )); + } + + public static function providerIsBetweenExclusive(): array + { + return [ + [-1, -2, false], + [-1, -1, false], + [-1, 0, true], + [-1, 1, true], + [0, -1, true], + [0, 0, true], + [0, 1, true], + [1, -1, true], + [1, 0, true], + [1, 1, false], + [1, 2, false], + ]; + } + + public static function providerIsBetweenInclusive(): array + { + return [ + [-1, -2, false], + [-1, -1, true], + [-1, 0, true], + [-1, 1, true], + [0, -1, true], + [0, 0, true], + [0, 1, true], + [1, -1, true], + [1, 0, true], + [1, 1, true], + [1, 2, false], + ]; + } + + #[DataProvider('providerGetIntervalTo')] + public function testGetIntervalTo(int $second1, int $nano1, int $second2, int $nano2, string $expectedInterval): void + { + $actualResult = Instant::of($second1, $nano1)->getIntervalTo(Instant::of($second2, $nano2)); + + self::assertSame($expectedInterval, (string) $actualResult); + } + + public static function providerGetIntervalTo(): array + { + return [ + [1672567200, 0, 1672567200, 0, '2023-01-01T10:00Z/2023-01-01T10:00Z'], + [1672567200, 0, 1672567210, 0, '2023-01-01T10:00Z/2023-01-01T10:00:10Z'], + [1672567200, 1000000, 1672567210, 2000000, '2023-01-01T10:00:00.001Z/2023-01-01T10:00:10.002Z'], + [1672567200, 1, 1672567200, 9, '2023-01-01T10:00:00.000000001Z/2023-01-01T10:00:00.000000009Z'], + [1672567200, 0, 1672653600, 0, '2023-01-01T10:00Z/2023-01-02T10:00Z'], + ]; + } + + /** + * @param int $second The epoch second. + * @param int $nano The nano adjustment. + * @param string $expected The expected decimal output. + */ + #[DataProvider('providerToDecimal')] + public function testToDecimal(int $second, int $nano, string $expected): void + { + self::assertSame($expected, Instant::of($second, $nano)->toDecimal()); + } + + public static function providerToDecimal(): array + { + return [ + [123456789, 0, '123456789'], + [123456789, 1, '123456789.000000001'], + [123456789, 10, '123456789.00000001'], + [123456789, 100, '123456789.0000001'], + [123456789, 1000, '123456789.000001'], + [123456789, 10000, '123456789.00001'], + [123456789, 100000, '123456789.0001'], + [123456789, 1000000, '123456789.001'], + [123456789, 10000000, '123456789.01'], + [123456789, 100000000, '123456789.1'], + [123456789, 550000000, '123456789.55'], + ]; + } + + /** + * @param int $epochSecond The epoch second to test. + * @param int $nano The nano adjustment to the epoch second. + * @param string $expectedString The expected string output. + */ + #[DataProvider('providerToString')] + public function testJsonSerialize(int $epochSecond, int $nano, string $expectedString): void + { + self::assertSame(json_encode($expectedString, JSON_THROW_ON_ERROR), json_encode(Instant::of($epochSecond, $nano), JSON_THROW_ON_ERROR)); + } + + /** + * @param int $epochSecond The epoch second to test. + * @param int $nano The nano adjustment to the epoch second. + * @param string $expectedString The expected string output. + */ + #[DataProvider('providerToString')] + public function testToISOString(int $epochSecond, int $nano, string $expectedString): void + { + self::assertSame($expectedString, Instant::of($epochSecond, $nano)->toISOString()); + } + + /** + * @param int $epochSecond The epoch second to test. + * @param int $nano The nano adjustment to the epoch second. + * @param string $expectedString The expected string output. + */ + #[DataProvider('providerToString')] + public function testToString(int $epochSecond, int $nano, string $expectedString): void + { + self::assertSame($expectedString, (string) Instant::of($epochSecond, $nano)); + } + + public static function providerToString(): array + { + return [ + [-2000000000, 0, '1906-08-16T20:26:40Z'], + [-1, 0, '1969-12-31T23:59:59Z'], + [-1, 123, '1969-12-31T23:59:59.000000123Z'], + [0, 0, '1970-01-01T00:00Z'], + [0, 123456, '1970-01-01T00:00:00.000123456Z'], + [1, 0, '1970-01-01T00:00:01Z'], + [1, 123456789, '1970-01-01T00:00:01.123456789Z'], + [2000000000, 0, '2033-05-18T03:33:20Z'], + ]; + } + + public function testAtTimeZone(): void + { + $timeZone = TimeZone::utc(); + $instant = Instant::of(1000000000); + $result = $instant->atTimeZone($timeZone); + self::assertSame(1000000000, $result->getInstant()->getEpochSecond()); + self::assertSame('2001-09-09T01:46:40', (string) $result->getDateTime()); + } +} diff --git a/tests/IntervalTest.php b/tests/IntervalTest.php new file mode 100644 index 0000000..9fc4724 --- /dev/null +++ b/tests/IntervalTest.php @@ -0,0 +1,303 @@ +expectException(DateTimeException::class); + $this->expectExceptionMessage('The end instant must not be before the start instant.'); + + Interval::of($end, $start); + } + + public function testGetStartEnd(): void + { + $start = Instant::of(2000000000, 987654321); + $end = Instant::of(2000000009, 123456789); + + $interval = Interval::of($start, $end); + + self::assertInstantIs(2000000000, 987654321, $interval->getStart()); + self::assertInstantIs(2000000009, 123456789, $interval->getEnd()); + } + + #[Depends('testGetStartEnd')] + public function testWithStart(): void + { + $interval = Interval::of( + Instant::of(2000000000), + Instant::of(2000000001), + ); + + $newInterval = $interval->withStart(Instant::of(1999999999, 999999999)); + + self::assertNotSame($newInterval, $interval); + + // ensure that the original isn't changed + self::assertInstantIs(2000000000, 0, $interval->getStart()); + self::assertInstantIs(2000000001, 0, $interval->getEnd()); + + // test the new instance + self::assertInstantIs(1999999999, 999999999, $newInterval->getStart()); + self::assertInstantIs(2000000001, 0, $newInterval->getEnd()); + } + + #[Depends('testGetStartEnd')] + public function testWithEnd(): void + { + $interval = Interval::of( + Instant::of(2000000000), + Instant::of(2000000001), + ); + + $newInterval = $interval->withEnd(Instant::of(2000000002, 222222222)); + + self::assertNotSame($newInterval, $interval); + + // ensure that the original isn't changed + self::assertInstantIs(2000000000, 0, $interval->getStart()); + self::assertInstantIs(2000000001, 0, $interval->getEnd()); + + // test the new instance + self::assertInstantIs(2000000000, 0, $newInterval->getStart()); + self::assertInstantIs(2000000002, 222222222, $newInterval->getEnd()); + } + + public function testGetDuration(): void + { + $interval = Interval::of( + Instant::of(1999999999, 555555), + Instant::of(2000000001, 111), + ); + + $duration = $interval->getDuration(); + + self::assertDurationIs(1, 999444556, $duration); + } + + #[DataProvider('providerContains')] + public function testContains(int $start, int $end, int $now, bool $expected, string $errorMessage): void + { + $interval = Interval::of(Instant::of($start), Instant::of($end)); + + self::assertSame($expected, $interval->contains(Instant::of($now)), $errorMessage); + } + + public static function providerContains(): array + { + return [ + 'at the start' => [ + 1000000000, + 2000000000, + 1000000000, + true, + 'an Interval must contain its start', + ], + 'at the end' => [ + 1000000000, + 2000000000, + 2000000000, + false, + 'an Interval must not contain its end', + ], + 'in the middle' => [ + 1000000001, + 1000000003, + 1000000002, + true, + 'an Interval must contain its intermediate values', + ], + ]; + } + + #[DataProvider('providerIntersectsWith')] + public function testIntersectsWith(int $start1, int $end1, int $start2, int $end2, bool $expected): void + { + $interval1 = Interval::of(Instant::of($start1), Instant::of($end1)); + $interval2 = Interval::of(Instant::of($start2), Instant::of($end2)); + self::assertSame($expected, $interval1->intersectsWith($interval2)); + } + + public static function providerIntersectsWith(): array + { + return [ + 'second is after first' => [ + 100000, 200000, + 400000, 500000, + false, + ], + 'second is before first' => [ + 400000, 500000, + 100000, 200000, + false, + ], + 'end of the first is start of the second' => [ + 100000, 200000, + 200000, 300000, + false, + ], + 'start of the first is end of the second' => [ + 200000, 300000, + 100000, 200000, + false, + ], + 'intersection' => [ + 100000, 200000, + 150000, 250000, + true, + ], + ]; + } + + #[DataProvider('providerGetIntersectionWith')] + public function testGetIntersectionWith( + int $start1, + int $end1, + int $start2, + int $end2, + int $expectedStart, + int $expectedEnd, + ): void { + $interval1 = Interval::of(Instant::of($start1), Instant::of($end1)); + $interval2 = Interval::of(Instant::of($start2), Instant::of($end2)); + $expected = Interval::of(Instant::of($expectedStart), Instant::of($expectedEnd)); + + self::assertTrue($expected->isEqualTo($interval1->getIntersectionWith($interval2))); + } + + public static function providerGetIntersectionWith(): array + { + return [ + 'first before second' => [ + 100000, 200000, + 150000, 250000, + 150000, 200000, + ], + 'first after second' => [ + 150000, 250000, + 100000, 200000, + 150000, 200000, + ], + 'first inside second' => [ + 200000, 300000, + 100000, 400000, + 200000, 300000, + ], + 'second inside first' => [ + 100000, 400000, + 200000, 300000, + 200000, 300000, + ], + 'first = second' => [ + 5000, 6000, + 5000, 6000, + 5000, 6000, + ], + ]; + } + + public function testGetIntersectionWithInvalidParams(): void + { + $interval1 = Interval::of(Instant::of(100000), Instant::of(200000)); + $interval2 = Interval::of(Instant::of(300000), Instant::of(400000)); + + $this->expectException(DateTimeException::class); + $this->expectExceptionMessage('Intervals "1970-01-02T03:46:40Z/1970-01-03T07:33:20Z" and "1970-01-04T11:20Z/1970-01-05T15:06:40Z" do not intersect.'); + + $interval1->getIntersectionWith($interval2); + } + + #[DataProvider('providerIsEqualTo')] + public function testIsEqualTo(Interval $a, Interval $b, bool $expectedResult): void + { + self::assertSame($expectedResult, $a->isEqualTo($b)); + self::assertSame($expectedResult, $b->isEqualTo($a)); + } + + public static function providerIsEqualTo(): array + { + return [ + 'start is not equal' => [ + Interval::of(Instant::of(100000), Instant::of(200000)), + Interval::of(Instant::of(150000), Instant::of(200000)), + false, + ], + 'end is not equal' => [ + Interval::of(Instant::of(100000), Instant::of(200000)), + Interval::of(Instant::of(100000), Instant::of(250000)), + false, + ], + 'both start and end are not equal' => [ + Interval::of(Instant::of(100000), Instant::of(200000)), + Interval::of(Instant::of(150000), Instant::of(250000)), + false, + ], + 'intervals are equal' => [ + Interval::of(Instant::of(100000), Instant::of(200000)), + Interval::of(Instant::of(100000), Instant::of(200000)), + true, + ], + ]; + } + + #[DataProvider('providerToString')] + public function testJsonSerialize(int $epochSecondStart, int $epochSecondEnd, string $expectedString): void + { + $interval = Interval::of( + Instant::of($epochSecondStart), + Instant::of($epochSecondEnd), + ); + + self::assertSame(json_encode($expectedString, JSON_THROW_ON_ERROR), json_encode($interval, JSON_THROW_ON_ERROR)); + } + + #[DataProvider('providerToString')] + public function testToISOString(int $epochSecondStart, int $epochSecondEnd, string $expectedString): void + { + $interval = Interval::of( + Instant::of($epochSecondStart), + Instant::of($epochSecondEnd), + ); + + self::assertSame($expectedString, $interval->toISOString()); + } + + #[DataProvider('providerToString')] + public function testToString(int $epochSecondStart, int $epochSecondEnd, string $expectedString): void + { + $interval = Interval::of( + Instant::of($epochSecondStart), + Instant::of($epochSecondEnd), + ); + + self::assertSame($expectedString, (string) $interval); + } + + public static function providerToString(): array + { + return [ + [1000000000, 1000000000, '2001-09-09T01:46:40Z/2001-09-09T01:46:40Z'], + [1000000000, 2000000000, '2001-09-09T01:46:40Z/2033-05-18T03:33:20Z'], + ]; + } +} diff --git a/tests/ZonedDateTimeTest.php b/tests/ZonedDateTimeTest.php new file mode 100644 index 0000000..b612225 --- /dev/null +++ b/tests/ZonedDateTimeTest.php @@ -0,0 +1,1141 @@ +plusSeconds($shift); + + $zonedDateTime = ZonedDateTime::of($localDateTime, $timeZone); + + self::assertInstanceOf(ZonedDateTime::class, $zonedDateTime); + + self::assertLocalDateTimeEquals($expectedDateTime, $zonedDateTime->getDateTime()); + self::assertTimeZoneEquals($timeZone, $zonedDateTime->getTimeZone()); + self::assertTimeZoneEquals($offset, $zonedDateTime->getTimeZoneOffset()); + + self::assertSame($epochSecond, $zonedDateTime->getEpochSecond()); + self::assertSame($nanoOfSecond, $zonedDateTime->getNano()); + } + + public static function providerOf(): array + { + return [ + // WITH OFFSET FROM UTC + // The date-time is resolved to an instant according to the offset without ambiguity. + + ['2000-01-01T12:34:56.123456789', '-18:00', '-18:00', 0, 946794896, 123456789], + ['2001-02-02T12:34:56.123456789', '-17:00', '-17:00', 0, 981178496, 123456789], + ['2002-03-03T12:34:56.123456789', '-16:00', '-16:00', 0, 1015216496, 123456789], + ['2003-04-04T12:34:56.123456789', '-15:00', '-15:00', 0, 1049513696, 123456789], + ['2004-05-05T12:34:56.123456789', '-14:00', '-14:00', 0, 1083810896, 123456789], + ['2005-06-06T12:34:56.123456789', '-06:00', '-06:00', 0, 1118082896, 123456789], + ['2006-07-07T12:34:56.123456789', '-05:00', '-05:00', 0, 1152293696, 123456789], + ['2007-08-08T12:34:56.123456789', '-04:00', '-04:00', 0, 1186590896, 123456789], + ['2008-09-09T12:34:56.123456789', '-03:00', '-03:00', 0, 1220974496, 123456789], + ['2009-10-10T12:34:56.123456789', '-02:00', '-02:00', 0, 1255185296, 123456789], + ['2010-11-11T12:34:56.123456789', '-01:30', '-01:30', 0, 1289484296, 123456789], + ['2011-12-12T12:34:56.123456789', '-01:00', '-01:00', 0, 1323696896, 123456789], + ['2012-01-13T12:34:56.123456789', '-00:45', '-00:45', 0, 1326460796, 123456789], + ['2013-02-14T12:34:56.123456789', '-00:30', '-00:30', 0, 1360847096, 123456789], + ['2014-03-15T12:34:56.123456789', '-00:15', '-00:15', 0, 1394887796, 123456789], + ['2015-04-16T12:34:56.123456789', '+00:00', '+00:00', 0, 1429187696, 123456789], + ['2016-05-17T12:34:56.123456789', '+00:15', '+00:15', 0, 1463487596, 123456789], + ['2017-06-18T12:34:56.123456789', '+00:30', '+00:30', 0, 1497787496, 123456789], + ['2018-07-19T12:34:56.123456789', '+00:45', '+00:45', 0, 1532000996, 123456789], + ['2019-08-20T12:34:56.123456789', '+01:00', '+01:00', 0, 1566300896, 123456789], + ['2011-09-21T12:34:56.123456789', '+01:30', '+01:30', 0, 1316603096, 123456789], + ['2011-10-22T12:34:56.123456789', '+02:00', '+02:00', 0, 1319279696, 123456789], + ['2011-11-23T12:34:56.123456789', '+03:00', '+03:00', 0, 1322040896, 123456789], + ['2011-12-24T12:34:56.123456789', '+04:00', '+04:00', 0, 1324715696, 123456789], + ['2011-01-25T12:34:56.123456789', '+05:00', '+05:00', 0, 1295940896, 123456789], + ['2011-02-26T12:34:56.123456789', '+06:00', '+06:00', 0, 1298702096, 123456789], + ['2011-03-27T12:34:56.123456789', '+14:00', '+14:00', 0, 1301178896, 123456789], + ['2011-04-28T12:34:56.123456789', '+15:00', '+15:00', 0, 1303940096, 123456789], + ['2011-05-29T12:34:56.123456789', '+16:00', '+16:00', 0, 1306614896, 123456789], + ['2011-06-30T12:34:56.123456789', '+17:00', '+17:00', 0, 1309376096, 123456789], + ['2011-07-31T12:34:56.123456789', '+18:00', '+18:00', 0, 1312050896, 123456789], + + // WITH REGION, NORMAL: NOT WITHIN A DST TRANSITION + // The region is resolved to an offset without ambiguity. + // The date-time is resolved to an instant without ambiguity. + + ['2001-06-01T12:34:56.123456789', 'Pacific/Niue', '-11:00', 0, 991438496, 123456789], + ['2002-06-02T12:34:56.123456789', 'Pacific/Honolulu', '-10:00', 0, 1023057296, 123456789], + ['2003-06-03T12:34:56.123456789', 'Pacific/Marquesas', '-09:30', 0, 1054677896, 123456789], + ['2004-12-04T12:34:56.123456789', 'America/Anchorage', '-09:00', 0, 1102196096, 123456789], + ['2005-06-05T12:34:56.123456789', 'America/Anchorage', '-08:00', 0, 1118003696, 123456789], + ['2006-06-06T12:34:56.123456789', 'America/Los_Angeles', '-07:00', 0, 1149622496, 123456789], + ['2007-12-07T12:34:56.123456789', 'America/Mexico_City', '-06:00', 0, 1197052496, 123456789], + ['2008-12-08T12:34:56.123456789', 'America/Nassau', '-05:00', 0, 1228757696, 123456789], + ['2009-01-09T12:34:56.123456789', 'America/Caracas', '-04:30', 0, 1231520696, 123456789], + ['2010-06-10T12:34:56.123456789', 'America/Nassau', '-04:00', 0, 1276187696, 123456789], + ['2011-12-11T12:34:56.123456789', 'America/St_Johns', '-03:30', 0, 1323619496, 123456789], + ['2012-06-12T12:34:56.123456789', 'America/Sao_Paulo', '-03:00', 0, 1339515296, 123456789], + ['2013-06-13T12:34:56.123456789', 'America/St_Johns', '-02:30', 0, 1371135896, 123456789], + ['2014-06-14T12:34:56.123456789', 'America/Noronha', '-02:00', 0, 1402756496, 123456789], + ['2015-06-15T12:34:56.123456789', 'Atlantic/Cape_Verde', '-01:00', 0, 1434375296, 123456789], + ['2016-12-16T12:34:56.123456789', 'Africa/Casablanca', '+00:00', 0, 1481891696, 123456789], + ['2017-06-17T12:34:56.123456789', 'Europe/London', '+01:00', 0, 1497699296, 123456789], + ['2019-06-19T12:34:56.123456789', 'Europe/Paris', '+02:00', 0, 1560940496, 123456789], + ['2020-06-20T12:34:56.123456789', 'Africa/Nairobi', '+03:00', 0, 1592645696, 123456789], + ['2001-12-21T12:34:56.123456789', 'Asia/Tehran', '+03:30', 0, 1008925496, 123456789], + ['2002-12-22T12:34:56.123456789', 'Asia/Yerevan', '+04:00', 0, 1040546096, 123456789], + ['2003-06-23T12:34:56.123456789', 'Asia/Tehran', '+04:30', 0, 1056355496, 123456789], + ['2004-06-24T12:34:56.123456789', 'Asia/Dushanbe', '+05:00', 0, 1088062496, 123456789], + ['2011-06-25T12:34:56.123456789', 'Asia/Colombo', '+05:30', 0, 1308985496, 123456789], + ['2006-06-26T12:34:56.123456789', 'Asia/Kathmandu', '+05:45', 0, 1151304596, 123456789], + ['2007-06-27T12:34:56.123456789', 'Asia/Dhaka', '+06:00', 0, 1182926096, 123456789], + ['2008-06-28T12:34:56.123456789', 'Asia/Rangoon', '+06:30', 0, 1214633096, 123456789], + ['2009-06-29T12:34:56.123456789', 'Asia/Bangkok', '+07:00', 0, 1246253696, 123456789], + ['2010-06-30T12:34:56.123456789', 'Asia/Hong_Kong', '+08:00', 0, 1277872496, 123456789], + ['2011-07-31T12:34:56.123456789', 'Australia/Eucla', '+08:45', 0, 1312084196, 123456789], + ['2012-07-01T12:34:56.123456789', 'Asia/Seoul', '+09:00', 0, 1341113696, 123456789], + ['2013-06-02T12:34:56.123456789', 'Australia/Adelaide', '+09:30', 0, 1370142296, 123456789], + ['2014-06-03T12:34:56.123456789', 'Pacific/Port_Moresby', '+10:00', 0, 1401762896, 123456789], + ['2015-06-04T12:34:56.123456789', 'Australia/Lord_Howe', '+10:30', 0, 1433383496, 123456789], + ['2016-06-05T12:34:56.123456789', 'Pacific/Noumea', '+11:00', 0, 1465090496, 123456789], + ['2017-06-06T12:34:56.123456789', 'Pacific/Norfolk', '+11:00', 0, 1496712896, 123456789], + ['2018-06-07T12:34:56.123456789', 'Pacific/Auckland', '+12:00', 0, 1528331696, 123456789], + ['2019-06-08T12:34:56.123456789', 'Pacific/Chatham', '+12:45', 0, 1559951396, 123456789], + ['2020-06-09T12:34:56.123456789', 'Pacific/Apia', '+13:00', 0, 1591659296, 123456789], + ['2001-06-10T12:34:56.123456789', 'Pacific/Kiritimati', '+14:00', 0, 992126096, 123456789], + + // WITH REGION, OVERLAP: SUMMER TO WINTER DST TRANSITION + // If the date-time falls within the overlap (inclusive of the start time and exclusive of the end time), + // the region can be resolved to two different offsets; the offset closest to UTC is chosen. + // The date-time is resolved to an instant according to the resolved offset. + + // America/Juneau: at 2:00 AM (-08:00) clocks are set backward 1 hour, to 1:00 AM (-09:00) + // Date-times falling between 1:00 AM and 2:00 AM on the transition day are resolved to -08:00 + + ['2014-11-02T00:59:59.999999999', 'America/Juneau', '-08:00', 0, 1414918799, 999999999], + ['2014-11-02T01:00:00.000000000', 'America/Juneau', '-08:00', 0, 1414918800, 0], + ['2014-11-02T01:59:59.999999999', 'America/Juneau', '-08:00', 0, 1414922399, 999999999], + ['2014-11-02T02:00:00.000000000', 'America/Juneau', '-09:00', 0, 1414926000, 0], + ['2014-11-02T02:59:59.999999999', 'America/Juneau', '-09:00', 0, 1414929599, 999999999], + + // America/Chicago: at 2:00 AM (-05:00) clocks are set backward 1 hour, to 1:00 AM (-06:00) + // Date-times falling between 1:00 AM and 2:00 AM on the transition day are resolved to -05:00 + + ['2013-11-03T00:59:59.999999999', 'America/Chicago', '-05:00', 0, 1383458399, 999999999], + ['2013-11-03T01:00:00.000000000', 'America/Chicago', '-05:00', 0, 1383458400, 0], + ['2013-11-03T01:59:59.999999999', 'America/Chicago', '-05:00', 0, 1383461999, 999999999], + ['2013-11-03T02:00:00.000000000', 'America/Chicago', '-06:00', 0, 1383465600, 0], + ['2013-11-03T02:59:59.999999999', 'America/Chicago', '-06:00', 0, 1383469199, 999999999], + + ['2014-11-02T00:59:59.999999999', 'America/Chicago', '-05:00', 0, 1414907999, 999999999], + ['2014-11-02T01:00:00.000000000', 'America/Chicago', '-05:00', 0, 1414908000, 0], + ['2014-11-02T01:59:59.999999999', 'America/Chicago', '-05:00', 0, 1414911599, 999999999], + ['2014-11-02T02:00:00.000000000', 'America/Chicago', '-06:00', 0, 1414915200, 0], + ['2014-11-02T02:59:59.999999999', 'America/Chicago', '-06:00', 0, 1414918799, 999999999], + + // America/Miquelon: at 2:00 AM (-02:00) clocks are set backward 1 hour, to 1:00 AM (-03:00) + // Date-times falling between 1:00 AM and 2:00 AM on the transition day are resolved to -02:00 + + ['2013-11-03T00:59:59.999999999', 'America/Miquelon', '-02:00', 0, 1383447599, 999999999], + ['2013-11-03T01:00:00.000000000', 'America/Miquelon', '-02:00', 0, 1383447600, 0], + ['2013-11-03T01:59:59.999999999', 'America/Miquelon', '-02:00', 0, 1383451199, 999999999], + ['2013-11-03T02:00:00.000000000', 'America/Miquelon', '-03:00', 0, 1383454800, 0], + ['2013-11-03T02:59:59.999999999', 'America/Miquelon', '-03:00', 0, 1383458399, 999999999], + + ['2014-11-02T00:59:59.999999999', 'America/Miquelon', '-02:00', 0, 1414897199, 999999999], + ['2014-11-02T01:00:00.000000000', 'America/Miquelon', '-02:00', 0, 1414897200, 0], + ['2014-11-02T01:59:59.999999999', 'America/Miquelon', '-02:00', 0, 1414900799, 999999999], + ['2014-11-02T02:00:00.000000000', 'America/Miquelon', '-03:00', 0, 1414904400, 0], + ['2014-11-02T02:59:59.999999999', 'America/Miquelon', '-03:00', 0, 1414907999, 999999999], + + // Europe/Paris: at 3:00 AM (+02:00) clocks are set backward 1 hour, to 2:00 AM (+01:00) + // Date-times falling between 2:00 AM and 3:00 AM on the transition day are resolved to +01:00 + + ['2013-10-27T01:00:00.000000000', 'Europe/Paris', '+02:00', 0, 1382828400, 0], + ['2013-10-27T01:59:59.999999999', 'Europe/Paris', '+02:00', 0, 1382831999, 999999999], + ['2013-10-27T02:00:00.000000000', 'Europe/Paris', '+01:00', 0, 1382835600, 0], + ['2013-10-27T02:59:59.999999999', 'Europe/Paris', '+01:00', 0, 1382839199, 999999999], + ['2013-10-27T03:00:00.000000000', 'Europe/Paris', '+01:00', 0, 1382839200, 0], + + ['2014-10-26T01:00:00.000000000', 'Europe/Paris', '+02:00', 0, 1414278000, 0], + ['2014-10-26T01:59:59.999999999', 'Europe/Paris', '+02:00', 0, 1414281599, 999999999], + ['2014-10-26T02:00:00.000000000', 'Europe/Paris', '+01:00', 0, 1414285200, 0], + ['2014-10-26T02:59:59.999999999', 'Europe/Paris', '+01:00', 0, 1414288799, 999999999], + ['2014-10-26T03:00:00.000000000', 'Europe/Paris', '+01:00', 0, 1414288800, 0], + + // Europe/Athens: at 4:00 AM (+03:00) clocks are set backward 1 hour, to 3:00 AM (+02:00) + // Date-times falling between 3:00 AM nd 4:00 AM on the transition day are resolved to +02:00 + + ['2013-10-27T02:00:00.000000000', 'Europe/Athens', '+03:00', 0, 1382828400, 0], + ['2013-10-27T02:59:59.999999999', 'Europe/Athens', '+03:00', 0, 1382831999, 999999999], + ['2013-10-27T03:00:00.000000000', 'Europe/Athens', '+02:00', 0, 1382835600, 0], + ['2013-10-27T03:59:59.999999999', 'Europe/Athens', '+02:00', 0, 1382839199, 999999999], + ['2013-10-27T04:00:00.000000000', 'Europe/Athens', '+02:00', 0, 1382839200, 0], + + ['2014-10-26T02:00:00.000000000', 'Europe/Athens', '+03:00', 0, 1414278000, 0], + ['2014-10-26T02:59:59.999999999', 'Europe/Athens', '+03:00', 0, 1414281599, 999999999], + ['2014-10-26T03:00:00.000000000', 'Europe/Athens', '+02:00', 0, 1414285200, 0], + ['2014-10-26T03:59:59.999999999', 'Europe/Athens', '+02:00', 0, 1414288799, 999999999], + ['2014-10-26T04:00:00.000000000', 'Europe/Athens', '+02:00', 0, 1414288800, 0], + + // Australia/Sydney: at 3:00 AM (+11:00) clocks are set backward 1 hour, to 2:00 AM (+10:00) + // Date-times falling between 2:00 AM and 3:00 AM on the transition day are resolved to +10:00 + + ['2013-04-07T01:00:00.000000000', 'Australia/Sydney', '+11:00', 0, 1365256800, 0], + ['2013-04-07T01:59:59.999999999', 'Australia/Sydney', '+11:00', 0, 1365260399, 999999999], + ['2013-04-07T02:00:00.000000000', 'Australia/Sydney', '+10:00', 0, 1365264000, 0], + ['2013-04-07T02:59:59.999999999', 'Australia/Sydney', '+10:00', 0, 1365267599, 999999999], + ['2013-04-07T03:00:00.000000000', 'Australia/Sydney', '+10:00', 0, 1365267600, 0], + ['2013-04-07T03:59:59.999999999', 'Australia/Sydney', '+10:00', 0, 1365271199, 999999999], + ['2013-04-07T04:00:00.000000000', 'Australia/Sydney', '+10:00', 0, 1365271200, 0], + + ['2014-04-06T01:00:00.000000000', 'Australia/Sydney', '+11:00', 0, 1396706400, 0], + ['2014-04-06T01:59:59.999999999', 'Australia/Sydney', '+11:00', 0, 1396709999, 999999999], + ['2014-04-06T02:00:00.000000000', 'Australia/Sydney', '+10:00', 0, 1396713600, 0], + ['2014-04-06T02:59:59.999999999', 'Australia/Sydney', '+10:00', 0, 1396717199, 999999999], + ['2014-04-06T03:00:00.000000000', 'Australia/Sydney', '+10:00', 0, 1396717200, 0], + ['2014-04-06T03:59:59.999999999', 'Australia/Sydney', '+10:00', 0, 1396720799, 999999999], + ['2014-04-06T04:00:00.000000000', 'Australia/Sydney', '+10:00', 0, 1396720800, 0], + + // WITH REGION, GAP: WINTER TO SUMMER DST TRANSITION + // If the date-time falls within the gap (inclusive of the start time and exclusive of the end time), + // the date-time is shifted forward by the length of the gap, and the region is resolved to + // the later offset (summer time). + + // America/Juneau: at 2:00 AM (-09:00) clocks are set forward 1 hour, to 3:00 AM (-08:00) + // Date-times falling between 2:00 AM and 3:00 AM on the transition day are shifted forward 1 hour + + ['2014-03-09T01:59:59.999999999', 'America/Juneau', '-09:00', 0, 1394362799, 999999999], + ['2014-03-09T02:00:00.000000000', 'America/Juneau', '-08:00', 3600, 1394362800, 0], + ['2014-03-09T02:59:59.999999999', 'America/Juneau', '-08:00', 3600, 1394366399, 999999999], + ['2014-03-09T03:00:00.000000000', 'America/Juneau', '-08:00', 0, 1394362800, 0], + ['2014-03-09T03:59:59.999999999', 'America/Juneau', '-08:00', 0, 1394366399, 999999999], + + // America/Chicago: at 2:00 AM (-06:00) clocks are set forward 1 hour, to 3:00 AM (-05:00) + // Date-times falling between 2:00 AM and 3:00 AM on the transition day are shifted forward 1 hour + + ['2013-03-10T01:59:59.999999999', 'America/Chicago', '-06:00', 0, 1362902399, 999999999], + ['2013-03-10T02:00:00.000000000', 'America/Chicago', '-05:00', 3600, 1362902400, 0], + ['2013-03-10T02:59:59.999999999', 'America/Chicago', '-05:00', 3600, 1362905999, 999999999], + ['2013-03-10T03:00:00.000000000', 'America/Chicago', '-05:00', 0, 1362902400, 0], + ['2013-03-10T03:59:59.999999999', 'America/Chicago', '-05:00', 0, 1362905999, 999999999], + + ['2014-03-09T01:59:59.999999999', 'America/Chicago', '-06:00', 0, 1394351999, 999999999], + ['2014-03-09T02:00:00.000000000', 'America/Chicago', '-05:00', 3600, 1394352000, 0], + ['2014-03-09T02:59:59.999999999', 'America/Chicago', '-05:00', 3600, 1394355599, 999999999], + ['2014-03-09T03:00:00.000000000', 'America/Chicago', '-05:00', 0, 1394352000, 0], + ['2014-03-09T03:59:59.999999999', 'America/Chicago', '-05:00', 0, 1394355599, 999999999], + + // America/Miquelon: at 2:00 AM (-03:00) clocks are set forward 1 hour, to 3:00 AM (-02:00) + // Date-times falling between 2:00 AM and 3:00 AM on the transition day are shifted forward 1 hour + + ['2013-03-10T01:59:59.999999999', 'America/Miquelon', '-03:00', 0, 1362891599, 999999999], + ['2013-03-10T02:00:00.000000000', 'America/Miquelon', '-02:00', 3600, 1362891600, 0], + ['2013-03-10T02:59:59.999999999', 'America/Miquelon', '-02:00', 3600, 1362895199, 999999999], + ['2013-03-10T03:00:00.000000000', 'America/Miquelon', '-02:00', 0, 1362891600, 0], + ['2013-03-10T03:59:59.999999999', 'America/Miquelon', '-02:00', 0, 1362895199, 999999999], + + ['2014-03-09T01:59:59.999999999', 'America/Miquelon', '-03:00', 0, 1394341199, 999999999], + ['2014-03-09T02:00:00.000000000', 'America/Miquelon', '-02:00', 3600, 1394341200, 0], + ['2014-03-09T02:59:59.999999999', 'America/Miquelon', '-02:00', 3600, 1394344799, 999999999], + ['2014-03-09T03:00:00.000000000', 'America/Miquelon', '-02:00', 0, 1394341200, 0], + ['2014-03-09T03:59:59.999999999', 'America/Miquelon', '-02:00', 0, 1394344799, 999999999], + + // Europe/Paris: at 2:00 AM (+01:00) clocks are set forward 1 hour, to 3:00 AM (+02:00) + // Date-times falling between 2:00 AM and 3:00 AM on the transition day are shifted forward 1 hour + + ['2013-03-31T01:59:59.999999999', 'Europe/Paris', '+01:00', 0, 1364691599, 999999999], + ['2013-03-31T02:00:00.000000000', 'Europe/Paris', '+02:00', 3600, 1364691600, 0], + ['2013-03-31T02:59:59.999999999', 'Europe/Paris', '+02:00', 3600, 1364695199, 999999999], + ['2013-03-31T03:00:00.000000000', 'Europe/Paris', '+02:00', 0, 1364691600, 0], + ['2013-03-31T03:59:59.999999999', 'Europe/Paris', '+02:00', 0, 1364695199, 999999999], + + ['2014-03-30T01:59:59.999999999', 'Europe/Paris', '+01:00', 0, 1396141199, 999999999], + ['2014-03-30T02:00:00.000000000', 'Europe/Paris', '+02:00', 3600, 1396141200, 0], + ['2014-03-30T02:59:59.999999999', 'Europe/Paris', '+02:00', 3600, 1396144799, 999999999], + ['2014-03-30T03:00:00.000000000', 'Europe/Paris', '+02:00', 0, 1396141200, 0], + ['2014-03-30T03:59:59.999999999', 'Europe/Paris', '+02:00', 0, 1396144799, 999999999], + + // Europe/Athens: at 3:00 AM (+02:00) clocks are set forward 1 hour, to 4:00 AM (+03:00) + // Date-times falling between 3:00 AM and 4:00 AM on the transition day are shifted forward 1 hour + + ['2013-03-31T02:59:59.999999999', 'Europe/Athens', '+02:00', 0, 1364691599, 999999999], + ['2013-03-31T03:00:00.000000000', 'Europe/Athens', '+03:00', 3600, 1364691600, 0], + ['2013-03-31T03:59:59.999999999', 'Europe/Athens', '+03:00', 3600, 1364695199, 999999999], + ['2013-03-31T04:00:00.000000000', 'Europe/Athens', '+03:00', 0, 1364691600, 0], + ['2013-03-31T04:59:59.999999999', 'Europe/Athens', '+03:00', 0, 1364695199, 999999999], + + ['2014-03-30T02:59:59.999999999', 'Europe/Athens', '+02:00', 0, 1396141199, 999999999], + ['2014-03-30T03:00:00.000000000', 'Europe/Athens', '+03:00', 3600, 1396141200, 0], + ['2014-03-30T03:59:59.999999999', 'Europe/Athens', '+03:00', 3600, 1396144799, 999999999], + ['2014-03-30T04:00:00.000000000', 'Europe/Athens', '+03:00', 0, 1396141200, 0], + ['2014-03-30T04:59:59.999999999', 'Europe/Athens', '+03:00', 0, 1396144799, 999999999], + + // Australia/Sydney: at 2:00 AM (+10:00) clocks are set forward 1 hour, to 3:00 AM (+11:00) + // Date-times falling between 2:00 AM and 3:00 AM on the transition day are shifted forward 1 hour + + ['2013-10-06T01:59:59.999999999', 'Australia/Sydney', '+10:00', 0, 1380988799, 999999999], + ['2013-10-06T02:00:00.000000000', 'Australia/Sydney', '+11:00', 3600, 1380988800, 0], + ['2013-10-06T02:59:59.999999999', 'Australia/Sydney', '+11:00', 3600, 1380992399, 999999999], + ['2013-10-06T03:00:00.000000000', 'Australia/Sydney', '+11:00', 0, 1380988800, 0], + ['2013-10-06T03:59:59.999999999', 'Australia/Sydney', '+11:00', 0, 1380992399, 999999999], + + ['2014-10-05T01:59:59.999999999', 'Australia/Sydney', '+10:00', 0, 1412438399, 999999999], + ['2014-10-05T02:00:00.000000000', 'Australia/Sydney', '+11:00', 3600, 1412438400, 0], + ['2014-10-05T02:59:59.999999999', 'Australia/Sydney', '+11:00', 3600, 1412441999, 999999999], + ['2014-10-05T03:00:00.000000000', 'Australia/Sydney', '+11:00', 0, 1412438400, 0], + ['2014-10-05T03:59:59.999999999', 'Australia/Sydney', '+11:00', 0, 1412441999, 999999999], + ]; + } + + #[DataProvider('providerOfInstant')] + public function testOfInstant(string $formattedDatetime, string $timeZone): void + { + $instant = Instant::of(1000000000); + $zonedDateTime = ZonedDateTime::ofInstant($instant, TimeZone::parse($timeZone)); + + self::assertSame(1000000000, $zonedDateTime->getInstant()->getEpochSecond()); + self::assertSame($formattedDatetime, (string) $zonedDateTime->getDateTime()); + } + + public static function providerOfInstant(): array + { + return [ + ['2001-09-09T01:46:40', 'UTC'], + ['2001-09-08T18:46:40', 'America/Los_Angeles'], + ]; + } + + /** + * @param string $text The string to parse. + * @param string $date The expected date string. + * @param string $time The expected time string. + * @param string $offset The expected time-zone offset. + * @param string $zone The expected time-zone, should be the same as offset when no region is specified. + */ + #[DataProvider('providerParse')] + public function testParse(string $text, string $date, string $time, string $offset, string $zone): void + { + $zonedDateTime = ZonedDateTime::parse($text); + + self::assertSame($date, (string) $zonedDateTime->getDate()); + self::assertSame($time, (string) $zonedDateTime->getTime()); + self::assertSame($offset, (string) $zonedDateTime->getTimeZoneOffset()); + self::assertSame($zone, (string) $zonedDateTime->getTimeZone()); + } + + public static function providerParse(): iterable + { + yield from [ + ['2001-02-03T01:02Z', '2001-02-03', '01:02', 'Z', 'Z'], + ['2001-02-03T01:02:03Z', '2001-02-03', '01:02:03', 'Z', 'Z'], + ['2001-02-03T01:02:03.456Z', '2001-02-03', '01:02:03.456', 'Z', 'Z'], + ['2001-02-03T01:02-03:00', '2001-02-03', '01:02', '-03:00', '-03:00'], + ['2001-02-03T01:02:03+04:00', '2001-02-03', '01:02:03', '+04:00', '+04:00'], + ['2001-02-03T01:02:03.456+12:34', '2001-02-03', '01:02:03.456', '+12:34', '+12:34'], + ['2001-02-03T01:02Z[Europe/London]', '2001-02-03', '01:02', 'Z', 'Europe/London'], + ['2001-02-03T01:02+00:00[Europe/London]', '2001-02-03', '01:02', 'Z', 'Europe/London'], + ['2001-02-03T01:02:03-00:00[Europe/London]', '2001-02-03', '01:02:03', 'Z', 'Europe/London'], + ['2001-02-03T01:02:03.456+00:00[Europe/London]', '2001-02-03', '01:02:03.456', 'Z', 'Europe/London'], + ]; + + if (PHP_VERSION_ID >= 80107) { + yield ['2001-02-03T01:02:03.456+12:34:56', '2001-02-03', '01:02:03.456', '+12:34:56', '+12:34:56']; + } + } + + #[DataProvider('providerParseInvalidStringThrowsException')] + public function testParseInvalidStringThrowsException(string $text): void + { + $this->expectException(DateTimeParseException::class); + ZonedDateTime::parse($text); + } + + public static function providerParseInvalidStringThrowsException(): array + { + return [ + [''], + ['2001'], + ['2001-'], + ['2001-02'], + ['2001-02-'], + ['2001-02-03'], + ['2001-02-03T'], + ['2001-02-03T04'], + ['2001-02-03T04:'], + ['2001-02-03T04:05'], + ['2001-02-03T04:05:06'], + ['2001-02-03T04:05:06.789'], + ['2001-02-03T04:05Z[]'], + ['2001-02-03T04:05[Europe/London]'], + ['2001-02-03T04:05:06[Europe/London]'], + ['2001-02-03T04:05.789Z[Europe/London]'], + ['2001-02-03T04:05:06Z[Europe/London'], + + [' 2001-02-03T01:02:03Z'], + ['2001-02-03T01:02:03Z '], + ]; + } + + public static function providerParseSecondsOffsetThrowsException(): array + { + return [ + ['2001-02-03T01:02:03.456+12:34:56'], + ]; + } + + #[DataProvider('providerFromNativeDateTime')] + public function testFromNativeDateTime(string $dateTimeString, string $timeZone, string $expected): void + { + $dateTime = new DateTime($dateTimeString, new DateTimeZone($timeZone)); + self::assertIs(ZonedDateTime::class, $expected, ZonedDateTime::fromNativeDateTime($dateTime)); + } + + public static function providerFromNativeDateTime(): array + { + return [ + ['2018-07-21 14:09:10.23456', 'America/Los_Angeles', '2018-07-21T14:09:10.23456-07:00[America/Los_Angeles]'], + ['2019-01-21 17:59', 'America/Los_Angeles', '2019-01-21T17:59-08:00[America/Los_Angeles]'], + ['2019-01-23 09:10:11.123', '+05:30', '2019-01-23T09:10:11.123+05:30'], + ]; + } + + public function testChangeTimeZone(): void + { + $timezone1 = TimeZone::parse('UTC'); + $timezone2 = TimeZone::parse('America/Los_Angeles'); + + $datetime1 = ZonedDateTime::ofInstant(Instant::of(1000000000), $timezone1); + $datetime2 = $datetime1->withTimeZoneSameInstant($timezone2); + + self::assertSame($timezone1, $datetime1->getTimeZone()); + self::assertSame($timezone2, $datetime2->getTimeZone()); + self::assertSame('2001-09-08T18:46:40', (string) $datetime2->getDateTime()); + + $datetime2 = $datetime1->withTimeZoneSameLocal($timezone2); + + self::assertSame($timezone1, $datetime1->getTimeZone()); + self::assertSame($timezone2, $datetime2->getTimeZone()); + self::assertSame('2001-09-09T01:46:40', (string) $datetime2->getDateTime()); + } + + /** + * @param string $z1 The first zoned date-time. + * @param string $z2 The second zoned date-time. + * @param int $cmp The comparison value. + */ + #[DataProvider('providerCompareTo')] + public function testCompareTo(string $z1, string $z2, int $cmp): void + { + $z1 = ZonedDateTime::parse($z1); + $z2 = ZonedDateTime::parse($z2); + + self::assertSame($cmp, $z1->compareTo($z2)); + self::assertSame($cmp === 0, $z1->isEqualTo($z2)); + self::assertSame($cmp === -1, $z1->isBefore($z2)); + self::assertSame($cmp === 1, $z1->isAfter($z2)); + self::assertSame($cmp <= 0, $z1->isBeforeOrEqualTo($z2)); + self::assertSame($cmp >= 0, $z1->isAfterOrEqualTo($z2)); + + self::assertSame(-$cmp, $z2->compareTo($z1)); + self::assertSame($cmp === 0, $z2->isEqualTo($z1)); + self::assertSame($cmp === 1, $z2->isBefore($z1)); + self::assertSame($cmp === -1, $z2->isAfter($z1)); + self::assertSame($cmp >= 0, $z2->isBeforeOrEqualTo($z1)); + self::assertSame($cmp <= 0, $z2->isAfterOrEqualTo($z1)); + } + + public static function providerCompareTo(): array + { + return [ + ['2020-06-06T14:30:30Z', '2014-12-31T23:59:59.999Z', 1], + ['2020-06-06T14:30:30Z', '2020-06-06T14:30:30+00:00', 0], + ['2020-06-06T14:30:30Z', '2020-06-06T14:29:29.999999999+00:00', 1], + ['2020-06-06T14:30:30Z', '2020-06-06T14:30:30.000000000+00:00', 0], + ['2020-06-06T14:30:30Z', '2020-06-06T14:30:30.000000001+00:00', -1], + ['2020-06-06T14:30:30Z', '2020-06-06T15:30:30+01:00', 0], + ['2020-06-06T14:30:30Z', '2020-06-06T16:00:30+01:30', 0], + ['2020-06-06T14:30:30Z', '2020-06-06T15:30:30+02:00', 1], + ['2020-06-06T14:30:30Z', '2020-06-06T13:30:30-02:00', -1], + ]; + } + + public function testGetYear(): void + { + self::assertSame(2000, $this->getTestZonedDateTime()->getYear()); + } + + #[DataProvider('providerGetMonth')] + public function testGetMonth(int $monthValue, Month $month): void + { + $zonedDateTime = ZonedDateTime::of( + LocalDateTime::of(2000, $monthValue, 1), + TimeZone::utc(), + ); + + self::assertSame($month, $zonedDateTime->getMonth()); + } + + public static function providerGetMonth(): array + { + return [ + [1, Month::JANUARY], + [2, Month::FEBRUARY], + [3, Month::MARCH], + [4, Month::APRIL], + [5, Month::MAY], + [6, Month::JUNE], + [7, Month::JULY], + [8, Month::AUGUST], + [9, Month::SEPTEMBER], + [10, Month::OCTOBER], + [11, Month::NOVEMBER], + [12, Month::DECEMBER], + ]; + } + + public function testGetMonthValue(): void + { + self::assertSame(1, $this->getTestZonedDateTime()->getMonthValue()); + } + + public function testGetDayOfMonth(): void + { + self::assertSame(20, $this->getTestZonedDateTime()->getDayOfMonth()); + } + + public function testGetDayOfWeek(): void + { + self::assertSame(DayOfWeek::THURSDAY, $this->getTestZonedDateTime()->getDayOfWeek()); + } + + public function testGetDayOfYear(): void + { + self::assertSame(20, $this->getTestZonedDateTime()->getDayOfYear()); + } + + public function testGetHour(): void + { + self::assertSame(12, $this->getTestZonedDateTime()->getHour()); + } + + public function testGetMinute(): void + { + self::assertSame(34, $this->getTestZonedDateTime()->getMinute()); + } + + public function testGetSecond(): void + { + self::assertSame(56, $this->getTestZonedDateTime()->getSecond()); + } + + public function testWithDate(): void + { + $newDate = LocalDate::of(2000, 1, 22); + + self::assertIs(ZonedDateTime::class, '2000-01-22T12:34:56.123456789-08:00[America/Los_Angeles]', $this->getTestZonedDateTime()->withDate($newDate)); + } + + public function testWithTime(): void + { + $time = LocalTime::of(1, 2, 3, 987654321); + + self::assertIs(ZonedDateTime::class, '2000-01-20T01:02:03.987654321-08:00[America/Los_Angeles]', $this->getTestZonedDateTime()->withTime($time)); + } + + public function testWithYear(): void + { + self::assertIs(ZonedDateTime::class, '2020-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', $this->getTestZonedDateTime()->withYear(2020)); + } + + public function testWithMonth(): void + { + self::assertIs(ZonedDateTime::class, '2000-07-20T12:34:56.123456789-07:00[America/Los_Angeles]', $this->getTestZonedDateTime()->withMonth(7)); + self::assertIs(ZonedDateTime::class, '2000-07-20T12:34:56.123456789-07:00[America/Los_Angeles]', $this->getTestZonedDateTime()->withMonth(Month::JULY)); + } + + public function testWithDay(): void + { + self::assertIs(ZonedDateTime::class, '2000-01-31T12:34:56.123456789-08:00[America/Los_Angeles]', $this->getTestZonedDateTime()->withDay(31)); + } + + public function testWithHour(): void + { + self::assertIs(ZonedDateTime::class, '2000-01-20T23:34:56.123456789-08:00[America/Los_Angeles]', $this->getTestZonedDateTime()->withHour(23)); + } + + public function testWithMinute(): void + { + self::assertIs(ZonedDateTime::class, '2000-01-20T12:00:56.123456789-08:00[America/Los_Angeles]', $this->getTestZonedDateTime()->withMinute(0)); + } + + public function testWithSecond(): void + { + self::assertIs(ZonedDateTime::class, '2000-01-20T12:34:06.123456789-08:00[America/Los_Angeles]', $this->getTestZonedDateTime()->withSecond(6)); + } + + public function testWithNano(): void + { + self::assertIs(ZonedDateTime::class, '2000-01-20T12:34:56.000000123-08:00[America/Los_Angeles]', $this->getTestZonedDateTime()->withNano(123)); + } + + public function testWithFixedOffsetTimeZone(): void + { + self::assertIs(ZonedDateTime::class, '2000-01-20T12:34:56.123456789-08:00', $this->getTestZonedDateTime()->withFixedOffsetTimeZone()); + } + + #[DataProvider('providerPlusPeriod')] + public function testPlusPeriod(string $zonedDateTime, Period $plusPeriod, string $expected): void + { + $actual = ZonedDateTime::parse($zonedDateTime)->plusPeriod($plusPeriod); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + public static function providerPlusPeriod(): Generator + { + yield from [ + ['2000-03-29T12:34:56.123456789-08:00[America/Los_Angeles]', Period::ofDays(3), '2000-04-01T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-03-29T12:34:56.123456789-08:00[America/Los_Angeles]', Period::ofDays(4), '2000-04-02T12:34:56.123456789-07:00[America/Los_Angeles]'], + ['2000-04-03T12:34:56.123456789-07:00[America/Los_Angeles]', Period::ofDays(-1), '2000-04-02T12:34:56.123456789-07:00[America/Los_Angeles]'], + ['2000-04-03T12:34:56.123456789-07:00[America/Los_Angeles]', Period::ofDays(-2), '2000-04-01T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', Period::ofWeeks(10), '2000-03-30T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', Period::ofWeeks(11), '2000-04-06T12:34:56.123456789-07:00[America/Los_Angeles]'], + ['2000-04-06T12:34:56.123456789-07:00[America/Los_Angeles]', Period::ofWeeks(-2), '2000-03-23T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-02-16T12:34:56.123456789-08:00[America/Los_Angeles]', Period::ofMonths(1), '2000-03-16T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-02-16T12:34:56.123456789-08:00[America/Los_Angeles]', Period::ofMonths(2), '2000-04-16T12:34:56.123456789-07:00[America/Los_Angeles]'], + ['2000-05-16T12:34:56.123456789-07:00[America/Los_Angeles]', Period::ofMonths(-1), '2000-04-16T12:34:56.123456789-07:00[America/Los_Angeles]'], + ['2000-05-16T12:34:56.123456789-07:00[America/Los_Angeles]', Period::ofMonths(-2), '2000-03-16T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-02-16T12:34:56.123456789-07:00[America/Los_Angeles]', Period::of(1, -2, 3), '2000-12-19T12:34:56.123456789-08:00[America/Los_Angeles]'], + ]; + + foreach (self::providerPlusDays() as [$zonedDateTime, $plusDays, $expected]) { + yield [$zonedDateTime, Period::ofDays($plusDays), $expected]; + } + + foreach (self::providerPlusWeeks() as [$zonedDateTime, $plusWeeks, $expected]) { + yield [$zonedDateTime, Period::ofWeeks($plusWeeks), $expected]; + } + + foreach (self::providerPlusMonths() as [$zonedDateTime, $plusMonths, $expected]) { + yield [$zonedDateTime, Period::ofMonths($plusMonths), $expected]; + } + + foreach (self::providerPlusYears() as [$zonedDateTime, $plusYears, $expected]) { + yield [$zonedDateTime, Period::ofYears($plusYears), $expected]; + } + } + + #[DataProvider('providerPlusDuration')] + public function testPlusDuration(string $zonedDateTime, Duration $plusDuration, string $expected): void + { + $actual = ZonedDateTime::parse($zonedDateTime)->plusDuration($plusDuration); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + public static function providerPlusDuration(): Generator + { + yield ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', Duration::ofSeconds(-5), '2000-01-20T12:34:51.123456789-08:00[America/Los_Angeles]']; + yield ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', Duration::ofSeconds(5), '2000-01-20T12:35:01.123456789-08:00[America/Los_Angeles]']; + yield ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', Duration::ofMinutes(-10), '2000-01-20T12:24:56.123456789-08:00[America/Los_Angeles]']; + yield ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', Duration::ofMinutes(10), '2000-01-20T12:44:56.123456789-08:00[America/Los_Angeles]']; + yield ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', Duration::ofHours(-24), '2000-01-19T12:34:56.123456789-08:00[America/Los_Angeles]']; + yield ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', Duration::ofHours(24), '2000-01-21T12:34:56.123456789-08:00[America/Los_Angeles]']; + yield ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', Duration::ofSeconds(1, 5000), '2000-01-20T12:34:57.123461789-08:00[America/Los_Angeles]']; + + foreach (self::providerPlusSeconds() as [$zonedDateTime, $plusSeconds, $expected]) { + yield [$zonedDateTime, Duration::ofSeconds($plusSeconds), $expected]; + } + + foreach (self::providerPlusMinutes() as [$zonedDateTime, $plusMinutes, $expected]) { + yield [$zonedDateTime, Duration::ofMinutes($plusMinutes), $expected]; + } + + foreach (self::providerPlusHours() as [$zonedDateTime, $plusHours, $expected]) { + yield [$zonedDateTime, Duration::ofHours($plusHours), $expected]; + } + } + + #[DataProvider('providerPlusYears')] + public function testPlusYears(string $zonedDateTime, int $plusYears, string $expected): void + { + $actual = ZonedDateTime::parse($zonedDateTime)->plusYears($plusYears); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + public static function providerPlusYears(): array + { + return [ + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', -2, '1998-01-20T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', -1, '1999-01-20T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', 0, '2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', 1, '2001-01-20T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', 2, '2002-01-20T12:34:56.123456789-08:00[America/Los_Angeles]'], + ]; + } + + #[DataProvider('providerPlusMonths')] + public function testPlusMonths(string $zonedDateTime, int $plusMonths, string $expected): void + { + $actual = ZonedDateTime::parse($zonedDateTime)->plusMonths($plusMonths); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + public static function providerPlusMonths(): array + { + return [ + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', -4, '1999-09-20T12:34:56.123456789-07:00[America/Los_Angeles]'], + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', -3, '1999-10-20T12:34:56.123456789-07:00[America/Los_Angeles]'], + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', -2, '1999-11-20T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', -1, '1999-12-20T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', 0, '2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', 1, '2000-02-20T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', 2, '2000-03-20T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', 3, '2000-04-20T12:34:56.123456789-07:00[America/Los_Angeles]'], + ['2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]', 4, '2000-05-20T12:34:56.123456789-07:00[America/Los_Angeles]'], + ]; + } + + #[DataProvider('providerPlusWeeks')] + public function testPlusWeeks(string $zonedDateTime, int $plusWeeks, string $expected): void + { + $actual = ZonedDateTime::parse($zonedDateTime)->plusWeeks($plusWeeks); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + public static function providerPlusWeeks(): array + { + return [ + ['2000-01-01T12:34:56.123456789-08:00[America/Los_Angeles]', -25, '1999-07-10T12:34:56.123456789-07:00[America/Los_Angeles]'], + ['2000-01-01T12:34:56.123456789-08:00[America/Los_Angeles]', -1, '1999-12-25T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-01-01T12:34:56.123456789-08:00[America/Los_Angeles]', 0, '2000-01-01T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-01-01T12:34:56.123456789-08:00[America/Los_Angeles]', 1, '2000-01-08T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-01-01T12:34:56.123456789-08:00[America/Los_Angeles]', 25, '2000-06-24T12:34:56.123456789-07:00[America/Los_Angeles]'], + + // https://github.com/brick/date-time/issues/115 + ['2025-03-23T01:00+01:00[Europe/Prague]', 1, '2025-03-30T01:00+01:00[Europe/Prague]'], + ['2025-03-23T01:30+01:00[Europe/Prague]', 1, '2025-03-30T01:30+01:00[Europe/Prague]'], + ['2025-03-23T02:00+01:00[Europe/Prague]', 1, '2025-03-30T03:00+02:00[Europe/Prague]'], + ['2025-03-23T02:30+01:00[Europe/Prague]', 1, '2025-03-30T03:30+02:00[Europe/Prague]'], + ['2025-03-23T03:00+01:00[Europe/Prague]', 1, '2025-03-30T03:00+02:00[Europe/Prague]'], + ['2025-03-23T03:30+01:00[Europe/Prague]', 1, '2025-03-30T03:30+02:00[Europe/Prague]'], + ]; + } + + #[DataProvider('providerPlusDays')] + public function testPlusDays(string $zonedDateTime, int $plusDays, string $expected): void + { + $actual = ZonedDateTime::parse($zonedDateTime)->plusDays($plusDays); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + public static function providerPlusDays(): array + { + return [ + ['2000-03-31T12:34:56.123456789-08:00[America/Los_Angeles]', -1, '2000-03-30T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-03-31T12:34:56.123456789-08:00[America/Los_Angeles]', 0, '2000-03-31T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-03-31T12:34:56.123456789-08:00[America/Los_Angeles]', 1, '2000-04-01T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-03-31T12:34:56.123456789-08:00[America/Los_Angeles]', 2, '2000-04-02T12:34:56.123456789-07:00[America/Los_Angeles]'], + ['2000-03-31T12:34:56.123456789-08:00[America/Los_Angeles]', 3, '2000-04-03T12:34:56.123456789-07:00[America/Los_Angeles]'], + + ['2000-04-03T12:34:56.123456789-07:00[America/Los_Angeles]', -1, '2000-04-02T12:34:56.123456789-07:00[America/Los_Angeles]'], + ['2000-04-03T12:34:56.123456789-07:00[America/Los_Angeles]', -2, '2000-04-01T12:34:56.123456789-08:00[America/Los_Angeles]'], + + // https://github.com/brick/date-time/issues/115 + ['2025-03-29T01:00+01:00[Europe/Prague]', 1, '2025-03-30T01:00+01:00[Europe/Prague]'], + ['2025-03-29T01:30+01:00[Europe/Prague]', 1, '2025-03-30T01:30+01:00[Europe/Prague]'], + ['2025-03-29T02:00+01:00[Europe/Prague]', 1, '2025-03-30T03:00+02:00[Europe/Prague]'], + ['2025-03-29T02:30+01:00[Europe/Prague]', 1, '2025-03-30T03:30+02:00[Europe/Prague]'], + ['2025-03-29T03:00+01:00[Europe/Prague]', 1, '2025-03-30T03:00+02:00[Europe/Prague]'], + ['2025-03-29T03:30+01:00[Europe/Prague]', 1, '2025-03-30T03:30+02:00[Europe/Prague]'], + ]; + } + + #[DataProvider('providerPlusHours')] + public function testPlusHours(string $zonedDateTime, int $plusHours, string $expected): void + { + $actual = ZonedDateTime::parse($zonedDateTime)->plusHours($plusHours); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + public static function providerPlusHours(): array + { + return [ + ['2000-04-01T23:12:34.123456789-08:00[America/Los_Angeles]', -1, '2000-04-01T22:12:34.123456789-08:00[America/Los_Angeles]'], + ['2000-04-01T23:12:34.123456789-08:00[America/Los_Angeles]', 0, '2000-04-01T23:12:34.123456789-08:00[America/Los_Angeles]'], + ['2000-04-01T23:12:34.123456789-08:00[America/Los_Angeles]', 1, '2000-04-02T00:12:34.123456789-08:00[America/Los_Angeles]'], + ['2000-04-01T23:12:34.123456789-08:00[America/Los_Angeles]', 2, '2000-04-02T01:12:34.123456789-08:00[America/Los_Angeles]'], + ['2000-04-01T23:12:34.123456789-08:00[America/Los_Angeles]', 3, '2000-04-02T03:12:34.123456789-07:00[America/Los_Angeles]'], + ['2000-04-01T23:12:34.123456789-08:00[America/Los_Angeles]', 4, '2000-04-02T04:12:34.123456789-07:00[America/Los_Angeles]'], + + ['2000-04-02T04:12:34.123456789-07:00[America/Los_Angeles]', -3, '2000-04-02T00:12:34.123456789-08:00[America/Los_Angeles]'], + ['2000-04-02T04:12:34.123456789-07:00[America/Los_Angeles]', -2, '2000-04-02T01:12:34.123456789-08:00[America/Los_Angeles]'], + ['2000-04-02T04:12:34.123456789-07:00[America/Los_Angeles]', -1, '2000-04-02T03:12:34.123456789-07:00[America/Los_Angeles]'], + ['2000-04-02T04:12:34.123456789-07:00[America/Los_Angeles]', 0, '2000-04-02T04:12:34.123456789-07:00[America/Los_Angeles]'], + ['2000-04-02T04:12:34.123456789-07:00[America/Los_Angeles]', 1, '2000-04-02T05:12:34.123456789-07:00[America/Los_Angeles]'], + + // https://github.com/brick/date-time/issues/115 + ['2025-03-29T01:00+01:00[Europe/Prague]', 24, '2025-03-30T01:00+01:00[Europe/Prague]'], + ['2025-03-29T01:30+01:00[Europe/Prague]', 24, '2025-03-30T01:30+01:00[Europe/Prague]'], + ['2025-03-29T02:00+01:00[Europe/Prague]', 24, '2025-03-30T03:00+02:00[Europe/Prague]'], + ['2025-03-29T02:30+01:00[Europe/Prague]', 24, '2025-03-30T03:30+02:00[Europe/Prague]'], + ['2025-03-29T03:00+01:00[Europe/Prague]', 24, '2025-03-30T04:00+02:00[Europe/Prague]'], + ['2025-03-29T03:30+01:00[Europe/Prague]', 24, '2025-03-30T04:30+02:00[Europe/Prague]'], + ]; + } + + #[DataProvider('providerPlusMinutes')] + public static function testPlusMinutes(string $zonedDateTime, int $plusMinutes, string $expected): void + { + $actual = ZonedDateTime::parse($zonedDateTime)->plusMinutes($plusMinutes); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + public static function providerPlusMinutes(): array + { + return [ + ['2000-04-02T01:59:59.654321-08:00[America/Los_Angeles]', -2, '2000-04-02T01:57:59.654321-08:00[America/Los_Angeles]'], + ['2000-04-02T01:59:59.654321-08:00[America/Los_Angeles]', -1, '2000-04-02T01:58:59.654321-08:00[America/Los_Angeles]'], + ['2000-04-02T01:59:59.654321-08:00[America/Los_Angeles]', 0, '2000-04-02T01:59:59.654321-08:00[America/Los_Angeles]'], + ['2000-04-02T01:59:59.654321-08:00[America/Los_Angeles]', 1, '2000-04-02T03:00:59.654321-07:00[America/Los_Angeles]'], + ['2000-04-02T01:59:59.654321-08:00[America/Los_Angeles]', 2, '2000-04-02T03:01:59.654321-07:00[America/Los_Angeles]'], + ['2000-04-02T01:59:59.654321-08:00[America/Los_Angeles]', 3, '2000-04-02T03:02:59.654321-07:00[America/Los_Angeles]'], + + ['2000-04-02T03:01:01.654321-07:00[America/Los_Angeles]', -3, '2000-04-02T01:58:01.654321-08:00[America/Los_Angeles]'], + ['2000-04-02T03:01:01.654321-07:00[America/Los_Angeles]', -2, '2000-04-02T01:59:01.654321-08:00[America/Los_Angeles]'], + ['2000-04-02T03:01:01.654321-07:00[America/Los_Angeles]', -1, '2000-04-02T03:00:01.654321-07:00[America/Los_Angeles]'], + ['2000-04-02T03:01:01.654321-07:00[America/Los_Angeles]', 0, '2000-04-02T03:01:01.654321-07:00[America/Los_Angeles]'], + ['2000-04-02T03:01:01.654321-07:00[America/Los_Angeles]', 1, '2000-04-02T03:02:01.654321-07:00[America/Los_Angeles]'], + + // https://github.com/brick/date-time/issues/115 + ['2025-03-30T01:30+01:00[Europe/Prague]', 50, '2025-03-30T03:20+02:00[Europe/Prague]'], + ['2025-03-30T01:30+01:00[Europe/Prague]', 100, '2025-03-30T04:10+02:00[Europe/Prague]'], + ['2025-03-30T03:20+02:00[Europe/Prague]', -50, '2025-03-30T01:30+01:00[Europe/Prague]'], + ['2025-03-30T04:10+02:00[Europe/Prague]', -100, '2025-03-30T01:30+01:00[Europe/Prague]'], + ]; + } + + #[DataProvider('providerPlusSeconds')] + public function testPlusSeconds(string $zonedDateTime, int $plusSeconds, string $expected): void + { + $actual = ZonedDateTime::parse($zonedDateTime)->plusSeconds($plusSeconds); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + public static function providerPlusSeconds(): array + { + return [ + ['2000-04-02T01:59:59.654321-08:00[America/Los_Angeles]', -2, '2000-04-02T01:59:57.654321-08:00[America/Los_Angeles]'], + ['2000-04-02T01:59:59.654321-08:00[America/Los_Angeles]', -1, '2000-04-02T01:59:58.654321-08:00[America/Los_Angeles]'], + ['2000-04-02T01:59:59.654321-08:00[America/Los_Angeles]', 0, '2000-04-02T01:59:59.654321-08:00[America/Los_Angeles]'], + ['2000-04-02T01:59:59.654321-08:00[America/Los_Angeles]', 1, '2000-04-02T03:00:00.654321-07:00[America/Los_Angeles]'], + ['2000-04-02T01:59:59.654321-08:00[America/Los_Angeles]', 2, '2000-04-02T03:00:01.654321-07:00[America/Los_Angeles]'], + + ['2000-04-02T03:00:01.654321-07:00[America/Los_Angeles]', -3, '2000-04-02T01:59:58.654321-08:00[America/Los_Angeles]'], + ['2000-04-02T03:00:01.654321-07:00[America/Los_Angeles]', -2, '2000-04-02T01:59:59.654321-08:00[America/Los_Angeles]'], + ['2000-04-02T03:00:01.654321-07:00[America/Los_Angeles]', -1, '2000-04-02T03:00:00.654321-07:00[America/Los_Angeles]'], + ['2000-04-02T03:00:01.654321-07:00[America/Los_Angeles]', 0, '2000-04-02T03:00:01.654321-07:00[America/Los_Angeles]'], + ['2000-04-02T03:00:01.654321-07:00[America/Los_Angeles]', 1, '2000-04-02T03:00:02.654321-07:00[America/Los_Angeles]'], + + // https://github.com/brick/date-time/issues/115 + ['2025-03-30T01:30+01:00[Europe/Prague]', 3000, '2025-03-30T03:20+02:00[Europe/Prague]'], + ['2025-03-30T01:30+01:00[Europe/Prague]', 6000, '2025-03-30T04:10+02:00[Europe/Prague]'], + ['2025-03-30T03:20+02:00[Europe/Prague]', -3000, '2025-03-30T01:30+01:00[Europe/Prague]'], + ['2025-03-30T04:10+02:00[Europe/Prague]', -6000, '2025-03-30T01:30+01:00[Europe/Prague]'], + ]; + } + + #[DataProvider('providerPlusPeriod')] + public function testMinusPeriod(string $dateTime, Period $plusPeriod, string $expected): void + { + $actual = ZonedDateTime::parse($dateTime)->minusPeriod($plusPeriod->negated()); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + #[DataProvider('providerPlusDuration')] + public function testMinusDuration(string $dateTime, Duration $plusDuration, string $expected): void + { + $actual = ZonedDateTime::parse($dateTime)->minusDuration($plusDuration->negated()); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + #[DataProvider('providerPlusYears')] + public function testMinusYears(string $dateTime, int $plusYears, string $expected): void + { + $actual = ZonedDateTime::parse($dateTime)->minusYears(-$plusYears); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + #[DataProvider('providerPlusMonths')] + public function testMinusMonths(string $dateTime, int $plusMonths, string $expected): void + { + $actual = ZonedDateTime::parse($dateTime)->minusMonths(-$plusMonths); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + #[DataProvider('providerPlusWeeks')] + public function testMinusWeeks(string $dateTime, int $plusWeeks, string $expected): void + { + $actual = ZonedDateTime::parse($dateTime)->minusWeeks(-$plusWeeks); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + #[DataProvider('providerPlusDays')] + public function testMinusDays(string $dateTime, int $plusDays, string $expected): void + { + $actual = ZonedDateTime::parse($dateTime)->minusDays(-$plusDays); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + #[DataProvider('providerPlusHours')] + public function testMinusHours(string $dateTime, int $plusHours, string $expected): void + { + $actual = ZonedDateTime::parse($dateTime)->minusHours(-$plusHours); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + #[DataProvider('providerPlusMinutes')] + public function testMinusMinutes(string $dateTime, int $plusMinutes, string $expected): void + { + $actual = ZonedDateTime::parse($dateTime)->minusMinutes(-$plusMinutes); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + #[DataProvider('providerPlusSeconds')] + public function testMinusSeconds(string $dateTime, int $plusSeconds, string $expected): void + { + $actual = ZonedDateTime::parse($dateTime)->minusSeconds(-$plusSeconds); + self::assertIs(ZonedDateTime::class, $expected, $actual); + } + + public function testIsBetweenInclusive(): void + { + $timeZone = TimeZone::parse('America/Los_Angeles'); + $localDateTime = '2000-01-20T12:34:56.123456789'; + $localDateTime = LocalDateTime::parse($localDateTime); + $fromZonedDateTime = ZonedDateTime::of($localDateTime, $timeZone); + + $localDateTime = '2015-01-20T12:34:56.123456789'; + $localDateTime = LocalDateTime::parse($localDateTime); + $toZonedDateTime = ZonedDateTime::of($localDateTime, $timeZone); + + $localDateTime = '2018-01-20T12:34:56.123456789'; + $localDateTime = LocalDateTime::parse($localDateTime); + $notIncluZonedDateTime = ZonedDateTime::of($localDateTime, $timeZone); + + self::assertTrue($fromZonedDateTime->isBetweenInclusive($fromZonedDateTime, $toZonedDateTime)); + self::assertFalse($fromZonedDateTime->isBetweenInclusive($toZonedDateTime, $notIncluZonedDateTime)); + } + + public function testIsBetweenExclusive(): void + { + $timeZone = TimeZone::parse('America/Los_Angeles'); + $localDateTime = '2000-01-20T12:34:56.123456789'; + $localDateTime = LocalDateTime::parse($localDateTime); + $fromZonedDateTime = ZonedDateTime::of($localDateTime, $timeZone); + + $localDateTime = '2015-01-20T12:34:56.123456789'; + $localDateTime = LocalDateTime::parse($localDateTime); + $toZonedDateTime = ZonedDateTime::of($localDateTime, $timeZone); + + $localDateTime = '2014-01-20T12:34:56.123456789'; + $localDateTime = LocalDateTime::parse($localDateTime); + $incluZonedDateTime = ZonedDateTime::of($localDateTime, $timeZone); + + self::assertTrue($incluZonedDateTime->isBetweenExclusive($fromZonedDateTime, $toZonedDateTime)); + self::assertFalse($fromZonedDateTime->isBetweenExclusive($fromZonedDateTime, $toZonedDateTime)); + } + + #[DataProvider('providerForPastFuture')] + public function testIsFuture(int $clockTimestamp, string $zonedDateTime, bool $isFuture): void + { + $clock = new FixedClock(Instant::of($clockTimestamp)); + $zonedDateTime = ZonedDateTime::parse($zonedDateTime); + self::assertSame($isFuture, $zonedDateTime->isFuture($clock)); + } + + #[DataProvider('providerForPastFuture')] + public function testIsPast(int $clockTimestamp, string $zonedDateTime, bool $isFuture): void + { + $clock = new FixedClock(Instant::of($clockTimestamp)); + $zonedDateTime = ZonedDateTime::parse($zonedDateTime); + self::assertSame(! $isFuture, $zonedDateTime->isPast($clock)); + } + + public static function providerForPastFuture(): array + { + return [ + [1234567890, '2009-02-14T00:31:29+01:00', false], + [1234567890, '2009-02-14T00:31:31+01:00', true], + [2345678901, '2044-04-30T17:28:20-08:00', false], + [2345678901, '2044-04-30T17:28:22-08:00', true], + ]; + } + + /** + * @param string $dateTime The date-time string that will be parse()d by ZonedDateTime. + * @param string $expected The expected output from the native DateTime object. + */ + #[DataProvider('providerToNativeDateTime')] + public function testToNativeDateTime(string $dateTime, string $expected): void + { + $zonedDateTime = ZonedDateTime::parse($dateTime); + $dateTime = $zonedDateTime->toNativeDateTime(); + + self::assertInstanceOf(DateTime::class, $dateTime); + self::assertSame($expected, $dateTime->format('Y-m-d\TH:i:s.uO')); + } + + /** + * @param string $dateTime The date-time string that will be parse()d by ZonedDateTime. + * @param string $expected The expected output from the native DateTime object. + */ + #[DataProvider('providerToNativeDateTime')] + public function testToNativeDateTimeImmutable(string $dateTime, string $expected): void + { + $zonedDateTime = ZonedDateTime::parse($dateTime); + $dateTime = $zonedDateTime->toNativeDateTimeImmutable(); + + self::assertInstanceOf(DateTimeImmutable::class, $dateTime); + self::assertSame($expected, $dateTime->format('Y-m-d\TH:i:s.uO')); + } + + public static function providerToNativeDateTime(): array + { + return [ + ['2018-10-18T12:34Z', '2018-10-18T12:34:00.000000+0000'], + ['2018-10-18T12:34:56Z', '2018-10-18T12:34:56.000000+0000'], + ['2018-10-18T12:34:00.001Z', '2018-10-18T12:34:00.001000+0000'], + ['2018-10-18T12:34:56.123002Z', '2018-10-18T12:34:56.123002+0000'], + ['2011-07-31T23:59:59+01:00', '2011-07-31T23:59:59.000000+0100'], + ['2011-07-31T23:59:59.02-05:30', '2011-07-31T23:59:59.020000-0530'], + ['2011-07-31T23:59:59+01:00[Europe/London]', '2011-07-31T23:59:59.000000+0100'], + ['2011-07-31T23:59:59.000123456-07:00', '2011-07-31T23:59:59.000123-0700'], + ]; + } + + #[DataProvider('providerToString')] + public function testJsonSerialize(string $localDateTime, string $timeZone, string $expectedString): void + { + $localDateTime = LocalDateTime::parse($localDateTime); + $zonedDateTime = ZonedDateTime::of($localDateTime, TimeZone::parse($timeZone)); + + self::assertSame(json_encode($expectedString, JSON_THROW_ON_ERROR), json_encode($zonedDateTime, JSON_THROW_ON_ERROR)); + } + + #[DataProvider('providerToString')] + public function testToISOString(string $localDateTime, string $timeZone, string $expectedString): void + { + $localDateTime = LocalDateTime::parse($localDateTime); + $zonedDateTime = ZonedDateTime::of($localDateTime, TimeZone::parse($timeZone)); + + self::assertSame($expectedString, $zonedDateTime->toISOString()); + } + + #[DataProvider('providerToString')] + public function testToString(string $localDateTime, string $timeZone, string $expectedString): void + { + $localDateTime = LocalDateTime::parse($localDateTime); + $zonedDateTime = ZonedDateTime::of($localDateTime, TimeZone::parse($timeZone)); + + self::assertSame($expectedString, (string) $zonedDateTime); + } + + public static function providerToString(): array + { + return [ + ['2000-01-20T12:34:56.123456789', 'America/Los_Angeles', '2000-01-20T12:34:56.123456789-08:00[America/Los_Angeles]'], + ['2000-01-20T12:34:56.123456789', '-07:00', '2000-01-20T12:34:56.123456789-07:00'], + ]; + } + + #[DataProvider('providerGetDurationTo')] + public function testGetDurationTo(string $firstDate, string $secondDate, int $expectedSeconds, int $expectedNanos): void + { + $actualResult = ZonedDateTime::parse($firstDate)->getDurationTo(ZonedDateTime::parse($secondDate)); + + self::assertDurationIs($expectedSeconds, $expectedNanos, $actualResult); + } + + public static function providerGetDurationTo(): array + { + return [ + ['2023-01-01T10:00:00Z', '2023-01-01T10:00:00Z', 0, 0], + ['2023-01-01T10:00:00Z', '2023-01-01T10:00:10Z', 10, 0], + ['2023-01-01T10:00:00.001Z', '2023-01-01T10:00:10.002Z', 10, 1000000], + ['2023-01-01T10:00:00.001Z', '2023-01-01T13:00:10.002+03:00', 10, 1000000], + ['2023-01-01T10:00:00.000000001Z', '2023-01-01T10:00:00.000000009Z', 0, 8], + ['2023-01-01T10:00:00Z', '2023-01-02T10:00:00Z', 24 * 60 * 60, 0], + ['2023-01-02T10:00:00Z', '2023-01-01T10:00:00Z', -24 * 60 * 60, 0], + ]; + } + + #[DataProvider('providerGetIntervalTo')] + public function testGetIntervalTo(string $firstDate, string $secondDate, string $expectedInterval): void + { + $actualResult = ZonedDateTime::parse($firstDate)->getIntervalTo(ZonedDateTime::parse($secondDate)); + + self::assertSame($expectedInterval, (string) $actualResult); + } + + public static function providerGetIntervalTo(): array + { + return [ + ['2023-01-01T10:00:00Z', '2023-01-01T10:00:00Z', '2023-01-01T10:00Z/2023-01-01T10:00Z'], + ['2023-01-01T10:00:00Z', '2023-01-01T10:00:10Z', '2023-01-01T10:00Z/2023-01-01T10:00:10Z'], + ['2023-01-01T10:00:00.001Z', '2023-01-01T10:00:10.002Z', '2023-01-01T10:00:00.001Z/2023-01-01T10:00:10.002Z'], + ['2023-01-01T10:00:00.001Z', '2023-01-01T13:00:10.002+03:00', '2023-01-01T10:00:00.001Z/2023-01-01T10:00:10.002Z'], + ['2023-01-01T10:00:00.001+03:00', '2023-01-01T13:00:10.002+03:00', '2023-01-01T07:00:00.001Z/2023-01-01T10:00:10.002Z'], + ['2023-01-01T10:00:00.000000001Z', '2023-01-01T10:00:00.000000009Z', '2023-01-01T10:00:00.000000001Z/2023-01-01T10:00:00.000000009Z'], + ['2023-01-01T10:00:00Z', '2023-01-02T10:00:00Z', '2023-01-01T10:00Z/2023-01-02T10:00Z'], + ]; + } + + private function getTestZonedDateTime(): ZonedDateTime + { + $timeZone = TimeZone::parse('America/Los_Angeles'); + $localDateTime = LocalDateTime::parse('2000-01-20T12:34:56.123456789'); + + return ZonedDateTime::of($localDateTime, $timeZone); + } +}