diff --git a/docs/INSTALL.md b/docs/INSTALL.md index ec7e53d..9ef58a5 100755 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -31,9 +31,9 @@ Create a quick test script (e.g., demo.php): value(); // 21 +echo IntegerPositive::fromString('21')->value(); // 21 ``` Run it: diff --git a/docs/USAGE.md b/docs/USAGE.md index 4197395..9daa5ce 100755 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -41,25 +41,25 @@ Static usage examples --------------------- ```php -use PhpTypedValues\DateTime\DateTimeAtom;use PhpTypedValues\DateTime\Timestamp\TimestampSeconds;use PhpTypedValues\Float\FloatBasic;use PhpTypedValues\Float\NonNegativeFloat;use PhpTypedValues\Integer\IntegerBasic;use PhpTypedValues\Integer\NonNegativeInt;use PhpTypedValues\Integer\PositiveInt;use PhpTypedValues\Integer\WeekDayInt;use PhpTypedValues\String\NonEmptyStr;use PhpTypedValues\String\StringBasic; +use PhpTypedValues\DateTime\DateTimeAtom;use PhpTypedValues\DateTime\Timestamp\TimestampSeconds;use PhpTypedValues\Float\FloatStandard;use PhpTypedValues\Float\FloatNonNegative;use PhpTypedValues\Integer\IntegerStandard;use PhpTypedValues\Integer\IntegerNonNegative;use PhpTypedValues\Integer\IntegerPositive;use PhpTypedValues\Integer\IntegerWeekDay;use PhpTypedValues\String\StringNonEmpty;use PhpTypedValues\String\StringStandard; // Integers -$any = IntegerBasic::fromInt(-10); -$pos = PositiveInt::fromInt(1); -$nn = NonNegativeInt::fromInt(0); -$wd = WeekDayInt::fromInt(7); // 1..7 +$any = IntegerStandard::fromInt(-10); +$pos = IntegerPositive::fromInt(1); +$nn = IntegerNonNegative::fromInt(0); +$wd = IntegerWeekDay::fromInt(7); // 1..7 // From string (integers) -$posFromString = PositiveInt::fromString('123'); -$wdFromString = WeekDayInt::fromString('5'); +$posFromString = IntegerPositive::fromString('123'); +$wdFromString = IntegerWeekDay::fromString('5'); // Strings -$greeting = StringBasic::fromString('hello'); -$name = NonEmptyStr::fromString('Alice'); +$greeting = StringStandard::fromString('hello'); +$name = StringNonEmpty::fromString('Alice'); // Floats -$price = FloatBasic::fromString('19.99'); -$ratio = NonNegativeFloat::fromFloat(0.5); // >= 0 +$price = FloatStandard::fromString('19.99'); +$ratio = FloatNonNegative::fromFloat(0.5); // >= 0 // DateTime (RFC 3339 / ATOM) $dt = DateTimeAtom::fromString('2025-01-02T03:04:05+00:00'); @@ -81,20 +81,20 @@ Validation errors (static constructors) Invalid input throws an exception with a helpful message. ```php -use PhpTypedValues\Integer\PositiveInt; -use PhpTypedValues\Integer\WeekDayInt; -use PhpTypedValues\String\NonEmptyStr; -use PhpTypedValues\Float\NonNegativeFloat; +use PhpTypedValues\Integer\IntegerPositive; +use PhpTypedValues\Integer\IntegerWeekDay; +use PhpTypedValues\String\StringNonEmpty; +use PhpTypedValues\Float\FloatNonNegative; use PhpTypedValues\DateTime\DateTimeAtom; -PositiveInt::fromInt(0); // throws: must be > 0 -PositiveInt::fromString('12.3'); // throws: String has no valid integer +IntegerPositive::fromInt(0); // throws: must be > 0 +IntegerPositive::fromString('12.3'); // throws: String has no valid integer -WeekDayInt::fromInt(0); // throws: Value must be between 1 and 7 +IntegerWeekDay::fromInt(0); // throws: Value must be between 1 and 7 -NonEmptyStr::fromString(''); // throws: Value must be a non-empty string +StringNonEmpty::fromString(''); // throws: Value must be a non-empty string -NonNegativeFloat::fromString('abc'); // throws: String has no valid float +FloatNonNegative::fromString('abc'); // throws: String has no valid float DateTimeAtom::fromString('not-a-date'); // throws: String has no valid datetime ``` @@ -110,9 +110,9 @@ declare(strict_types=1); namespace App\Domain; -use PhpTypedValues\Integer\PositiveInt; +use PhpTypedValues\Integer\IntegerPositive; -final class UserId extends PositiveInt {} +final class UserId extends IntegerPositive {} // Usage $userId = UserId::fromInt(42); @@ -190,20 +190,20 @@ declare(strict_types=1); namespace App\Domain; -use PhpTypedValues\Integer\PositiveInt; -use PhpTypedValues\String\NonEmptyStr; -use PhpTypedValues\Float\NonNegativeFloat; +use PhpTypedValues\Integer\IntegerPositive; +use PhpTypedValues\String\StringNonEmpty; +use PhpTypedValues\Float\FloatNonNegative; use PhpTypedValues\DateTime\DateTimeAtom; final class Profile { public function __construct( - public readonly PositiveInt $id, - public readonly NonEmptyStr $firstName, - public readonly NonEmptyStr $lastName, - public readonly ?NonEmptyStr $middleName, // nullable field + public readonly IntegerPositive $id, + public readonly StringNonEmpty $firstName, + public readonly StringNonEmpty $lastName, + public readonly ?StringNonEmpty $middleName, // nullable field public readonly ?DateTimeAtom $birthDate, // nullable field - public readonly ?NonNegativeFloat $heightM // nullable field + public readonly ?FloatNonNegative $heightM // nullable field ) {} // Convenience named constructor that accepts raw scalars and builds primitives internally @@ -216,12 +216,12 @@ final class Profile int|float|string|null $heightM ): self { return new self( - PositiveInt::fromInt($id), - NonEmptyStr::fromString($firstName), - NonEmptyStr::fromString($lastName), - $middleName !== null ? NonEmptyStr::fromString($middleName) : null, + IntegerPositive::fromInt($id), + StringNonEmpty::fromString($firstName), + StringNonEmpty::fromString($lastName), + $middleName !== null ? StringNonEmpty::fromString($middleName) : null, $birthDateAtom !== null ? DateTimeAtom::fromString($birthDateAtom) : null, - $heightM !== null ? NonNegativeFloat::fromString((string)$heightM) : null, + $heightM !== null ? FloatNonNegative::fromString((string)$heightM) : null, ); } } @@ -237,10 +237,10 @@ $p1 = Profile::fromScalars( ); $p2 = new Profile( - id: PositiveInt::fromInt(202), - firstName: NonEmptyStr::fromString('Bob'), - lastName: NonEmptyStr::fromString('Johnson'), - middleName: NonEmptyStr::fromString('A.'), + id: IntegerPositive::fromInt(202), + firstName: StringNonEmpty::fromString('Bob'), + lastName: StringNonEmpty::fromString('Johnson'), + middleName: StringNonEmpty::fromString('A.'), birthDate: null, heightM: null, ); diff --git a/src/Code/Exception/ReasonableRangeDateTimeTypeException.php b/src/Code/Exception/ReasonableRangeDateTimeTypeException.php index e9ca5bf..872e3c7 100755 --- a/src/Code/Exception/ReasonableRangeDateTimeTypeException.php +++ b/src/Code/Exception/ReasonableRangeDateTimeTypeException.php @@ -4,6 +4,6 @@ namespace PhpTypedValues\Code\Exception; -class ReasonableRangeDateTimeTypeException extends TypeException +class ReasonableRangeDateTimeTypeException extends DateTimeTypeException { } diff --git a/src/DateTime/Timestamp/TimestampMilliseconds.php b/src/DateTime/Timestamp/TimestampMilliseconds.php index 46cce89..65b360b 100755 --- a/src/DateTime/Timestamp/TimestampMilliseconds.php +++ b/src/DateTime/Timestamp/TimestampMilliseconds.php @@ -66,7 +66,8 @@ public function toString(): string $seconds = (int) $dt->format('U'); $micros = (int) $dt->format('u'); - $milliseconds = ($seconds * 1000) + intdiv($micros, 1000); + // Using intdiv will throw a TypeError if $seconds is not an int, ensuring the cast is meaningful + $milliseconds = (intdiv($seconds, 1) * 1000) + intdiv($micros, 1000); return (string) $milliseconds; } diff --git a/src/Float/Alias/NonNegativeFloat.php b/src/Float/Alias/NonNegativeFloat.php new file mode 100755 index 0000000..e8d63f5 --- /dev/null +++ b/src/Float/Alias/NonNegativeFloat.php @@ -0,0 +1,14 @@ + */ protected int $value; diff --git a/src/String/Alias/NonEmptyStr.php b/src/String/Alias/NonEmptyStr.php new file mode 100755 index 0000000..8308136 --- /dev/null +++ b/src/String/Alias/NonEmptyStr.php @@ -0,0 +1,14 @@ + 255) { + throw new StringTypeException('String is too long, max 255 chars allowed'); + } + + $this->value = $value; + } + + /** + * @throws StringTypeException + */ + public static function fromString(string $value): static + { + return new static($value); + } + + public function value(): string + { + return $this->value; + } +} diff --git a/src/String/NonEmptyStr.php b/src/String/StringNonEmpty.php similarity index 94% rename from src/String/NonEmptyStr.php rename to src/String/StringNonEmpty.php index cf98b5b..9268a1c 100755 --- a/src/String/NonEmptyStr.php +++ b/src/String/StringNonEmpty.php @@ -12,7 +12,7 @@ /** * @psalm-immutable */ -readonly class NonEmptyStr extends StrType +readonly class StringNonEmpty extends StrType { /** @var non-empty-string */ protected string $value; diff --git a/src/String/StringBasic.php b/src/String/StringStandard.php similarity index 91% rename from src/String/StringBasic.php rename to src/String/StringStandard.php index 760adb8..517d05c 100755 --- a/src/String/StringBasic.php +++ b/src/String/StringStandard.php @@ -11,7 +11,7 @@ * * @psalm-immutable */ -readonly class StringBasic extends StrType +readonly class StringStandard extends StrType { protected string $value; diff --git a/src/String/StringUuidV4.php b/src/String/StringUuidV4.php new file mode 100755 index 0000000..61d005a --- /dev/null +++ b/src/String/StringUuidV4.php @@ -0,0 +1,59 @@ +value = $normalized; + } + + /** + * @throws StringTypeException + */ + public static function fromString(string $value): static + { + return new static($value); + } + + /** @return non-empty-string */ + public function value(): string + { + return $this->value; + } +} diff --git a/src/psalmTest.php b/src/psalmTest.php index 6991b7a..f3edc9e 100755 --- a/src/psalmTest.php +++ b/src/psalmTest.php @@ -12,58 +12,53 @@ use PhpTypedValues\DateTime\DateTimeRFC3339; use PhpTypedValues\DateTime\Timestamp\TimestampMilliseconds; use PhpTypedValues\DateTime\Timestamp\TimestampSeconds; -use PhpTypedValues\Float\FloatBasic; -use PhpTypedValues\Float\NonNegativeFloat; -use PhpTypedValues\Integer\IntegerBasic; -use PhpTypedValues\Integer\NonNegativeInt; -use PhpTypedValues\Integer\PositiveInt; -use PhpTypedValues\Integer\WeekDayInt; -use PhpTypedValues\String\NonEmptyStr; -use PhpTypedValues\String\StringBasic; - -// try { -// echo DateTimeImmutable::createFromFormat('U.u', '953402300800.000000')->format('U.u'); -// } catch (Throwable $e) { -// var_dump('error'); -// var_dump($e); -// } -// -// try { -// echo TimestampMilliseconds::fromString('953402300800000')->toString(); // '253402300800000' -// } catch (Throwable $e) { -// var_dump('error'); -// var_dump($e); -// } -// exit('ssssssssss'); +use PhpTypedValues\Float\Alias\NonNegativeFloat; +use PhpTypedValues\Float\FloatNonNegative; +use PhpTypedValues\Float\FloatStandard; +use PhpTypedValues\Integer\Alias\Id; +use PhpTypedValues\Integer\Alias\NonNegativeInt; +use PhpTypedValues\Integer\Alias\PositiveInt; +use PhpTypedValues\Integer\IntegerNonNegative; +use PhpTypedValues\Integer\IntegerPositive; +use PhpTypedValues\Integer\IntegerStandard; +use PhpTypedValues\Integer\IntegerWeekDay; +use PhpTypedValues\String\Alias\NonEmptyStr; +use PhpTypedValues\String\StringNonEmpty; +use PhpTypedValues\String\StringStandard; /** * Integer. */ -testInteger(IntegerBasic::fromInt(10)->value()); -testPositiveInt(PositiveInt::fromInt(10)->value()); -testNonNegativeInt(NonNegativeInt::fromInt(10)->value()); -testWeekDayInt(WeekDayInt::fromInt(7)->value()); +testInteger(IntegerStandard::fromInt(10)->value()); +testPositiveInt(IntegerPositive::fromInt(10)->value()); +testNonNegativeInt(IntegerNonNegative::fromInt(10)->value()); +testWeekDayInt(IntegerWeekDay::fromInt(7)->value()); -echo IntegerBasic::fromString('10')->toString() . \PHP_EOL; +echo NonNegativeInt::fromString('10')->toString() . \PHP_EOL; +echo PositiveInt::fromString('10')->toString() . \PHP_EOL; +echo IntegerStandard::fromString('10')->toString() . \PHP_EOL; +echo Id::fromString('10')->toString() . \PHP_EOL; /** * String. */ -testString(StringBasic::fromString('hi')->value()); -testNonEmptyString(NonEmptyStr::fromString('hi')->value()); +testString(StringStandard::fromString('hi')->value()); +testNonEmptyString(StringNonEmpty::fromString('hi')->value()); -echo StringBasic::fromString('hi')->toString() . \PHP_EOL; +echo StringStandard::fromString('hi')->toString() . \PHP_EOL; +echo NonEmptyStr::fromString('hi')->toString() . \PHP_EOL; /** * Float. */ -testFloat(FloatBasic::fromFloat(3.14)->value()); +testFloat(FloatStandard::fromFloat(3.14)->value()); -echo FloatBasic::fromString('2.71828')->toString() . \PHP_EOL; +echo FloatStandard::fromString('2.71828')->toString() . \PHP_EOL; +echo NonNegativeFloat::fromString('2.71828')->toString() . \PHP_EOL; // PositiveFloat usage -testPositiveFloat(NonNegativeFloat::fromFloat(0.5)->value()); -echo NonNegativeFloat::fromString('3.14159')->toString() . \PHP_EOL; +testPositiveFloat(FloatNonNegative::fromFloat(0.5)->value()); +echo FloatNonNegative::fromString('3.14159')->toString() . \PHP_EOL; /** * DateTime. diff --git a/tests/Unit/Code/Float/FloatTypeTest.php b/tests/Unit/Code/Float/FloatTypeTest.php index 237dc54..45fbd3b 100755 --- a/tests/Unit/Code/Float/FloatTypeTest.php +++ b/tests/Unit/Code/Float/FloatTypeTest.php @@ -3,29 +3,29 @@ declare(strict_types=1); use PhpTypedValues\Code\Exception\FloatTypeException; -use PhpTypedValues\Float\FloatBasic; +use PhpTypedValues\Float\FloatStandard; it('fromFloat returns exact value and toString matches', function (): void { - $f1 = FloatBasic::fromFloat(-10.5); + $f1 = FloatStandard::fromFloat(-10.5); expect($f1->value())->toBe(-10.5) ->and($f1->toString())->toBe('-10.5'); - $f2 = FloatBasic::fromFloat(0.0); + $f2 = FloatStandard::fromFloat(0.0); expect($f2->value())->toBe(0.0) ->and($f2->toString())->toBe('0'); }); it('fromString parses valid float strings including negatives, decimals, and scientific', function (): void { - expect(FloatBasic::fromString('-15.25')->value())->toBe(-15.25) - ->and(FloatBasic::fromString('0007.5')->value())->toBe(7.5) - ->and(FloatBasic::fromString('+5.0')->value())->toBe(5.0) - ->and(FloatBasic::fromString('1e3')->value())->toBe(1000.0) - ->and(FloatBasic::fromString('42')->toString())->toBe('42'); + expect(FloatStandard::fromString('-15.25')->value())->toBe(-15.25) + ->and(FloatStandard::fromString('0007.5')->value())->toBe(7.5) + ->and(FloatStandard::fromString('+5.0')->value())->toBe(5.0) + ->and(FloatStandard::fromString('1e3')->value())->toBe(1000.0) + ->and(FloatStandard::fromString('42')->toString())->toBe('42'); }); it('fromString rejects non-numeric strings', function (): void { $invalid = ['5a', 'a5', '', 'abc', '--5', '5,5']; foreach ($invalid as $str) { - expect(fn() => FloatBasic::fromString($str))->toThrow(FloatTypeException::class); + expect(fn() => FloatStandard::fromString($str))->toThrow(FloatTypeException::class); } }); diff --git a/tests/Unit/Code/Integer/IntTypeTest.php b/tests/Unit/Code/Integer/IntTypeTest.php index 62f49f8..f11fea5 100755 --- a/tests/Unit/Code/Integer/IntTypeTest.php +++ b/tests/Unit/Code/Integer/IntTypeTest.php @@ -3,27 +3,27 @@ declare(strict_types=1); use PhpTypedValues\Code\Exception\IntegerTypeException; -use PhpTypedValues\Integer\IntegerBasic; +use PhpTypedValues\Integer\IntegerStandard; it('fromInt returns exact value and toString matches', function (): void { - $i1 = IntegerBasic::fromInt(-10); + $i1 = IntegerStandard::fromInt(-10); expect($i1->value())->toBe(-10) ->and($i1->toString())->toBe('-10'); - $i2 = IntegerBasic::fromInt(0); + $i2 = IntegerStandard::fromInt(0); expect($i2->value())->toBe(0) ->and($i2->toString())->toBe('0'); }); it('fromString parses valid integer strings including negatives and leading zeros', function (): void { - expect(IntegerBasic::fromString('-15')->value())->toBe(-15) - ->and(IntegerBasic::fromString('0')->toString())->toBe('0') - ->and(IntegerBasic::fromString('42')->toString())->toBe('42'); + expect(IntegerStandard::fromString('-15')->value())->toBe(-15) + ->and(IntegerStandard::fromString('0')->toString())->toBe('0') + ->and(IntegerStandard::fromString('42')->toString())->toBe('42'); }); it('fromString rejects non-integer strings', function (): void { $invalid = ['5a', 'a5', '', 'abc', ' 5', '5 ', '+5', '05', '--5', '3.14']; foreach ($invalid as $str) { - expect(fn() => IntegerBasic::fromString($str))->toThrow(IntegerTypeException::class); + expect(fn() => IntegerStandard::fromString($str))->toThrow(IntegerTypeException::class); } }); diff --git a/tests/Unit/Code/String/StrTypeTest.php b/tests/Unit/Code/String/StrTypeTest.php index 499b240..fbd5a5f 100755 --- a/tests/Unit/Code/String/StrTypeTest.php +++ b/tests/Unit/Code/String/StrTypeTest.php @@ -2,24 +2,24 @@ declare(strict_types=1); -use PhpTypedValues\String\StringBasic; +use PhpTypedValues\String\StringStandard; it('fromString returns exact value and toString matches', function (): void { - $s1 = StringBasic::fromString('hello'); + $s1 = StringStandard::fromString('hello'); expect($s1->value())->toBe('hello') ->and($s1->toString())->toBe('hello'); - $s2 = StringBasic::fromString(''); + $s2 = StringStandard::fromString(''); expect($s2->value())->toBe('') ->and($s2->toString())->toBe(''); }); it('handles unicode and whitespace transparently', function (): void { - $unicode = StringBasic::fromString('Привет 🌟'); + $unicode = StringStandard::fromString('Привет 🌟'); expect($unicode->value())->toBe('Привет 🌟') ->and($unicode->toString())->toBe('Привет 🌟'); - $ws = StringBasic::fromString(' spaced '); + $ws = StringStandard::fromString(' spaced '); expect($ws->value())->toBe(' spaced ') ->and($ws->toString())->toBe(' spaced '); }); diff --git a/tests/Unit/DateTime/Timestamp/TimestampMillisecondsTest.php b/tests/Unit/DateTime/Timestamp/TimestampMillisecondsTest.php index 35442a7..0fb2c23 100755 --- a/tests/Unit/DateTime/Timestamp/TimestampMillisecondsTest.php +++ b/tests/Unit/DateTime/Timestamp/TimestampMillisecondsTest.php @@ -14,6 +14,22 @@ ->and($vo->value()->getTimezone()->getName())->toBe('+00:00'); }); +it('fromString maps remainder milliseconds to microseconds exactly (123 -> 123000)', function (): void { + // 1732445696123 ms -> seconds=1732445696, remainder=123 ms => microseconds=123000 + $vo = TimestampMilliseconds::fromString('1732445696123'); + + expect($vo->value()->format('U.u'))->toBe('1732445696.123000') + ->and($vo->toString())->toBe('1732445696123'); +}); + +it('fromString maps 999 remainder correctly to 999000 microseconds (no off-by-one)', function (): void { + // 1732445696999 ms -> seconds=1732445696, remainder=999 ms => microseconds=999000 + $vo = TimestampMilliseconds::fromString('1732445696999'); + + expect($vo->value()->format('U.u'))->toBe('1732445696.999000') + ->and($vo->toString())->toBe('1732445696999'); +}); + it('fromDateTime preserves instant and renders milliseconds (truncates microseconds)', function (): void { $dt = new DateTimeImmutable('2025-01-02T03:04:05.678+00:00'); $vo = TimestampMilliseconds::fromDateTime($dt); @@ -24,6 +40,18 @@ ->and($vo->value()->getTimezone()->getName())->toBe('UTC'); }); +it('toString truncates microseconds using divisor 1000, not 999 or 1001', function (): void { + // Use a datetime with 999999 microseconds at a known second. + // Truncation by 1000 must yield +999 ms (not 1000 or 1001) + $dt = new DateTimeImmutable('2025-01-02T03:04:05.999999+00:00'); + $vo = TimestampMilliseconds::fromDateTime($dt); + + // Seconds part for this instant + expect($vo->value()->format('U'))->toBe('1735787045') + // Milliseconds should be 1735787045*1000 + 999 + ->and($vo->toString())->toBe('1735787045999'); +}); + it('fromDateTime normalizes timezone to UTC while preserving the instant', function (): void { // Source datetime has +03:00 offset, should be normalized to UTC internally $dt = new DateTimeImmutable('2025-01-02T03:04:05.123+03:00'); diff --git a/tests/Unit/DateTime/Timestamp/TimestampSecondsTest.php b/tests/Unit/DateTime/Timestamp/TimestampSecondsTest.php index 9df488a..3349453 100755 --- a/tests/Unit/DateTime/Timestamp/TimestampSecondsTest.php +++ b/tests/Unit/DateTime/Timestamp/TimestampSecondsTest.php @@ -69,3 +69,43 @@ ->and($e->getMessage())->toContain('5'); } }); + +it('fromString throws when seconds are above supported range (max + 1)', function (): void { + try { + // One second beyond 9999-12-31T23:59:59Z + TimestampSeconds::fromString('253402300800'); + expect()->fail('Exception was not thrown'); + } catch (Throwable $e) { + expect($e)->toBeInstanceOf(PhpTypedValues\Code\Exception\ReasonableRangeDateTimeTypeException::class) + ->and($e->getMessage())->toContain('Timestamp "253402300800" out of supported range "-62135596800"-"253402300799"') + ->and($e->getMessage())->toContain('253402300800'); + } +}); + +it('fromString throws when seconds are below supported range (min - 1)', function (): void { + try { + // One second before 0001-01-01T00:00:00Z + TimestampSeconds::fromString('-62135596801'); + expect()->fail('Exception was not thrown'); + } catch (Throwable $e) { + expect($e)->toBeInstanceOf(PhpTypedValues\Code\Exception\ReasonableRangeDateTimeTypeException::class) + ->and($e->getMessage())->toContain('Timestamp "-62135596801" out of supported range "-62135596800"-"253402300799"') + ->and($e->getMessage())->toContain('-62135596801'); + } +}); + +it('fromString accepts maximum supported seconds (max boundary)', function (): void { + // Exactly 9999-12-31T23:59:59Z + $vo = TimestampSeconds::fromString('253402300799'); + + expect($vo->toString())->toBe('253402300799') + ->and($vo->value()->format('U'))->toBe('253402300799'); +}); + +it('fromString accepts minimum supported seconds (min boundary)', function (): void { + // Exactly 0001-01-01T00:00:00Z + $vo = TimestampSeconds::fromString('-62135596800'); + + expect($vo->toString())->toBe('-62135596800') + ->and($vo->value()->format('U'))->toBe('-62135596800'); +}); diff --git a/tests/Unit/Code/Float/NonNegativeFloatTypeTest.php b/tests/Unit/Float/FloatNonNegativeTypeTest.php similarity index 57% rename from tests/Unit/Code/Float/NonNegativeFloatTypeTest.php rename to tests/Unit/Float/FloatNonNegativeTypeTest.php index 1a1303c..c7b6efe 100755 --- a/tests/Unit/Code/Float/NonNegativeFloatTypeTest.php +++ b/tests/Unit/Float/FloatNonNegativeTypeTest.php @@ -3,43 +3,43 @@ declare(strict_types=1); use PhpTypedValues\Code\Exception\FloatTypeException; -use PhpTypedValues\Float\NonNegativeFloat; +use PhpTypedValues\Float\FloatNonNegative; it('accepts non-negative floats via fromFloat and toString matches', function (): void { - $f0 = NonNegativeFloat::fromFloat(0.0); + $f0 = FloatNonNegative::fromFloat(0.0); expect($f0->value())->toBe(0.0) ->and($f0->toString())->toBe('0'); - $f1 = NonNegativeFloat::fromFloat(1.5); + $f1 = FloatNonNegative::fromFloat(1.5); expect($f1->value())->toBe(1.5) ->and($f1->toString())->toBe('1.5'); }); it('parses non-negative numeric strings via fromString', function (): void { - expect(NonNegativeFloat::fromString('0')->value())->toBe(0.0) - ->and(NonNegativeFloat::fromString('0.0')->value())->toBe(0.0) - ->and(NonNegativeFloat::fromString('3.14')->value())->toBe(3.14) - ->and(NonNegativeFloat::fromString('1e2')->value())->toBe(100.0) - ->and(NonNegativeFloat::fromString('42')->toString())->toBe('42'); + expect(FloatNonNegative::fromString('0')->value())->toBe(0.0) + ->and(FloatNonNegative::fromString('0.0')->value())->toBe(0.0) + ->and(FloatNonNegative::fromString('3.14')->value())->toBe(3.14) + ->and(FloatNonNegative::fromString('1e2')->value())->toBe(100.0) + ->and(FloatNonNegative::fromString('42')->toString())->toBe('42'); }); it('rejects negative values', function (): void { - expect(fn() => new NonNegativeFloat(-0.001)) + expect(fn() => new FloatNonNegative(-0.001)) ->toThrow(FloatTypeException::class); - expect(fn() => NonNegativeFloat::fromFloat(-0.001)) + expect(fn() => FloatNonNegative::fromFloat(-0.001)) ->toThrow(FloatTypeException::class); }); it('rejects non-numeric or negative strings', function (): void { // Non-numeric foreach (['', 'abc', '5,5'] as $str) { - expect(fn() => NonNegativeFloat::fromString($str)) + expect(fn() => FloatNonNegative::fromString($str)) ->toThrow(FloatTypeException::class); } // Numeric but negative foreach (['-1', '-0.1'] as $str) { - expect(fn() => NonNegativeFloat::fromString($str)) + expect(fn() => FloatNonNegative::fromString($str)) ->toThrow(FloatTypeException::class); } }); diff --git a/tests/Unit/Integer/Alias/IdTypeTest.php b/tests/Unit/Integer/Alias/IdTypeTest.php new file mode 100755 index 0000000..55086f7 --- /dev/null +++ b/tests/Unit/Integer/Alias/IdTypeTest.php @@ -0,0 +1,42 @@ +value())->toBe(1); +}); + +it('fails on 0', function (): void { + expect(fn() => Id::fromInt(0))->toThrow(IntegerTypeException::class); +}); + +it('fails on negatives', function (): void { + expect(fn() => Id::fromInt(-1))->toThrow(IntegerTypeException::class); +}); + +it('creates Id from string', function (): void { + expect(Id::fromString('1')->value())->toBe(1); +}); + +it('fails Id from integerish string', function (): void { + expect(fn() => Id::fromString('5.0'))->toThrow(IntegerTypeException::class); +}); + +it('fails creating Id from string 0', function (): void { + expect(fn() => Id::fromString('0'))->toThrow(IntegerTypeException::class); +}); + +it('fails creating Id from negative string', function (): void { + expect(fn() => Id::fromString('-3'))->toThrow(IntegerTypeException::class); +}); + +it('toString returns scalar string for Id', function (): void { + expect((new Id(3))->toString())->toBe('3'); +}); + +it('fails creating Id from float string', function (): void { + expect(fn() => Id::fromString('5.5'))->toThrow(IntegerTypeException::class); +}); diff --git a/tests/Unit/Integer/NonNegativeIntTypeTest.php b/tests/Unit/Integer/Alias/NonNegativeIntTypeTest.php similarity index 95% rename from tests/Unit/Integer/NonNegativeIntTypeTest.php rename to tests/Unit/Integer/Alias/NonNegativeIntTypeTest.php index eae4a49..bd9bd89 100755 --- a/tests/Unit/Integer/NonNegativeIntTypeTest.php +++ b/tests/Unit/Integer/Alias/NonNegativeIntTypeTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use PhpTypedValues\Code\Exception\IntegerTypeException; -use PhpTypedValues\Integer\NonNegativeInt; +use PhpTypedValues\Integer\Alias\NonNegativeInt; it('creates NonNegativeInt', function (): void { expect((new NonNegativeInt(0))->value())->toBe(0); diff --git a/tests/Unit/Integer/PositiveIntTypeTest.php b/tests/Unit/Integer/Alias/PositiveIntTypeTest.php similarity index 96% rename from tests/Unit/Integer/PositiveIntTypeTest.php rename to tests/Unit/Integer/Alias/PositiveIntTypeTest.php index 56d8475..c4f703c 100755 --- a/tests/Unit/Integer/PositiveIntTypeTest.php +++ b/tests/Unit/Integer/Alias/PositiveIntTypeTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use PhpTypedValues\Code\Exception\IntegerTypeException; -use PhpTypedValues\Integer\PositiveInt; +use PhpTypedValues\Integer\Alias\PositiveInt; it('creates PositiveInt', function (): void { expect(PositiveInt::fromInt(1)->value())->toBe(1); diff --git a/tests/Unit/Integer/Alias/WeekDayIntTypeTest.php b/tests/Unit/Integer/Alias/WeekDayIntTypeTest.php new file mode 100755 index 0000000..c4f703c --- /dev/null +++ b/tests/Unit/Integer/Alias/WeekDayIntTypeTest.php @@ -0,0 +1,42 @@ +value())->toBe(1); +}); + +it('fails on 0', function (): void { + expect(fn() => PositiveInt::fromInt(0))->toThrow(IntegerTypeException::class); +}); + +it('fails on negatives', function (): void { + expect(fn() => PositiveInt::fromInt(-1))->toThrow(IntegerTypeException::class); +}); + +it('creates PositiveInt from string', function (): void { + expect(PositiveInt::fromString('1')->value())->toBe(1); +}); + +it('fails PositiveInt from integerish string', function (): void { + expect(fn() => PositiveInt::fromString('5.0'))->toThrow(IntegerTypeException::class); +}); + +it('fails creating PositiveInt from string 0', function (): void { + expect(fn() => PositiveInt::fromString('0'))->toThrow(IntegerTypeException::class); +}); + +it('fails creating PositiveInt from negative string', function (): void { + expect(fn() => PositiveInt::fromString('-3'))->toThrow(IntegerTypeException::class); +}); + +it('toString returns scalar string for PositiveInt', function (): void { + expect((new PositiveInt(3))->toString())->toBe('3'); +}); + +it('fails creating PositiveInt from float string', function (): void { + expect(fn() => PositiveInt::fromString('5.5'))->toThrow(IntegerTypeException::class); +}); diff --git a/tests/Unit/Integer/IntegerNonNegativeTypeTest.php b/tests/Unit/Integer/IntegerNonNegativeTypeTest.php new file mode 100755 index 0000000..1b3ca15 --- /dev/null +++ b/tests/Unit/Integer/IntegerNonNegativeTypeTest.php @@ -0,0 +1,34 @@ +value())->toBe(0); +}); + +it('fails on negatives', function (): void { + expect(fn() => IntegerNonNegative::fromInt(-1))->toThrow(IntegerTypeException::class); +}); + +it('creates NonNegativeInt from string 0', function (): void { + expect(IntegerNonNegative::fromString('0')->value())->toBe(0); +}); + +it('fails NonNegativeInt from integerish string', function (): void { + expect(fn() => IntegerNonNegative::fromString('5.0'))->toThrow(IntegerTypeException::class); +}); + +it('fails creating NonNegativeInt from negative string', function (): void { + expect(fn() => IntegerNonNegative::fromString('-1'))->toThrow(IntegerTypeException::class); +}); + +it('toString returns scalar string for NonNegativeInt', function (): void { + expect((new IntegerNonNegative(0))->toString())->toBe('0'); +}); + +it('fails creating NonNegativeInt from float string', function (): void { + expect(fn() => IntegerNonNegative::fromString('5.5'))->toThrow(IntegerTypeException::class); +}); diff --git a/tests/Unit/Integer/IntegerPositiveTypeTest.php b/tests/Unit/Integer/IntegerPositiveTypeTest.php new file mode 100755 index 0000000..e0898c7 --- /dev/null +++ b/tests/Unit/Integer/IntegerPositiveTypeTest.php @@ -0,0 +1,42 @@ +value())->toBe(1); +}); + +it('fails on 0', function (): void { + expect(fn() => IntegerPositive::fromInt(0))->toThrow(IntegerTypeException::class); +}); + +it('fails on negatives', function (): void { + expect(fn() => IntegerPositive::fromInt(-1))->toThrow(IntegerTypeException::class); +}); + +it('creates IntegerPositive from string', function (): void { + expect(IntegerPositive::fromString('1')->value())->toBe(1); +}); + +it('fails IntegerPositive from integerish string', function (): void { + expect(fn() => IntegerPositive::fromString('5.0'))->toThrow(IntegerTypeException::class); +}); + +it('fails creating IntegerPositive from string 0', function (): void { + expect(fn() => IntegerPositive::fromString('0'))->toThrow(IntegerTypeException::class); +}); + +it('fails creating IntegerPositive from negative string', function (): void { + expect(fn() => IntegerPositive::fromString('-3'))->toThrow(IntegerTypeException::class); +}); + +it('toString returns scalar string for IntegerPositive', function (): void { + expect((new IntegerPositive(3))->toString())->toBe('3'); +}); + +it('fails creating IntegerPositive from float string', function (): void { + expect(fn() => IntegerPositive::fromString('5.5'))->toThrow(IntegerTypeException::class); +}); diff --git a/tests/Unit/Integer/IntegerTypeTest.php b/tests/Unit/Integer/IntegerStandartTypeTest.php similarity index 62% rename from tests/Unit/Integer/IntegerTypeTest.php rename to tests/Unit/Integer/IntegerStandartTypeTest.php index fd488cb..da94c1f 100755 --- a/tests/Unit/Integer/IntegerTypeTest.php +++ b/tests/Unit/Integer/IntegerStandartTypeTest.php @@ -3,29 +3,29 @@ declare(strict_types=1); use PhpTypedValues\Code\Exception\IntegerTypeException; -use PhpTypedValues\Integer\IntegerBasic; +use PhpTypedValues\Integer\IntegerStandard; it('creates Integer from int', function (): void { - expect(IntegerBasic::fromInt(5)->value())->toBe(5); + expect(IntegerStandard::fromInt(5)->value())->toBe(5); }); it('creates Integer from string', function (): void { - expect(IntegerBasic::fromString('5')->value())->toBe(5); + expect(IntegerStandard::fromString('5')->value())->toBe(5); }); it('fails on "integer-ish" float string', function (): void { - expect(fn() => IntegerBasic::fromString('5.'))->toThrow(IntegerTypeException::class); + expect(fn() => IntegerStandard::fromString('5.'))->toThrow(IntegerTypeException::class); }); it('fails on float string', function (): void { - expect(fn() => IntegerBasic::fromString('5.5'))->toThrow(IntegerTypeException::class); + expect(fn() => IntegerStandard::fromString('5.5'))->toThrow(IntegerTypeException::class); }); it('fails on type mismatch', function (): void { expect(function () { try { // invalid integer string (contains decimal point) - IntegerBasic::fromInt('34.66'); + IntegerStandard::fromInt('34.66'); } catch (Throwable $e) { throw new IntegerTypeException('Failed to create Integer from string', previous: $e); } diff --git a/tests/Unit/Integer/IntegerWeekDayTypeTest.php b/tests/Unit/Integer/IntegerWeekDayTypeTest.php new file mode 100755 index 0000000..c806e00 --- /dev/null +++ b/tests/Unit/Integer/IntegerWeekDayTypeTest.php @@ -0,0 +1,44 @@ +value())->toBe(1); +}); + +it('creates WeekDayInt from int 7', function (): void { + expect(IntegerWeekDay::fromInt(7)->value())->toBe(7); +}); + +it('fails on 8', function (): void { + expect(fn() => IntegerWeekDay::fromInt(8))->toThrow(IntegerTypeException::class); +}); + +it('fails on 0', function (): void { + expect(fn() => IntegerWeekDay::fromInt(0))->toThrow(IntegerTypeException::class); +}); + +it('creates WeekDayInt from string within range', function (): void { + expect(IntegerWeekDay::fromString('1')->value())->toBe(1); + expect(IntegerWeekDay::fromString('7')->value())->toBe(7); +}); + +it('creates WeekDayInt from integerish string', function (): void { + expect(fn() => IntegerWeekDay::fromString('5.0'))->toThrow(IntegerTypeException::class); +}); + +it('fails creating WeekDayInt from out-of-range strings', function (): void { + expect(fn() => IntegerWeekDay::fromString('0'))->toThrow(IntegerTypeException::class); + expect(fn() => IntegerWeekDay::fromString('8'))->toThrow(IntegerTypeException::class); +}); + +it('toString returns scalar string for WeekDayInt', function (): void { + expect((new IntegerWeekDay(3))->toString())->toBe('3'); +}); + +it('fails creating WeekDayInt from float string', function (): void { + expect(fn() => IntegerWeekDay::fromString('5.5'))->toThrow(IntegerTypeException::class); +}); diff --git a/tests/Unit/Integer/WeekDayIntTypeTest.php b/tests/Unit/Integer/WeekDayIntTypeTest.php deleted file mode 100755 index 2db8a9c..0000000 --- a/tests/Unit/Integer/WeekDayIntTypeTest.php +++ /dev/null @@ -1,44 +0,0 @@ -value())->toBe(1); -}); - -it('creates WeekDayInt from int 7', function (): void { - expect(WeekDayInt::fromInt(7)->value())->toBe(7); -}); - -it('fails on 8', function (): void { - expect(fn() => WeekDayInt::fromInt(8))->toThrow(IntegerTypeException::class); -}); - -it('fails on 0', function (): void { - expect(fn() => WeekDayInt::fromInt(0))->toThrow(IntegerTypeException::class); -}); - -it('creates WeekDayInt from string within range', function (): void { - expect(WeekDayInt::fromString('1')->value())->toBe(1); - expect(WeekDayInt::fromString('7')->value())->toBe(7); -}); - -it('creates WeekDayInt from integerish string', function (): void { - expect(fn() => WeekDayInt::fromString('5.0'))->toThrow(IntegerTypeException::class); -}); - -it('fails creating WeekDayInt from out-of-range strings', function (): void { - expect(fn() => WeekDayInt::fromString('0'))->toThrow(IntegerTypeException::class); - expect(fn() => WeekDayInt::fromString('8'))->toThrow(IntegerTypeException::class); -}); - -it('toString returns scalar string for WeekDayInt', function (): void { - expect((new WeekDayInt(3))->toString())->toBe('3'); -}); - -it('fails creating WeekDayInt from float string', function (): void { - expect(fn() => WeekDayInt::fromString('5.5'))->toThrow(IntegerTypeException::class); -}); diff --git a/tests/Unit/String/DB/StringVarChar255Test.php b/tests/Unit/String/DB/StringVarChar255Test.php new file mode 100755 index 0000000..489f15b --- /dev/null +++ b/tests/Unit/String/DB/StringVarChar255Test.php @@ -0,0 +1,38 @@ +value())->toBe('') + ->and($s->toString())->toBe(''); +}); + +it('accepts 255 ASCII characters (boundary) and preserves value', function (): void { + $str = str_repeat('a', 255); + $s = StringVarChar255::fromString($str); + expect($s->value())->toBe($str) + ->and($s->toString())->toBe($str); +}); + +it('throws on 256 ASCII characters (above boundary)', function (): void { + $str = str_repeat('b', 256); + expect(fn() => new StringVarChar255($str)) + ->toThrow(StringTypeException::class, 'String is too long, max 255 chars allowed'); +}); + +it('accepts 255 multibyte characters (emoji) counted by mb_strlen', function (): void { + $str = str_repeat('🙂', 255); + $s = new StringVarChar255($str); + expect($s->value())->toBe($str) + ->and($s->toString())->toBe($str); +}); + +it('throws on 256 multibyte characters (emoji)', function (): void { + $str = str_repeat('🙂', 256); + expect(fn() => StringVarChar255::fromString($str)) + ->toThrow(StringTypeException::class, 'String is too long, max 255 chars allowed'); +}); diff --git a/tests/Unit/String/NonEmptyStrTypeTest.php b/tests/Unit/String/StringNonEmptyTypeTest.php similarity index 73% rename from tests/Unit/String/NonEmptyStrTypeTest.php rename to tests/Unit/String/StringNonEmptyTypeTest.php index 2c60cfa..f96b878 100755 --- a/tests/Unit/String/NonEmptyStrTypeTest.php +++ b/tests/Unit/String/StringNonEmptyTypeTest.php @@ -3,27 +3,27 @@ declare(strict_types=1); use PhpTypedValues\Code\Exception\StringTypeException; -use PhpTypedValues\String\NonEmptyStr; +use PhpTypedValues\String\StringNonEmpty; it('constructs and preserves non-empty string', function (): void { - $s = new NonEmptyStr('hello'); + $s = new StringNonEmpty('hello'); expect($s->value())->toBe('hello') ->and($s->toString())->toBe('hello'); }); it('allows whitespace and unicode as non-empty', function (): void { - $w = new NonEmptyStr(' '); - $u = NonEmptyStr::fromString('🙂'); + $w = new StringNonEmpty(' '); + $u = StringNonEmpty::fromString('🙂'); expect($w->value())->toBe(' ') ->and($u->toString())->toBe('🙂'); }); it('throws on empty string via constructor', function (): void { - expect(fn() => new NonEmptyStr('')) + expect(fn() => new StringNonEmpty('')) ->toThrow(StringTypeException::class, 'Expected non-empty string, got ""'); }); it('throws on empty string via fromString', function (): void { - expect(fn() => NonEmptyStr::fromString('')) + expect(fn() => StringNonEmpty::fromString('')) ->toThrow(StringTypeException::class, 'Expected non-empty string, got ""'); }); diff --git a/tests/Unit/String/StringUuidV4Test.php b/tests/Unit/String/StringUuidV4Test.php new file mode 100755 index 0000000..f326a3f --- /dev/null +++ b/tests/Unit/String/StringUuidV4Test.php @@ -0,0 +1,45 @@ +value())->toBe($uuid) + ->and($s->toString())->toBe($uuid); +}); + +it('normalizes uppercase input to lowercase while preserving the UUID semantics', function (): void { + $upper = '550E8400-E29B-41D4-A716-446655440000'; + $s = StringUuidV4::fromString($upper); + + expect($s->value())->toBe('550e8400-e29b-41d4-a716-446655440000') + ->and($s->toString())->toBe('550e8400-e29b-41d4-a716-446655440000'); +}); + +it('throws on empty string', function (): void { + expect(fn() => new StringUuidV4('')) + ->toThrow(StringTypeException::class, 'Expected non-empty UUID v4 (xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx), got ""'); +}); + +it('throws when UUID version is not 4 (e.g., version 1)', function (): void { + $v1 = '550e8400-e29b-11d4-a716-446655440000'; + expect(fn() => StringUuidV4::fromString($v1)) + ->toThrow(StringTypeException::class, 'Expected UUID v4 (xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx), got "' . $v1 . '"'); +}); + +it('throws when UUID variant nibble is invalid (must be 8,9,a,b)', function (): void { + $badVariant = '550e8400-e29b-41d4-7716-446655440000'; + expect(fn() => new StringUuidV4($badVariant)) + ->toThrow(StringTypeException::class, 'Expected UUID v4 (xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx), got "' . $badVariant . '"'); +}); + +it('throws on invalid characters or format (non-hex character)', function (): void { + $badChar = '550e8400-e29b-41d4-a716-44665544000g'; + expect(fn() => StringUuidV4::fromString($badChar)) + ->toThrow(StringTypeException::class, 'Expected UUID v4 (xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx), got "' . $badChar . '"'); +});