From 763ce621536d10015415f3ed72451ef6709f7af4 Mon Sep 17 00:00:00 2001 From: Unay Santisteban Date: Mon, 13 Oct 2025 19:22:39 +1000 Subject: [PATCH 1/2] Fix/reduce cognitive complexity and improve exception handling in IsModel --- src/IsModel.php | 83 +++++++++++++++++++++++++++---------------------- 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/src/IsModel.php b/src/IsModel.php index 15c9b82..d1b7e65 100644 --- a/src/IsModel.php +++ b/src/IsModel.php @@ -10,8 +10,9 @@ use ReflectionClass; use ReflectionMethod; use ReflectionNamedType; +use ReflectionParameter; use ReflectionUnionType; -use RuntimeException; +use ComplexHeart\Domain\Model\Exceptions\InstantiationException; use TypeError; /** @@ -51,7 +52,7 @@ final public static function make(mixed ...$params): static $constructor = $reflection->getConstructor(); if (!$constructor) { - throw new RuntimeException( + throw new InstantiationException( sprintf('%s must have a constructor to use make()', static::class) ); } @@ -121,46 +122,54 @@ private static function validateConstructorParameters( // Validate types for each parameter foreach ($constructorParams as $index => $param) { - if (!isset($params[$index])) { - continue; // Optional parameter not provided + if (isset($params[$index])) { + self::validateParameterType($param, $params[$index]); } + } + } - $value = $params[$index]; - $type = $param->getType(); - if ($type === null) { - continue; // No type hint - } + /** + * Validate a single parameter's type. + * + * @param ReflectionParameter $param + * @param mixed $value + * @return void + * @throws TypeError + */ + private static function validateParameterType(ReflectionParameter $param, mixed $value): void + { + $type = $param->getType(); - $isValid = false; - $expectedTypes = ''; - - if ($type instanceof ReflectionNamedType) { - // Single type - $isValid = self::validateType($value, $type->getName(), $type->allowsNull()); - $expectedTypes = $type->getName(); - } elseif ($type instanceof ReflectionUnionType) { - // Union type (e.g., int|float|string) - $isValid = self::validateUnionType($value, $type); - $expectedTypes = implode('|', array_map( - fn ($t) => $t instanceof ReflectionNamedType ? $t->getName() : 'mixed', - $type->getTypes() - )); - } else { - continue; // Intersection types or other complex types not supported yet - } + if ($type === null) { + return; // No type hint + } - if (!$isValid) { - throw new TypeError( - sprintf( - '%s::make() parameter "%s" must be of type %s, %s given', - basename(str_replace('\\', '/', static::class)), - $param->getName(), - $expectedTypes, - get_debug_type($value) - ) - ); - } + if ($type instanceof ReflectionNamedType) { + // Single type + $isValid = self::validateType($value, $type->getName(), $type->allowsNull()); + $expectedTypes = $type->getName(); + } elseif ($type instanceof ReflectionUnionType) { + // Union type (e.g., int|float|string) + $isValid = self::validateUnionType($value, $type); + $expectedTypes = implode('|', array_map( + fn ($t) => $t instanceof ReflectionNamedType ? $t->getName() : 'mixed', + $type->getTypes() + )); + } else { + return; // Intersection types or other complex types not supported yet + } + + if (!$isValid) { + throw new TypeError( + sprintf( + '%s::make() parameter "%s" must be of type %s, %s given', + basename(str_replace('\\', '/', static::class)), + $param->getName(), + $expectedTypes, + get_debug_type($value) + ) + ); } } From eafc1bbf08945d3dd8fd132e12e9039d0c28a45e Mon Sep 17 00:00:00 2001 From: Unay Santisteban Date: Mon, 13 Oct 2025 23:16:42 +1000 Subject: [PATCH 2/2] Add unit tests for HasEquality and HasImmutability traits --- composer.json | 4 +- src/Traits/HasImmutability.php | 2 +- src/Traits/HasTypeCheck.php | 12 +- tests/AggregatesTest.php | 6 +- tests/AutoCheckInvariantsTest.php | 43 ------ tests/HasEqualityTest.php | 146 ++++++++++++++++++ tests/HasImmutabilityTest.php | 29 ++++ .../{TraitsTest.php => HasInvariantsTest.php} | 62 +++++--- tests/HasTypeCheckTest.php | 114 ++++++++++++++ tests/ModelTest.php | 13 -- tests/TypeValidationTest.php | 50 ++++++ tests/ValueObjectsTest.php | 108 ++++++++++++- 12 files changed, 500 insertions(+), 89 deletions(-) delete mode 100644 tests/AutoCheckInvariantsTest.php create mode 100644 tests/HasEqualityTest.php create mode 100644 tests/HasImmutabilityTest.php rename tests/{TraitsTest.php => HasInvariantsTest.php} (86%) create mode 100644 tests/HasTypeCheckTest.php delete mode 100644 tests/ModelTest.php diff --git a/composer.json b/composer.json index df29e00..61f98dc 100644 --- a/composer.json +++ b/composer.json @@ -40,8 +40,8 @@ } }, "scripts": { - "test": "vendor/bin/pest --configuration=phpunit.xml --coverage-clover=coverage.xml --log-junit=test.xml", - "test-cov": "vendor/bin/pest --configuration=phpunit.xml --coverage-html=coverage", + "test": "vendor/bin/pest --configuration=phpunit.xml --coverage --coverage-clover=coverage.xml --log-junit=test.xml", + "test-cov": "vendor/bin/pest --configuration=phpunit.xml --coverage --coverage-html=coverage", "analyse": "vendor/bin/phpstan analyse src --no-progress --memory-limit=4G --level=8", "check": [ "@analyse", diff --git a/src/Traits/HasImmutability.php b/src/Traits/HasImmutability.php index 0c864ac..c775bae 100644 --- a/src/Traits/HasImmutability.php +++ b/src/Traits/HasImmutability.php @@ -26,7 +26,7 @@ trait HasImmutability * @param mixed $_ * @return void */ - final public function __set(string $name, $_): void + final public function __set(string $name, mixed $_): void { $class = static::class; throw new ImmutabilityError("Cannot modify property $name from immutable $class object."); diff --git a/src/Traits/HasTypeCheck.php b/src/Traits/HasTypeCheck.php index 463fda1..ce1f156 100644 --- a/src/Traits/HasTypeCheck.php +++ b/src/Traits/HasTypeCheck.php @@ -26,7 +26,17 @@ protected function isValueTypeValid(mixed $value, string $validType): bool return true; } - $primitives = ['integer', 'boolean', 'float', 'string', 'array', 'object', 'callable']; + // Handle float/double equivalence (PHP uses 'double' internally) + if ($validType === 'float') { + $validType = 'double'; + } + + // Handle callable type check + if ($validType === 'callable') { + return is_callable($value); + } + + $primitives = ['integer', 'boolean', 'double', 'string', 'array', 'object']; $validation = in_array($validType, $primitives) ? fn ($value): bool => gettype($value) === $validType : fn ($value): bool => $value instanceof $validType; diff --git a/tests/AggregatesTest.php b/tests/AggregatesTest.php index 012eb81..f301a39 100644 --- a/tests/AggregatesTest.php +++ b/tests/AggregatesTest.php @@ -25,9 +25,9 @@ $order2 = Order::create(1, ['id' => UUIDValue::random(), 'name' => 'Vincent Vega']); $order3 = Order::create(2, ['id' => UUIDValue::random(), 'name' => 'Vincent Vega']); - expect($order1->equals($order2))->toBeTrue(); - expect($order1->equals($order3))->toBeFalse(); - expect($order1->equals(new stdClass()))->toBeFalse(); + expect($order1->equals($order2))->toBeTrue() + ->and($order1->equals($order3))->toBeFalse() + ->and($order1->equals(new stdClass()))->toBeFalse(); }) ->group('Unit'); diff --git a/tests/AutoCheckInvariantsTest.php b/tests/AutoCheckInvariantsTest.php deleted file mode 100644 index d22a387..0000000 --- a/tests/AutoCheckInvariantsTest.php +++ /dev/null @@ -1,43 +0,0 @@ -throws(InvariantViolation::class); - -test('ValueObject should auto-check invariants and succeed', function () { - $email = Email::make('valid@example.com'); - - expect($email)->toBeInstanceOf(Email::class); -}); - -test('ValueObject with multiple invariants should validate all', function () { - Money::make(-10, 'USD'); -})->throws(InvariantViolation::class); - -test('ValueObject should fail on invalid currency invariant', function () { - Money::make(100, 'US'); -})->throws(InvariantViolation::class); - -test('Entity with auto-check disabled should not check invariants', function () { - // This should NOT throw even though name is empty - $entity = CustomEntity::make(UUIDValue::random(), ''); - - expect($entity)->toBeInstanceOf(CustomEntity::class); -}); - -test('Entity with auto-check enabled would fail on invalid data', function () { - // Entities have auto-check enabled by default (via IsEntity) - // But our CustomEntity overrides it to false - // Let's test that regular entities DO auto-check - - // This should throw because invalid email - Email::make('invalid-email'); -})->throws(InvariantViolation::class); diff --git a/tests/HasEqualityTest.php b/tests/HasEqualityTest.php new file mode 100644 index 0000000..881759d --- /dev/null +++ b/tests/HasEqualityTest.php @@ -0,0 +1,146 @@ +value; + } + }); + + $obj1 = new $class('test'); + $obj2 = new $class('test'); + + expect($obj1->equals($obj2))->toBeTrue(); +}) + ->group('Unit'); + +test('HasEquality should return false for different string representations', function () { + $obj1 = new class ('test1') { + use HasEquality; + + public function __construct(private string $value) + { + } + + public function __toString(): string + { + return $this->value; + } + }; + + $obj2 = new class ('test2') { + use HasEquality; + + public function __construct(private string $value) + { + } + + public function __toString(): string + { + return $this->value; + } + }; + + expect($obj1->equals($obj2))->toBeFalse(); +}) + ->group('Unit'); + +test('HasEquality should return false for different class types', function () { + $obj1 = new class ('test') { + use HasEquality; + + public function __construct(private string $value) + { + } + + public function __toString(): string + { + return $this->value; + } + }; + + $obj2 = new stdClass(); + + expect($obj1->equals($obj2))->toBeFalse(); +}) + ->group('Unit'); + +test('HasEquality::hash should generate consistent SHA256 hash', function () { + $obj = new class ('test-value') { + use HasEquality; + + public function __construct(private string $value) + { + } + + public function __toString(): string + { + return $this->value; + } + + public function getHash(): string + { + return $this->hash(); + } + }; + + $expectedHash = hash('sha256', 'test-value'); + + expect($obj->getHash())->toBe($expectedHash); +}) + ->group('Unit'); + +test('HasEquality should handle complex objects', function () { + $class = get_class(new class (['dummy' => 'data']) { + use HasEquality; + + public function __construct(private array $data) + { + } + + public function __toString(): string + { + return json_encode($this->data); + } + }); + + $obj1 = new $class(['name' => 'John', 'age' => 30]); + $obj2 = new $class(['name' => 'John', 'age' => 30]); + $obj3 = new $class(['name' => 'Jane', 'age' => 25]); + + expect($obj1->equals($obj2))->toBeTrue() + ->and($obj1->equals($obj3))->toBeFalse(); +}) + ->group('Unit'); + +test('HasEquality should handle empty strings', function () { + $class = get_class(new class ('dummy') { + use HasEquality; + + public function __construct(private string $value) + { + } + + public function __toString(): string + { + return $this->value; + } + }); + + $obj1 = new $class(''); + $obj2 = new $class(''); + + expect($obj1->equals($obj2))->toBeTrue(); +}) + ->group('Unit'); diff --git a/tests/HasImmutabilityTest.php b/tests/HasImmutabilityTest.php new file mode 100644 index 0000000..7cbb3c8 --- /dev/null +++ b/tests/HasImmutabilityTest.php @@ -0,0 +1,29 @@ +amount = 0.0; +}) + ->group('Unit') + ->throws(ImmutabilityError::class); + +test('Object with HasImmutability should expose primitive values.', function () { + $price = new Price(100.0, 'EUR'); + expect($price->amount)->toBeFloat() + ->and($price->currency)->toBeString(); +}) + ->group('Unit'); + +test('Object with HasImmutability should return new instance with override values.', function () { + $price = new Price(100.0, 'EUR'); + $newPrice = $price->applyDiscount(10.0); + + expect($newPrice)->toBeInstanceOf(Price::class) + ->and($newPrice->amount)->toBe(90.0); +}) + ->group('Unit'); diff --git a/tests/TraitsTest.php b/tests/HasInvariantsTest.php similarity index 86% rename from tests/TraitsTest.php rename to tests/HasInvariantsTest.php index 2e9a381..7a47bbb 100644 --- a/tests/TraitsTest.php +++ b/tests/HasInvariantsTest.php @@ -3,34 +3,14 @@ declare(strict_types=1); use ComplexHeart\Domain\Model\Contracts\Aggregatable; -use ComplexHeart\Domain\Model\Errors\ImmutabilityError; use ComplexHeart\Domain\Model\Exceptions\InvariantViolation; use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Errors\InvalidPriceError; use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Price; +use ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety\CustomEntity; +use ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety\Email; +use ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety\Money; use ComplexHeart\Domain\Model\Traits\HasInvariants; - -test('Object with HasImmutability should throw ImmutabilityError for any update properties attempts.', function () { - $price = new Price(100.0, 'EUR'); - $price->amount = 0.0; -}) - ->group('Unit') - ->throws(ImmutabilityError::class); - -test('Object with HasImmutability should expose primitive values.', function () { - $price = new Price(100.0, 'EUR'); - expect($price->amount)->toBeFloat() - ->and($price->currency)->toBeString(); -}) - ->group('Unit'); - -test('Object with HasImmutability should return new instance with override values.', function () { - $price = new Price(100.0, 'EUR'); - $newPrice = $price->applyDiscount(10.0); - - expect($newPrice)->toBeInstanceOf(Price::class) - ->and($newPrice->amount)->toBe(90.0); -}) - ->group('Unit'); +use ComplexHeart\Domain\Model\ValueObjects\UUIDValue; test('Object with HasInvariants should support custom invariant handler.', function () { new Price(-10.0, 'EURO'); @@ -275,3 +255,37 @@ protected function invariantThirdAggregatable(): bool }) ->group('Unit') ->throws(RuntimeException::class, 'Non-aggregatable error'); + +// Auto-check feature tests +test('ValueObject should auto-check invariants and fail', function () { + Email::make('invalid-email'); +}) + ->group('Unit') + ->throws(InvariantViolation::class); + +test('ValueObject should auto-check invariants and succeed', function () { + $email = Email::make('valid@example.com'); + + expect($email)->toBeInstanceOf(Email::class); +}) + ->group('Unit'); + +test('ValueObject with multiple invariants should validate all', function () { + Money::make(-10, 'USD'); +}) + ->group('Unit') + ->throws(InvariantViolation::class); + +test('ValueObject should fail on invalid currency invariant', function () { + Money::make(100, 'US'); +}) + ->group('Unit') + ->throws(InvariantViolation::class); + +test('Entity with auto-check disabled should not check invariants', function () { + // This should NOT throw even though name is empty + $entity = CustomEntity::make(UUIDValue::random(), ''); + + expect($entity)->toBeInstanceOf(CustomEntity::class); +}) + ->group('Unit'); diff --git a/tests/HasTypeCheckTest.php b/tests/HasTypeCheckTest.php new file mode 100644 index 0000000..e6beb7c --- /dev/null +++ b/tests/HasTypeCheckTest.php @@ -0,0 +1,114 @@ +isValueTypeValid($value, $type); + } + }; + + expect($object->testValidation(42, 'integer'))->toBeTrue() + ->and($object->testValidation(3.14, 'float'))->toBeTrue() + ->and($object->testValidation('hello', 'string'))->toBeTrue() + ->and($object->testValidation(true, 'boolean'))->toBeTrue() + ->and($object->testValidation([1, 2, 3], 'array'))->toBeTrue() + ->and($object->testValidation(new stdClass(), 'object'))->toBeTrue() + ->and($object->testValidation(fn () => null, 'callable'))->toBeTrue(); +}) + ->group('Unit'); + +test('HasTypeCheck::isValueTypeValid should return false for invalid primitive types', function () { + $object = new class () { + use HasTypeCheck; + + public function testValidation(mixed $value, string $type): bool + { + return $this->isValueTypeValid($value, $type); + } + }; + + expect($object->testValidation('42', 'integer'))->toBeFalse() + ->and($object->testValidation(42, 'string'))->toBeFalse() + ->and($object->testValidation([1, 2], 'string'))->toBeFalse() + ->and($object->testValidation(new stdClass(), 'array'))->toBeFalse(); +}) + ->group('Unit'); + +test('HasTypeCheck::isValueTypeValid should handle mixed type', function () { + $object = new class () { + use HasTypeCheck; + + public function testValidation(mixed $value, string $type): bool + { + return $this->isValueTypeValid($value, $type); + } + }; + + expect($object->testValidation(42, 'mixed'))->toBeTrue() + ->and($object->testValidation('string', 'mixed'))->toBeTrue() + ->and($object->testValidation(null, 'mixed'))->toBeTrue() + ->and($object->testValidation([], 'mixed'))->toBeTrue(); +}) + ->group('Unit'); + +test('HasTypeCheck::isValueTypeValid should validate class instances', function () { + $object = new class () { + use HasTypeCheck; + + public function testValidation(mixed $value, string $type): bool + { + return $this->isValueTypeValid($value, $type); + } + }; + + $stringValue = new class ('test') extends StringValue { + }; + + expect($object->testValidation($stringValue, StringValue::class))->toBeTrue() + ->and($object->testValidation(new stdClass(), StringValue::class))->toBeFalse() + ->and($object->testValidation($stringValue, stdClass::class))->toBeFalse(); +}) + ->group('Unit'); + +test('HasTypeCheck::isValueTypeNotValid should return opposite of isValueTypeValid', function () { + $object = new class () { + use HasTypeCheck; + + public function testValidation(mixed $value, string $type): bool + { + return $this->isValueTypeNotValid($value, $type); + } + }; + + expect($object->testValidation(42, 'string'))->toBeTrue() + ->and($object->testValidation(42, 'integer'))->toBeFalse() + ->and($object->testValidation('hello', 'string'))->toBeFalse() + ->and($object->testValidation('hello', 'integer'))->toBeTrue(); +}) + ->group('Unit'); + +test('HasTypeCheck should work with objects implementing interfaces', function () { + $object = new class () { + use HasTypeCheck; + + public function testValidation(mixed $value, string $type): bool + { + return $this->isValueTypeValid($value, $type); + } + }; + + $countable = new ArrayObject([1, 2, 3]); + + expect($object->testValidation($countable, 'Countable'))->toBeTrue() + ->and($object->testValidation($countable, ArrayObject::class))->toBeTrue() + ->and($object->testValidation(new stdClass(), 'Countable'))->toBeFalse(); +}) + ->group('Unit'); diff --git a/tests/ModelTest.php b/tests/ModelTest.php deleted file mode 100644 index e6830b8..0000000 --- a/tests/ModelTest.php +++ /dev/null @@ -1,13 +0,0 @@ -values(fn ($attribute) => "-->$attribute"); - - expect($values['amount'])->toStartWith('-->'); - expect($values['currency'])->toStartWith('-->'); -}) - ->group('Unit'); diff --git a/tests/TypeValidationTest.php b/tests/TypeValidationTest.php index 091f486..65d0d08 100644 --- a/tests/TypeValidationTest.php +++ b/tests/TypeValidationTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use ComplexHeart\Domain\Model\Exceptions\InstantiationException; +use ComplexHeart\Domain\Model\IsModel; use ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety\Email; use ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety\Money; use ComplexHeart\Domain\Model\Test\Fixtures\TypeSafety\ComplexModel; @@ -240,3 +242,51 @@ test('new() should validate union types', function () { Money::new(['invalid'], 'USD'); })->throws(TypeError::class, 'parameter "amount" must be of type int|float'); + +// InstantiationException tests +test('InstantiationException should be thrown when make() is called on class without constructor', function () { + (new class () { + use IsModel; + + public function __toString(): string + { + return 'no-constructor'; + } + })::make(); +}) + ->throws(InstantiationException::class, 'must have a constructor') + ->group('Unit'); + +test('InstantiationException should extend RuntimeException', function () { + $exception = new InstantiationException('test message'); + + expect($exception)->toBeInstanceOf(RuntimeException::class) + ->and($exception->getMessage())->toBe('test message'); +}) + ->group('Unit'); + +test('InstantiationException should support error codes', function () { + $exception = new InstantiationException('test message', 500); + + expect($exception->getCode())->toBe(500); +}) + ->group('Unit'); + +test('InstantiationException should support previous exceptions', function () { + $previous = new Exception('Previous error'); + $exception = new InstantiationException('test message', 0, $previous); + + expect($exception->getPrevious())->toBe($previous); +}) + ->group('Unit'); + +// Model tests +test('Model values should be mapped by custom function successfully', function () { + $money = Money::make(100, 'USD'); + + $values = $money->values(fn ($attribute) => "-->$attribute"); + + expect($values['amount'])->toStartWith('-->') + ->and($values['currency'])->toStartWith('-->'); +}) + ->group('Unit'); diff --git a/tests/ValueObjectsTest.php b/tests/ValueObjectsTest.php index 7806fbf..4adb370 100644 --- a/tests/ValueObjectsTest.php +++ b/tests/ValueObjectsTest.php @@ -17,8 +17,8 @@ test('StringValue should create a valid StringValue Object.', function () { $vo = new Reference('F2022.12.01-00001'); - expect($vo)->toEqual('F2022.12.01-00001'); - expect((string) $vo)->toEqual('F2022.12.01-00001'); + expect($vo)->toEqual('F2022.12.01-00001') + ->and((string) $vo)->toEqual('F2022.12.01-00001'); }) ->group('Unit'); @@ -247,3 +247,107 @@ ->and($vo::getLabels()[1])->toBe('TWO'); }) ->group('Unit'); + +// FloatValue edge cases +test('FloatValue should handle negative values', function () { + $vo = new class (-3.14) extends FloatValue { + }; + + expect($vo->value())->toBe(-3.14) + ->and((string) $vo)->toBe('-3.14'); +}) + ->group('Unit'); + +test('FloatValue should handle zero', function () { + $vo = new class (0.0) extends FloatValue { + }; + + expect($vo->value())->toBe(0.0) + ->and((string) $vo)->toBe('0'); +}) + ->group('Unit'); + +test('FloatValue should be equal when values match', function () { + $class = new class (3.14) extends FloatValue { + }; + + $vo1 = $class::make(3.14); + $vo2 = $class::make(3.14); + + expect($vo1->equals($vo2))->toBeTrue(); +}) + ->group('Unit'); + +// IntegerValue edge cases +test('IntegerValue should handle zero correctly', function () { + $vo = new class (0) extends IntegerValue { + }; + + expect($vo->value())->toBe(0) + ->and((string) $vo)->toBe('0'); +}) + ->group('Unit'); + +test('IntegerValue should handle PHP_INT_MAX', function () { + $vo = new class (PHP_INT_MAX) extends IntegerValue { + }; + + expect($vo->value())->toBe(PHP_INT_MAX); +}) + ->group('Unit'); + +test('IntegerValue should handle PHP_INT_MIN', function () { + $vo = new class (PHP_INT_MIN) extends IntegerValue { + }; + + expect($vo->value())->toBe(PHP_INT_MIN); +}) + ->group('Unit'); + +// DateTimeValue edge cases +test('DateTimeValue should create from ISO 8601 format', function () { + $vo = new DateTimeValue('2023-12-25T10:30:00+00:00'); + + expect($vo->values())->toBe(['value' => '2023-12-25T10:30:00+00:00']); +}) + ->group('Unit'); + +test('DateTimeValue should handle different timezones', function () { + $vo1 = new DateTimeValue('2023-01-01T12:00:00+01:00'); + $vo2 = new DateTimeValue('2023-01-01T13:00:00+02:00'); + + expect($vo1->values()['value'])->toBe('2023-01-01T12:00:00+01:00') + ->and($vo2->values()['value'])->toBe('2023-01-01T13:00:00+02:00'); +}) + ->group('Unit'); + +test('DateTimeValue should be equal for same datetime string', function () { + $vo1 = new DateTimeValue('2023-01-01T12:00:00+00:00'); + $vo2 = new DateTimeValue('2023-01-01T12:00:00+00:00'); + + expect($vo1->equals($vo2))->toBeTrue(); +}) + ->group('Unit'); + +test('DateTimeValue should not be equal for different datetime strings', function () { + $vo1 = new DateTimeValue('2023-01-01T12:00:00+00:00'); + $vo2 = new DateTimeValue('2023-01-01T13:00:00+00:00'); + + expect($vo1->equals($vo2))->toBeFalse(); +}) + ->group('Unit'); + +test('DateTimeValue should throw exception for invalid format', function () { + new DateTimeValue('invalid-date'); +}) + ->throws(Exception::class) + ->group('Unit'); + +test('DateTimeValue should handle edge dates', function () { + $vo1 = new DateTimeValue('1970-01-01T00:00:00+00:00'); + $vo2 = new DateTimeValue('2038-01-19T03:14:07+00:00'); + + expect($vo1->values()['value'])->toBe('1970-01-01T00:00:00+00:00') + ->and($vo2->values()['value'])->toBe('2038-01-19T03:14:07+00:00'); +}) + ->group('Unit');