diff --git a/README.md b/README.md index 6dcf3c4..3ef967c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ * [Instant](#instant) * [Duration](#duration) * [Period](#period) + * [DayOfWeek](#dayofweek) + * [TimeOfDay](#timeofday) * [Timezone](#timezone) * [Timezones](#timezones) * [License](#license) @@ -113,9 +115,9 @@ use TinyBlocks\Time\Duration; $instant = Instant::fromString(value: '2026-02-17T10:00: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 +$instant->plus(duration: Duration::fromMinutes(minutes: 30))->toIso8601(); # 2026-02-17T10:30:00+00:00 +$instant->plus(duration: Duration::fromHours(hours: 2))->toIso8601(); # 2026-02-17T12:00:00+00:00 +$instant->minus(duration: Duration::fromSeconds(seconds: 60))->toIso8601(); # 2026-02-17T09:59:00+00:00 ``` #### Measuring distance between instants @@ -130,7 +132,7 @@ $end = Instant::fromString(value: '2026-02-17T11:30:00+00:00'); $duration = $start->durationUntil(other: $end); -$duration->seconds; # 5400 +$duration->toSeconds(); # 5400 $duration->toMinutes(); # 90 $duration->toHours(); # 1 ``` @@ -138,7 +140,7 @@ $duration->toHours(); # 1 The result is always non-negative regardless of direction: ```php -$end->durationUntil(other: $start)->seconds; # 5400 +$end->durationUntil(other: $start)->toSeconds(); # 5400 ``` #### Comparing instants @@ -170,16 +172,16 @@ timeline — it expresses only "how much" time. 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); +$seconds = Duration::fromSeconds(seconds: 90); +$minutes = Duration::fromMinutes(minutes: 30); +$hours = Duration::fromHours(hours: 2); +$days = Duration::fromDays(days: 7); ``` All factories reject negative values: ```php -Duration::ofMinutes(minutes: -5); # throws InvalidDuration +Duration::fromMinutes(minutes: -5); # throws InvalidSeconds ``` #### Arithmetic @@ -187,17 +189,36 @@ Duration::ofMinutes(minutes: -5); # throws InvalidDuration ```php use TinyBlocks\Time\Duration; -$a = Duration::ofMinutes(minutes: 30); -$b = Duration::ofMinutes(minutes: 15); +$thirtyMinutes = Duration::fromMinutes(minutes: 30); +$fifteenMinutes = Duration::fromMinutes(minutes: 15); -$a->plus(other: $b)->seconds; # 2700 (45 minutes) -$a->minus(other: $b)->seconds; # 900 (15 minutes) +$thirtyMinutes->plus(other: $fifteenMinutes)->toSeconds(); # 2700 (45 minutes) +$thirtyMinutes->minus(other: $fifteenMinutes)->toSeconds(); # 900 (15 minutes) ``` Subtraction that would produce a negative result throws an exception: ```php -$b->minus(other: $a); # throws InvalidDuration +$fifteenMinutes->minus(other: $thirtyMinutes); # throws InvalidSeconds +``` + +#### Division + +Returns the number of times one `Duration` fits wholly into another. The result is truncated toward zero: + +```php +use TinyBlocks\Time\Duration; + +$total = Duration::fromMinutes(minutes: 90); +$slot = Duration::fromMinutes(minutes: 30); + +$total->divide(other: $slot); # 3 +``` + +Division by a zero `Duration` throws an exception: + +```php +$total->divide(other: Duration::zero()); # throws InvalidSeconds ``` #### Comparing durations @@ -205,8 +226,8 @@ $b->minus(other: $a); # throws InvalidDuration ```php use TinyBlocks\Time\Duration; -$short = Duration::ofMinutes(minutes: 15); -$long = Duration::ofHours(hours: 2); +$short = Duration::fromMinutes(minutes: 15); +$long = Duration::fromHours(hours: 2); $short->isLessThan(other: $long); # true $long->isGreaterThan(other: $short); # true @@ -221,8 +242,9 @@ Conversions truncate toward zero when the duration is not an exact multiple: ```php use TinyBlocks\Time\Duration; -$duration = Duration::ofSeconds(seconds: 5400); +$duration = Duration::fromSeconds(seconds: 5400); +$duration->toSeconds(); # 5400 $duration->toMinutes(); # 90 $duration->toHours(); # 1 $duration->toDays(); # 0 @@ -239,7 +261,7 @@ end is exclusive. use TinyBlocks\Time\Instant; use TinyBlocks\Time\Period; -$period = Period::of( +$period = Period::from( from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') ); @@ -251,7 +273,7 @@ $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 +Period::from(from: $later, to: $earlier); # throws InvalidPeriod ``` #### Creating from a start and duration @@ -263,7 +285,7 @@ use TinyBlocks\Time\Period; $period = Period::startingAt( from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), - duration: Duration::ofMinutes(minutes: 90) + duration: Duration::fromMinutes(minutes: 90) ); $period->from->toIso8601(); # 2026-02-17T10:00:00+00:00 @@ -273,7 +295,7 @@ $period->to->toIso8601(); # 2026-02-17T11:30:00+00:00 #### Getting the duration ```php -$period->duration()->seconds; # 5400 +$period->duration()->toSeconds(); # 5400 $period->duration()->toMinutes(); # 90 ``` @@ -300,11 +322,11 @@ use TinyBlocks\Time\Period; $periodA = Period::startingAt( from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), - duration: Duration::ofHours(hours: 1) + duration: Duration::fromHours(hours: 1) ); $periodB = Period::startingAt( from: Instant::fromString(value: '2026-02-17T10:30:00+00:00'), - duration: Duration::ofHours(hours: 1) + duration: Duration::fromHours(hours: 1) ); $periodA->overlapsWith(other: $periodB); # true @@ -320,16 +342,138 @@ use TinyBlocks\Time\Period; $first = Period::startingAt( from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), - duration: Duration::ofHours(hours: 1) + duration: Duration::fromHours(hours: 1) ); $second = Period::startingAt( from: Instant::fromString(value: '2026-02-17T11:00:00+00:00'), - duration: Duration::ofHours(hours: 1) + duration: Duration::fromHours(hours: 1) ); $first->overlapsWith(other: $second); # false ``` +### DayOfWeek + +A `DayOfWeek` represents a day of the week following ISO 8601, where Monday is 1 and Sunday is 7. + +#### Deriving from an Instant + +```php +use TinyBlocks\Time\DayOfWeek; +use TinyBlocks\Time\Instant; + +$instant = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); +$day = DayOfWeek::fromInstant(instant: $instant); + +$day; # DayOfWeek::Tuesday +$day->value; # 2 +``` + +#### Checking weekday or weekend + +```php +use TinyBlocks\Time\DayOfWeek; + +DayOfWeek::Monday->isWeekday(); # true +DayOfWeek::Monday->isWeekend(); # false +DayOfWeek::Saturday->isWeekday(); # false +DayOfWeek::Saturday->isWeekend(); # true +``` + +### TimeOfDay + +A `TimeOfDay` represents a time of day (hour and minute) without date or timezone context. Values range from 00:00 to +23:59. + +#### Creating from components + +```php +use TinyBlocks\Time\TimeOfDay; + +$time = TimeOfDay::from(hour: 8, minute: 30); + +$time->hour; # 8 +$time->minute; # 30 +``` + +#### Creating from a string + +Parses a string in `HH:MM` format: + +```php +use TinyBlocks\Time\TimeOfDay; + +$time = TimeOfDay::fromString(value: '14:30'); + +$time->hour; # 14 +$time->minute; # 30 +``` + +#### Deriving from an Instant + +Extracts the time of day from an `Instant` in UTC: + +```php +use TinyBlocks\Time\Instant; +use TinyBlocks\Time\TimeOfDay; + +$instant = Instant::fromString(value: '2026-02-17T14:30:00+00:00'); +$time = TimeOfDay::fromInstant(instant: $instant); + +$time->hour; # 14 +$time->minute; # 30 +``` + +#### Named constructors + +```php +use TinyBlocks\Time\TimeOfDay; + +$midnight = TimeOfDay::midnight(); # 00:00 +$noon = TimeOfDay::noon(); # 12:00 +``` + +#### Comparing times + +```php +use TinyBlocks\Time\TimeOfDay; + +$morning = TimeOfDay::from(hour: 8, minute: 0); +$afternoon = TimeOfDay::from(hour: 14, minute: 30); + +$morning->isBefore(other: $afternoon); # true +$morning->isAfter(other: $afternoon); # false +$morning->isBeforeOrEqual(other: $afternoon); # true +$afternoon->isAfterOrEqual(other: $morning); # true +``` + +#### Measuring distance between times + +Returns the `Duration` between two times. The second time must be after the first: + +```php +use TinyBlocks\Time\TimeOfDay; + +$start = TimeOfDay::from(hour: 8, minute: 0); +$end = TimeOfDay::from(hour: 12, minute: 30); + +$duration = $start->durationUntil(other: $end); + +$duration->toMinutes(); # 270 +``` + +#### Converting to other representations + +```php +use TinyBlocks\Time\TimeOfDay; + +$time = TimeOfDay::from(hour: 8, minute: 30); + +$time->toMinutesSinceMidnight(); # 510 +$time->toDuration()->toSeconds(); # 30600 +$time->toString(); # 08:30 +``` + ### Timezone A `Timezone` is a Value Object representing a single valid [IANA timezone](https://www.iana.org) identifier. diff --git a/src/DayOfWeek.php b/src/DayOfWeek.php new file mode 100644 index 0000000..9f687d2 --- /dev/null +++ b/src/DayOfWeek.php @@ -0,0 +1,52 @@ +toDateTimeImmutable()->format('N'); + + return self::from($isoDay); + } + + /** + * Checks whether this day falls on a weekday (Monday through Friday). + * + * @return bool True if this is a weekday. + */ + public function isWeekday(): bool + { + return $this->value <= 5; + } + + /** + * Checks whether this day falls on a weekend (Saturday or Sunday). + * + * @return bool True if this is a weekend day. + */ + public function isWeekend(): bool + { + return $this->value >= 6; + } +} diff --git a/src/Duration.php b/src/Duration.php index a913cd7..fb60d34 100644 --- a/src/Duration.php +++ b/src/Duration.php @@ -4,7 +4,8 @@ namespace TinyBlocks\Time; -use TinyBlocks\Time\Internal\Exceptions\InvalidDuration; +use TinyBlocks\Time\Internal\Exceptions\InvalidSeconds; +use TinyBlocks\Time\Internal\Seconds; use TinyBlocks\Vo\ValueObject; use TinyBlocks\Vo\ValueObjectBehavior; @@ -20,7 +21,7 @@ private const int SECONDS_PER_HOUR = 3600; private const int SECONDS_PER_DAY = 86400; - private function __construct(public int $seconds) + private function __construct(private Seconds $seconds) { } @@ -31,7 +32,7 @@ private function __construct(public int $seconds) */ public static function zero(): Duration { - return new Duration(seconds: 0); + return new Duration(seconds: Seconds::zero()); } /** @@ -39,15 +40,11 @@ public static function zero(): Duration * * @param int $seconds The number of seconds (must be non-negative). * @return Duration The created Duration. - * @throws InvalidDuration If the value is negative. + * @throws InvalidSeconds If the value is negative. */ - public static function ofSeconds(int $seconds): Duration + public static function fromSeconds(int $seconds): Duration { - if ($seconds < 0) { - throw InvalidDuration::becauseIsNegative(value: $seconds, unit: 'seconds'); - } - - return new Duration(seconds: $seconds); + return new Duration(seconds: Seconds::from(value: $seconds)); } /** @@ -55,15 +52,11 @@ public static function ofSeconds(int $seconds): Duration * * @param int $minutes The number of minutes (must be non-negative). * @return Duration The created Duration. - * @throws InvalidDuration If the value is negative. + * @throws InvalidSeconds If the value is negative. */ - public static function ofMinutes(int $minutes): Duration + public static function fromMinutes(int $minutes): Duration { - if ($minutes < 0) { - throw InvalidDuration::becauseIsNegative(value: $minutes, unit: 'minutes'); - } - - return new Duration(seconds: $minutes * self::SECONDS_PER_MINUTE); + return new Duration(seconds: Seconds::from(value: $minutes * self::SECONDS_PER_MINUTE)); } /** @@ -71,15 +64,11 @@ public static function ofMinutes(int $minutes): Duration * * @param int $hours The number of hours (must be non-negative). * @return Duration The created Duration. - * @throws InvalidDuration If the value is negative. + * @throws InvalidSeconds If the value is negative. */ - public static function ofHours(int $hours): Duration + public static function fromHours(int $hours): Duration { - if ($hours < 0) { - throw InvalidDuration::becauseIsNegative(value: $hours, unit: 'hours'); - } - - return new Duration(seconds: $hours * self::SECONDS_PER_HOUR); + return new Duration(seconds: Seconds::from(value: $hours * self::SECONDS_PER_HOUR)); } /** @@ -87,15 +76,11 @@ public static function ofHours(int $hours): Duration * * @param int $days The number of days (must be non-negative). * @return Duration The created Duration. - * @throws InvalidDuration If the value is negative. + * @throws InvalidSeconds If the value is negative. */ - public static function ofDays(int $days): Duration + public static function fromDays(int $days): Duration { - if ($days < 0) { - throw InvalidDuration::becauseIsNegative(value: $days, unit: 'days'); - } - - return new Duration(seconds: $days * self::SECONDS_PER_DAY); + return new Duration(seconds: Seconds::from(value: $days * self::SECONDS_PER_DAY)); } /** @@ -106,7 +91,7 @@ public static function ofDays(int $days): Duration */ public function plus(Duration $other): Duration { - return new Duration(seconds: $this->seconds + $other->seconds); + return new Duration(seconds: $this->seconds->plus(other: $other->seconds)); } /** @@ -114,17 +99,24 @@ public function plus(Duration $other): Duration * * @param Duration $other The Duration to subtract. * @return Duration A new Duration representing the difference. - * @throws InvalidDuration If the result was negative. + * @throws InvalidSeconds 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: $this->seconds->minus(other: $other->seconds)); + } - return new Duration(seconds: $result); + /** + * Returns the number of times the other Duration fits wholly into this one. + * The result is truncated toward zero. + * + * @param Duration $other The divisor Duration. + * @return int The number of whole times the other fits into this Duration. + * @throws InvalidSeconds If the divisor is zero. + */ + public function divide(Duration $other): int + { + return $this->seconds->divide(other: $other->seconds); } /** @@ -134,7 +126,7 @@ public function minus(Duration $other): Duration */ public function isZero(): bool { - return $this->seconds === 0; + return $this->seconds->isZero(); } /** @@ -145,7 +137,7 @@ public function isZero(): bool */ public function isGreaterThan(Duration $other): bool { - return $this->seconds > $other->seconds; + return $this->seconds->isGreaterThan(other: $other->seconds); } /** @@ -156,7 +148,17 @@ public function isGreaterThan(Duration $other): bool */ public function isLessThan(Duration $other): bool { - return $this->seconds < $other->seconds; + return $this->seconds->isLessThan(other: $other->seconds); + } + + /** + * Returns the total number of seconds in this Duration. + * + * @return int The number of seconds. + */ + public function toSeconds(): int + { + return $this->seconds->value; } /** @@ -166,7 +168,7 @@ public function isLessThan(Duration $other): bool */ public function toMinutes(): int { - return intdiv($this->seconds, self::SECONDS_PER_MINUTE); + return $this->seconds->divideByScalar(divisor: self::SECONDS_PER_MINUTE); } /** @@ -176,7 +178,7 @@ public function toMinutes(): int */ public function toHours(): int { - return intdiv($this->seconds, self::SECONDS_PER_HOUR); + return $this->seconds->divideByScalar(divisor: self::SECONDS_PER_HOUR); } /** @@ -186,6 +188,6 @@ public function toHours(): int */ public function toDays(): int { - return intdiv($this->seconds, self::SECONDS_PER_DAY); + return $this->seconds->divideByScalar(divisor: self::SECONDS_PER_DAY); } } diff --git a/src/Instant.php b/src/Instant.php index fab128e..4ca21b5 100644 --- a/src/Instant.php +++ b/src/Instant.php @@ -81,7 +81,7 @@ public static function fromUnixSeconds(int $seconds): Instant */ public function plus(Duration $duration): Instant { - $modified = $this->datetime->modify(sprintf('+%d seconds', $duration->seconds)); + $modified = $this->datetime->modify(sprintf('+%d seconds', $duration->toSeconds())); return new Instant(datetime: $modified); } @@ -94,7 +94,7 @@ public function plus(Duration $duration): Instant */ public function minus(Duration $duration): Instant { - $modified = $this->datetime->modify(sprintf('-%d seconds', $duration->seconds)); + $modified = $this->datetime->modify(sprintf('-%d seconds', $duration->toSeconds())); return new Instant(datetime: $modified); } @@ -110,7 +110,7 @@ public function durationUntil(Instant $other): Duration { $difference = abs($this->datetime->getTimestamp() - $other->datetime->getTimestamp()); - return Duration::ofSeconds(seconds: $difference); + return Duration::fromSeconds(seconds: $difference); } /** diff --git a/src/Internal/Exceptions/InvalidDuration.php b/src/Internal/Exceptions/InvalidDuration.php deleted file mode 100644 index 1162a7b..0000000 --- a/src/Internal/Exceptions/InvalidDuration.php +++ /dev/null @@ -1,24 +0,0 @@ -.'; - - 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/InvalidSeconds.php b/src/Internal/Exceptions/InvalidSeconds.php new file mode 100644 index 0000000..5ca6c9b --- /dev/null +++ b/src/Internal/Exceptions/InvalidSeconds.php @@ -0,0 +1,29 @@ +.'; + + return new InvalidSeconds(message: sprintf($template, $value)); + } + + public static function becauseResultIsNegative(int $current, int $subtracted): InvalidSeconds + { + $template = 'Seconds subtraction would result in a negative value: <%d> - <%d>.'; + + return new InvalidSeconds(message: sprintf($template, $current, $subtracted)); + } + + public static function becauseDivisorIsZero(): InvalidSeconds + { + return new InvalidSeconds(message: 'Seconds cannot be divided by zero.'); + } +} diff --git a/src/Internal/Exceptions/InvalidTimeOfDay.php b/src/Internal/Exceptions/InvalidTimeOfDay.php new file mode 100644 index 0000000..cc37594 --- /dev/null +++ b/src/Internal/Exceptions/InvalidTimeOfDay.php @@ -0,0 +1,39 @@ +.'; + + return new InvalidTimeOfDay(message: sprintf($template, $hour)); + } + + public static function becauseMinuteIsOutOfRange(int $minute): InvalidTimeOfDay + { + $template = 'Minute must be between 0 and 59, got <%d>.'; + + return new InvalidTimeOfDay(message: sprintf($template, $minute)); + } + + public static function becauseFormatIsInvalid(string $value): InvalidTimeOfDay + { + $template = 'Time of day <%s> must be in HH:MM format.'; + + return new InvalidTimeOfDay(message: sprintf($template, $value)); + } + + public static function becauseEndIsNotAfterStart(TimeOfDay $from, TimeOfDay $to): InvalidTimeOfDay + { + $template = 'End time <%s> must be after start time <%s>.'; + + return new InvalidTimeOfDay(message: sprintf($template, $to->toString(), $from->toString())); + } +} diff --git a/src/Internal/Seconds.php b/src/Internal/Seconds.php new file mode 100644 index 0000000..1623f1d --- /dev/null +++ b/src/Internal/Seconds.php @@ -0,0 +1,79 @@ +value + $other->value); + } + + public function minus(Seconds $other): Seconds + { + $result = $this->value - $other->value; + + if ($result < self::ZERO) { + throw InvalidSeconds::becauseResultIsNegative(current: $this->value, subtracted: $other->value); + } + + return new Seconds(value: $result); + } + + public function divide(Seconds $other): int + { + if ($other->isZero()) { + throw InvalidSeconds::becauseDivisorIsZero(); + } + + return intdiv($this->value, $other->value); + } + + public function isZero(): bool + { + return $this->value === self::ZERO; + } + + public function isGreaterThan(Seconds $other): bool + { + return $this->value > $other->value; + } + + public function isLessThan(Seconds $other): bool + { + return $this->value < $other->value; + } + + public function divideByScalar(int $divisor): int + { + return intdiv($this->value, $divisor); + } +} diff --git a/src/Period.php b/src/Period.php index 0dec2d5..b9212bc 100644 --- a/src/Period.php +++ b/src/Period.php @@ -28,7 +28,7 @@ private function __construct(public Instant $from, public Instant $to) * @return Period The created period. * @throws InvalidPeriod If the start is not before the end. */ - public static function of(Instant $from, Instant $to): Period + public static function from(Instant $from, Instant $to): Period { if ($from->isAfterOrEqual(other: $to)) { throw InvalidPeriod::becauseStartIsNotBeforeEnd(from: $from, to: $to); diff --git a/src/TimeOfDay.php b/src/TimeOfDay.php new file mode 100644 index 0000000..12594e2 --- /dev/null +++ b/src/TimeOfDay.php @@ -0,0 +1,192 @@ + self::MAX_HOUR) { + throw InvalidTimeOfDay::becauseHourIsOutOfRange(hour: $hour); + } + + if ($minute < 0 || $minute > self::MAX_MINUTE) { + throw InvalidTimeOfDay::becauseMinuteIsOutOfRange(minute: $minute); + } + + return new TimeOfDay(hour: $hour, minute: $minute); + } + + /** + * Creates a TimeOfDay from a string in "HH:MM" format. + * + * @param string $value The time string (e.g. "08:30", "14:00"). + * @return TimeOfDay The created time of day. + * @throws InvalidTimeOfDay If the format is invalid or values are out of range. + */ + public static function fromString(string $value): TimeOfDay + { + if (preg_match('/^(?P\d{2}):(?P\d{2})$/', $value, $matches) !== 1) { + throw InvalidTimeOfDay::becauseFormatIsInvalid(value: $value); + } + + return self::from(hour: (int)$matches['hour'], minute: (int)$matches['minute']); + } + + /** + * Derives the time of day from an Instant (in UTC). + * + * @param Instant $instant The point in time to extract the time from. + * @return TimeOfDay The corresponding time of day. + */ + public static function fromInstant(Instant $instant): TimeOfDay + { + $dateTime = $instant->toDateTimeImmutable(); + + return new TimeOfDay( + hour: (int)$dateTime->format('G'), + minute: (int)$dateTime->format('i') + ); + } + + /** + * Creates a TimeOfDay representing noon (12:00). + * + * @return TimeOfDay Noon. + */ + public static function noon(): TimeOfDay + { + return new TimeOfDay(hour: 12, minute: 0); + } + + /** + * Creates a TimeOfDay representing midnight (00:00). + * + * @return TimeOfDay Midnight. + */ + public static function midnight(): TimeOfDay + { + return new TimeOfDay(hour: 0, minute: 0); + } + + /** + * Returns the total number of minutes since midnight. + * + * @return int Minutes since 00:00. + */ + public function toMinutesSinceMidnight(): int + { + return ($this->hour * self::MINUTES_PER_HOUR) + $this->minute; + } + + /** + * Returns the Duration from midnight to this time of day. + * + * @return Duration The duration since midnight. + */ + public function toDuration(): Duration + { + return Duration::fromMinutes(minutes: $this->toMinutesSinceMidnight()); + } + + /** + * Returns true if this time is strictly before another. + * + * @param TimeOfDay $other The time to compare against. + * @return bool True if this time precedes the other. + */ + public function isBefore(TimeOfDay $other): bool + { + return $this->toMinutesSinceMidnight() < $other->toMinutesSinceMidnight(); + } + + /** + * Returns true if this time is strictly after another. + * + * @param TimeOfDay $other The time to compare against. + * @return bool True if this time follows the other. + */ + public function isAfter(TimeOfDay $other): bool + { + return $this->toMinutesSinceMidnight() > $other->toMinutesSinceMidnight(); + } + + /** + * Returns true if this time is before or equal to another. + * + * @param TimeOfDay $other The time to compare against. + * @return bool True if this time is at or before the other. + */ + public function isBeforeOrEqual(TimeOfDay $other): bool + { + return $this->toMinutesSinceMidnight() <= $other->toMinutesSinceMidnight(); + } + + /** + * Returns true if this time is after or equal to another. + * + * @param TimeOfDay $other The time to compare against. + * @return bool True if this time is at or after the other. + */ + public function isAfterOrEqual(TimeOfDay $other): bool + { + return $this->toMinutesSinceMidnight() >= $other->toMinutesSinceMidnight(); + } + + /** + * Returns the Duration between this time and another. + * The other time must be after this time. + * + * @param TimeOfDay $other The later time of day. + * @return Duration The duration between the two times. + * @throws InvalidTimeOfDay If the other time is not after this time. + */ + public function durationUntil(TimeOfDay $other): Duration + { + $diff = $other->toMinutesSinceMidnight() - $this->toMinutesSinceMidnight(); + + if ($diff <= 0) { + throw InvalidTimeOfDay::becauseEndIsNotAfterStart(from: $this, to: $other); + } + + return Duration::fromMinutes(minutes: $diff); + } + + /** + * Formats this time as "HH:MM". + * + * @return string The formatted time string. + */ + public function toString(): string + { + return sprintf('%02d:%02d', $this->hour, $this->minute); + } +} diff --git a/tests/DayOfWeekTest.php b/tests/DayOfWeekTest.php new file mode 100644 index 0000000..2caac2a --- /dev/null +++ b/tests/DayOfWeekTest.php @@ -0,0 +1,145 @@ +isWeekday()); + self::assertFalse(DayOfWeek::Monday->isWeekend()); + } + + public function testDayOfWeekFridayIsWeekday(): void + { + /** @Then Friday should be a weekday */ + self::assertTrue(DayOfWeek::Friday->isWeekday()); + self::assertFalse(DayOfWeek::Friday->isWeekend()); + } + + public function testDayOfWeekSaturdayIsWeekend(): void + { + /** @Then Saturday should be a weekend day */ + self::assertTrue(DayOfWeek::Saturday->isWeekend()); + self::assertFalse(DayOfWeek::Saturday->isWeekday()); + } + + public function testDayOfWeekSundayIsWeekend(): void + { + /** @Then Sunday should be a weekend day */ + self::assertTrue(DayOfWeek::Sunday->isWeekend()); + self::assertFalse(DayOfWeek::Sunday->isWeekday()); + } + + public function testDayOfWeekAllDaysHaveCorrectIsoValues(): void + { + /** @Then each day should map to its ISO 8601 numeric value */ + self::assertSame(1, DayOfWeek::Monday->value); + self::assertSame(2, DayOfWeek::Tuesday->value); + self::assertSame(3, DayOfWeek::Wednesday->value); + self::assertSame(4, DayOfWeek::Thursday->value); + self::assertSame(5, DayOfWeek::Friday->value); + self::assertSame(6, DayOfWeek::Saturday->value); + self::assertSame(7, DayOfWeek::Sunday->value); + } + + public function testDayOfWeekFromInstantOnMonday(): void + { + /** @Given an Instant on Monday 2026-02-16 */ + $instant = Instant::fromString(value: '2026-02-16T10:00:00+00:00'); + + /** @Then the day should be Monday */ + self::assertSame(DayOfWeek::Monday, DayOfWeek::fromInstant(instant: $instant)); + } + + public function testDayOfWeekFromInstantOnTuesday(): void + { + /** @Given an Instant on Tuesday 2026-02-17 */ + $instant = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + + /** @Then the day should be Tuesday */ + self::assertSame(DayOfWeek::Tuesday, DayOfWeek::fromInstant(instant: $instant)); + } + + public function testDayOfWeekFromInstantOnWednesday(): void + { + /** @Given an Instant on Wednesday 2026-02-18 */ + $instant = Instant::fromString(value: '2026-02-18T14:30:00+00:00'); + + /** @Then the day should be Wednesday */ + self::assertSame(DayOfWeek::Wednesday, DayOfWeek::fromInstant(instant: $instant)); + } + + public function testDayOfWeekFromInstantOnThursday(): void + { + /** @Given an Instant at midnight on Thursday 2026-02-19 */ + $instant = Instant::fromString(value: '2026-02-19T00:00:00+00:00'); + + /** @Then the day should be Thursday */ + self::assertSame(DayOfWeek::Thursday, DayOfWeek::fromInstant(instant: $instant)); + } + + public function testDayOfWeekFromInstantOnFriday(): void + { + /** @Given an Instant on Friday 2026-02-20 */ + $instant = Instant::fromString(value: '2026-02-20T17:00:00+00:00'); + + /** @Then the day should be Friday */ + self::assertSame(DayOfWeek::Friday, DayOfWeek::fromInstant(instant: $instant)); + } + + public function testDayOfWeekFromInstantOnSaturday(): void + { + /** @Given an Instant on Saturday 2026-02-21 */ + $instant = Instant::fromString(value: '2026-02-21T08:00:00+00:00'); + + /** @Then the day should be Saturday */ + self::assertSame(DayOfWeek::Saturday, DayOfWeek::fromInstant(instant: $instant)); + } + + public function testDayOfWeekFromInstantOnSunday(): void + { + /** @Given an Instant on Sunday 2026-02-22 */ + $instant = Instant::fromString(value: '2026-02-22T23:59:59+00:00'); + + /** @Then the day should be Sunday */ + self::assertSame(DayOfWeek::Sunday, DayOfWeek::fromInstant(instant: $instant)); + } + + public function testDayOfWeekWeekdayAndWeekendAreMutuallyExclusive(): void + { + /** @Then every day should be exactly one of weekday or weekend */ + foreach (DayOfWeek::cases() as $day) { + self::assertNotSame($day->isWeekday(), $day->isWeekend()); + } + } + + public function testDayOfWeekExactlyFiveWeekdays(): void + { + /** @Then there should be exactly 5 weekdays */ + $weekdays = array_filter( + DayOfWeek::cases(), + static fn(DayOfWeek $day): bool => $day->isWeekday() + ); + + self::assertCount(5, $weekdays); + } + + public function testDayOfWeekExactlyTwoWeekendDays(): void + { + /** @Then there should be exactly 2 weekend days */ + $weekends = array_filter( + DayOfWeek::cases(), + static fn(DayOfWeek $day): bool => $day->isWeekend() + ); + + self::assertCount(2, $weekends); + } +} diff --git a/tests/DurationTest.php b/tests/DurationTest.php index 8b13b05..5843518 100644 --- a/tests/DurationTest.php +++ b/tests/DurationTest.php @@ -6,154 +6,188 @@ use PHPUnit\Framework\TestCase; use TinyBlocks\Time\Duration; -use TinyBlocks\Time\Internal\Exceptions\InvalidDuration; +use TinyBlocks\Time\Internal\Exceptions\InvalidSeconds; final class DurationTest extends TestCase { - public function testZeroCreatesZeroDuration(): void + public function testDurationZeroCreatesZeroDuration(): void { /** @Given a zero Duration */ $duration = Duration::zero(); /** @Then the seconds should be zero */ - self::assertSame(0, $duration->seconds); + self::assertSame(0, $duration->toSeconds()); + + /** @And it should be identified as zero */ self::assertTrue($duration->isZero()); } - public function testOfSecondsCreatesCorrectDuration(): void + public function testDurationFromSecondsCreatesCorrectDuration(): void { - /** @Given a Duration of 1800 seconds */ - $duration = Duration::ofSeconds(seconds: 1800); + /** @Given a Duration created from 1800 seconds */ + $duration = Duration::fromSeconds(seconds: 1800); /** @Then it should hold 1800 seconds */ - self::assertSame(1800, $duration->seconds); + self::assertSame(1800, $duration->toSeconds()); + + /** @And it should not be zero */ self::assertFalse($duration->isZero()); } - public function testOfSecondsWithZero(): void + public function testDurationFromSecondsWithZero(): void { - /** @Given a Duration of zero seconds */ - $duration = Duration::ofSeconds(seconds: 0); + /** @Given a Duration created from zero seconds */ + $duration = Duration::fromSeconds(seconds: 0); - /** @Then it should be zero */ - self::assertSame(0, $duration->seconds); + /** @Then it should hold zero seconds */ + self::assertSame(0, $duration->toSeconds()); + + /** @And it should be identified as zero */ self::assertTrue($duration->isZero()); } - public function testOfSecondsThrowsWhenNegative(): void + public function testDurationWhenNegativeSeconds(): void { - /** @Then an InvalidDuration exception should be thrown */ - $this->expectException(InvalidDuration::class); + /** @Then an exception indicating that seconds must be non-negative should be thrown */ + $this->expectException(InvalidSeconds::class); /** @When creating a Duration with negative seconds */ - Duration::ofSeconds(seconds: -1); + Duration::fromSeconds(seconds: -1); } - public function testOfMinutesConvertsToSeconds(): void + public function testDurationFromMinutesConvertsToSeconds(): void { - /** @Given a Duration of 30 minutes */ - $duration = Duration::ofMinutes(minutes: 30); + /** @Given a Duration created from 30 minutes */ + $duration = Duration::fromMinutes(minutes: 30); /** @Then it should hold 1800 seconds */ - self::assertSame(1800, $duration->seconds); + self::assertSame(1800, $duration->toSeconds()); } - public function testOfMinutesWithZero(): void + public function testDurationFromMinutesWithZero(): void { - /** @Given a Duration of zero minutes */ - $duration = Duration::ofMinutes(minutes: 0); + /** @Given a Duration created from zero minutes */ + $duration = Duration::fromMinutes(minutes: 0); /** @Then it should be zero */ self::assertTrue($duration->isZero()); } - public function testOfMinutesThrowsWhenNegative(): void + public function testDurationWhenNegativeMinutes(): void { - /** @Then an InvalidDuration exception should be thrown */ - $this->expectException(InvalidDuration::class); + /** @Then an exception indicating that seconds must be non-negative should be thrown */ + $this->expectException(InvalidSeconds::class); /** @When creating a Duration with negative minutes */ - Duration::ofMinutes(minutes: -5); + Duration::fromMinutes(minutes: -5); } - public function testOfHoursConvertsToSeconds(): void + public function testDurationFromHoursConvertsToSeconds(): void { - /** @Given a Duration of 2 hours */ - $duration = Duration::ofHours(hours: 2); + /** @Given a Duration created from 2 hours */ + $duration = Duration::fromHours(hours: 2); /** @Then it should hold 7200 seconds */ - self::assertSame(7200, $duration->seconds); + self::assertSame(7200, $duration->toSeconds()); + } + + public function testDurationFromHoursWithZero(): void + { + /** @Given a Duration created from zero hours */ + $duration = Duration::fromHours(hours: 0); + + /** @Then it should hold zero seconds */ + self::assertSame(0, $duration->toSeconds()); + + /** @And it should be identified as zero */ + self::assertTrue($duration->isZero()); } - public function testOfHoursThrowsWhenNegative(): void + public function testDurationWhenNegativeHours(): void { - /** @Then an InvalidDuration exception should be thrown */ - $this->expectException(InvalidDuration::class); + /** @Then an exception indicating that seconds must be non-negative should be thrown */ + $this->expectException(InvalidSeconds::class); /** @When creating a Duration with negative hours */ - Duration::ofHours(hours: -1); + Duration::fromHours(hours: -1); } - public function testOfDaysConvertsToSeconds(): void + public function testDurationFromDaysConvertsToSeconds(): void { - /** @Given a Duration of 1 day */ - $duration = Duration::ofDays(days: 1); + /** @Given a Duration created from 1 day */ + $duration = Duration::fromDays(days: 1); /** @Then it should hold 86400 seconds */ - self::assertSame(86400, $duration->seconds); + self::assertSame(86400, $duration->toSeconds()); } - public function testOfDaysThrowsWhenNegative(): void + public function testDurationFromDaysWithZero(): void { - /** @Then an InvalidDuration exception should be thrown */ - $this->expectException(InvalidDuration::class); + /** @Given a Duration created from zero days */ + $duration = Duration::fromDays(days: 0); + + /** @Then it should hold zero seconds */ + self::assertSame(0, $duration->toSeconds()); + + /** @And it should be identified as zero */ + self::assertTrue($duration->isZero()); + } + + public function testDurationWhenNegativeDays(): void + { + /** @Then an exception indicating that seconds must be non-negative should be thrown */ + $this->expectException(InvalidSeconds::class); /** @When creating a Duration with negative days */ - Duration::ofDays(days: -1); + Duration::fromDays(days: -1); } - public function testPlusAddsTwoDurations(): void + public function testDurationPlusAddsTwoDurations(): void { - /** @Given a Duration of 30 minutes and another of 15 minutes */ - $thirtyMinutes = Duration::ofMinutes(minutes: 30); - $fifteenMinutes = Duration::ofMinutes(minutes: 15); + /** @Given a Duration of 30 minutes */ + $thirtyMinutes = Duration::fromMinutes(minutes: 30); + + /** @And a Duration of 15 minutes */ + $fifteenMinutes = Duration::fromMinutes(minutes: 15); /** @When adding them */ $result = $thirtyMinutes->plus(other: $fifteenMinutes); - /** @Then the result should be 45 minutes in seconds */ - self::assertSame(2700, $result->seconds); + /** @Then the result should be 2700 seconds (45 minutes) */ + self::assertSame(2700, $result->toSeconds()); } - public function testPlusWithZeroReturnsSameValue(): void + public function testDurationPlusWithZeroReturnsSameValue(): void { /** @Given a Duration of 1 hour */ - $oneHour = Duration::ofHours(hours: 1); + $oneHour = Duration::fromHours(hours: 1); /** @When adding zero */ $result = $oneHour->plus(other: Duration::zero()); /** @Then the result should be unchanged */ - self::assertSame(3600, $result->seconds); + self::assertSame(3600, $result->toSeconds()); } - public function testMinusSubtractsTwoDurations(): void + public function testDurationMinusSubtractsTwoDurations(): void { - /** @Given a Duration of 60 minutes and another of 15 minutes */ - $sixtyMinutes = Duration::ofMinutes(minutes: 60); - $fifteenMinutes = Duration::ofMinutes(minutes: 15); + /** @Given a Duration of 60 minutes */ + $sixtyMinutes = Duration::fromMinutes(minutes: 60); + + /** @And a Duration of 15 minutes */ + $fifteenMinutes = Duration::fromMinutes(minutes: 15); /** @When subtracting */ $result = $sixtyMinutes->minus(other: $fifteenMinutes); - /** @Then the result should be 45 minutes in seconds */ - self::assertSame(2700, $result->seconds); + /** @Then the result should be 2700 seconds (45 minutes) */ + self::assertSame(2700, $result->toSeconds()); } - public function testMinusToZero(): void + public function testDurationMinusToZero(): void { /** @Given a Duration of 30 minutes */ - $thirtyMinutes = Duration::ofMinutes(minutes: 30); + $thirtyMinutes = Duration::fromMinutes(minutes: 30); /** @When subtracting itself */ $result = $thirtyMinutes->minus(other: $thirtyMinutes); @@ -162,159 +196,289 @@ public function testMinusToZero(): void self::assertTrue($result->isZero()); } - public function testMinusThrowsWhenResultIsNegative(): void + public function testDurationMinusWhenResultIsNegative(): void { - /** @Given a smaller Duration subtracting a larger one */ - $tenMinutes = Duration::ofMinutes(minutes: 10); - $thirtyMinutes = Duration::ofMinutes(minutes: 30); + /** @Given a Duration of 10 minutes */ + $tenMinutes = Duration::fromMinutes(minutes: 10); - /** @Then an InvalidDuration exception should be thrown */ - $this->expectException(InvalidDuration::class); + /** @And a larger Duration of 30 minutes */ + $thirtyMinutes = Duration::fromMinutes(minutes: 30); - /** @When subtracting */ + /** @Then an exception indicating that subtraction would result in a negative value should be thrown */ + $this->expectException(InvalidSeconds::class); + + /** @When subtracting the larger from the smaller */ $tenMinutes->minus(other: $thirtyMinutes); } - public function testIsGreaterThanReturnsTrueWhenLonger(): void + public function testDurationDivideReturnsWholeCount(): void + { + /** @Given a Duration of 90 minutes */ + $ninetyMinutes = Duration::fromMinutes(minutes: 90); + + /** @And a Duration of 30 minutes */ + $thirtyMinutes = Duration::fromMinutes(minutes: 30); + + /** @When dividing */ + $result = $ninetyMinutes->divide(other: $thirtyMinutes); + + /** @Then the result should be 3 */ + self::assertSame(3, $result); + } + + public function testDurationDivideTruncatesRemainder(): void + { + /** @Given a Duration of 100 seconds */ + $hundredSeconds = Duration::fromSeconds(seconds: 100); + + /** @And a Duration of 30 seconds */ + $thirtySeconds = Duration::fromSeconds(seconds: 30); + + /** @When dividing */ + $result = $hundredSeconds->divide(other: $thirtySeconds); + + /** @Then the result should be 3 (truncated from 3.33) */ + self::assertSame(3, $result); + } + + public function testDurationDivideByItselfReturnsOne(): void + { + /** @Given a Duration of 45 minutes */ + $fortyFiveMinutes = Duration::fromMinutes(minutes: 45); + + /** @When dividing by itself */ + $result = $fortyFiveMinutes->divide(other: $fortyFiveMinutes); + + /** @Then the result should be 1 */ + self::assertSame(1, $result); + } + + public function testDurationDivideByLargerReturnsZero(): void + { + /** @Given a Duration of 15 minutes */ + $fifteenMinutes = Duration::fromMinutes(minutes: 15); + + /** @And a larger Duration of 1 hour */ + $oneHour = Duration::fromHours(hours: 1); + + /** @When dividing the smaller by the larger */ + $result = $fifteenMinutes->divide(other: $oneHour); + + /** @Then the result should be 0 */ + self::assertSame(0, $result); + } + + public function testDurationDivideWithExactMultiple(): void { - /** @Given a Duration of 2 hours and another of 30 minutes */ - $twoHours = Duration::ofHours(hours: 2); - $thirtyMinutes = Duration::ofMinutes(minutes: 30); + /** @Given a Duration of 2 hours */ + $twoHours = Duration::fromHours(hours: 2); + + /** @And a Duration of 30 minutes */ + $thirtyMinutes = Duration::fromMinutes(minutes: 30); + + /** @When dividing */ + $result = $twoHours->divide(other: $thirtyMinutes); + + /** @Then the result should be 4 */ + self::assertSame(4, $result); + } + + public function testDurationDivideWhenDivisorIsZero(): void + { + /** @Given a Duration of 1 hour */ + $oneHour = Duration::fromHours(hours: 1); + + /** @Then an exception indicating that seconds cannot be divided by zero should be thrown */ + $this->expectException(InvalidSeconds::class); + + /** @When dividing by zero */ + $oneHour->divide(other: Duration::zero()); + } + + public function testDurationDivideWithZeroDurationFromSecondsWhenDivisorIsZero(): void + { + /** @Given a Duration of 30 minutes */ + $thirtyMinutes = Duration::fromMinutes(minutes: 30); + + /** @And a Duration of zero seconds */ + $zeroDuration = Duration::fromSeconds(seconds: 0); + + /** @Then an exception indicating that seconds cannot be divided by zero should be thrown */ + $this->expectException(InvalidSeconds::class); + + /** @When dividing by the zero Duration */ + $thirtyMinutes->divide(other: $zeroDuration); + } + + public function testDurationIsGreaterThanReturnsTrueWhenLonger(): void + { + /** @Given a Duration of 2 hours */ + $twoHours = Duration::fromHours(hours: 2); + + /** @And a Duration of 30 minutes */ + $thirtyMinutes = Duration::fromMinutes(minutes: 30); /** @Then the longer should be greater than the shorter */ self::assertTrue($twoHours->isGreaterThan(other: $thirtyMinutes)); + + /** @And the shorter should not be greater than the longer */ self::assertFalse($thirtyMinutes->isGreaterThan(other: $twoHours)); } - public function testIsGreaterThanReturnsFalseWhenEqual(): void + public function testDurationIsGreaterThanReturnsFalseWhenEqual(): void { - /** @Given two equal Durations of 30 minutes */ - $firstThirtyMinutes = Duration::ofMinutes(minutes: 30); - $secondThirtyMinutes = Duration::ofMinutes(minutes: 30); + /** @Given a Duration of 30 minutes */ + $firstThirtyMinutes = Duration::fromMinutes(minutes: 30); + + /** @And another Duration of 30 minutes */ + $secondThirtyMinutes = Duration::fromMinutes(minutes: 30); /** @Then neither should be greater than the other */ self::assertFalse($firstThirtyMinutes->isGreaterThan(other: $secondThirtyMinutes)); } - public function testIsLessThanReturnsTrueWhenShorter(): void + public function testDurationIsLessThanReturnsTrueWhenShorter(): void { - /** @Given a Duration of 15 minutes and another of 1 hour */ - $fifteenMinutes = Duration::ofMinutes(minutes: 15); - $oneHour = Duration::ofHours(hours: 1); + /** @Given a Duration of 15 minutes */ + $fifteenMinutes = Duration::fromMinutes(minutes: 15); + + /** @And a Duration of 1 hour */ + $oneHour = Duration::fromHours(hours: 1); /** @Then the shorter should be less than the longer */ self::assertTrue($fifteenMinutes->isLessThan(other: $oneHour)); + + /** @And the longer should not be less than the shorter */ self::assertFalse($oneHour->isLessThan(other: $fifteenMinutes)); } - public function testIsLessThanReturnsFalseWhenEqual(): void + public function testDurationIsLessThanReturnsFalseWhenEqual(): void { - /** @Given two equal Durations of 1 hour */ - $firstHour = Duration::ofHours(hours: 1); - $secondHour = Duration::ofHours(hours: 1); + /** @Given a Duration of 1 hour */ + $firstHour = Duration::fromHours(hours: 1); + + /** @And another Duration of 1 hour */ + $secondHour = Duration::fromHours(hours: 1); /** @Then neither should be less than the other */ self::assertFalse($firstHour->isLessThan(other: $secondHour)); } - public function testToMinutes(): void + public function testDurationToSeconds(): void { - /** @Given a Duration of 5400 seconds (90 minutes) */ - $ninetyMinutesInSeconds = Duration::ofSeconds(seconds: 5400); + /** @Given a Duration created from 30 minutes */ + $duration = Duration::fromMinutes(minutes: 30); + + /** @Then toSeconds should return 1800 */ + self::assertSame(1800, $duration->toSeconds()); + } + + public function testDurationToMinutes(): void + { + /** @Given a Duration created from 5400 seconds */ + $duration = Duration::fromSeconds(seconds: 5400); /** @Then toMinutes should return 90 */ - self::assertSame(90, $ninetyMinutesInSeconds->toMinutes()); + self::assertSame(90, $duration->toMinutes()); } - public function testToMinutesTruncates(): void + public function testDurationToMinutesTruncates(): void { - /** @Given a Duration of 100 seconds (1 minute and 40 seconds) */ - $hundredSeconds = Duration::ofSeconds(seconds: 100); + /** @Given a Duration created from 100 seconds */ + $duration = Duration::fromSeconds(seconds: 100); - /** @Then toMinutes should return 1 (truncated) */ - self::assertSame(1, $hundredSeconds->toMinutes()); + /** @Then toMinutes should return 1 (truncated from 1.67) */ + self::assertSame(1, $duration->toMinutes()); } - public function testToHours(): void + public function testDurationToHours(): void { - /** @Given a Duration of 2 hours */ - $twoHours = Duration::ofHours(hours: 2); + /** @Given a Duration created from 2 hours */ + $duration = Duration::fromHours(hours: 2); /** @Then toHours should return 2 */ - self::assertSame(2, $twoHours->toHours()); + self::assertSame(2, $duration->toHours()); } - public function testToHoursTruncates(): void + public function testDurationToHoursTruncates(): void { - /** @Given a Duration of 5400 seconds (1 hour and 30 minutes) */ - $ninetyMinutesInSeconds = Duration::ofSeconds(seconds: 5400); + /** @Given a Duration created from 5400 seconds */ + $duration = Duration::fromSeconds(seconds: 5400); - /** @Then toHours should return 1 (truncated) */ - self::assertSame(1, $ninetyMinutesInSeconds->toHours()); + /** @Then toHours should return 1 (truncated from 1.5) */ + self::assertSame(1, $duration->toHours()); } - public function testToDays(): void + public function testDurationToDays(): void { - /** @Given a Duration of 3 days */ - $threeDays = Duration::ofDays(days: 3); + /** @Given a Duration created from 3 days */ + $duration = Duration::fromDays(days: 3); /** @Then toDays should return 3 */ - self::assertSame(3, $threeDays->toDays()); + self::assertSame(3, $duration->toDays()); } - public function testToDaysTruncates(): void + public function testDurationToDaysTruncates(): void { - /** @Given a Duration of 36 hours (1.5 days) */ - $thirtySixHours = Duration::ofHours(hours: 36); + /** @Given a Duration created from 36 hours */ + $duration = Duration::fromHours(hours: 36); - /** @Then toDays should return 1 (truncated) */ - self::assertSame(1, $thirtySixHours->toDays()); + /** @Then toDays should return 1 (truncated from 1.5) */ + self::assertSame(1, $duration->toDays()); } - public function testPlusAndMinusAreInverse(): void + public function testDurationPlusAndMinusAreInverse(): void { - /** @Given a Duration of 45 minutes and an addend of 15 minutes */ - $fortyFiveMinutes = Duration::ofMinutes(minutes: 45); - $fifteenMinutes = Duration::ofMinutes(minutes: 15); + /** @Given a Duration of 45 minutes */ + $fortyFiveMinutes = Duration::fromMinutes(minutes: 45); + + /** @And an addend of 15 minutes */ + $fifteenMinutes = Duration::fromMinutes(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); + self::assertSame($fortyFiveMinutes->toSeconds(), $result->toSeconds()); } - public function testDifferentFactoriesProduceSameResult(): void + public function testDurationDifferentFactoriesProduceSameResult(): 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); + /** @Given a Duration of 86400 seconds */ + $fromSeconds = Duration::fromSeconds(seconds: 86400); + + /** @And a Duration of 1440 minutes */ + $fromMinutes = Duration::fromMinutes(minutes: 1440); + + /** @And a Duration of 24 hours */ + $fromHours = Duration::fromHours(hours: 24); + + /** @And a Duration of 1 day */ + $fromDays = Duration::fromDays(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); + self::assertSame($fromSeconds->toSeconds(), $fromMinutes->toSeconds()); + self::assertSame($fromMinutes->toSeconds(), $fromHours->toSeconds()); + self::assertSame($fromHours->toSeconds(), $fromDays->toSeconds()); } - public function testOfHoursWithZero(): void + public function testDurationDivideIsConsistentWithSlotExpansion(): void { - /** @Given a Duration of zero hours */ - $duration = Duration::ofHours(hours: 0); + /** @Given a Duration of 90 minutes */ + $appointmentDuration = Duration::fromMinutes(minutes: 90); - /** @Then it should be zero */ - self::assertSame(0, $duration->seconds); - self::assertTrue($duration->isZero()); - } + /** @And a slot size of 30 minutes */ + $slotSize = Duration::fromMinutes(minutes: 30); - public function testOfDaysWithZero(): void - { - /** @Given a Duration of zero days */ - $duration = Duration::ofDays(days: 0); + /** @When dividing the appointment by the slot size */ + $slotCount = $appointmentDuration->divide(other: $slotSize); - /** @Then it should be zero */ - self::assertSame(0, $duration->seconds); - self::assertTrue($duration->isZero()); + /** @Then the slot count should be 3 */ + self::assertSame(3, $slotCount); + + /** @And reconstructing from slot count should match the original duration */ + $reconstructed = Duration::fromMinutes(minutes: $slotCount * $slotSize->toMinutes()); + + self::assertSame($appointmentDuration->toSeconds(), $reconstructed->toSeconds()); } } diff --git a/tests/InstantTest.php b/tests/InstantTest.php index 0a29219..e39096b 100644 --- a/tests/InstantTest.php +++ b/tests/InstantTest.php @@ -14,7 +14,6 @@ final class InstantTest extends TestCase { public function testInstantNowIsInUtc(): void { - /** @Given the current moment */ /** @When creating an Instant from now */ $instant = Instant::now(); @@ -65,8 +64,10 @@ public function testInstantNowIso8601HasNoFractionalSeconds(): void public function testInstantNowProducesDistinctInstances(): void { - /** @Given two Instants created from now in sequence */ + /** @Given an Instant created from now */ $first = Instant::now(); + + /** @And a second Instant created immediately after */ $second = Instant::now(); /** @Then both should be valid Instants in UTC */ @@ -86,7 +87,6 @@ public function testInstantFromString( string $expectedIso8601, int $expectedUnixSeconds ): void { - /** @Given a valid date-time string with offset */ /** @When creating an Instant from the string */ $instant = Instant::fromString(value: $value); @@ -102,7 +102,6 @@ public function testInstantFromUnixSeconds( int $seconds, string $expectedIso8601 ): void { - /** @Given a valid Unix timestamp in seconds */ /** @When creating an Instant from Unix seconds */ $instant = Instant::fromUnixSeconds(seconds: $seconds); @@ -264,8 +263,7 @@ public function testInstantFromStringWithMaxNegativeOffset(): void #[DataProvider('invalidStringsDataProvider')] public function testInstantWhenInvalidString(string $value): void { - /** @Given an invalid date-time string */ - /** @Then an InvalidInstant exception should be thrown */ + /** @Then an exception indicating that the value could not be decoded into a valid instant should be thrown */ $this->expectException(InvalidInstant::class); $this->expectExceptionMessage(sprintf('The value <%s> could not be decoded into a valid instant.', $value)); @@ -278,8 +276,7 @@ public function testInstantFromDatabaseString( string $value, string $expectedIso8601 ): void { - /** @Given a valid database date-time string in UTC */ - /** @When creating an Instant from the string */ + /** @When creating an Instant from the database string */ $instant = Instant::fromString(value: $value); /** @Then the ISO 8601 representation should match the expected UTC value */ @@ -322,11 +319,13 @@ public function testInstantFromDatabaseStringIsInUtc(): void self::assertSame('UTC', $dateTime->getTimezone()->getName()); } - public function testPlusShiftsForwardByDuration(): void + public function testInstantPlusShiftsForwardByDuration(): void { - /** @Given an Instant at a known time and a Duration of 30 minutes */ + /** @Given an Instant at a known time */ $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); - $duration = Duration::ofMinutes(minutes: 30); + + /** @And a Duration of 30 minutes */ + $duration = Duration::fromMinutes(minutes: 30); /** @When adding the Duration */ $result = $instant->plus(duration: $duration); @@ -335,9 +334,9 @@ public function testPlusShiftsForwardByDuration(): void self::assertSame('2026-02-17T10:30:00+00:00', $result->toIso8601()); } - public function testPlusWithZeroDurationReturnsSameTime(): void + public function testInstantPlusWithZeroDurationReturnsSameTime(): void { - /** @Given an Instant and a zero Duration */ + /** @Given an Instant at a known time */ $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); /** @When adding zero Duration */ @@ -347,47 +346,49 @@ public function testPlusWithZeroDurationReturnsSameTime(): void self::assertSame('2026-02-17T10:00:00+00:00', $result->toIso8601()); } - public function testPlusCrossesDayBoundary(): void + public function testInstantPlusCrossesDayBoundary(): 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)); + $result = $instant->plus(duration: Duration::fromHours(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 + public function testInstantPlusPreservesUtcTimezone(): 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)); + $result = $instant->plus(duration: Duration::fromMinutes(minutes: 90)); /** @Then the result should remain in UTC */ self::assertSame('UTC', $result->toDateTimeImmutable()->getTimezone()->getName()); } - public function testPlusWithLargeDuration(): void + public function testInstantPlusWithLargeDuration(): 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)); + $result = $instant->plus(duration: Duration::fromDays(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 + public function testInstantMinusShiftsBackwardByDuration(): void { - /** @Given an Instant at a known time and a Duration of 30 minutes */ + /** @Given an Instant at a known time */ $instant = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); - $duration = Duration::ofMinutes(minutes: 30); + + /** @And a Duration of 30 minutes */ + $duration = Duration::fromMinutes(minutes: 30); /** @When subtracting the Duration */ $result = $instant->minus(duration: $duration); @@ -396,9 +397,9 @@ public function testMinusShiftsBackwardByDuration(): void self::assertSame('2026-02-17T10:00:00+00:00', $result->toIso8601()); } - public function testMinusWithZeroDurationReturnsSameTime(): void + public function testInstantMinusWithZeroDurationReturnsSameTime(): void { - /** @Given an Instant and a zero Duration */ + /** @Given an Instant at a known time */ $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); /** @When subtracting zero Duration */ @@ -408,35 +409,37 @@ public function testMinusWithZeroDurationReturnsSameTime(): void self::assertSame('2026-02-17T10:00:00+00:00', $result->toIso8601()); } - public function testMinusCrossesDayBoundaryBackward(): void + public function testInstantMinusCrossesDayBoundaryBackward(): 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)); + $result = $instant->minus(duration: Duration::fromHours(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 + public function testInstantMinusPreservesUtcTimezone(): 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)); + $result = $instant->minus(duration: Duration::fromMinutes(minutes: 90)); /** @Then the result should remain in UTC */ self::assertSame('UTC', $result->toDateTimeImmutable()->getTimezone()->getName()); } - public function testPlusAndMinusAreInverse(): void + public function testInstantPlusAndMinusAreInverse(): void { - /** @Given an Instant and a Duration */ + /** @Given an Instant at a known time */ $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); - $duration = Duration::ofMinutes(minutes: 45); + + /** @And a Duration of 45 minutes */ + $duration = Duration::fromMinutes(minutes: 45); /** @When adding and then subtracting the same Duration */ $result = $instant->plus(duration: $duration)->minus(duration: $duration); @@ -446,223 +449,263 @@ public function testPlusAndMinusAreInverse(): void self::assertSame($instant->toUnixSeconds(), $result->toUnixSeconds()); } - public function testPlusResultIsAfterOriginal(): void + public function testInstantPlusResultIsAfterOriginal(): 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)); + $later = $instant->plus(duration: Duration::fromMinutes(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 + public function testInstantMinusResultIsBeforeOriginal(): 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)); + $earlier = $instant->minus(duration: Duration::fromMinutes(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 + public function testInstantDurationUntilReturnsAbsoluteDistance(): void { - /** @Given two instants 30 minutes apart */ + /** @Given an earlier Instant */ $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @And a later Instant 30 minutes apart */ $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); + self::assertSame(1800, $earlier->durationUntil(other: $later)->toSeconds()); + self::assertSame(1800, $later->durationUntil(other: $earlier)->toSeconds()); } - public function testDurationUntilSameInstantIsZero(): void + public function testInstantDurationUntilSameInstantIsZero(): void { - /** @Given two instants at the same moment */ + /** @Given an Instant at a known moment */ $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @And another Instant at the same moment */ $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::assertSame(0, $duration->toSeconds()); self::assertTrue($duration->isZero()); } - public function testDurationUntilIsSymmetric(): void + public function testInstantDurationUntilIsSymmetric(): void { - /** @Given two distinct instants */ + /** @Given an Instant at 10:00 */ $a = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @And an Instant at 11: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 + $a->durationUntil(other: $b)->toSeconds(), + $b->durationUntil(other: $a)->toSeconds() ); } - public function testDurationUntilAcrossDayBoundary(): void + public function testInstantDurationUntilAcrossDayBoundary(): void { - /** @Given two instants crossing midnight */ + /** @Given an Instant before midnight */ $before = Instant::fromString(value: '2026-02-17T23:00:00+00:00'); + + /** @And an Instant after midnight */ $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); + self::assertSame(7200, $before->durationUntil(other: $after)->toSeconds()); } - public function testDurationUntilConsistentWithPlusAndMinus(): void + public function testInstantDurationUntilConsistentWithPlusAndMinus(): void { - /** @Given an Instant and a Duration of 90 minutes */ + /** @Given an Instant at a known time */ $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); - $duration = Duration::ofMinutes(minutes: 90); + + /** @And a Duration of 90 minutes */ + $duration = Duration::fromMinutes(minutes: 90); + + /** @When shifting the Instant forward by the Duration */ $shifted = $instant->plus(duration: $duration); /** @Then the durationUntil should equal the original Duration */ - self::assertSame($duration->seconds, $instant->durationUntil(other: $shifted)->seconds); + self::assertSame($duration->toSeconds(), $instant->durationUntil(other: $shifted)->toSeconds()); } - public function testDurationUntilWithDifferentOrigins(): void + public function testInstantDurationUntilWithDifferentOrigins(): void { - /** @Given an Instant from a string with offset and from Unix seconds */ + /** @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 the duration between them should be zero */ self::assertTrue($fromString->durationUntil(other: $fromUnix)->isZero()); } - public function testIsBeforeReturnsTrueWhenEarlier(): void + public function testInstantIsBeforeReturnsTrueWhenEarlier(): void { - /** @Given two Instants where the first is earlier */ + /** @Given an earlier Instant */ $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @And a later Instant */ $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 + public function testInstantIsBeforeReturnsFalseWhenLater(): void { - /** @Given two Instants where the first is later */ + /** @Given a later Instant */ $later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + + /** @And an earlier Instant */ $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 + public function testInstantIsBeforeReturnsFalseWhenEqual(): void { - /** @Given two Instants at the same moment */ + /** @Given an Instant at a known moment */ $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @And another Instant at the same moment */ $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 + public function testInstantIsAfterReturnsTrueWhenLater(): void { - /** @Given two Instants where the first is later */ + /** @Given a later Instant */ $later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + + /** @And an earlier Instant */ $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 + public function testInstantIsAfterReturnsFalseWhenEarlier(): void { - /** @Given two Instants where the first is earlier */ + /** @Given an earlier Instant */ $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @And a later Instant */ $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 + public function testInstantIsAfterReturnsFalseWhenEqual(): void { - /** @Given two Instants at the same moment */ + /** @Given an Instant at a known moment */ $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @And another Instant at the same moment */ $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 + public function testInstantIsBeforeOrEqualReturnsTrueWhenEarlier(): void { - /** @Given two Instants where the first is earlier */ + /** @Given an earlier Instant */ $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @And a later Instant */ $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 + public function testInstantIsBeforeOrEqualReturnsTrueWhenEqual(): void { - /** @Given two Instants at the same moment */ + /** @Given an Instant at a known moment */ $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @And another Instant at the same moment */ $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 + public function testInstantIsBeforeOrEqualReturnsFalseWhenLater(): void { - /** @Given two Instants where the first is later */ + /** @Given a later Instant */ $later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + + /** @And an earlier Instant */ $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 + public function testInstantIsAfterOrEqualReturnsTrueWhenLater(): void { - /** @Given two Instants where the first is later */ + /** @Given a later Instant */ $later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + + /** @And an earlier Instant */ $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 + public function testInstantIsAfterOrEqualReturnsTrueWhenEqual(): void { - /** @Given two Instants at the same moment */ + /** @Given an Instant at a known moment */ $instant = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @And another Instant at the same moment */ $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 + public function testInstantIsAfterOrEqualReturnsFalseWhenEarlier(): void { - /** @Given two Instants where the first is earlier */ + /** @Given an earlier Instant */ $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @And a later Instant */ $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 + public function testInstantIsBeforeAndIsAfterAreMutuallyExclusive(): void { - /** @Given two distinct Instants */ + /** @Given an earlier Instant */ $earlier = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @And a later Instant */ $later = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); /** @Then isBefore and isAfter should be mutually exclusive */ @@ -672,7 +715,7 @@ public function testIsBeforeAndIsAfterAreMutuallyExclusive(): void self::assertFalse($later->isBefore(other: $earlier)); } - public function testComparisonWithDifferentOriginsProducesSameResult(): void + public function testInstantComparisonWithDifferentOriginsProducesSameResult(): void { /** @Given an Instant from a string with offset */ $fromString = Instant::fromString(value: '2026-02-17T13:30:00-03:00'); diff --git a/tests/PeriodTest.php b/tests/PeriodTest.php index f30bf42..7457fa5 100644 --- a/tests/PeriodTest.php +++ b/tests/PeriodTest.php @@ -12,50 +12,56 @@ final class PeriodTest extends TestCase { - public function testOfCreatesPeriodWithValidRange(): void + public function testPeriodFromCreatesPeriodWithValidRange(): void { - /** @Given two instants where from is before to */ + /** @Given an Instant at the start of the range */ $from = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @And an Instant at the end of the range */ $to = Instant::fromString(value: '2026-02-17T11:00:00+00:00'); /** @When creating a Period */ - $period = Period::of(from: $from, to: $to); + $period = Period::from(from: $from, to: $to); /** @Then the period should expose the correct boundaries */ self::assertSame('2026-02-17T10:00:00+00:00', $period->from->toIso8601()); self::assertSame('2026-02-17T11:00:00+00:00', $period->to->toIso8601()); } - public function testOfThrowsWhenStartEqualsEnd(): void + public function testPeriodFromWhenStartEqualsEnd(): 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 */ + /** @Then an exception indicating that start must be before end should be thrown */ $this->expectException(InvalidPeriod::class); /** @When creating a Period with equal boundaries */ - Period::of(from: $instant, to: $instant); + Period::from(from: $instant, to: $instant); } - public function testOfThrowsWhenStartIsAfterEnd(): void + public function testPeriodFromWhenStartIsAfterEnd(): void { - /** @Given two instants where from is after to */ + /** @Given an Instant after the intended end */ $from = Instant::fromString(value: '2026-02-17T11:00:00+00:00'); + + /** @And an Instant before the intended start */ $to = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); - /** @Then an InvalidPeriod exception should be thrown */ + /** @Then an exception indicating that start must be before end should be thrown */ $this->expectException(InvalidPeriod::class); /** @When creating a Period with inverted boundaries */ - Period::of(from: $from, to: $to); + Period::from(from: $from, to: $to); } - public function testStartingAtCreatesPeriodFromDuration(): void + public function testPeriodStartingAtCreatesPeriodFromDuration(): void { - /** @Given a start instant and a Duration of 30 minutes */ + /** @Given a start Instant */ $from = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); - $duration = Duration::ofMinutes(minutes: 30); + + /** @And a Duration of 30 minutes */ + $duration = Duration::fromMinutes(minutes: 30); /** @When creating a Period from start and Duration */ $period = Period::startingAt(from: $from, duration: $duration); @@ -65,23 +71,25 @@ public function testStartingAtCreatesPeriodFromDuration(): void self::assertSame('2026-02-17T10:30:00+00:00', $period->to->toIso8601()); } - public function testStartingAtThrowsWhenDurationIsZero(): void + public function testPeriodStartingAtWhenDurationIsZero(): void { - /** @Given a start instant and a zero Duration */ + /** @Given a start Instant */ $from = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); - /** @Then an InvalidPeriod exception should be thrown */ + /** @Then an exception indicating that duration must not be zero 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 + public function testPeriodStartingAtCrossesDayBoundary(): void { - /** @Given a start near midnight and a Duration that crosses the day */ + /** @Given a start near midnight */ $from = Instant::fromString(value: '2026-02-17T23:00:00+00:00'); - $duration = Duration::ofHours(hours: 2); + + /** @And a Duration that crosses the day */ + $duration = Duration::fromHours(hours: 2); /** @When creating a Period */ $period = Period::startingAt(from: $from, duration: $duration); @@ -90,10 +98,10 @@ public function testStartingAtCrossesDayBoundary(): void self::assertSame('2026-02-18T01:00:00+00:00', $period->to->toIso8601()); } - public function testDurationReturnsCorrectValue(): void + public function testPeriodDurationReturnsCorrectValue(): void { /** @Given a Period spanning 1 hour */ - $period = Period::of( + $period = Period::from( from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') ); @@ -102,13 +110,15 @@ public function testDurationReturnsCorrectValue(): void $duration = $period->duration(); /** @Then the duration should be 3600 seconds */ - self::assertSame(3600, $duration->seconds); + self::assertSame(3600, $duration->toSeconds()); } - public function testDurationFromStartingAt(): void + public function testPeriodDurationFromStartingAt(): void { - /** @Given a Period created from start and Duration of 90 minutes */ - $inputDuration = Duration::ofMinutes(minutes: 90); + /** @Given a Duration of 90 minutes */ + $inputDuration = Duration::fromMinutes(minutes: 90); + + /** @And a Period created from start and that Duration */ $period = Period::startingAt( from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), duration: $inputDuration @@ -118,15 +128,15 @@ public function testDurationFromStartingAt(): void $duration = $period->duration(); /** @Then the duration should match the input */ - self::assertSame($inputDuration->seconds, $duration->seconds); + self::assertSame($inputDuration->toSeconds(), $duration->toSeconds()); } - public function testDurationReturnsDurationObject(): void + public function testPeriodDurationReturnsDurationObject(): 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) + duration: Duration::fromMinutes(minutes: 30) ); /** @When getting the Duration */ @@ -136,10 +146,10 @@ public function testDurationReturnsDurationObject(): void self::assertSame(30, $duration->toMinutes()); } - public function testContainsReturnsTrueForInstantAtStart(): void + public function testPeriodContainsReturnsTrueForInstantAtStart(): void { /** @Given a Period [10:00, 11:00) */ - $period = Period::of( + $period = Period::from( from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') ); @@ -151,10 +161,10 @@ public function testContainsReturnsTrueForInstantAtStart(): void self::assertTrue($result); } - public function testContainsReturnsTrueForInstantInMiddle(): void + public function testPeriodContainsReturnsTrueForInstantInMiddle(): void { /** @Given a Period [10:00, 11:00) */ - $period = Period::of( + $period = Period::from( from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') ); @@ -166,10 +176,10 @@ public function testContainsReturnsTrueForInstantInMiddle(): void self::assertTrue($result); } - public function testContainsReturnsFalseForInstantAtEnd(): void + public function testPeriodContainsReturnsFalseForInstantAtEnd(): void { /** @Given a Period [10:00, 11:00) */ - $period = Period::of( + $period = Period::from( from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') ); @@ -181,10 +191,10 @@ public function testContainsReturnsFalseForInstantAtEnd(): void self::assertFalse($result); } - public function testContainsReturnsFalseForInstantBeforeStart(): void + public function testPeriodContainsReturnsFalseForInstantBeforeStart(): void { /** @Given a Period [10:00, 11:00) */ - $period = Period::of( + $period = Period::from( from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') ); @@ -196,10 +206,10 @@ public function testContainsReturnsFalseForInstantBeforeStart(): void self::assertFalse($result); } - public function testContainsReturnsFalseForInstantAfterEnd(): void + public function testPeriodContainsReturnsFalseForInstantAfterEnd(): void { /** @Given a Period [10:00, 11:00) */ - $period = Period::of( + $period = Period::from( from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') ); @@ -211,14 +221,16 @@ public function testContainsReturnsFalseForInstantAfterEnd(): void self::assertFalse($result); } - public function testOverlapsWithReturnsTrueForPartialOverlap(): void + public function testPeriodOverlapsWithReturnsTrueForPartialOverlap(): void { - /** @Given two partially overlapping periods */ - $periodA = Period::of( + /** @Given a Period [10:00, 11:00) */ + $periodA = Period::from( 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( + + /** @And a partially overlapping Period [10:30, 11:30) */ + $periodB = Period::from( from: Instant::fromString(value: '2026-02-17T10:30:00+00:00'), to: Instant::fromString(value: '2026-02-17T11:30:00+00:00') ); @@ -228,14 +240,16 @@ public function testOverlapsWithReturnsTrueForPartialOverlap(): void self::assertTrue($periodB->overlapsWith(other: $periodA)); } - public function testOverlapsWithReturnsTrueWhenOneContainsAnother(): void + public function testPeriodOverlapsWithReturnsTrueWhenOneContainsAnother(): void { - /** @Given a period that fully contains another */ - $outer = Period::of( + /** @Given an outer Period [09:00, 12:00) */ + $outer = Period::from( 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( + + /** @And an inner Period [10:00, 11:00) fully contained */ + $inner = Period::from( from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') ); @@ -245,14 +259,16 @@ public function testOverlapsWithReturnsTrueWhenOneContainsAnother(): void self::assertTrue($inner->overlapsWith(other: $outer)); } - public function testOverlapsWithReturnsFalseForAdjacentPeriods(): void + public function testPeriodOverlapsWithReturnsFalseForAdjacentPeriods(): void { - /** @Given two adjacent periods [10:00, 11:00) and [11:00, 12:00) */ - $periodA = Period::of( + /** @Given a Period [10:00, 11:00) */ + $periodA = Period::from( 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( + + /** @And an adjacent Period [11:00, 12:00) */ + $periodB = Period::from( from: Instant::fromString(value: '2026-02-17T11:00:00+00:00'), to: Instant::fromString(value: '2026-02-17T12:00:00+00:00') ); @@ -262,14 +278,16 @@ public function testOverlapsWithReturnsFalseForAdjacentPeriods(): void self::assertFalse($periodB->overlapsWith(other: $periodA)); } - public function testOverlapsWithReturnsFalseForDisjointPeriods(): void + public function testPeriodOverlapsWithReturnsFalseForDisjointPeriods(): void { - /** @Given two completely disjoint periods */ - $periodA = Period::of( + /** @Given a Period [08:00, 09:00) */ + $periodA = Period::from( 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( + + /** @And a completely disjoint Period [14:00, 15:00) */ + $periodB = Period::from( from: Instant::fromString(value: '2026-02-17T14:00:00+00:00'), to: Instant::fromString(value: '2026-02-17T15:00:00+00:00') ); @@ -279,14 +297,16 @@ public function testOverlapsWithReturnsFalseForDisjointPeriods(): void self::assertFalse($periodB->overlapsWith(other: $periodA)); } - public function testOverlapsWithReturnsTrueForIdenticalPeriods(): void + public function testPeriodOverlapsWithReturnsTrueForIdenticalPeriods(): void { - /** @Given two identical periods */ - $periodA = Period::of( + /** @Given a Period [10:00, 11:00) */ + $periodA = Period::from( 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( + + /** @And an identical Period [10:00, 11:00) */ + $periodB = Period::from( from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') ); @@ -295,14 +315,16 @@ public function testOverlapsWithReturnsTrueForIdenticalPeriods(): void self::assertTrue($periodA->overlapsWith(other: $periodB)); } - public function testOverlapsWithIsSymmetric(): void + public function testPeriodOverlapsWithIsSymmetric(): void { - /** @Given two overlapping periods */ - $periodA = Period::of( + /** @Given a Period [10:00, 11:00) */ + $periodA = Period::from( 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( + + /** @And an overlapping Period [10:30, 11:30) */ + $periodB = Period::from( from: Instant::fromString(value: '2026-02-17T10:30:00+00:00'), to: Instant::fromString(value: '2026-02-17T11:30:00+00:00') ); @@ -314,47 +336,58 @@ public function testOverlapsWithIsSymmetric(): void ); } - public function testDurationIsConsistentBetweenOfAndStartingAt(): void + public function testPeriodDurationIsConsistentBetweenFromAndStartingAt(): void { - /** @Given a Period from of() and one from startingAt() with the same boundaries */ + /** @Given a start Instant */ $from = Instant::fromString(value: '2026-02-17T10:00:00+00:00'); + + /** @And an end Instant */ $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)); + /** @And a Period created via from() */ + $periodFromFrom = Period::from(from: $from, to: $to); + + /** @And a Period created via startingAt() with equivalent Duration */ + $periodFromStartingAt = Period::startingAt(from: $from, duration: Duration::fromMinutes(minutes: 90)); /** @Then both should have the same Duration */ self::assertSame( - $periodFromOf->duration()->seconds, - $periodFromStartingAt->duration()->seconds + $periodFromFrom->duration()->toSeconds(), + $periodFromStartingAt->duration()->toSeconds() ); } - public function testContainsIsConsistentWithOverlapsForSingleInstant(): void + public function testPeriodContainsIsConsistentWithOverlapsForSingleInstant(): void { - /** @Given a Period and a 1-second micro-period at a contained instant */ - $period = Period::of( + /** @Given a Period [10:00, 11:00) */ + $period = Period::from( from: Instant::fromString(value: '2026-02-17T10:00:00+00:00'), to: Instant::fromString(value: '2026-02-17T11:00:00+00:00') ); + + /** @And a contained Instant at 10:30 */ $contained = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); - $microPeriod = Period::startingAt(from: $contained, duration: Duration::ofSeconds(seconds: 1)); + + /** @And a 1-second micro-period starting at that Instant */ + $microPeriod = Period::startingAt(from: $contained, duration: Duration::fromSeconds(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 + public function testPeriodOverlapsWithNonOverlappingIsSymmetric(): void { - /** @Given two non-overlapping periods */ + /** @Given a Period [08:00, 09:00) */ $periodA = Period::startingAt( from: Instant::fromString(value: '2026-02-17T08:00:00+00:00'), - duration: Duration::ofHours(hours: 1) + duration: Duration::fromHours(hours: 1) ); + + /** @And a non-overlapping Period [14:00, 15:00) */ $periodB = Period::startingAt( from: Instant::fromString(value: '2026-02-17T14:00:00+00:00'), - duration: Duration::ofHours(hours: 1) + duration: Duration::fromHours(hours: 1) ); /** @Then symmetry should hold for non-overlapping case */ diff --git a/tests/TimeOfDayTest.php b/tests/TimeOfDayTest.php new file mode 100644 index 0000000..106483b --- /dev/null +++ b/tests/TimeOfDayTest.php @@ -0,0 +1,475 @@ +hour); + self::assertSame(30, $time->minute); + } + + public function testTimeOfDayFromWithZeros(): void + { + /** @Given hour 0 and minute 0 */ + $time = TimeOfDay::from(hour: 0, minute: 0); + + /** @Then it should be midnight */ + self::assertSame(0, $time->hour); + self::assertSame(0, $time->minute); + } + + public function testTimeOfDayFromWithMaxValues(): void + { + /** @Given maximum valid hour and minute */ + $time = TimeOfDay::from(hour: 23, minute: 59); + + /** @Then the components should match */ + self::assertSame(23, $time->hour); + self::assertSame(59, $time->minute); + } + + public function testTimeOfDayWhenHourIsNegative(): void + { + /** @Then an exception indicating that hour is out of range should be thrown */ + $this->expectException(InvalidTimeOfDay::class); + + /** @When creating with negative hour */ + TimeOfDay::from(hour: -1, minute: 0); + } + + public function testTimeOfDayWhenHourExceeds23(): void + { + /** @Then an exception indicating that hour is out of range should be thrown */ + $this->expectException(InvalidTimeOfDay::class); + + /** @When creating with hour 24 */ + TimeOfDay::from(hour: 24, minute: 0); + } + + public function testTimeOfDayWhenMinuteIsNegative(): void + { + /** @Then an exception indicating that minute is out of range should be thrown */ + $this->expectException(InvalidTimeOfDay::class); + + /** @When creating with negative minute */ + TimeOfDay::from(hour: 10, minute: -1); + } + + public function testTimeOfDayWhenMinuteExceeds59(): void + { + /** @Then an exception indicating that minute is out of range should be thrown */ + $this->expectException(InvalidTimeOfDay::class); + + /** @When creating with minute 60 */ + TimeOfDay::from(hour: 10, minute: 60); + } + + public function testTimeOfDayMidnight(): void + { + /** @Given midnight */ + $time = TimeOfDay::midnight(); + + /** @Then it should be 00:00 */ + self::assertSame(0, $time->hour); + self::assertSame(0, $time->minute); + self::assertSame('00:00', $time->toString()); + } + + public function testTimeOfDayNoon(): void + { + /** @Given noon */ + $time = TimeOfDay::noon(); + + /** @Then it should be 12:00 */ + self::assertSame(12, $time->hour); + self::assertSame(0, $time->minute); + self::assertSame('12:00', $time->toString()); + } + + public function testTimeOfDayFromInstant(): void + { + /** @Given an Instant at 14:30 UTC */ + $instant = Instant::fromString(value: '2026-02-17T14:30:00+00:00'); + + /** @When extracting the time of day */ + $time = TimeOfDay::fromInstant(instant: $instant); + + /** @Then the components should match */ + self::assertSame(14, $time->hour); + self::assertSame(30, $time->minute); + } + + public function testTimeOfDayFromInstantAtMidnight(): void + { + /** @Given an Instant at midnight */ + $instant = Instant::fromString(value: '2026-02-17T00:00:00+00:00'); + + /** @When extracting the time of day */ + $time = TimeOfDay::fromInstant(instant: $instant); + + /** @Then it should be midnight */ + self::assertSame(0, $time->hour); + self::assertSame(0, $time->minute); + } + + public function testTimeOfDayFromInstantAtEndOfDay(): void + { + /** @Given an Instant at 23:59 */ + $instant = Instant::fromString(value: '2026-02-17T23:59:00+00:00'); + + /** @When extracting the time of day */ + $time = TimeOfDay::fromInstant(instant: $instant); + + /** @Then it should be 23:59 */ + self::assertSame(23, $time->hour); + self::assertSame(59, $time->minute); + } + + public function testTimeOfDayFromStringValid(): void + { + /** @Given a valid time string */ + $time = TimeOfDay::fromString(value: '08:30'); + + /** @Then the components should match */ + self::assertSame(8, $time->hour); + self::assertSame(30, $time->minute); + } + + public function testTimeOfDayFromStringMidnight(): void + { + /** @Given midnight as string */ + $time = TimeOfDay::fromString(value: '00:00'); + + /** @Then it should be midnight */ + self::assertSame(0, $time->hour); + self::assertSame(0, $time->minute); + } + + public function testTimeOfDayFromStringEndOfDay(): void + { + /** @Given end of day as string */ + $time = TimeOfDay::fromString(value: '23:59'); + + /** @Then it should be 23:59 */ + self::assertSame(23, $time->hour); + self::assertSame(59, $time->minute); + } + + public function testTimeOfDayFromStringWhenInvalidFormat(): void + { + /** @Then an exception indicating that the format is invalid should be thrown */ + $this->expectException(InvalidTimeOfDay::class); + + /** @When parsing an invalid string */ + TimeOfDay::fromString(value: '8:30'); + } + + public function testTimeOfDayFromStringWhenEmpty(): void + { + /** @Then an exception indicating that the format is invalid should be thrown */ + $this->expectException(InvalidTimeOfDay::class); + + /** @When parsing an empty string */ + TimeOfDay::fromString(value: ''); + } + + public function testTimeOfDayFromStringWhenHasSeconds(): void + { + /** @Then an exception indicating that the format is invalid should be thrown */ + $this->expectException(InvalidTimeOfDay::class); + + /** @When parsing a string with seconds */ + TimeOfDay::fromString(value: '08:30:00'); + } + + public function testTimeOfDayFromStringWhenHourOutOfRange(): void + { + /** @Then an exception indicating that hour is out of range should be thrown */ + $this->expectException(InvalidTimeOfDay::class); + + /** @When parsing a string with hour 25 */ + TimeOfDay::fromString(value: '25:00'); + } + + public function testTimeOfDayFromStringWhenMinuteOutOfRange(): void + { + /** @Then an exception indicating that minute is out of range should be thrown */ + $this->expectException(InvalidTimeOfDay::class); + + /** @When parsing a string with minute 60 */ + TimeOfDay::fromString(value: '10:60'); + } + + public function testTimeOfDayToMinutesSinceMidnightAtMidnight(): void + { + /** @Given midnight */ + $time = TimeOfDay::midnight(); + + /** @Then minutes since midnight should be 0 */ + self::assertSame(0, $time->toMinutesSinceMidnight()); + } + + public function testTimeOfDayToMinutesSinceMidnightAtNoon(): void + { + /** @Given noon */ + $time = TimeOfDay::noon(); + + /** @Then minutes since midnight should be 720 */ + self::assertSame(720, $time->toMinutesSinceMidnight()); + } + + public function testTimeOfDayToMinutesSinceMidnightAt0830(): void + { + /** @Given 08:30 */ + $time = TimeOfDay::from(hour: 8, minute: 30); + + /** @Then minutes since midnight should be 510 */ + self::assertSame(510, $time->toMinutesSinceMidnight()); + } + + public function testTimeOfDayToMinutesSinceMidnightAtEndOfDay(): void + { + /** @Given 23:59 */ + $time = TimeOfDay::from(hour: 23, minute: 59); + + /** @Then minutes since midnight should be 1439 */ + self::assertSame(1439, $time->toMinutesSinceMidnight()); + } + + public function testTimeOfDayToDuration(): void + { + /** @Given 08:30 */ + $time = TimeOfDay::from(hour: 8, minute: 30); + + /** @When converting to Duration */ + $duration = $time->toDuration(); + + /** @Then the duration should be 510 minutes in seconds */ + self::assertSame(510, $duration->toMinutes()); + self::assertSame(30600, $duration->toSeconds()); + } + + public function testTimeOfDayToDurationAtMidnight(): void + { + /** @Given midnight */ + $time = TimeOfDay::midnight(); + + /** @When converting to Duration */ + $duration = $time->toDuration(); + + /** @Then the duration should be zero */ + self::assertTrue($duration->isZero()); + } + + public function testTimeOfDayIsBeforeReturnsTrueWhenEarlier(): void + { + /** @Given an earlier time */ + $earlier = TimeOfDay::from(hour: 8, minute: 0); + + /** @And a later time */ + $later = TimeOfDay::from(hour: 14, minute: 30); + + /** @Then the earlier should be before the later */ + self::assertTrue($earlier->isBefore(other: $later)); + } + + public function testTimeOfDayIsBeforeReturnsFalseWhenLater(): void + { + /** @Given a later time */ + $later = TimeOfDay::from(hour: 14, minute: 30); + + /** @And an earlier time */ + $earlier = TimeOfDay::from(hour: 8, minute: 0); + + /** @Then the later should not be before the earlier */ + self::assertFalse($later->isBefore(other: $earlier)); + } + + public function testTimeOfDayIsBeforeReturnsFalseWhenEqual(): void + { + /** @Given a time */ + $time = TimeOfDay::from(hour: 10, minute: 0); + + /** @And the same time */ + $same = TimeOfDay::from(hour: 10, minute: 0); + + /** @Then isBefore should return false */ + self::assertFalse($time->isBefore(other: $same)); + } + + public function testTimeOfDayIsAfterReturnsTrueWhenLater(): void + { + /** @Given a later time */ + $later = TimeOfDay::from(hour: 18, minute: 0); + + /** @And an earlier time */ + $earlier = TimeOfDay::from(hour: 8, minute: 0); + + /** @Then the later should be after the earlier */ + self::assertTrue($later->isAfter(other: $earlier)); + } + + public function testTimeOfDayIsAfterReturnsFalseWhenEqual(): void + { + /** @Given a time */ + $time = TimeOfDay::from(hour: 10, minute: 0); + + /** @And the same time */ + $same = TimeOfDay::from(hour: 10, minute: 0); + + /** @Then isAfter should return false */ + self::assertFalse($time->isAfter(other: $same)); + } + + public function testTimeOfDayIsBeforeOrEqualReturnsTrueWhenEqual(): void + { + /** @Given a time */ + $time = TimeOfDay::from(hour: 10, minute: 0); + + /** @And the same time */ + $same = TimeOfDay::from(hour: 10, minute: 0); + + /** @Then isBeforeOrEqual should return true */ + self::assertTrue($time->isBeforeOrEqual(other: $same)); + } + + public function testTimeOfDayIsAfterOrEqualReturnsTrueWhenEqual(): void + { + /** @Given a time */ + $time = TimeOfDay::from(hour: 10, minute: 0); + + /** @And the same time */ + $same = TimeOfDay::from(hour: 10, minute: 0); + + /** @Then isAfterOrEqual should return true */ + self::assertTrue($time->isAfterOrEqual(other: $same)); + } + + public function testTimeOfDayIsBeforeAndIsAfterAreMutuallyExclusive(): void + { + /** @Given an earlier time */ + $earlier = TimeOfDay::from(hour: 8, minute: 0); + + /** @And a later time */ + $later = TimeOfDay::from(hour: 18, minute: 0); + + /** @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 testTimeOfDayDurationUntilReturnsCorrectDuration(): void + { + /** @Given a start time at 08:00 */ + $from = TimeOfDay::from(hour: 8, minute: 0); + + /** @And an end time at 12:30 */ + $to = TimeOfDay::from(hour: 12, minute: 30); + + /** @When calculating the duration */ + $duration = $from->durationUntil(other: $to); + + /** @Then the duration should be 270 minutes */ + self::assertSame(270, $duration->toMinutes()); + } + + public function testTimeOfDayDurationUntilWhenEndIsBeforeStart(): void + { + /** @Given a start time at 14:00 */ + $from = TimeOfDay::from(hour: 14, minute: 0); + + /** @And an end time at 08:00 */ + $to = TimeOfDay::from(hour: 8, minute: 0); + + /** @Then an exception indicating that end must be after start should be thrown */ + $this->expectException(InvalidTimeOfDay::class); + + /** @When calculating the duration */ + $from->durationUntil(other: $to); + } + + public function testTimeOfDayDurationUntilWhenEqual(): void + { + /** @Given a time at 10:00 */ + $time = TimeOfDay::from(hour: 10, minute: 0); + + /** @And the same time at 10:00 */ + $same = TimeOfDay::from(hour: 10, minute: 0); + + /** @Then an exception indicating that end must be after start should be thrown */ + $this->expectException(InvalidTimeOfDay::class); + + /** @When calculating the duration */ + $time->durationUntil(other: $same); + } + + public function testTimeOfDayToStringFormatsCorrectly(): void + { + /** @Then various times should format correctly */ + self::assertSame('00:00', TimeOfDay::from(hour: 0, minute: 0)->toString()); + self::assertSame('08:05', TimeOfDay::from(hour: 8, minute: 5)->toString()); + self::assertSame('14:30', TimeOfDay::from(hour: 14, minute: 30)->toString()); + self::assertSame('23:59', TimeOfDay::from(hour: 23, minute: 59)->toString()); + } + + public function testTimeOfDayFromStringAndToStringRoundTrip(): void + { + /** @Given a time string */ + $original = '14:30'; + + /** @When parsing and formatting back */ + $result = TimeOfDay::fromString(value: $original)->toString(); + + /** @Then the result should match the original */ + self::assertSame($original, $result); + } + + public function testTimeOfDayFromInstantAndFromProduceSameResult(): void + { + /** @Given an Instant at 14:30 UTC */ + $instant = Instant::fromString(value: '2026-02-17T14:30:00+00:00'); + + /** @When creating from both methods */ + $fromInstant = TimeOfDay::fromInstant(instant: $instant); + + /** @And creating from hour and minute directly */ + $fromFactory = TimeOfDay::from(hour: 14, minute: 30); + + /** @Then both should produce the same result */ + self::assertSame($fromInstant->hour, $fromFactory->hour); + self::assertSame($fromInstant->minute, $fromFactory->minute); + } + + public function testTimeOfDayFromStringWhenPrefixBeforeValidPattern(): void + { + /** @Then an exception indicating that the format is invalid should be thrown */ + $this->expectException(InvalidTimeOfDay::class); + + /** @When parsing a string with a prefix before a valid HH:MM pattern */ + TimeOfDay::fromString(value: 'abc08:30'); + } + + public function testTimeOfDayFromStringWhenSuffixAfterValidPattern(): void + { + /** @Then an exception indicating that the format is invalid should be thrown */ + $this->expectException(InvalidTimeOfDay::class); + + /** @When parsing a string with a suffix after a valid HH:MM pattern */ + TimeOfDay::fromString(value: '08:30xyz'); + } +}