From 34e636cbfe52e284996a2c034bec8d06f2097242 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Tue, 25 Aug 2026 19:00:01 -0300 Subject: [PATCH] Compare time value objects by value, never by instance. Instant and LocalDate declared the value-object contract while holding a DateTimeImmutable, and structural equality compares a property that is not itself a value object with ===, which on an object is identity. Two instances of the same moment were never equal, their hash codes never matched, and neither did any value object that carried one as a property. Duration had the same defect through Internal\Seconds, and Timezone did not declare the contract at all. Overriding equals would not have been enough: ValueObjectEquality walks the properties by reflection and never calls equals, so a wrapper would keep comparing the inner DateTimeImmutable by identity. The state is what had to change, so Instant and LocalDate now hold their canonical text, and IsoMoment and IsoDate own the conversion in Internal, where the style rule allows the helpers. Fixing LocalDate surfaced a second defect. Its parser filled the time of day from the clock of the moment it ran, so two instances of the same date built a second apart reported isAfter and isBefore as true at the same time. Holding the canonical date removes the wall clock along with the identity comparison. Both createFromFormat calls are gone with it, which removed one PHPStan suppression instead of adding another. Two notes for consumers. Timezone gains equals and hashCode, which is new public surface. toDateTimeImmutable now returns a new object on every call rather than the stored one, so identity comparisons of its return value change; value comparisons and every other behaviour are unchanged. --- README.md | 67 ++++++++++++++++++++++++++++++++++++ phpstan.neon.dist | 3 -- src/Instant.php | 66 ++++++++++++++++++++--------------- src/Internal/IsoDate.php | 27 +++++++++++++++ src/Internal/IsoMoment.php | 29 ++++++++++++++++ src/Internal/Seconds.php | 6 +++- src/LocalDate.php | 37 +++++++++++--------- src/Timezone.php | 8 ++++- tests/Unit/DurationTest.php | 22 ++++++++++++ tests/Unit/InstantTest.php | 33 ++++++++++++++++++ tests/Unit/IsoDateTest.php | 25 ++++++++++++++ tests/Unit/IsoMomentTest.php | 25 ++++++++++++++ tests/Unit/LocalDateTest.php | 35 +++++++++++++++++++ tests/Unit/PeriodTest.php | 34 ++++++++++++++++++ tests/Unit/TimezoneTest.php | 22 ++++++++++++ 15 files changed, 390 insertions(+), 49 deletions(-) create mode 100644 src/Internal/IsoDate.php create mode 100644 src/Internal/IsoMoment.php create mode 100644 tests/Unit/IsoDateTest.php create mode 100644 tests/Unit/IsoMomentTest.php 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()); + } }