diff --git a/README.md b/README.md index 7b1b717..2b58557 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ - [Finding a timezone by identifier with UTC fallback](#finding-a-timezone-by-identifier-with-utc-fallback) - [Checking if a timezone exists in the collection](#checking-if-a-timezone-exists-in-the-collection) - [Getting all identifiers as strings](#getting-all-identifiers-as-strings) + + [Value equality](#value-equality) + - [Checking equality](#checking-equality) + - [Checking equality across factories](#checking-equality-across-factories) + - [Checking equality of a nested value object](#checking-equality-of-a-nested-value-object) * [License](#license) * [Contributing](#contributing) @@ -1242,6 +1246,69 @@ $timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Europe/London') $timezones->toStrings(); # ["UTC", "America/Sao_Paulo", "Europe/London"] ``` +### Value equality + +Every type of this library that implements the value-object contract compares by value, never by instance. A value +object that carries another as a property inherits that, because the comparison walks the properties structurally. + +#### Checking equality + +Two instances of the same value are equal and share the same hash code, however each one was written. + +```php +equals(other: $sameMoment); # true, both normalize to the same UTC moment +$morning->hashCode() === $sameMoment->hashCode(); # true +``` + +#### Checking equality across factories + +The factory that built the instance is not part of the value, so instances from different factories are equal. + +```php +equals(other: $composed); # true +$parsed->hashCode() === $composed->hashCode(); # true +``` + +#### Checking equality of a nested value object + +A value object holding another compares by value, because the comparison recurses into each property. + +```php +equals(other: $samePeriod); # true, the comparison walks into each Instant +``` + ## License Time is licensed under [MIT](LICENSE). diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 5dd9b4f..215464f 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,9 +5,6 @@ parameters: - tests tmpDir: reports/phpstan ignoreErrors: - # DateTimeImmutable::createFromFormat returns DateTimeImmutable|false; the UNIX format 'U' always succeeds for a valid integer, so the false branch is unreachable. - - identifier: method.nonObject - path: src/Instant.php # DateTimeImmutable::createFromFormat returns DateTimeImmutable|false; the recomposed wall-clock string is always well-formed, so the false branch is unreachable. - identifier: method.nonObject path: src/Internal/ZonedShift.php diff --git a/src/Instant.php b/src/Instant.php index 298e6d4..b36d1a3 100644 --- a/src/Instant.php +++ b/src/Instant.php @@ -8,6 +8,7 @@ use TinyBlocks\Mapper\ScalarCodec; use TinyBlocks\Time\Exceptions\InvalidInstant; use TinyBlocks\Time\Exceptions\InvalidLocalDate; +use TinyBlocks\Time\Internal\IsoMoment; use TinyBlocks\Time\Internal\TextDecoder; use TinyBlocks\Time\Internal\ZonedShift; use TinyBlocks\Vo\ValueObject; @@ -15,20 +16,25 @@ /** * Represents a single point on the timeline, always normalized to UTC with microsecond precision. + * + *
Two instances of this type for the same moment are always equal by value. The state is the + * canonical UTC text at microsecond precision rather than a date-time object, because structural + * equality compares a property that is not itself a value object by identity: an instance holding a + * date-time object would never equal another holding the same moment, and neither would any value + * object that wraps one.
*/ #[ScalarCodec(decode: 'fromString', encode: 'toIso8601')] final readonly class Instant implements ValueObject { use ValueObjectBehavior; - private const string UNIX_FORMAT = 'U'; private const string OFFSET_FORMAT = 'P'; private const string ISO8601_FORMAT = 'Y-m-d\TH:i:sP'; private const string ISO8601_MICRO_FORMAT = 'Y-m-d\TH:i:s.uP'; private const string ISO8601_DATETIME_FORMAT = 'Y-m-d\TH:i:s'; private const string FRACTIONAL_SECONDS_FORMAT = 'u'; - private function __construct(private DateTimeImmutable $datetime) + private function __construct(private string $canonical) { } @@ -40,8 +46,9 @@ private function __construct(private DateTimeImmutable $datetime) public static function now(): Instant { $utc = Timezone::utc()->toDateTimeZone(); + $datetime = new DateTimeImmutable(timezone: $utc); - return new Instant(datetime: new DateTimeImmutable(timezone: $utc)); + return new Instant(canonical: IsoMoment::canonicalize(datetime: $datetime)); } /** @@ -56,7 +63,7 @@ public static function fromString(string $value): Instant $decoder = TextDecoder::create(); $datetime = $decoder->decode(value: $value); - return new Instant(datetime: $datetime); + return new Instant(canonical: IsoMoment::canonicalize(datetime: $datetime)); } /** @@ -67,10 +74,10 @@ public static function fromString(string $value): Instant */ public static function fromUnixSeconds(int $seconds): Instant { - $utc = Timezone::utc()->toDateTimeZone(); - $datetime = DateTimeImmutable::createFromFormat(self::UNIX_FORMAT, (string)$seconds, $utc); + $template = '@%d'; + $datetime = new DateTimeImmutable(datetime: sprintf($template, $seconds)); - return new Instant(datetime: $datetime->setTimezone($utc)); + return new Instant(canonical: IsoMoment::canonicalize(datetime: $datetime)); } /** @@ -82,9 +89,9 @@ public static function fromUnixSeconds(int $seconds): Instant public function plus(Duration $duration): Instant { $template = '+%d seconds'; - $modified = $this->datetime->modify(sprintf($template, $duration->toSeconds())); + $modified = $this->toDateTimeImmutable()->modify(sprintf($template, $duration->toSeconds())); - return new Instant(datetime: $modified); + return new Instant(canonical: IsoMoment::canonicalize(datetime: $modified)); } /** @@ -96,9 +103,9 @@ public function plus(Duration $duration): Instant public function minus(Duration $duration): Instant { $template = '-%d seconds'; - $modified = $this->datetime->modify(sprintf($template, $duration->toSeconds())); + $modified = $this->toDateTimeImmutable()->modify(sprintf($template, $duration->toSeconds())); - return new Instant(datetime: $modified); + return new Instant(canonical: IsoMoment::canonicalize(datetime: $modified)); } /** @@ -109,7 +116,7 @@ public function minus(Duration $duration): Instant */ public function isAfter(Instant $other): bool { - return $this->datetime > $other->datetime; + return $this->toDateTimeImmutable() > $other->toDateTimeImmutable(); } /** @@ -120,7 +127,7 @@ public function isAfter(Instant $other): bool */ public function isBefore(Instant $other): bool { - return $this->datetime < $other->datetime; + return $this->toDateTimeImmutable() < $other->toDateTimeImmutable(); } /** @@ -138,10 +145,11 @@ public function isBefore(Instant $other): bool public function plusYears(int $years, ?Timezone $zone = null): Instant { $timezone = ($zone ?? Timezone::utc()); + $datetime = $this->toDateTimeImmutable(); $shiftedDate = $this->toLocalDate(zone: $timezone)->plusYears(years: $years); - $recomposed = ZonedShift::recompose(zone: $timezone, original: $this->datetime, shiftedDate: $shiftedDate); + $recomposed = ZonedShift::recompose(zone: $timezone, original: $datetime, shiftedDate: $shiftedDate); - return new Instant(datetime: $recomposed); + return new Instant(canonical: IsoMoment::canonicalize(datetime: $recomposed)); } /** @@ -168,15 +176,16 @@ public function plusYears(int $years, ?Timezone $zone = null): Instant public function toIso8601(Precision $precision = Precision::Seconds): string { $template = '%s.%s%s'; + $datetime = $this->toDateTimeImmutable(); return match ($precision) { - Precision::Seconds => $this->datetime->format(self::ISO8601_FORMAT), - Precision::Microseconds => $this->datetime->format(self::ISO8601_MICRO_FORMAT), + Precision::Seconds => $datetime->format(self::ISO8601_FORMAT), + Precision::Microseconds => $datetime->format(self::ISO8601_MICRO_FORMAT), Precision::Milliseconds => sprintf( $template, - $this->datetime->format(self::ISO8601_DATETIME_FORMAT), - substr($this->datetime->format(self::FRACTIONAL_SECONDS_FORMAT), 0, 3), - $this->datetime->format(self::OFFSET_FORMAT) + $datetime->format(self::ISO8601_DATETIME_FORMAT), + substr($datetime->format(self::FRACTIONAL_SECONDS_FORMAT), 0, 3), + $datetime->format(self::OFFSET_FORMAT) ) }; } @@ -220,10 +229,11 @@ public function minusYears(int $years, ?Timezone $zone = null): Instant public function plusMonths(int $months, ?Timezone $zone = null): Instant { $timezone = ($zone ?? Timezone::utc()); + $datetime = $this->toDateTimeImmutable(); $shiftedDate = $this->toLocalDate(zone: $timezone)->plusMonths(months: $months); - $recomposed = ZonedShift::recompose(zone: $timezone, original: $this->datetime, shiftedDate: $shiftedDate); + $recomposed = ZonedShift::recompose(zone: $timezone, original: $datetime, shiftedDate: $shiftedDate); - return new Instant(datetime: $recomposed); + return new Instant(canonical: IsoMoment::canonicalize(datetime: $recomposed)); } /** @@ -251,7 +261,7 @@ public function minusMonths(int $months, ?Timezone $zone = null): Instant */ public function toLocalDate(Timezone $zone): LocalDate { - $datetime = $this->datetime->setTimezone($zone->toDateTimeZone()); + $datetime = $this->toDateTimeImmutable()->setTimezone($zone->toDateTimeZone()); return LocalDate::fromString(value: $datetime->format('Y-m-d')); } @@ -265,7 +275,7 @@ public function toLocalDate(Timezone $zone): LocalDate */ public function durationUntil(Instant $other): Duration { - $difference = abs($this->datetime->getTimestamp() - $other->datetime->getTimestamp()); + $difference = abs($this->toDateTimeImmutable()->getTimestamp() - $other->toDateTimeImmutable()->getTimestamp()); return Duration::fromSeconds(seconds: $difference); } @@ -277,7 +287,7 @@ public function durationUntil(Instant $other): Duration */ public function toUnixSeconds(): int { - return $this->datetime->getTimestamp(); + return $this->toDateTimeImmutable()->getTimestamp(); } /** @@ -288,7 +298,7 @@ public function toUnixSeconds(): int */ public function isAfterOrEqual(Instant $other): bool { - return $this->datetime >= $other->datetime; + return $this->toDateTimeImmutable() >= $other->toDateTimeImmutable(); } /** @@ -299,7 +309,7 @@ public function isAfterOrEqual(Instant $other): bool */ public function isBeforeOrEqual(Instant $other): bool { - return $this->datetime <= $other->datetime; + return $this->toDateTimeImmutable() <= $other->toDateTimeImmutable(); } /** @@ -309,6 +319,6 @@ public function isBeforeOrEqual(Instant $other): bool */ public function toDateTimeImmutable(): DateTimeImmutable { - return $this->datetime; + return IsoMoment::restore(canonical: $this->canonical); } } diff --git a/src/Internal/IsoDate.php b/src/Internal/IsoDate.php new file mode 100644 index 0000000..689b959 --- /dev/null +++ b/src/Internal/IsoDate.php @@ -0,0 +1,27 @@ +toDateTimeZone()); + } + + public static function canonicalize(DateTimeImmutable $datetime): string + { + return $datetime->format(self::CANONICAL_FORMAT); + } +} diff --git a/src/Internal/IsoMoment.php b/src/Internal/IsoMoment.php new file mode 100644 index 0000000..d7a4804 --- /dev/null +++ b/src/Internal/IsoMoment.php @@ -0,0 +1,29 @@ +setTimezone(Timezone::utc()->toDateTimeZone()); + } + + public static function canonicalize(DateTimeImmutable $datetime): string + { + return $datetime->setTimezone(Timezone::utc()->toDateTimeZone())->format(self::CANONICAL_FORMAT); + } +} diff --git a/src/Internal/Seconds.php b/src/Internal/Seconds.php index f0e4dbf..5509543 100644 --- a/src/Internal/Seconds.php +++ b/src/Internal/Seconds.php @@ -5,9 +5,13 @@ namespace TinyBlocks\Time\Internal; use TinyBlocks\Time\Exceptions\InvalidSeconds; +use TinyBlocks\Vo\ValueObject; +use TinyBlocks\Vo\ValueObjectBehavior; -final readonly class Seconds +final readonly class Seconds implements ValueObject { + use ValueObjectBehavior; + private const int ZERO = 0; private function __construct(public int $value) diff --git a/src/LocalDate.php b/src/LocalDate.php index b36e33e..4295717 100644 --- a/src/LocalDate.php +++ b/src/LocalDate.php @@ -8,13 +8,18 @@ use TinyBlocks\Mapper\ScalarCodec; use TinyBlocks\Time\Exceptions\InvalidLocalDate; use TinyBlocks\Time\Internal\CalendarDay; +use TinyBlocks\Time\Internal\IsoDate; use TinyBlocks\Vo\ValueObject; use TinyBlocks\Vo\ValueObjectBehavior; /** * Represents a calendar date (year, month, day) without time and without timezone. * - *Two instances of this type for the same date are always equal by value.
+ *Two instances of this type for the same date are always equal by value, and their ordering + * depends on the date alone. The state is the canonical text rather than a date-time object for + * both reasons: structural equality compares a property that is not itself a value object by + * identity, and a date-time object parsed from a date carries the wall clock of the moment it was + * parsed, which made two instances of the same date order as if one preceded the other.
*/ #[ScalarCodec(decode: 'fromString', encode: 'toIso8601')] final readonly class LocalDate implements ValueObject @@ -26,7 +31,7 @@ private const string DATE_FORMAT = 'Y-m-d'; private const string DATE_PATTERN = '/^\d{4}-\d{2}-\d{2}$/'; - private function __construct(private DateTimeImmutable $date) + private function __construct(private string $canonical) { } @@ -83,7 +88,7 @@ public static function fromString(string $value): LocalDate throw InvalidLocalDate::becauseValueIsInvalid(value: $value); } - return new LocalDate(date: $parsed); + return new LocalDate(canonical: IsoDate::canonicalize(datetime: $parsed)); } /** @@ -93,7 +98,7 @@ public static function fromString(string $value): LocalDate */ public function year(): int { - return (int)$this->date->format('Y'); + return (int)IsoDate::restore(canonical: $this->canonical)->format('Y'); } /** @@ -103,7 +108,7 @@ public function year(): int */ public function month(): int { - return (int)$this->date->format('n'); + return (int)IsoDate::restore(canonical: $this->canonical)->format('n'); } /** @@ -139,7 +144,7 @@ public function atTime(TimeOfDay $time, Timezone $zone): Instant */ public function isAfter(LocalDate $other): bool { - return $this->date > $other->date; + return IsoDate::restore(canonical: $this->canonical) > IsoDate::restore(canonical: $other->canonical); } /** @@ -150,7 +155,7 @@ public function isAfter(LocalDate $other): bool */ public function isBefore(LocalDate $other): bool { - return $this->date < $other->date; + return IsoDate::restore(canonical: $this->canonical) < IsoDate::restore(canonical: $other->canonical); } /** @@ -164,9 +169,9 @@ public function isBefore(LocalDate $other): bool public function plusDays(int $days): LocalDate { $template = '%+d days'; - $modified = $this->date->modify(sprintf($template, $days)); + $modified = IsoDate::restore(canonical: $this->canonical)->modify(sprintf($template, $days)); - return new LocalDate(date: $modified); + return new LocalDate(canonical: IsoDate::canonicalize(datetime: $modified)); } /** @@ -176,7 +181,7 @@ public function plusDays(int $days): LocalDate */ public function dayOfWeek(): DayOfWeek { - return DayOfWeek::from((int)$this->date->format('N')); + return DayOfWeek::from((int)IsoDate::restore(canonical: $this->canonical)->format('N')); } /** @@ -190,9 +195,9 @@ public function dayOfWeek(): DayOfWeek public function minusDays(int $days): LocalDate { $template = '%+d days'; - $modified = $this->date->modify(sprintf($template, -$days)); + $modified = IsoDate::restore(canonical: $this->canonical)->modify(sprintf($template, -$days)); - return new LocalDate(date: $modified); + return new LocalDate(canonical: IsoDate::canonicalize(datetime: $modified)); } /** @@ -227,7 +232,7 @@ public function plusYears(int $years): LocalDate */ public function toIso8601(): string { - return $this->date->format(self::DATE_FORMAT); + return $this->canonical; } /** @@ -237,7 +242,7 @@ public function toIso8601(): string */ public function dayOfMonth(): int { - return (int)$this->date->format('j'); + return (int)IsoDate::restore(canonical: $this->canonical)->format('j'); } /** @@ -317,7 +322,7 @@ public function minusMonths(int $months): LocalDate */ public function isAfterOrEqual(LocalDate $other): bool { - return $this->date >= $other->date; + return IsoDate::restore(canonical: $this->canonical) >= IsoDate::restore(canonical: $other->canonical); } /** @@ -328,6 +333,6 @@ public function isAfterOrEqual(LocalDate $other): bool */ public function isBeforeOrEqual(LocalDate $other): bool { - return $this->date <= $other->date; + return IsoDate::restore(canonical: $this->canonical) <= IsoDate::restore(canonical: $other->canonical); } } diff --git a/src/Timezone.php b/src/Timezone.php index 7f026bf..7140320 100644 --- a/src/Timezone.php +++ b/src/Timezone.php @@ -6,12 +6,18 @@ use DateTimeZone; use TinyBlocks\Time\Exceptions\InvalidTimezone; +use TinyBlocks\Vo\ValueObject; +use TinyBlocks\Vo\ValueObjectBehavior; /** * Represents a single IANA timezone identifier (e.g. America/Sao_Paulo). + * + *Two instances of this type for the same identifier are always equal by value.
*/ -final readonly class Timezone +final readonly class Timezone implements ValueObject { + use ValueObjectBehavior; + private function __construct(public string $value) { if ($this->value === '' || !in_array($this->value, DateTimeZone::listIdentifiers(), true)) { diff --git a/tests/Unit/DurationTest.php b/tests/Unit/DurationTest.php index 8d8614b..da1b6d5 100644 --- a/tests/Unit/DurationTest.php +++ b/tests/Unit/DurationTest.php @@ -527,4 +527,26 @@ public function testFactoriesWhenSameDurationExpressedDifferentlyThenProduceSame self::assertSame($fromMinutes->toSeconds(), $fromHours->toSeconds()); self::assertSame($fromHours->toSeconds(), $fromDays->toSeconds()); } + + public function testEqualsWhenSameAmountThenIsTrue(): void + { + /** @Given the same amount of time reached by two units */ + $one = Duration::fromMinutes(minutes: 2); + $other = Duration::fromSeconds(seconds: 120); + + /** @Then they are equal by value and share the hash code */ + self::assertTrue($one->equals(other: $other)); + self::assertSame($one->hashCode(), $other->hashCode()); + } + + public function testEqualsWhenDifferentAmountThenIsFalse(): void + { + /** @Given two durations one second apart */ + $one = Duration::fromSeconds(seconds: 120); + $other = Duration::fromSeconds(seconds: 121); + + /** @Then they are not equal and the hash codes differ */ + self::assertFalse($one->equals(other: $other)); + self::assertNotSame($one->hashCode(), $other->hashCode()); + } } diff --git a/tests/Unit/InstantTest.php b/tests/Unit/InstantTest.php index 54fa394..348c191 100644 --- a/tests/Unit/InstantTest.php +++ b/tests/Unit/InstantTest.php @@ -1306,4 +1306,37 @@ public static function validDatabaseStringsDataProvider(): array ] ]; } + + public function testEqualsWhenSameMomentThenIsTrue(): void + { + /** @Given two Instants built separately from the same moment */ + $one = Instant::fromString(value: '2026-02-17T08:27:21.106011+00:00'); + $other = Instant::fromString(value: '2026-02-17T08:27:21.106011+00:00'); + + /** @Then they are equal by value and share the hash code */ + self::assertTrue($one->equals(other: $other)); + self::assertSame($one->hashCode(), $other->hashCode()); + } + + public function testEqualsWhenSameMomentInAnotherOffsetThenIsTrue(): void + { + /** @Given two Instants for the same moment written in different offsets */ + $one = Instant::fromString(value: '2026-02-17T08:27:21+00:00'); + $other = Instant::fromString(value: '2026-02-17T05:27:21-03:00'); + + /** @Then they are equal by value, because the state is normalized to UTC */ + self::assertTrue($one->equals(other: $other)); + self::assertSame($one->hashCode(), $other->hashCode()); + } + + public function testEqualsWhenDifferentMomentThenIsFalse(): void + { + /** @Given two Instants one microsecond apart */ + $one = Instant::fromString(value: '2026-02-17T08:27:21.106011+00:00'); + $other = Instant::fromString(value: '2026-02-17T08:27:21.106012+00:00'); + + /** @Then they are not equal and the hash codes differ */ + self::assertFalse($one->equals(other: $other)); + self::assertNotSame($one->hashCode(), $other->hashCode()); + } } diff --git a/tests/Unit/IsoDateTest.php b/tests/Unit/IsoDateTest.php new file mode 100644 index 0000000..461bfff --- /dev/null +++ b/tests/Unit/IsoDateTest.php @@ -0,0 +1,25 @@ +newInstanceWithoutConstructor(); + + /** @When the private constructor of the static-only collaborator is invoked through reflection */ + new ReflectionMethod(IsoDate::class, '__construct')->invoke($instance); + + /** @Then the collaborator is instantiated */ + self::assertInstanceOf(IsoDate::class, $instance); + } +} diff --git a/tests/Unit/IsoMomentTest.php b/tests/Unit/IsoMomentTest.php new file mode 100644 index 0000000..37f405a --- /dev/null +++ b/tests/Unit/IsoMomentTest.php @@ -0,0 +1,25 @@ +newInstanceWithoutConstructor(); + + /** @When the private constructor of the static-only collaborator is invoked through reflection */ + new ReflectionMethod(IsoMoment::class, '__construct')->invoke($instance); + + /** @Then the collaborator is instantiated */ + self::assertInstanceOf(IsoMoment::class, $instance); + } +} diff --git a/tests/Unit/LocalDateTest.php b/tests/Unit/LocalDateTest.php index 4a5738b..8a4aff3 100644 --- a/tests/Unit/LocalDateTest.php +++ b/tests/Unit/LocalDateTest.php @@ -1125,4 +1125,39 @@ public static function invalidStringsDataProvider(): array 'Date with leading whitespace' => ['value' => ' 2026-05-23'] ]; } + + public function testEqualsWhenSameDateThenIsTrue(): void + { + /** @Given two LocalDates built separately from the same date */ + $one = LocalDate::fromString(value: '2026-02-17'); + $other = LocalDate::of(year: 2026, month: 2, day: 17); + + /** @Then they are equal by value and share the hash code */ + self::assertTrue($one->equals(other: $other)); + self::assertSame($one->hashCode(), $other->hashCode()); + } + + public function testEqualsWhenDifferentDateThenIsFalse(): void + { + /** @Given two LocalDates one day apart */ + $one = LocalDate::fromString(value: '2026-02-17'); + $other = LocalDate::fromString(value: '2026-02-18'); + + /** @Then they are not equal and the hash codes differ */ + self::assertFalse($one->equals(other: $other)); + self::assertNotSame($one->hashCode(), $other->hashCode()); + } + + public function testComparisonWhenSameDateBuiltApartThenNeitherPrecedesTheOther(): void + { + /** @Given the same date reached by parsing and by shifting */ + $one = LocalDate::fromString(value: '2026-02-17'); + $other = LocalDate::fromString(value: '2026-02-16')->plusDays(days: 1); + + /** @Then the ordering depends on the date alone, never on when each was built */ + self::assertFalse($one->isAfter(other: $other)); + self::assertFalse($one->isBefore(other: $other)); + self::assertTrue($one->isAfterOrEqual(other: $other)); + self::assertTrue($one->isBeforeOrEqual(other: $other)); + } } diff --git a/tests/Unit/PeriodTest.php b/tests/Unit/PeriodTest.php index 51145a9..cfdbe5a 100644 --- a/tests/Unit/PeriodTest.php +++ b/tests/Unit/PeriodTest.php @@ -419,4 +419,38 @@ public function testDurationWhenPeriodCreatedFromStartingAtThenMatchesInputDurat /** @Then the duration should match the input */ self::assertSame($inputDuration->toSeconds(), $duration->toSeconds()); } + + public function testEqualsWhenSameBoundsThenIsTrue(): void + { + /** @Given two Periods over the same bounds, one of them written in another offset */ + $one = Period::from( + from: Instant::fromString(value: '2026-02-17T08:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T09:00:00+00:00') + ); + $other = Period::from( + from: Instant::fromString(value: '2026-02-17T05:00:00-03:00'), + to: Instant::fromString(value: '2026-02-17T09:00:00+00:00') + ); + + /** @Then they are equal by value, which only holds because Instant compares by value */ + self::assertTrue($one->equals(other: $other)); + self::assertSame($one->hashCode(), $other->hashCode()); + } + + public function testEqualsWhenDifferentBoundsThenIsFalse(): void + { + /** @Given two Periods that end at different moments */ + $one = Period::from( + from: Instant::fromString(value: '2026-02-17T08:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T09:00:00+00:00') + ); + $other = Period::from( + from: Instant::fromString(value: '2026-02-17T08:00:00+00:00'), + to: Instant::fromString(value: '2026-02-17T10:00:00+00:00') + ); + + /** @Then they are not equal and the hash codes differ */ + self::assertFalse($one->equals(other: $other)); + self::assertNotSame($one->hashCode(), $other->hashCode()); + } } diff --git a/tests/Unit/TimezoneTest.php b/tests/Unit/TimezoneTest.php index 5728341..71a17c6 100644 --- a/tests/Unit/TimezoneTest.php +++ b/tests/Unit/TimezoneTest.php @@ -109,4 +109,26 @@ public static function invalidIdentifiersDataProvider(): array 'Numeric offset' => ['identifier' => '+00:00'] ]; } + + public function testEqualsWhenSameIdentifierThenIsTrue(): void + { + /** @Given two Timezones built separately from the same identifier */ + $one = Timezone::from(identifier: 'America/Sao_Paulo'); + $other = Timezone::from(identifier: 'America/Sao_Paulo'); + + /** @Then they are equal by value and share the hash code */ + self::assertTrue($one->equals(other: $other)); + self::assertSame($one->hashCode(), $other->hashCode()); + } + + public function testEqualsWhenDifferentIdentifierThenIsFalse(): void + { + /** @Given two Timezones with different identifiers */ + $one = Timezone::utc(); + $other = Timezone::from(identifier: 'America/Sao_Paulo'); + + /** @Then they are not equal and the hash codes differ */ + self::assertFalse($one->equals(other: $other)); + self::assertNotSame($one->hashCode(), $other->hashCode()); + } }