diff options
| author | Zhineng Li <[email protected]> | 2026-01-05 16:26:11 +0800 |
|---|---|---|
| committer | Zhineng Li <[email protected]> | 2026-01-05 16:26:11 +0800 |
| commit | a49763dd739c3c68c4a8322896d594e926ac8e6b (patch) | |
| tree | ab96cf55ccf828ec3c61d7cf5c440cc8fe837a9b | |
first commit
| -rw-r--r-- | .editorconfig | 10 | ||||
| -rw-r--r-- | .gitattributes | 7 | ||||
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | .php-cs-fixer.dist.php | 19 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | README.md | 109 | ||||
| -rw-r--r-- | composer.json | 44 | ||||
| -rw-r--r-- | phpstan.neon.dist | 7 | ||||
| -rw-r--r-- | phpunit.xml.dist | 27 | ||||
| -rw-r--r-- | src/Component.php | 33 | ||||
| -rw-r--r-- | src/Constant.php | 36 | ||||
| -rw-r--r-- | src/Field.php | 58 | ||||
| -rw-r--r-- | src/Makable.php | 13 | ||||
| -rw-r--r-- | src/Manager.php | 69 | ||||
| -rw-r--r-- | src/Sequence.php | 42 | ||||
| -rw-r--r-- | src/Structure.php | 62 | ||||
| -rw-r--r-- | src/Timestamp.php | 38 | ||||
| -rw-r--r-- | tests/ConstantTest.php | 70 | ||||
| -rw-r--r-- | tests/ManagerTest.php | 70 | ||||
| -rw-r--r-- | tests/SequenceTest.php | 53 | ||||
| -rw-r--r-- | tests/StructureTest.php | 70 | ||||
| -rw-r--r-- | tests/TimestampTest.php | 67 |
22 files changed, 930 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7183dc9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.php] +charset = utf-8 +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..805f87c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +* text=auto +/.editorconfig export-ignore +/.gitignore export-ignore +/.php-cs-fixer.dist.php export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a3bb9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.php-cs-fixer.cache +.phpunit.cache/ +/vendor/ +composer.lock +phpunit.xml diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..931a884 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +use PhpCsFixer\Config; +use PhpCsFixer\Finder; +use PhpCsFixer\Runner\Parallel\ParallelConfigFactory; + +return (new Config()) + ->setParallelConfig(ParallelConfigFactory::detect()) + ->setRiskyAllowed(false) + ->setRules([ + '@auto' => true, + '@PSR12' => true, + 'new_with_parentheses' => [ + 'named_class' => false, + ], + ]) + ->setFinder((new Finder())->in(__DIR__)); @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Zhineng Li + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d74b4a --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# Snowflake ID Generator + +A flexible, customizable distributed ID generator inspired by Twitter's +Snowflake, built for PHP 8.2+. + +Unlike traditional implementations with hard-coded structures, this package +lets you **build your own ID format** to perfectly suit your application's +needs. + +## Installation + +The package can be installed through Composer: + +```bash +composer require lizhineng/snowflake +``` + +**Requirements:** PHP 8.2 or higher + +## Quick Start + +```php +use Zhineng\Snowflake\Constant; +use Zhineng\Snowflake\Manager; +use Zhineng\Snowflake\Sequence; +use Zhineng\Snowflake\Structure; +use Zhineng\Snowflake\Timestamp; + +$structure = new Structure; +$structure->add(Sequence::make('sequence', 12)); +$structure->add(Constant::make('instance', 10)); +$structure->add(Timestamp::make('timestamp', 41)); + +$manager = new Manager; +$manager->structureUsing($structure); + +$id1 = $manager->nextId(); // e.g., 123456789012345678 +$id2 = $manager->nextId(); // e.g., 123456789012345679 +``` + +## Usage + +### Field Types + +#### Timestamp + +Timestamp field tracks milliseconds since epoch, defaulting to 41 bits +and the name `timestamp`: + +```php +Timestamp::make(); +``` + +Optionally set a custom epoch via the third parameter—accepts a `DateTime` +object or an integer (milliseconds since Unix epoch): + +```php +$epoch = new \DateTime('2026-01-01 00:00:00'); +Timestamp::make('timestamp', 41, $epoch); +``` + +#### Constant + +Fixed value field for static identifiers (e.g., datacenter ID, machine ID). +Value defaults to 0 if not specified. + +```php +// Machine ID with value 5 (10 bits) +Constant::make('machine_id', 10, 5); + +// Datacenter ID with value 2 (5 bits) +Constant::make('datacenter_id', 5, 2); +``` + +#### Sequence + +Auto-incrementing sequence that resets to 0 whenever any other field changes. +An `OverflowException` is thrown if the value exceeds its maximum. + +```php +// 12-bit sequence (range 0-4095) +Sequence::make('sequence', 12); +``` + +### Building Custom Structures + +Customize the ID format to suit your needs—the structure is built from +**right to left** (LSB to MSB): + +```php +$structure = new Structure; + +// Bit layout: [timestamp:41][instance:10][sequence:12] = 63 bits +$structure->add(Sequence::make('sequence', 12)); +$structure->add(Constant::make('instance', 10)); +$structure->add(Timestamp::make('timestamp', 41)); + +echo $structure->size(); // 63 +``` + +## Testing + +```bash +composer run test +``` + +## License + +MIT License. See [LICENSE](LICENSE) for details. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1776b5f --- /dev/null +++ b/composer.json @@ -0,0 +1,44 @@ +{ + "name": "lizhineng/snowflake", + "description": "Build your own Snowflake.", + "type": "library", + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5", + "friendsofphp/php-cs-fixer": "^3.92", + "phpstan/phpstan": "^2.1" + }, + "license": "MIT", + "autoload": { + "psr-4": { + "Zhineng\\Snowflake\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Zhineng\\Snowflake\\Tests\\": "tests/" + } + }, + "authors": [ + { + "name": "Zhineng Li", + "email": "[email protected]" + } + ], + "minimum-stability": "stable", + "scripts": { + "test": "phpunit --testdox", + "format": "php-cs-fixer fix", + "lint": "phpstan analyse", + "all": [ + "@format", + "@lint", + "@test" + ] + }, + "config": { + "sort-packages": true + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..0c805cf --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +parameters: + level: max + paths: + - src + - tests + ignoreErrors: + - '#^Parameter \#1 \$name of class Zhineng\\Snowflake\\(Constant|Sequence|Timestamp) constructor expects string, mixed given\.$#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..377fe27 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" + bootstrap="vendor/autoload.php" + cacheDirectory=".phpunit.cache" + executionOrder="depends,defects" + shortenArraysForExportThreshold="10" + requireCoverageMetadata="true" + beStrictAboutCoverageMetadata="true" + beStrictAboutOutputDuringTests="true" + displayDetailsOnPhpunitDeprecations="true" + failOnPhpunitDeprecation="true" + failOnRisky="true" + failOnWarning="true" + colors="true"> + <testsuites> + <testsuite name="default"> + <directory>tests</directory> + </testsuite> + </testsuites> + + <source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true"> + <include> + <directory>src</directory> + </include> + </source> +</phpunit> diff --git a/src/Component.php b/src/Component.php new file mode 100644 index 0000000..67cf96a --- /dev/null +++ b/src/Component.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace Zhineng\Snowflake; + +interface Component +{ + /** + * The bit size. + */ + public function bits(): int; + + /** + * The component value. + */ + public function value(): int; + + /** + * The maximum value based on the number of bits. + */ + public function maxValue(): int; + + /** + * Set the bit offset. + */ + public function setOffset(int $offset): Component; + + /** + * The bit offset. + */ + public function offset(): int; +} diff --git a/src/Constant.php b/src/Constant.php new file mode 100644 index 0000000..6cd63a7 --- /dev/null +++ b/src/Constant.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +namespace Zhineng\Snowflake; + +final class Constant extends Field +{ + use Makable; + + public function __construct( + string $name, + int $bits, + public readonly int $value = 0 + ) { + parent::__construct($name, $bits); + + if ($this->value < 0) { + throw new \InvalidArgumentException('Field value must be non-negative.'); + } + + if ($this->value > $this->maxValue()) { + throw new \InvalidArgumentException(sprintf( + 'Field value %d exceeds maximum %d for %d bits.', + $this->value, + $this->maxValue(), + $this->bits + )); + } + } + + public function value(): int + { + return $this->value; + } +} diff --git a/src/Field.php b/src/Field.php new file mode 100644 index 0000000..9e11bad --- /dev/null +++ b/src/Field.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace Zhineng\Snowflake; + +abstract class Field implements Component +{ + /** + * The bit offset. + */ + private int $offset = 0; + + public function __construct( + public readonly string $name, + public readonly int $bits, + ) { + if ($bits < 1 || $bits > 63) { + throw new \InvalidArgumentException('Bits must be between 1 and 63.'); + } + } + + /** + * The bit size. + */ + public function bits(): int + { + return $this->bits; + } + + /** + * The maximum value based on the number of bits. + */ + public function maxValue(): int + { + return (1 << $this->bits) - 1; + } + + /** + * Set the bit offset. + */ + public function setOffset(int $offset): static + { + $this->offset = $offset; + + return $this; + } + + /** + * The bit offset. + */ + public function offset(): int + { + return $this->offset; + } + + abstract public function value(): int; +} diff --git a/src/Makable.php b/src/Makable.php new file mode 100644 index 0000000..15352dc --- /dev/null +++ b/src/Makable.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +namespace Zhineng\Snowflake; + +trait Makable +{ + public static function make(mixed ...$args): static + { + return new static(...$args); + } +} diff --git a/src/Manager.php b/src/Manager.php new file mode 100644 index 0000000..215ec50 --- /dev/null +++ b/src/Manager.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +namespace Zhineng\Snowflake; + +final class Manager +{ + /** + * The ID format. + */ + private Structure $structure; + + /** + * The last value except sequence. + */ + private int $lastCombination = 0; + + /** + * Set the ID format. + */ + public function structureUsing(Structure $structure): self + { + $this->structure = $structure; + $this->lastCombination = 0; + + return $this; + } + + /** + * Generate the next Snowflake ID. + */ + public function nextId(): int + { + if (! isset($this->structure)) { + throw new \RuntimeException('ID structure is not defined.'); + } + + $id = 0; + $sequence = null; + + foreach ($this->structure->components() as $field) { + // Defer sequence until after detecting other-field changes + // so we can reset it if needed. + if ($field instanceof Sequence) { + $sequence = $field; + + continue; + } + + $current = $field->maxValue() & $field->value(); + $id |= $current << $field->offset(); + } + + // The ID variable now holds the combination of all fields except + // sequence. Reset the sequence field if any other field has changed. + if ($this->lastCombination !== $id) { + $sequence?->reset(); + } + + $this->lastCombination = $id; + + if ($sequence instanceof Sequence) { + $id |= ($sequence->maxValue() & $sequence->value()) << $sequence->offset(); + } + + return $id; + } +} diff --git a/src/Sequence.php b/src/Sequence.php new file mode 100644 index 0000000..d799e29 --- /dev/null +++ b/src/Sequence.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +namespace Zhineng\Snowflake; + +final class Sequence extends Field +{ + use Makable; + + /** + * The current value. + */ + private int $value = 0; + + public function value(): int + { + return $this->next(); + } + + public function next(): int + { + $current = $this->value++; + + if ($current > $this->maxValue()) { + throw new \OverflowException(sprintf( + 'Sequence "%s" exceeded its maximum value of %d.', + $this->name, + $this->maxValue() + )); + } + + return $current; + } + + public function reset(): self + { + $this->value = 0; + + return $this; + } +} diff --git a/src/Structure.php b/src/Structure.php new file mode 100644 index 0000000..307397d --- /dev/null +++ b/src/Structure.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +namespace Zhineng\Snowflake; + +final class Structure +{ + /** + * The ID components. + * + * @var \Zhineng\Snowflake\Component[] + */ + private array $fields = []; + + /** + * The current bit offset. + */ + private int $currentOffset = 0; + + /** + * The number of sequence fields added. + */ + private int $sequenceCount = 0; + + /** + * Add a field to the structure. + */ + public function add(Component $field): self + { + if ($field instanceof Sequence && ++$this->sequenceCount > 1) { + throw new \LogicException('Only one sequence field is allowed in a structure.'); + } + + if ($this->currentOffset + $field->bits() > 63) { + throw new \OverflowException('Total structure size cannot exceed 63 bits.'); + } + + $this->fields[] = $field->setOffset($this->currentOffset); + $this->currentOffset += $field->bits(); + + return $this; + } + + /** + * The ID components. + * + * @return \Zhineng\Snowflake\Component[] + */ + public function components(): array + { + return $this->fields; + } + + /** + * The total size in bits. + */ + public function size(): int + { + return $this->currentOffset; + } +} diff --git a/src/Timestamp.php b/src/Timestamp.php new file mode 100644 index 0000000..a23ba13 --- /dev/null +++ b/src/Timestamp.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Zhineng\Snowflake; + +final class Timestamp extends Field +{ + use Makable; + + /** + * The epoch in milliseconds. + */ + public readonly int $epoch; + + public function __construct( + string $name = 'timestamp', + int $bits = 41, + \DateTime|int|null $epoch = null + ) { + parent::__construct($name, $bits); + + $this->epoch = $epoch instanceof \DateTime + ? $epoch->getTimestamp() * 1000 + : ($epoch ?? 0); + + if ($this->epoch < 0) { + throw new \InvalidArgumentException('Epoch must be non-negative.'); + } + } + + public function value(): int + { + $now = (int) floor(microtime(as_float: true) * 1000); + + return $now - $this->epoch; + } +} diff --git a/tests/ConstantTest.php b/tests/ConstantTest.php new file mode 100644 index 0000000..78d1be3 --- /dev/null +++ b/tests/ConstantTest.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +namespace Zhineng\Snowflake\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Zhineng\Snowflake\Constant; + +#[CoversClass(Constant::class)] +final class ConstantTest extends TestCase +{ + public function testConstantCanBeInitialized(): void + { + $field = new Constant('machine_id', 10); + $this->assertSame('machine_id', $field->name); + $this->assertSame(10, $field->bits); + $this->assertSame(0, $field->value); + } + + public function testMaxValueShouldBeCalculatedCorrectly(): void + { + $field = new Constant('machine_id', 10); + $maxValue = (1 << 10) - 1; + $this->assertSame($maxValue, $field->maxValue()); + } + + public function testInitialValueCanBeSet(): void + { + $field = new Constant('machine_id', 10, 2); + $this->assertSame(2, $field->value); + } + + public function testInitialValueMustBePositive(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Field value must be non-negative.'); + new Constant('machine_id', 10, -1); + } + + public function testInitialValueMustNotExceedMaxValue(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Field value 1024 exceeds maximum 1023 for 10 bits.'); + new Constant('machine_id', 10, 1024); + } + + public function testConstantHasMakeFactoryMethod(): void + { + $field = Constant::make('machine_id', 10, 5); + $this->assertSame('machine_id', $field->name); + $this->assertSame(10, $field->bits); + $this->assertSame(5, $field->value); + } + + public function testBitsMustBeAtLeastOne(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Bits must be between 1 and 63.'); + new Constant('machine_id', 0); + } + + public function testBitsMustNotExceed63(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Bits must be between 1 and 63.'); + new Constant('machine_id', 64); + } +} diff --git a/tests/ManagerTest.php b/tests/ManagerTest.php new file mode 100644 index 0000000..dc81a06 --- /dev/null +++ b/tests/ManagerTest.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +namespace Zhineng\Snowflake\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Zhineng\Snowflake\Constant; +use Zhineng\Snowflake\Field; +use Zhineng\Snowflake\Manager; +use Zhineng\Snowflake\Sequence; +use Zhineng\Snowflake\Structure; +use Zhineng\Snowflake\Timestamp; + +#[CoversClass(Manager::class)] +final class ManagerTest extends TestCase +{ + public function testNextIdResolution(): void + { + $struct = new Structure; + $struct->add(Sequence::make('sequence', 12)); + $struct->add(Constant::make('instance_id', 10)); + $struct->add(Timestamp::make()); + + $manager = new Manager; + $manager->structureUsing($struct); + $this->assertNotSame($manager->nextId(), $manager->nextId()); + } + + public function testSequenceShouldBeResetWhenAnyOtherFieldChanges(): void + { + $struct = new Structure; + $struct->add(Sequence::make('sequence', 12)); + $struct->add($field = new class ('test', 10) extends Field { + public int $value = 0; + + public function value(): int + { + return $this->value; + } + + public function setValue(int $value): void + { + $this->value = $value; + } + }); + + $manager = new Manager; + $manager->structureUsing($struct); + + $id1 = $manager->nextId(); + $id2 = $manager->nextId(); + $field->setValue(1); + $id3 = $manager->nextId(); + + $sequenceMask = (1 << 12) - 1; + $this->assertSame(0, $id1 & $sequenceMask); + $this->assertSame(1, $id2 & $sequenceMask); + $this->assertSame(0, $id3 & $sequenceMask); + } + + public function testExceptionShouldBeThrownWhenMissingStructure(): void + { + $manager = new Manager; + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('ID structure is not defined.'); + $manager->nextId(); + } +} diff --git a/tests/SequenceTest.php b/tests/SequenceTest.php new file mode 100644 index 0000000..fc02d87 --- /dev/null +++ b/tests/SequenceTest.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +namespace Zhineng\Snowflake\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Zhineng\Snowflake\Sequence; + +#[CoversClass(Sequence::class)] +final class SequenceTest extends TestCase +{ + public function testSequenceCanBeInitialized(): void + { + $seq = new Sequence('sequence', 12); + $this->assertSame('sequence', $seq->name); + $this->assertSame(12, $seq->bits); + } + + public function testSequenceHasMakeFactoryMethod(): void + { + $seq = Sequence::make('sequence', 12); + $this->assertSame('sequence', $seq->name); + $this->assertSame(12, $seq->bits); + } + + public function testNextValueResolution(): void + { + $seq = new Sequence('sequence', 12); + $this->assertSame(0, $seq->next()); + $this->assertSame(1, $seq->next()); + $this->assertSame(2, $seq->next()); + } + + public function testExceptionShouldBeThrownWhenMaxValueExceeded(): void + { + $seq = new Sequence('sequence', 1); // Max value is 1 + $this->assertSame(0, $seq->next()); + $this->assertSame(1, $seq->next()); + $this->expectException(\OverflowException::class); + $this->expectExceptionMessage('Sequence "sequence" exceeded its maximum value of 1.'); + $seq->next(); + } + + public function testSequenceCanBeReset(): void + { + $seq = new Sequence('sequence', 12); + $this->assertSame(0, $seq->next()); + $this->assertSame(1, $seq->next()); + $this->assertSame(0, $seq->reset()->next()); + } +} diff --git a/tests/StructureTest.php b/tests/StructureTest.php new file mode 100644 index 0000000..80d94fe --- /dev/null +++ b/tests/StructureTest.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +namespace Zhineng\Snowflake\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Zhineng\Snowflake\Constant; +use Zhineng\Snowflake\Sequence; +use Zhineng\Snowflake\Structure; + +#[CoversClass(Structure::class)] +final class StructureTest extends TestCase +{ + public function testAddFieldAndRetrieveComponents(): void + { + $struct = new Structure; + $struct->add(Constant::make('machine_id', 10)); + $this->assertCount(1, $struct->components()); + } + + public function testAddMultipleFields(): void + { + $struct = new Structure; + $struct->add(Constant::make('machine_id', 5)); + $struct->add(Constant::make('datacenter_id', 5)); + $this->assertCount(2, $struct->components()); + } + + public function testOffsetsAreCalculatedCorrectly(): void + { + $struct = new Structure; + $struct->add($machineId = Constant::make('machine_id', 5)); + $struct->add($dataCenterId = Constant::make('datacenter_id', 5)); + $this->assertSame(0, $machineId->offset()); + $this->assertSame(5, $dataCenterId->offset()); + } + + public function testSizeResolution(): void + { + $struct = new Structure; + $struct->add(Constant::make('machine_id', 5)); + $struct->add(Constant::make('datacenter_id', 5)); + $this->assertSame(10, $struct->size()); + } + + public function testAtMostOneSequenceIsAllowed(): void + { + $struct = new Structure; + $struct->add(Sequence::make('seq1', 12)); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Only one sequence field is allowed in a structure.'); + + $struct->add(Sequence::make('seq2', 12)); + } + + public function testTotalSizeCannotExceed63Bits(): void + { + $struct = new Structure; + $struct->add(Constant::make('field1', 32)); + $struct->add(Constant::make('field2', 31)); + + $this->expectException(\OverflowException::class); + $this->expectExceptionMessage('Total structure size cannot exceed 63 bits.'); + + $struct->add(Constant::make('field3', 1)); + } +} diff --git a/tests/TimestampTest.php b/tests/TimestampTest.php new file mode 100644 index 0000000..d157adf --- /dev/null +++ b/tests/TimestampTest.php @@ -0,0 +1,67 @@ +<?php + +declare(strict_types=1); + +namespace Zhineng\Snowflake\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Zhineng\Snowflake\Timestamp; + +#[CoversClass(Timestamp::class)] +final class TimestampTest extends TestCase +{ + public function testTimestampCanBeInitialized(): void + { + $field = new Timestamp('timestamp', 41); + $this->assertSame('timestamp', $field->name); + $this->assertSame(41, $field->bits); + } + + public function testTimestampHasDefaultParameterValues(): void + { + $field = new Timestamp; + $this->assertSame('timestamp', $field->name); + $this->assertSame(41, $field->bits); + } + + public function testTimestampHasMakeFactoryMethod(): void + { + $field = Timestamp::make('timestamp', 41); + $this->assertSame('timestamp', $field->name); + $this->assertSame(41, $field->bits); + } + + public function testTimestampHasDynamicValue(): void + { + $field = new Timestamp; + $value1 = $field->value(); + usleep(1000); // Sleep for 1 millisecond + $value2 = $field->value(); + $this->assertGreaterThan($value1, $value2); + } + + public function testEpochCanBeCustomized(): void + { + $epoch = new \DateTime('2026-01-01 00:00:00'); + $field = new Timestamp('timestamp', 41, $epoch); + $now = (int) floor(microtime(as_float: true) * 1000); + $this->assertSame($now - $epoch->getTimestamp() * 1000, $field->value()); + } + + public function testEpochCanBeSetAsInteger(): void + { + $epoch = new \DateTime('2026-01-01 00:00:00'); + $timestampInMillis = $epoch->getTimestamp() * 1000; + $field = new Timestamp('timestamp', 41, $timestampInMillis); + $now = (int) floor(microtime(as_float: true) * 1000); + $this->assertSame($now - $timestampInMillis, $field->value()); + } + + public function testEpochMustBeNonNegative(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Epoch must be non-negative.'); + new Timestamp('timestamp', 41, -1); + } +} |
