From 1dc290cc25addca38d2c0999c7865b344bea533d Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Mon, 2 Mar 2026 19:48:50 -0300 Subject: [PATCH] feat: Add Duration and Period value objects with associated arithmetic and validation. --- README.md | 245 +++++++++++-- src/Duration.php | 191 ++++++++++ src/Instant.php | 84 +++++ src/Internal/Exceptions/InvalidDuration.php | 24 ++ src/Internal/Exceptions/InvalidPeriod.php | 23 ++ src/Period.php | 91 +++++ tests/DurationTest.php | 320 +++++++++++++++++ tests/InstantTest.php | 366 +++++++++++++++++++ tests/PeriodTest.php | 367 ++++++++++++++++++++ 9 files changed, 1690 insertions(+), 21 deletions(-) create mode 100644 src/Duration.php create mode 100644 src/Internal/Exceptions/InvalidDuration.php create mode 100644 src/Internal/Exceptions/InvalidPeriod.php create mode 100644 src/Period.php create mode 100644 tests/DurationTest.php create mode 100644 tests/PeriodTest.php diff --git a/README.md b/README.md index 2f1791f..6dcf3c4 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ * [Installation](#installation) * [How to use](#how-to-use) * [Instant](#instant) + * [Duration](#duration) + * [Period](#period) * [Timezone](#timezone) * [Timezones](#timezones) * [License](#license) @@ -15,7 +17,8 @@ ## Overview -Value Object representing time in an immutable and strict way, focused on safe parsing, formatting and normalization. +Value Objects representing time in an immutable and strict way, focused on safe parsing, formatting, normalization and +temporal arithmetic.
@@ -29,8 +32,8 @@ composer require tiny-blocks/time ## How to use -The library provides immutable Value Objects for representing points in time and IANA timezones. All instants are -normalized to UTC internally. +The library provides immutable Value Objects for representing points in time, quantities of time and time intervals. +All instants are normalized to UTC internally. ### Instant @@ -45,9 +48,9 @@ use TinyBlocks\Time\Instant; $instant = Instant::now(); -$instant->toIso8601(); # 2026-02-17T10:30:00+00:00 (current UTC time) -$instant->toUnixSeconds(); # 1771324200 (current Unix timestamp) -$instant->toDateTimeImmutable(); # DateTimeImmutable (UTC, with microseconds) +$instant->toIso8601(); # 2026-02-17T10:30:00+00:00 +$instant->toUnixSeconds(); # 1771324200 +$instant->toDateTimeImmutable(); # DateTimeImmutable (UTC, with microseconds) ``` #### Creating from a string @@ -59,9 +62,8 @@ use TinyBlocks\Time\Instant; $instant = Instant::fromString(value: '2026-02-17T13:30:00-03:00'); -$instant->toIso8601(); # 2026-02-17T16:30:00+00:00 -$instant->toUnixSeconds(); # 1771345800 -$instant->toDateTimeImmutable(); # DateTimeImmutable (UTC) +$instant->toIso8601(); # 2026-02-17T16:30:00+00:00 +$instant->toUnixSeconds(); # 1771345800 ``` #### Creating from a database timestamp @@ -74,8 +76,8 @@ use TinyBlocks\Time\Instant; $instant = Instant::fromString(value: '2026-02-17 08:27:21.106011'); -$instant->toIso8601(); # 2026-02-17T08:27:21+00:00 -$instant->toDateTimeImmutable()->format('Y-m-d H:i:s.u'); # 2026-02-17 08:27:21.106011 +$instant->toIso8601(); # 2026-02-17T08:27:21+00:00 +$instant->toDateTimeImmutable()->format('Y-m-d H:i:s.u'); # 2026-02-17 08:27:21.106011 ``` Also supports timestamps without fractional seconds: @@ -101,30 +103,231 @@ $instant->toIso8601(); # 1970-01-01T00:00:00+00:00 $instant->toUnixSeconds(); # 0 ``` -#### Formatting as ISO 8601 +#### Adding and subtracting time -The `toIso8601` method always returns the format `YYYY-MM-DDTHH:MM:SS+00:00`, without fractional seconds. +Returns a new `Instant` shifted forward or backward by a `Duration`. ```php use TinyBlocks\Time\Instant; +use TinyBlocks\Time\Duration; -$instant = Instant::fromString(value: '2026-02-17T19:30:00+09:00'); +$instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); -$instant->toIso8601(); # 2026-02-17T10:30:00+00:00 +$instant->plus(duration: Duration::ofMinutes(minutes: 30))->toIso8601(); # 2026-02-17T10:30:00+00:00 +$instant->plus(duration: Duration::ofHours(hours: 2))->toIso8601(); # 2026-02-17T12:00:00+00:00 +$instant->minus(duration: Duration::ofSeconds(seconds: 60))->toIso8601(); # 2026-02-17T09:59:00+00:00 ``` -#### Accessing the underlying DateTimeImmutable +#### Measuring distance between instants -Returns a `DateTimeImmutable` in UTC with full microsecond precision. +Returns the absolute `Duration` between two `Instant` objects. ```php use TinyBlocks\Time\Instant; -$instant = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); -$dateTime = $instant->toDateTimeImmutable(); +$start = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); +$end = Instant::fromString(value: '2026-02-17T11:30:00+00:00'); -$dateTime->getTimezone()->getName(); # UTC -$dateTime->format('Y-m-d\TH:i:s.u'); # 2026-02-17T10:30:00.000000 +$duration = $start->durationUntil(other: $end); + +$duration->seconds; # 5400 +$duration->toMinutes(); # 90 +$duration->toHours(); # 1 +``` + +The result is always non-negative regardless of direction: + +```php +$end->durationUntil(other: $start)->seconds; # 5400 +``` + +#### Comparing instants + +Provides strict temporal ordering between two `Instant` instances. + +```php +use TinyBlocks\Time\Instant; + +$earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); +$later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + +$earlier->isBefore(other: $later); # true +$earlier->isAfter(other: $later); # false +$earlier->isBeforeOrEqual(other: $later); # true +$earlier->isAfterOrEqual(other: $later); # false +$later->isAfter(other: $earlier); # true +$later->isAfterOrEqual(other: $earlier); # true +``` + +### Duration + +A `Duration` represents an immutable, unsigned quantity of time measured in seconds. It has no reference point on the +timeline — it expresses only "how much" time. + +#### Creating durations + +```php +use TinyBlocks\Time\Duration; + +$zero = Duration::zero(); +$seconds = Duration::ofSeconds(seconds: 90); +$minutes = Duration::ofMinutes(minutes: 30); +$hours = Duration::ofHours(hours: 2); +$days = Duration::ofDays(days: 7); +``` + +All factories reject negative values: + +```php +Duration::ofMinutes(minutes: -5); # throws InvalidDuration +``` + +#### Arithmetic + +```php +use TinyBlocks\Time\Duration; + +$a = Duration::ofMinutes(minutes: 30); +$b = Duration::ofMinutes(minutes: 15); + +$a->plus(other: $b)->seconds; # 2700 (45 minutes) +$a->minus(other: $b)->seconds; # 900 (15 minutes) +``` + +Subtraction that would produce a negative result throws an exception: + +```php +$b->minus(other: $a); # throws InvalidDuration +``` + +#### Comparing durations + +```php +use TinyBlocks\Time\Duration; + +$short = Duration::ofMinutes(minutes: 15); +$long = Duration::ofHours(hours: 2); + +$short->isLessThan(other: $long); # true +$long->isGreaterThan(other: $short); # true +$short->isZero(); # false +Duration::zero()->isZero(); # true +``` + +#### Converting to other units + +Conversions truncate toward zero when the duration is not an exact multiple: + +```php +use TinyBlocks\Time\Duration; + +$duration = Duration::ofSeconds(seconds: 5400); + +$duration->toMinutes(); # 90 +$duration->toHours(); # 1 +$duration->toDays(); # 0 +``` + +### Period + +A `Period` represents a half-open time interval `[from, to)` between two UTC instants. The start is inclusive and the +end is exclusive. + +#### Creating from two instants + +```php +use TinyBlocks\Time\Instant; +use TinyBlocks\Time\Period; + +$period = Period::of( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') +); + +$period->from->toIso8601(); # 2026-02-17T10:00:00+00:00 +$period->to->toIso8601(); # 2026-02-17T11:00:00+00:00 +``` + +The start must be strictly before the end: + +```php +Period::of(from: $later, to: $earlier); # throws InvalidPeriod +``` + +#### Creating from a start and duration + +```php +use TinyBlocks\Time\Duration; +use TinyBlocks\Time\Instant; +use TinyBlocks\Time\Period; + +$period = Period::startingAt( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + duration: Duration::ofMinutes(minutes: 90) +); + +$period->from->toIso8601(); # 2026-02-17T10:00:00+00:00 +$period->to->toIso8601(); # 2026-02-17T11:30:00+00:00 +``` + +#### Getting the duration + +```php +$period->duration()->seconds; # 5400 +$period->duration()->toMinutes(); # 90 +``` + +#### Checking if an instant is contained + +The check is inclusive at the start and exclusive at the end: + +```php +use TinyBlocks\Time\Instant; + +$period->contains(instant: Instant::fromString(value: '2026-02-17T10:00:00+00:00')); # true (start, inclusive) +$period->contains(instant: Instant::fromString(value: '2026-02-17T10:30:00+00:00')); # true (middle) +$period->contains(instant: Instant::fromString(value: '2026-02-17T11:30:00+00:00')); # false (end, exclusive) +``` + +#### Detecting overlap + +Two half-open intervals `[A, B)` and `[C, D)` overlap when `A < D` and `C < B`: + +```php +use TinyBlocks\Time\Duration; +use TinyBlocks\Time\Instant; +use TinyBlocks\Time\Period; + +$periodA = Period::startingAt( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + duration: Duration::ofHours(hours: 1) +); +$periodB = Period::startingAt( + from: Instant::fromString(value: '2026-02-17T10:30:00+00:00'), + duration: Duration::ofHours(hours: 1) +); + +$periodA->overlapsWith(other: $periodB); # true +$periodB->overlapsWith(other: $periodA); # true +``` + +Adjacent periods do not overlap: + +```php +use TinyBlocks\Time\Duration; +use TinyBlocks\Time\Instant; +use TinyBlocks\Time\Period; + +$first = Period::startingAt( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + duration: Duration::ofHours(hours: 1) +); +$second = Period::startingAt( + from: Instant::fromString(value: '2026-02-17T11:00:00+00:00'), + duration: Duration::ofHours(hours: 1) +); + +$first->overlapsWith(other: $second); # false ``` ### Timezone diff --git a/src/Duration.php b/src/Duration.php new file mode 100644 index 0000000..a913cd7 --- /dev/null +++ b/src/Duration.php @@ -0,0 +1,191 @@ +seconds + $other->seconds); + } + + /** + * Returns a new Duration by subtracting another Duration from this one. + * + * @param Duration $other The Duration to subtract. + * @return Duration A new Duration representing the difference. + * @throws InvalidDuration If the result was negative. + */ + public function minus(Duration $other): Duration + { + $result = $this->seconds - $other->seconds; + + if ($result < 0) { + throw InvalidDuration::becauseResultIsNegative(current: $this->seconds, subtracted: $other->seconds); + } + + return new Duration(seconds: $result); + } + + /** + * Returns true if this Duration has zero length. + * + * @return bool True if this Duration is zero seconds. + */ + public function isZero(): bool + { + return $this->seconds === 0; + } + + /** + * Returns true if this Duration is strictly greater than another. + * + * @param Duration $other The Duration to compare against. + * @return bool True if this Duration is longer. + */ + public function isGreaterThan(Duration $other): bool + { + return $this->seconds > $other->seconds; + } + + /** + * Returns true if this Duration is strictly less than another. + * + * @param Duration $other The Duration to compare against. + * @return bool True if this Duration is shorter. + */ + public function isLessThan(Duration $other): bool + { + return $this->seconds < $other->seconds; + } + + /** + * Returns the total number of whole minutes in this Duration. + * + * @return int The number of whole minutes. + */ + public function toMinutes(): int + { + return intdiv($this->seconds, self::SECONDS_PER_MINUTE); + } + + /** + * Returns the total number of whole hours in this Duration. + * + * @return int The number of whole hours. + */ + public function toHours(): int + { + return intdiv($this->seconds, self::SECONDS_PER_HOUR); + } + + /** + * Returns the total number of whole days in this Duration. + * + * @return int The number of whole days. + */ + public function toDays(): int + { + return intdiv($this->seconds, self::SECONDS_PER_DAY); + } +} diff --git a/src/Instant.php b/src/Instant.php index 009d6c2..fab128e 100644 --- a/src/Instant.php +++ b/src/Instant.php @@ -73,6 +73,90 @@ public static function fromUnixSeconds(int $seconds): Instant return new Instant(datetime: $datetime->setTimezone($utc)); } + /** + * Returns a new Instant shifted forward by the given Duration. + * + * @param Duration $duration The amount of time to add. + * @return Instant A new Instant shifted forward in time. + */ + public function plus(Duration $duration): Instant + { + $modified = $this->datetime->modify(sprintf('+%d seconds', $duration->seconds)); + + return new Instant(datetime: $modified); + } + + /** + * Returns a new Instant shifted backward by the given Duration. + * + * @param Duration $duration The amount of time to subtract. + * @return Instant A new Instant shifted backward in time. + */ + public function minus(Duration $duration): Instant + { + $modified = $this->datetime->modify(sprintf('-%d seconds', $duration->seconds)); + + return new Instant(datetime: $modified); + } + + /** + * Returns the Duration between this instant and another. + * The result is always non-negative (absolute distance). + * + * @param Instant $other The instant to measure the distance to. + * @return Duration The absolute duration between the two instants. + */ + public function durationUntil(Instant $other): Duration + { + $difference = abs($this->datetime->getTimestamp() - $other->datetime->getTimestamp()); + + return Duration::ofSeconds(seconds: $difference); + } + + /** + * Returns true if this instant is strictly before the other. + * + * @param Instant $other The instant to compare against. + * @return bool True if this instant precedes the other. + */ + public function isBefore(Instant $other): bool + { + return $this->datetime < $other->datetime; + } + + /** + * Returns true if this instant is strictly after the other. + * + * @param Instant $other The instant to compare against. + * @return bool True if this instant follows the other. + */ + public function isAfter(Instant $other): bool + { + return $this->datetime > $other->datetime; + } + + /** + * Returns true if this instant is before or at the same moment as the other. + * + * @param Instant $other The instant to compare against. + * @return bool True if this instant is at or before the other. + */ + public function isBeforeOrEqual(Instant $other): bool + { + return $this->datetime <= $other->datetime; + } + + /** + * Returns true if this instant is after or at the same moment as the other. + * + * @param Instant $other The instant to compare against. + * @return bool True if this instant is at or after the other. + */ + public function isAfterOrEqual(Instant $other): bool + { + return $this->datetime >= $other->datetime; + } + /** * Formats this instant as an ISO 8601 string in UTC (e.g. 2026-02-17T10:30:00+00:00). * diff --git a/src/Internal/Exceptions/InvalidDuration.php b/src/Internal/Exceptions/InvalidDuration.php new file mode 100644 index 0000000..1162a7b --- /dev/null +++ b/src/Internal/Exceptions/InvalidDuration.php @@ -0,0 +1,24 @@ +.'; + + return new InvalidDuration(message: sprintf($template, $unit, $value)); + } + + public static function becauseResultIsNegative(int $current, int $subtracted): InvalidDuration + { + $template = 'Duration subtraction would result in a negative value: <%d> - <%d>.'; + + return new InvalidDuration(message: sprintf($template, $current, $subtracted)); + } +} diff --git a/src/Internal/Exceptions/InvalidPeriod.php b/src/Internal/Exceptions/InvalidPeriod.php new file mode 100644 index 0000000..429f6ca --- /dev/null +++ b/src/Internal/Exceptions/InvalidPeriod.php @@ -0,0 +1,23 @@ + must be strictly before end <%s>.'; + + return new InvalidPeriod(message: sprintf($template, $from->toIso8601(), $to->toIso8601())); + } + + public static function becauseDurationIsZero(): InvalidPeriod + { + return new InvalidPeriod(message: 'Period duration must not be zero.'); + } +} diff --git a/src/Period.php b/src/Period.php new file mode 100644 index 0000000..0dec2d5 --- /dev/null +++ b/src/Period.php @@ -0,0 +1,91 @@ +isAfterOrEqual(other: $to)) { + throw InvalidPeriod::becauseStartIsNotBeforeEnd(from: $from, to: $to); + } + + return new Period(from: $from, to: $to); + } + + /** + * Creates a Period from a start instant and a Duration. + * + * @param Instant $from The inclusive start of the period. + * @param Duration $duration The length of the period (must not be zero). + * @return Period The created period. + * @throws InvalidPeriod If the duration is zero. + */ + public static function startingAt(Instant $from, Duration $duration): Period + { + if ($duration->isZero()) { + throw InvalidPeriod::becauseDurationIsZero(); + } + + return new Period(from: $from, to: $from->plus(duration: $duration)); + } + + /** + * Returns the Duration of this period. + * + * @return Duration The time elapsed from start to end. + */ + public function duration(): Duration + { + return $this->from->durationUntil(other: $this->to); + } + + /** + * Checks whether the given instant falls within this period (inclusive start, exclusive end). + * + * @param Instant $instant The instant to check. + * @return bool True if the instant is within [from, to). + */ + public function contains(Instant $instant): bool + { + return $instant->isAfterOrEqual(other: $this->from) + && $instant->isBefore(other: $this->to); + } + + /** + * Checks whether this period overlaps with another period. + * Two half-open intervals [A, B) and [C, D) overlap when A < D and C < B. + * + * @param Period $other The period to check against. + * @return bool True if the periods share any common time. + */ + public function overlapsWith(Period $other): bool + { + return $this->from->isBefore(other: $other->to) + && $other->from->isBefore(other: $this->to); + } +} diff --git a/tests/DurationTest.php b/tests/DurationTest.php new file mode 100644 index 0000000..8b13b05 --- /dev/null +++ b/tests/DurationTest.php @@ -0,0 +1,320 @@ +seconds); + self::assertTrue($duration->isZero()); + } + + public function testOfSecondsCreatesCorrectDuration(): void + { + /** @Given a Duration of 1800 seconds */ + $duration = Duration::ofSeconds(seconds: 1800); + + /** @Then it should hold 1800 seconds */ + self::assertSame(1800, $duration->seconds); + self::assertFalse($duration->isZero()); + } + + public function testOfSecondsWithZero(): void + { + /** @Given a Duration of zero seconds */ + $duration = Duration::ofSeconds(seconds: 0); + + /** @Then it should be zero */ + self::assertSame(0, $duration->seconds); + self::assertTrue($duration->isZero()); + } + + public function testOfSecondsThrowsWhenNegative(): void + { + /** @Then an InvalidDuration exception should be thrown */ + $this->expectException(InvalidDuration::class); + + /** @When creating a Duration with negative seconds */ + Duration::ofSeconds(seconds: -1); + } + + public function testOfMinutesConvertsToSeconds(): void + { + /** @Given a Duration of 30 minutes */ + $duration = Duration::ofMinutes(minutes: 30); + + /** @Then it should hold 1800 seconds */ + self::assertSame(1800, $duration->seconds); + } + + public function testOfMinutesWithZero(): void + { + /** @Given a Duration of zero minutes */ + $duration = Duration::ofMinutes(minutes: 0); + + /** @Then it should be zero */ + self::assertTrue($duration->isZero()); + } + + public function testOfMinutesThrowsWhenNegative(): void + { + /** @Then an InvalidDuration exception should be thrown */ + $this->expectException(InvalidDuration::class); + + /** @When creating a Duration with negative minutes */ + Duration::ofMinutes(minutes: -5); + } + + public function testOfHoursConvertsToSeconds(): void + { + /** @Given a Duration of 2 hours */ + $duration = Duration::ofHours(hours: 2); + + /** @Then it should hold 7200 seconds */ + self::assertSame(7200, $duration->seconds); + } + + public function testOfHoursThrowsWhenNegative(): void + { + /** @Then an InvalidDuration exception should be thrown */ + $this->expectException(InvalidDuration::class); + + /** @When creating a Duration with negative hours */ + Duration::ofHours(hours: -1); + } + + public function testOfDaysConvertsToSeconds(): void + { + /** @Given a Duration of 1 day */ + $duration = Duration::ofDays(days: 1); + + /** @Then it should hold 86400 seconds */ + self::assertSame(86400, $duration->seconds); + } + + public function testOfDaysThrowsWhenNegative(): void + { + /** @Then an InvalidDuration exception should be thrown */ + $this->expectException(InvalidDuration::class); + + /** @When creating a Duration with negative days */ + Duration::ofDays(days: -1); + } + + public function testPlusAddsTwoDurations(): void + { + /** @Given a Duration of 30 minutes and another of 15 minutes */ + $thirtyMinutes = Duration::ofMinutes(minutes: 30); + $fifteenMinutes = Duration::ofMinutes(minutes: 15); + + /** @When adding them */ + $result = $thirtyMinutes->plus(other: $fifteenMinutes); + + /** @Then the result should be 45 minutes in seconds */ + self::assertSame(2700, $result->seconds); + } + + public function testPlusWithZeroReturnsSameValue(): void + { + /** @Given a Duration of 1 hour */ + $oneHour = Duration::ofHours(hours: 1); + + /** @When adding zero */ + $result = $oneHour->plus(other: Duration::zero()); + + /** @Then the result should be unchanged */ + self::assertSame(3600, $result->seconds); + } + + public function testMinusSubtractsTwoDurations(): void + { + /** @Given a Duration of 60 minutes and another of 15 minutes */ + $sixtyMinutes = Duration::ofMinutes(minutes: 60); + $fifteenMinutes = Duration::ofMinutes(minutes: 15); + + /** @When subtracting */ + $result = $sixtyMinutes->minus(other: $fifteenMinutes); + + /** @Then the result should be 45 minutes in seconds */ + self::assertSame(2700, $result->seconds); + } + + public function testMinusToZero(): void + { + /** @Given a Duration of 30 minutes */ + $thirtyMinutes = Duration::ofMinutes(minutes: 30); + + /** @When subtracting itself */ + $result = $thirtyMinutes->minus(other: $thirtyMinutes); + + /** @Then the result should be zero */ + self::assertTrue($result->isZero()); + } + + public function testMinusThrowsWhenResultIsNegative(): void + { + /** @Given a smaller Duration subtracting a larger one */ + $tenMinutes = Duration::ofMinutes(minutes: 10); + $thirtyMinutes = Duration::ofMinutes(minutes: 30); + + /** @Then an InvalidDuration exception should be thrown */ + $this->expectException(InvalidDuration::class); + + /** @When subtracting */ + $tenMinutes->minus(other: $thirtyMinutes); + } + + public function testIsGreaterThanReturnsTrueWhenLonger(): void + { + /** @Given a Duration of 2 hours and another of 30 minutes */ + $twoHours = Duration::ofHours(hours: 2); + $thirtyMinutes = Duration::ofMinutes(minutes: 30); + + /** @Then the longer should be greater than the shorter */ + self::assertTrue($twoHours->isGreaterThan(other: $thirtyMinutes)); + self::assertFalse($thirtyMinutes->isGreaterThan(other: $twoHours)); + } + + public function testIsGreaterThanReturnsFalseWhenEqual(): void + { + /** @Given two equal Durations of 30 minutes */ + $firstThirtyMinutes = Duration::ofMinutes(minutes: 30); + $secondThirtyMinutes = Duration::ofMinutes(minutes: 30); + + /** @Then neither should be greater than the other */ + self::assertFalse($firstThirtyMinutes->isGreaterThan(other: $secondThirtyMinutes)); + } + + public function testIsLessThanReturnsTrueWhenShorter(): void + { + /** @Given a Duration of 15 minutes and another of 1 hour */ + $fifteenMinutes = Duration::ofMinutes(minutes: 15); + $oneHour = Duration::ofHours(hours: 1); + + /** @Then the shorter should be less than the longer */ + self::assertTrue($fifteenMinutes->isLessThan(other: $oneHour)); + self::assertFalse($oneHour->isLessThan(other: $fifteenMinutes)); + } + + public function testIsLessThanReturnsFalseWhenEqual(): void + { + /** @Given two equal Durations of 1 hour */ + $firstHour = Duration::ofHours(hours: 1); + $secondHour = Duration::ofHours(hours: 1); + + /** @Then neither should be less than the other */ + self::assertFalse($firstHour->isLessThan(other: $secondHour)); + } + + public function testToMinutes(): void + { + /** @Given a Duration of 5400 seconds (90 minutes) */ + $ninetyMinutesInSeconds = Duration::ofSeconds(seconds: 5400); + + /** @Then toMinutes should return 90 */ + self::assertSame(90, $ninetyMinutesInSeconds->toMinutes()); + } + + public function testToMinutesTruncates(): void + { + /** @Given a Duration of 100 seconds (1 minute and 40 seconds) */ + $hundredSeconds = Duration::ofSeconds(seconds: 100); + + /** @Then toMinutes should return 1 (truncated) */ + self::assertSame(1, $hundredSeconds->toMinutes()); + } + + public function testToHours(): void + { + /** @Given a Duration of 2 hours */ + $twoHours = Duration::ofHours(hours: 2); + + /** @Then toHours should return 2 */ + self::assertSame(2, $twoHours->toHours()); + } + + public function testToHoursTruncates(): void + { + /** @Given a Duration of 5400 seconds (1 hour and 30 minutes) */ + $ninetyMinutesInSeconds = Duration::ofSeconds(seconds: 5400); + + /** @Then toHours should return 1 (truncated) */ + self::assertSame(1, $ninetyMinutesInSeconds->toHours()); + } + + public function testToDays(): void + { + /** @Given a Duration of 3 days */ + $threeDays = Duration::ofDays(days: 3); + + /** @Then toDays should return 3 */ + self::assertSame(3, $threeDays->toDays()); + } + + public function testToDaysTruncates(): void + { + /** @Given a Duration of 36 hours (1.5 days) */ + $thirtySixHours = Duration::ofHours(hours: 36); + + /** @Then toDays should return 1 (truncated) */ + self::assertSame(1, $thirtySixHours->toDays()); + } + + public function testPlusAndMinusAreInverse(): void + { + /** @Given a Duration of 45 minutes and an addend of 15 minutes */ + $fortyFiveMinutes = Duration::ofMinutes(minutes: 45); + $fifteenMinutes = Duration::ofMinutes(minutes: 15); + + /** @When adding and then subtracting the same amount */ + $result = $fortyFiveMinutes->plus(other: $fifteenMinutes)->minus(other: $fifteenMinutes); + + /** @Then the result should equal the original */ + self::assertSame($fortyFiveMinutes->seconds, $result->seconds); + } + + public function testDifferentFactoriesProduceSameResult(): void + { + /** @Given a Duration created from each factory for the same amount of time (1 day) */ + $fromSeconds = Duration::ofSeconds(seconds: 86400); + $fromMinutes = Duration::ofMinutes(minutes: 1440); + $fromHours = Duration::ofHours(hours: 24); + $fromDays = Duration::ofDays(days: 1); + + /** @Then all should hold the same number of seconds */ + self::assertSame($fromSeconds->seconds, $fromMinutes->seconds); + self::assertSame($fromMinutes->seconds, $fromHours->seconds); + self::assertSame($fromHours->seconds, $fromDays->seconds); + } + + public function testOfHoursWithZero(): void + { + /** @Given a Duration of zero hours */ + $duration = Duration::ofHours(hours: 0); + + /** @Then it should be zero */ + self::assertSame(0, $duration->seconds); + self::assertTrue($duration->isZero()); + } + + public function testOfDaysWithZero(): void + { + /** @Given a Duration of zero days */ + $duration = Duration::ofDays(days: 0); + + /** @Then it should be zero */ + self::assertSame(0, $duration->seconds); + self::assertTrue($duration->isZero()); + } +} diff --git a/tests/InstantTest.php b/tests/InstantTest.php index 5f5b81a..0a29219 100644 --- a/tests/InstantTest.php +++ b/tests/InstantTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use TinyBlocks\Time\Duration; use TinyBlocks\Time\Instant; use TinyBlocks\Time\Internal\Exceptions\InvalidInstant; @@ -321,6 +322,371 @@ public function testInstantFromDatabaseStringIsInUtc(): void self::assertSame('UTC', $dateTime->getTimezone()->getName()); } + public function testPlusShiftsForwardByDuration(): void + { + /** @Given an Instant at a known time and a Duration of 30 minutes */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $duration = Duration::ofMinutes(minutes: 30); + + /** @When adding the Duration */ + $result = $instant->plus(duration: $duration); + + /** @Then the result should be 30 minutes later */ + self::assertSame('2026-02-17T10:30:00+00:00', $result->toIso8601()); + } + + public function testPlusWithZeroDurationReturnsSameTime(): void + { + /** @Given an Instant and a zero Duration */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @When adding zero Duration */ + $result = $instant->plus(duration: Duration::zero()); + + /** @Then the result should be the same time */ + self::assertSame('2026-02-17T10:00:00+00:00', $result->toIso8601()); + } + + public function testPlusCrossesDayBoundary(): void + { + /** @Given an Instant near the end of the day */ + $instant = Instant::fromString(value: '2026-02-17T23:30:00+00:00'); + + /** @When adding 1 hour */ + $result = $instant->plus(duration: Duration::ofHours(hours: 1)); + + /** @Then the result should cross into the next day */ + self::assertSame('2026-02-18T00:30:00+00:00', $result->toIso8601()); + } + + public function testPlusPreservesUtcTimezone(): void + { + /** @Given an Instant in UTC */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @When adding a Duration */ + $result = $instant->plus(duration: Duration::ofMinutes(minutes: 90)); + + /** @Then the result should remain in UTC */ + self::assertSame('UTC', $result->toDateTimeImmutable()->getTimezone()->getName()); + } + + public function testPlusWithLargeDuration(): void + { + /** @Given an Instant at a known time */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @When adding 1 day */ + $result = $instant->plus(duration: Duration::ofDays(days: 1)); + + /** @Then the result should be exactly one day later */ + self::assertSame('2026-02-18T10:00:00+00:00', $result->toIso8601()); + } + + public function testMinusShiftsBackwardByDuration(): void + { + /** @Given an Instant at a known time and a Duration of 30 minutes */ + $instant = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + $duration = Duration::ofMinutes(minutes: 30); + + /** @When subtracting the Duration */ + $result = $instant->minus(duration: $duration); + + /** @Then the result should be 30 minutes earlier */ + self::assertSame('2026-02-17T10:00:00+00:00', $result->toIso8601()); + } + + public function testMinusWithZeroDurationReturnsSameTime(): void + { + /** @Given an Instant and a zero Duration */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @When subtracting zero Duration */ + $result = $instant->minus(duration: Duration::zero()); + + /** @Then the result should be the same time */ + self::assertSame('2026-02-17T10:00:00+00:00', $result->toIso8601()); + } + + public function testMinusCrossesDayBoundaryBackward(): void + { + /** @Given an Instant at the start of the day */ + $instant = Instant::fromString(value: '2026-02-17T00:30:00+00:00'); + + /** @When subtracting 1 hour */ + $result = $instant->minus(duration: Duration::ofHours(hours: 1)); + + /** @Then the result should cross into the previous day */ + self::assertSame('2026-02-16T23:30:00+00:00', $result->toIso8601()); + } + + public function testMinusPreservesUtcTimezone(): void + { + /** @Given an Instant in UTC */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @When subtracting a Duration */ + $result = $instant->minus(duration: Duration::ofMinutes(minutes: 90)); + + /** @Then the result should remain in UTC */ + self::assertSame('UTC', $result->toDateTimeImmutable()->getTimezone()->getName()); + } + + public function testPlusAndMinusAreInverse(): void + { + /** @Given an Instant and a Duration */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $duration = Duration::ofMinutes(minutes: 45); + + /** @When adding and then subtracting the same Duration */ + $result = $instant->plus(duration: $duration)->minus(duration: $duration); + + /** @Then the result should be the original time */ + self::assertSame($instant->toIso8601(), $result->toIso8601()); + self::assertSame($instant->toUnixSeconds(), $result->toUnixSeconds()); + } + + public function testPlusResultIsAfterOriginal(): void + { + /** @Given an Instant at a known time */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @When adding a positive Duration */ + $later = $instant->plus(duration: Duration::ofMinutes(minutes: 30)); + + /** @Then the result should be after the original */ + self::assertTrue($later->isAfter(other: $instant)); + self::assertTrue($instant->isBefore(other: $later)); + } + + public function testMinusResultIsBeforeOriginal(): void + { + /** @Given an Instant at a known time */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @When subtracting a positive Duration */ + $earlier = $instant->minus(duration: Duration::ofMinutes(minutes: 30)); + + /** @Then the result should be before the original */ + self::assertTrue($earlier->isBefore(other: $instant)); + self::assertTrue($instant->isAfter(other: $earlier)); + } + + public function testDurationUntilReturnsAbsoluteDistance(): void + { + /** @Given two instants 30 minutes apart */ + $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + + /** @Then the duration should be 1800 seconds regardless of direction */ + self::assertSame(1800, $earlier->durationUntil(other: $later)->seconds); + self::assertSame(1800, $later->durationUntil(other: $earlier)->seconds); + } + + public function testDurationUntilSameInstantIsZero(): void + { + /** @Given two instants at the same moment */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $same = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @Then the duration between them should be zero */ + $duration = $instant->durationUntil(other: $same); + self::assertSame(0, $duration->seconds); + self::assertTrue($duration->isZero()); + } + + public function testDurationUntilIsSymmetric(): void + { + /** @Given two distinct instants */ + $a = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $b = Instant::fromString(value: '2026-02-17T11:00:00+00:00'); + + /** @Then a->durationUntil(b) should equal b->durationUntil(a) */ + self::assertSame( + $a->durationUntil(other: $b)->seconds, + $b->durationUntil(other: $a)->seconds + ); + } + + public function testDurationUntilAcrossDayBoundary(): void + { + /** @Given two instants crossing midnight */ + $before = Instant::fromString(value: '2026-02-17T23:00:00+00:00'); + $after = Instant::fromString(value: '2026-02-18T01:00:00+00:00'); + + /** @Then the duration should be 7200 seconds (2 hours) */ + self::assertSame(7200, $before->durationUntil(other: $after)->seconds); + } + + public function testDurationUntilConsistentWithPlusAndMinus(): void + { + /** @Given an Instant and a Duration of 90 minutes */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $duration = Duration::ofMinutes(minutes: 90); + $shifted = $instant->plus(duration: $duration); + + /** @Then the durationUntil should equal the original Duration */ + self::assertSame($duration->seconds, $instant->durationUntil(other: $shifted)->seconds); + } + + public function testDurationUntilWithDifferentOrigins(): void + { + /** @Given an Instant from a string with offset and from Unix seconds */ + $fromString = Instant::fromString(value: '2026-02-17T13:30:00-03:00'); + $fromUnix = Instant::fromUnixSeconds(seconds: $fromString->toUnixSeconds()); + + /** @Then the duration between them should be zero */ + self::assertTrue($fromString->durationUntil(other: $fromUnix)->isZero()); + } + + public function testIsBeforeReturnsTrueWhenEarlier(): void + { + /** @Given two Instants where the first is earlier */ + $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + + /** @Then the earlier instant should be before the later */ + self::assertTrue($earlier->isBefore(other: $later)); + } + + public function testIsBeforeReturnsFalseWhenLater(): void + { + /** @Given two Instants where the first is later */ + $later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @Then the later instant should not be before the earlier */ + self::assertFalse($later->isBefore(other: $earlier)); + } + + public function testIsBeforeReturnsFalseWhenEqual(): void + { + /** @Given two Instants at the same moment */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $same = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @Then isBefore should return false for equal instants */ + self::assertFalse($instant->isBefore(other: $same)); + } + + public function testIsAfterReturnsTrueWhenLater(): void + { + /** @Given two Instants where the first is later */ + $later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @Then the later instant should be after the earlier */ + self::assertTrue($later->isAfter(other: $earlier)); + } + + public function testIsAfterReturnsFalseWhenEarlier(): void + { + /** @Given two Instants where the first is earlier */ + $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + + /** @Then the earlier instant should not be after the later */ + self::assertFalse($earlier->isAfter(other: $later)); + } + + public function testIsAfterReturnsFalseWhenEqual(): void + { + /** @Given two Instants at the same moment */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $same = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @Then isAfter should return false for equal instants */ + self::assertFalse($instant->isAfter(other: $same)); + } + + public function testIsBeforeOrEqualReturnsTrueWhenEarlier(): void + { + /** @Given two Instants where the first is earlier */ + $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + + /** @Then the earlier instant should be before or equal to the later */ + self::assertTrue($earlier->isBeforeOrEqual(other: $later)); + } + + public function testIsBeforeOrEqualReturnsTrueWhenEqual(): void + { + /** @Given two Instants at the same moment */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $same = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @Then isBeforeOrEqual should return true for equal instants */ + self::assertTrue($instant->isBeforeOrEqual(other: $same)); + } + + public function testIsBeforeOrEqualReturnsFalseWhenLater(): void + { + /** @Given two Instants where the first is later */ + $later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @Then the later instant should not be before or equal to the earlier */ + self::assertFalse($later->isBeforeOrEqual(other: $earlier)); + } + + public function testIsAfterOrEqualReturnsTrueWhenLater(): void + { + /** @Given two Instants where the first is later */ + $later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @Then the later instant should be after or equal to the earlier */ + self::assertTrue($later->isAfterOrEqual(other: $earlier)); + } + + public function testIsAfterOrEqualReturnsTrueWhenEqual(): void + { + /** @Given two Instants at the same moment */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $same = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @Then isAfterOrEqual should return true for equal instants */ + self::assertTrue($instant->isAfterOrEqual(other: $same)); + } + + public function testIsAfterOrEqualReturnsFalseWhenEarlier(): void + { + /** @Given two Instants where the first is earlier */ + $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + + /** @Then the earlier instant should not be after or equal to the later */ + self::assertFalse($earlier->isAfterOrEqual(other: $later)); + } + + public function testIsBeforeAndIsAfterAreMutuallyExclusive(): void + { + /** @Given two distinct Instants */ + $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + + /** @Then isBefore and isAfter should be mutually exclusive */ + self::assertTrue($earlier->isBefore(other: $later)); + self::assertFalse($earlier->isAfter(other: $later)); + self::assertTrue($later->isAfter(other: $earlier)); + self::assertFalse($later->isBefore(other: $earlier)); + } + + public function testComparisonWithDifferentOriginsProducesSameResult(): void + { + /** @Given an Instant from a string with offset */ + $fromString = Instant::fromString(value: '2026-02-17T13:30:00-03:00'); + + /** @And an Instant from the equivalent Unix seconds */ + $fromUnix = Instant::fromUnixSeconds(seconds: $fromString->toUnixSeconds()); + + /** @Then both should be equal by all comparison methods */ + self::assertFalse($fromString->isBefore(other: $fromUnix)); + self::assertFalse($fromString->isAfter(other: $fromUnix)); + self::assertTrue($fromString->isBeforeOrEqual(other: $fromUnix)); + self::assertTrue($fromString->isAfterOrEqual(other: $fromUnix)); + } + public static function validStringsDataProvider(): array { return [ diff --git a/tests/PeriodTest.php b/tests/PeriodTest.php new file mode 100644 index 0000000..f30bf42 --- /dev/null +++ b/tests/PeriodTest.php @@ -0,0 +1,367 @@ +from->toIso8601()); + self::assertSame('2026-02-17T11:00:00+00:00', $period->to->toIso8601()); + } + + public function testOfThrowsWhenStartEqualsEnd(): void + { + /** @Given two instants at the same moment */ + $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @Then an InvalidPeriod exception should be thrown */ + $this->expectException(InvalidPeriod::class); + + /** @When creating a Period with equal boundaries */ + Period::of(from: $instant, to: $instant); + } + + public function testOfThrowsWhenStartIsAfterEnd(): void + { + /** @Given two instants where from is after to */ + $from = Instant::fromString(value: '2026-02-17T11:00:00+00:00'); + $to = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @Then an InvalidPeriod exception should be thrown */ + $this->expectException(InvalidPeriod::class); + + /** @When creating a Period with inverted boundaries */ + Period::of(from: $from, to: $to); + } + + public function testStartingAtCreatesPeriodFromDuration(): void + { + /** @Given a start instant and a Duration of 30 minutes */ + $from = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $duration = Duration::ofMinutes(minutes: 30); + + /** @When creating a Period from start and Duration */ + $period = Period::startingAt(from: $from, duration: $duration); + + /** @Then the end should be 30 minutes after the start */ + self::assertSame('2026-02-17T10:00:00+00:00', $period->from->toIso8601()); + self::assertSame('2026-02-17T10:30:00+00:00', $period->to->toIso8601()); + } + + public function testStartingAtThrowsWhenDurationIsZero(): void + { + /** @Given a start instant and a zero Duration */ + $from = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @Then an InvalidPeriod exception should be thrown */ + $this->expectException(InvalidPeriod::class); + + /** @When creating a Period with zero Duration */ + Period::startingAt(from: $from, duration: Duration::zero()); + } + + public function testStartingAtCrossesDayBoundary(): void + { + /** @Given a start near midnight and a Duration that crosses the day */ + $from = Instant::fromString(value: '2026-02-17T23:00:00+00:00'); + $duration = Duration::ofHours(hours: 2); + + /** @When creating a Period */ + $period = Period::startingAt(from: $from, duration: $duration); + + /** @Then the end should be on the next day */ + self::assertSame('2026-02-18T01:00:00+00:00', $period->to->toIso8601()); + } + + public function testDurationReturnsCorrectValue(): void + { + /** @Given a Period spanning 1 hour */ + $period = Period::of( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') + ); + + /** @When getting the Duration */ + $duration = $period->duration(); + + /** @Then the duration should be 3600 seconds */ + self::assertSame(3600, $duration->seconds); + } + + public function testDurationFromStartingAt(): void + { + /** @Given a Period created from start and Duration of 90 minutes */ + $inputDuration = Duration::ofMinutes(minutes: 90); + $period = Period::startingAt( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + duration: $inputDuration + ); + + /** @When getting the Duration */ + $duration = $period->duration(); + + /** @Then the duration should match the input */ + self::assertSame($inputDuration->seconds, $duration->seconds); + } + + public function testDurationReturnsDurationObject(): void + { + /** @Given a Period of 30 minutes */ + $period = Period::startingAt( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + duration: Duration::ofMinutes(minutes: 30) + ); + + /** @When getting the Duration */ + $duration = $period->duration(); + + /** @Then the Duration should be convertible to minutes */ + self::assertSame(30, $duration->toMinutes()); + } + + public function testContainsReturnsTrueForInstantAtStart(): void + { + /** @Given a Period [10:00, 11:00) */ + $period = Period::of( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') + ); + + /** @When checking the start instant */ + $result = $period->contains(instant: Instant::fromString(value: '2026-02-17T10:00:00+00:00')); + + /** @Then the start should be contained (inclusive) */ + self::assertTrue($result); + } + + public function testContainsReturnsTrueForInstantInMiddle(): void + { + /** @Given a Period [10:00, 11:00) */ + $period = Period::of( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') + ); + + /** @When checking an instant in the middle */ + $result = $period->contains(instant: Instant::fromString(value: '2026-02-17T10:30:00+00:00')); + + /** @Then it should be contained */ + self::assertTrue($result); + } + + public function testContainsReturnsFalseForInstantAtEnd(): void + { + /** @Given a Period [10:00, 11:00) */ + $period = Period::of( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') + ); + + /** @When checking the end instant */ + $result = $period->contains(instant: Instant::fromString(value: '2026-02-17T11:00:00+00:00')); + + /** @Then the end should not be contained (exclusive) */ + self::assertFalse($result); + } + + public function testContainsReturnsFalseForInstantBeforeStart(): void + { + /** @Given a Period [10:00, 11:00) */ + $period = Period::of( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') + ); + + /** @When checking an instant before the start */ + $result = $period->contains(instant: Instant::fromString(value: '2026-02-17T09:59:59+00:00')); + + /** @Then it should not be contained */ + self::assertFalse($result); + } + + public function testContainsReturnsFalseForInstantAfterEnd(): void + { + /** @Given a Period [10:00, 11:00) */ + $period = Period::of( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') + ); + + /** @When checking an instant after the end */ + $result = $period->contains(instant: Instant::fromString(value: '2026-02-17T11:00:01+00:00')); + + /** @Then it should not be contained */ + self::assertFalse($result); + } + + public function testOverlapsWithReturnsTrueForPartialOverlap(): void + { + /** @Given two partially overlapping periods */ + $periodA = Period::of( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') + ); + $periodB = Period::of( + from: Instant::fromString(value: '2026-02-17T10:30:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:30:00+00:00') + ); + + /** @Then both should detect overlap */ + self::assertTrue($periodA->overlapsWith(other: $periodB)); + self::assertTrue($periodB->overlapsWith(other: $periodA)); + } + + public function testOverlapsWithReturnsTrueWhenOneContainsAnother(): void + { + /** @Given a period that fully contains another */ + $outer = Period::of( + from: Instant::fromString(value: '2026-02-17T09:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T12:00:00+00:00') + ); + $inner = Period::of( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') + ); + + /** @Then both should detect overlap */ + self::assertTrue($outer->overlapsWith(other: $inner)); + self::assertTrue($inner->overlapsWith(other: $outer)); + } + + public function testOverlapsWithReturnsFalseForAdjacentPeriods(): void + { + /** @Given two adjacent periods [10:00, 11:00) and [11:00, 12:00) */ + $periodA = Period::of( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') + ); + $periodB = Period::of( + from: Instant::fromString(value: '2026-02-17T11:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T12:00:00+00:00') + ); + + /** @Then they should not overlap (half-open intervals are disjoint when adjacent) */ + self::assertFalse($periodA->overlapsWith(other: $periodB)); + self::assertFalse($periodB->overlapsWith(other: $periodA)); + } + + public function testOverlapsWithReturnsFalseForDisjointPeriods(): void + { + /** @Given two completely disjoint periods */ + $periodA = Period::of( + from: Instant::fromString(value: '2026-02-17T08:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T09:00:00+00:00') + ); + $periodB = Period::of( + from: Instant::fromString(value: '2026-02-17T14:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T15:00:00+00:00') + ); + + /** @Then they should not overlap */ + self::assertFalse($periodA->overlapsWith(other: $periodB)); + self::assertFalse($periodB->overlapsWith(other: $periodA)); + } + + public function testOverlapsWithReturnsTrueForIdenticalPeriods(): void + { + /** @Given two identical periods */ + $periodA = Period::of( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') + ); + $periodB = Period::of( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') + ); + + /** @Then they should overlap */ + self::assertTrue($periodA->overlapsWith(other: $periodB)); + } + + public function testOverlapsWithIsSymmetric(): void + { + /** @Given two overlapping periods */ + $periodA = Period::of( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') + ); + $periodB = Period::of( + from: Instant::fromString(value: '2026-02-17T10:30:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:30:00+00:00') + ); + + /** @Then overlap detection should be symmetric */ + self::assertSame( + $periodA->overlapsWith(other: $periodB), + $periodB->overlapsWith(other: $periodA) + ); + } + + public function testDurationIsConsistentBetweenOfAndStartingAt(): void + { + /** @Given a Period from of() and one from startingAt() with the same boundaries */ + $from = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + $to = Instant::fromString(value: '2026-02-17T11:30:00+00:00'); + + $periodFromOf = Period::of(from: $from, to: $to); + $periodFromStartingAt = Period::startingAt(from: $from, duration: Duration::ofMinutes(minutes: 90)); + + /** @Then both should have the same Duration */ + self::assertSame( + $periodFromOf->duration()->seconds, + $periodFromStartingAt->duration()->seconds + ); + } + + public function testContainsIsConsistentWithOverlapsForSingleInstant(): void + { + /** @Given a Period and a 1-second micro-period at a contained instant */ + $period = Period::of( + from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') + ); + $contained = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + $microPeriod = Period::startingAt(from: $contained, duration: Duration::ofSeconds(seconds: 1)); + + /** @Then the period should contain the instant and overlap with the micro-period */ + self::assertTrue($period->contains(instant: $contained)); + self::assertTrue($period->overlapsWith(other: $microPeriod)); + } + + public function testOverlapsWithNonOverlappingIsSymmetric(): void + { + /** @Given two non-overlapping periods */ + $periodA = Period::startingAt( + from: Instant::fromString(value: '2026-02-17T08:00:00+00:00'), + duration: Duration::ofHours(hours: 1) + ); + $periodB = Period::startingAt( + from: Instant::fromString(value: '2026-02-17T14:00:00+00:00'), + duration: Duration::ofHours(hours: 1) + ); + + /** @Then symmetry should hold for non-overlapping case */ + self::assertSame( + $periodA->overlapsWith(other: $periodB), + $periodB->overlapsWith(other: $periodA) + ); + self::assertFalse($periodA->overlapsWith(other: $periodB)); + } +}