Compare commits

..

No commits in common. "trunk" and "router-v3" have entirely different histories.

35 changed files with 171 additions and 672 deletions

View file

@ -7,7 +7,7 @@ It provides a number of components that I would otherwise copy between projects
## Requirements and Dependencies
Index currently targets **PHP 8.4** with the `mbstring` extension installed.
Index currently targets **PHP 8.3** with the `mbstring` extension installed.
### `Index\Cache\Memcached`

View file

@ -1 +1 @@
0.2504.51944
0.2503.192123

34
composer.lock generated
View file

@ -976,16 +976,16 @@
},
{
"name": "phpstan/phpstan",
"version": "2.1.11",
"version": "2.1.8",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
"reference": "8ca5f79a8f63c49b2359065832a654e1ec70ac30"
"reference": "f9adff3b87c03b12cc7e46a30a524648e497758f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/8ca5f79a8f63c49b2359065832a654e1ec70ac30",
"reference": "8ca5f79a8f63c49b2359065832a654e1ec70ac30",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/f9adff3b87c03b12cc7e46a30a524648e497758f",
"reference": "f9adff3b87c03b12cc7e46a30a524648e497758f",
"shasum": ""
},
"require": {
@ -1030,20 +1030,20 @@
"type": "github"
}
],
"time": "2025-03-24T13:45:00+00:00"
"time": "2025-03-09T09:30:48+00:00"
},
{
"name": "phpunit/php-code-coverage",
"version": "12.1.2",
"version": "12.1.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
"reference": "05c33d01a856f9f62488d144bafddc3d7b7a4ebb"
"reference": "d331a5ced3d9a2b917baa9841b2211e72f9e780d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/05c33d01a856f9f62488d144bafddc3d7b7a4ebb",
"reference": "05c33d01a856f9f62488d144bafddc3d7b7a4ebb",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/d331a5ced3d9a2b917baa9841b2211e72f9e780d",
"reference": "d331a5ced3d9a2b917baa9841b2211e72f9e780d",
"shasum": ""
},
"require": {
@ -1099,7 +1099,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.1.2"
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.1.0"
},
"funding": [
{
@ -1107,7 +1107,7 @@
"type": "github"
}
],
"time": "2025-04-03T14:34:39+00:00"
"time": "2025-03-17T13:56:07+00:00"
},
{
"name": "phpunit/php-file-iterator",
@ -1356,16 +1356,16 @@
},
{
"name": "phpunit/phpunit",
"version": "12.0.10",
"version": "12.0.9",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "6075843014de23bcd6992842d69ca99d25d6a433"
"reference": "7835bb4276780e0bbb385ce0a777b839e03096db"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6075843014de23bcd6992842d69ca99d25d6a433",
"reference": "6075843014de23bcd6992842d69ca99d25d6a433",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7835bb4276780e0bbb385ce0a777b839e03096db",
"reference": "7835bb4276780e0bbb385ce0a777b839e03096db",
"shasum": ""
},
"require": {
@ -1433,7 +1433,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.0.10"
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.0.9"
},
"funding": [
{
@ -1449,7 +1449,7 @@
"type": "tidelift"
}
],
"time": "2025-03-23T16:03:59+00:00"
"time": "2025-03-19T13:47:33+00:00"
},
{
"name": "sebastian/cli-parser",

View file

@ -1,7 +1,7 @@
<?php
// NullDbStatement.php
// Created: 2021-05-02
// Updated: 2025-04-05
// Updated: 2025-01-18
namespace Index\Db\NullDb;
@ -11,7 +11,7 @@ use Index\Db\{DbType,DbResult,DbResultIterator,DbStatement};
* Represents a dummy database statement.
*/
class NullDbStatement implements DbStatement {
public private(set) int $paramCount = 0;
public private(set) int $parameterCount = 0;
public function addParameter(int $ordinal, mixed $value, DbType $type = DbType::Auto): void {}

View file

@ -1,7 +1,7 @@
<?php
// Dependencies.php
// Created: 2025-01-18
// Updated: 2025-04-02
// Updated: 2025-03-15
namespace Index;
@ -36,52 +36,52 @@ class Dependencies {
* @return array<string|int, mixed>
*/
private function resolveArgs(ReflectionFunctionAbstract $constructor, array $args): array {
$namedArgs = [];
$seqArgs = [];
foreach($args as $name => $value) {
if(is_int($name) || ctype_digit($name))
$seqArgs[] = $value;
else
$namedArgs[$name] = $value;
}
$args = [];
$realArgs = [];
$params = $constructor->getParameters();
foreach($params as $paramInfo) {
$paramName = $paramInfo->getName();
if(array_key_exists($paramName, $args)) {
$realArgs[] = $args[$paramName];
unset($args[$paramName]);
continue;
}
// named values always take precedence, including explicit type erroring
if(array_key_exists($paramName, $namedArgs)) {
$value = $namedArgs[$paramName];
} else {
$value = null;
try {
$typeInfo = $paramInfo->getType();
$allowsNull = $typeInfo === null || $typeInfo->allowsNull();
$value = $paramInfo->isDefaultValueAvailable() ? $paramInfo->getDefaultValue() : null;
// maybe support intersection and union someday
if($typeInfo instanceof ReflectionNamedType && !$typeInfo->isBuiltin()) {
$className = $typeInfo->getName();
if(class_exists($className) || interface_exists($className))
$value = $this->resolve($className);
if(!class_exists($className) && !interface_exists($className))
throw new RuntimeException(sprintf('class in type does not exist: %s', $className));
$value = $this->resolve($className);
if($value === null && !$allowsNull)
throw new RuntimeException(sprintf('was not able to resolve %s', $className));
}
if($value === null) {
if(count($seqArgs) > 0) {
$value = array_shift($seqArgs);
if(!$typeInfo instanceof ReflectionNamedType || !(!$typeInfo->isBuiltin() || $typeInfo->getName() === gettype($value)))
$value = null;
}
if($value === null && !$allowsNull)
throw new RuntimeException('required parameter has unsupported type definition');
if($value === null && !$allowsNull && $paramInfo->isDefaultValueAvailable())
$value = $paramInfo->getDefaultValue();
$realArgs[] = $value;
continue;
} catch(RuntimeException $ex) {
if(count($args) > 0) {
$realArgs[] = array_shift($args);
continue;
}
if(!$paramInfo->isOptional())
throw $ex;
$realArgs[] = null;
}
$args[$paramName] = $value;
}
return $args;
return $realArgs;
}
/**
@ -213,7 +213,7 @@ class Dependencies {
if(!array_key_exists($class, $this->objects))
return null;
$object = XArray::first($this->objects[$class], $predicate); // @phpstan-ignore argument.type
$object = XArray::first($this->objects[$class], $predicate); // @phpstan-ignore-line: IDC
if($object === null || !is_a($object, $class))
return null;
@ -254,8 +254,8 @@ class Dependencies {
return [];
if($predicate === null)
return $this->objects[$class]; // @phpstan-ignore return.type
return $this->objects[$class]; // @phpstan-ignore-line: trust me
return XArray::where($this->objects[$class], $predicate); // @phpstan-ignore argument.type, return.type
return XArray::where($this->objects[$class], $predicate); // @phpstan-ignore-line: this is fine
}
}

View file

@ -1,7 +1,7 @@
<?php
// MultipartFormContent.php
// Created: 2025-03-12
// Updated: 2025-03-25
// Updated: 2025-03-15
namespace Index\Http\Content;
@ -88,7 +88,7 @@ class MultipartFormContent implements FormContent, Iterator {
/**
* Parses multipart form data in a stream.
*
* @param StreamInterface $stream Stream to parse.
* @param StreamInterface $stream Stream to parse, if seekable gets rewound.
* @param string $boundary Boundary string.
* @throws RuntimeException if the form could not be parsed correctly
* @return MultipartFormContent
@ -174,10 +174,10 @@ class MultipartFormContent implements FormContent, Iterator {
continue;
$value = trim($infoParts[1]);
if(str_starts_with($value, '"') && str_ends_with($value, '"'))
$value = substr($value, 1, -1);
if(!str_starts_with($value, '"') || !str_ends_with($value, '"'))
continue;
$info[$name] = urldecode($value);
$info[$name] = urldecode(substr($value, 1, -1));
}
return $info;
@ -221,6 +221,9 @@ class MultipartFormContent implements FormContent, Iterator {
$params[$part->name] = [$part];
}
if($stream->isSeekable())
$stream->rewind();
return new MultipartFormContent($stream, $params, $files);
}
}

View file

@ -1,7 +1,7 @@
<?php
// UrlEncodedFormContent.php
// Created: 2025-03-12
// Updated: 2025-03-25
// Updated: 2025-03-15
namespace Index\Http\Content;
@ -58,10 +58,14 @@ class UrlEncodedFormContent implements FormContent {
/**
* Parses URL encoded form params in a stream.
*
* @param StreamInterface $stream Stream to parse.
* @param StreamInterface $stream Stream to parse, if seekable gets rewound.
* @return UrlEncodedFormContent
*/
public static function parseStream(StreamInterface $stream): UrlEncodedFormContent {
return new UrlEncodedFormContent($stream, HttpUri::parseQueryString((string)$stream));
$params = HttpUri::parseQueryString((string)$stream);
if($stream->isSeekable())
$stream->rewind();
return new UrlEncodedFormContent($stream, $params);
}
}

View file

@ -1,7 +1,7 @@
<?php
// HttpParameters.php
// Created: 2025-03-15
// Updated: 2025-03-20
// Updated: 2025-03-15
namespace Index\Http;
@ -84,12 +84,4 @@ interface HttpParameters {
array|int $options = 0,
mixed $default = null,
): mixed;
/**
* Retrieves parameters as a query string.
*
* @param ?bool $spacesAsPlus null to use the Index url encoder, other wise whether to represent spaces as a + instead of %20 with the legacy encoder.
* @return string Query string representation of all parameters.
*/
public function getParamString(?bool $spacesAsPlus = null): string;
}

View file

@ -1,7 +1,7 @@
<?php
// HttpParametersCommon.php
// Created: 2025-03-15
// Updated: 2025-03-20
// Updated: 2025-03-15
namespace Index\Http;
@ -99,26 +99,4 @@ trait HttpParametersCommon {
? filter_var($this->params[$name][$index], $filter, $options)
: $default;
}
/**
* Retrieves parameters as a query string.
*
* @param ?bool $spacesAsPlus null to use the Index url encoder, other wise whether to represent spaces as a + instead of %20 with the legacy encoder.
* @return string Query string representation of all parameters.
*/
public function getParamString(?bool $spacesAsPlus = null): string {
if($spacesAsPlus !== null) {
$params = [];
foreach($this->params as $name => $values)
$params[$name] = match(count($values)) {
0 => '',
1 => $values[0],
default => $values,
};
return http_build_query($params, '', '&', $spacesAsPlus ? PHP_QUERY_RFC1738 : PHP_QUERY_RFC3986);
}
return HttpUri::buildQueryString($this->params);
}
}

View file

@ -1,7 +1,7 @@
<?php
// HttpRequest.php
// Created: 2022-02-08
// Updated: 2025-04-02
// Updated: 2025-03-15
namespace Index\Http;
@ -267,6 +267,18 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface, HttpPar
return $this->with(params: $query);
}
/**
* Retrieves all HTTP request query fields as a query string.
*
* @param ?bool $spacesAsPlus null to use the Index url encoder, other wise whether to represent spaces as a + instead of %20 with the legacy encoder.
* @return string Query string representation of query fields.
*/
public function getParamString(?bool $spacesAsPlus = null): string {
return $spacesAsPlus === null
? HttpUri::buildQueryString($this->params)
: http_build_query($this->params, '', '&', $spacesAsPlus ? PHP_QUERY_RFC1738 : PHP_QUERY_RFC3986);
}
/**
* @return array<string, UploadedFileInterface[]> An array tree of UploadedFileInterface instances; an empty array MUST be returned if no data is present.
*/
@ -360,15 +372,16 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface, HttpPar
return new HttpRequest(
$request->getProtocolVersion(),
$request->getHeaders(), // @phpstan-ignore argument.type
// @phpstan-ignore-next-line: interface erroneously defines as string[][] instead of array<string, string[]>
$request->getHeaders(),
$request->getBody(),
[],
$request->getMethod(),
HttpUri::castUri($request->getUri()),
$queryParams,
$cookieParams,
$request->getParsedBody(), // @phpstan-ignore argument.type
$request->getUploadedFiles(), // @phpstan-ignore argument.type
$request->getParsedBody(), // @phpstan-ignore-line: dont care
$request->getUploadedFiles(), // @phpstan-ignore-line: dont care
);
}

View file

@ -1,7 +1,7 @@
<?php
// HttpResponseBuilder.php
// Created: 2022-02-08
// Updated: 2025-04-03
// Updated: 2025-03-12
namespace Index\Http;
@ -191,7 +191,19 @@ final class HttpResponseBuilder extends HttpMessageBuilder {
* @param Timings $timings Timings to supply to the devtools.
*/
public function setServerTiming(Timings $timings): void {
$this->setHeader('Server-Timing', (string)$timings);
$laps = $timings->laps;
$timings = [];
foreach($laps as $lap) {
$timing = $lap->name;
if(!empty($lap->comment))
$timing .= ';desc="' . strtr($lap->comment, ['"' => '\\"']) . '"';
$timing .= ';dur=' . round($lap->durationTime, 5);
$timings[] = $timing;
}
$this->setHeader('Server-Timing', implode(', ', $timings));
}
/**

View file

@ -1,7 +1,7 @@
<?php
// HttpUri.php
// Created: 2025-02-28
// Updated: 2025-03-20
// Updated: 2025-03-08
namespace Index\Http;

View file

@ -1,7 +1,7 @@
<?php
// PatternFilter.php
// Created: 2024-03-28
// Updated: 2025-03-23
// Updated: 2025-03-07
namespace Index\Http\Routing\Filters;
@ -18,14 +18,14 @@ class PatternFilter extends FilterAttribute {
/**
* @param string $pattern URI path pattern.
* @param bool $raw If true, $pattern is taken as is, if false, $pattern is prefixed with #^ and suffixed with #u or $#uD
* @param bool $prefix Only has effect if $raw is false. If true is #u appended to $pattern, otherwise /?$#uD.
* @param bool $prefix Only has effect if $raw is false. If true is #u appended to $pattern, otherwise $#uD.
*/
public function __construct(
string $pattern,
bool $raw = false,
bool $prefix = true,
) {
$this->pattern = $raw ? $pattern : sprintf('#^%s%s', $pattern, $prefix ? '#u' : '/?$#uD');
$this->pattern = $raw ? $pattern : sprintf('#^%s%s', $pattern, $prefix ? '#u' : '$#uD');
}
public function createInstance(Closure $handler): FilterInfo {

View file

@ -1,7 +1,7 @@
<?php
// Router.php
// Created: 2024-03-28
// Updated: 2025-03-25
// Updated: 2025-03-19
namespace Index\Http\Routing;
@ -268,15 +268,10 @@ class Router implements RequestHandlerInterface {
$match = $routeInfo->matcher->match($context->request->uri);
if($match !== false) {
$routeInfo->readAccessControl();
if(!array_key_exists($routeInfo->method, $methods)
|| ( // using less matched variables to mean the match was more accurate
$methods[$routeInfo->method]->matches === null ? 0 : count($methods[$routeInfo->method]->matches))
> ($match === true ? 0 : count($match))
)
$methods[$routeInfo->method] = (object)[
'info' => $routeInfo,
'matches' => is_array($match) ? $match : null,
];
$methods[$routeInfo->method] = (object)[
'info' => $routeInfo,
'matches' => is_array($match) ? $match : null,
];
}
}
@ -405,7 +400,7 @@ class Router implements RequestHandlerInterface {
foreach($response->getHeaders() as $name => $lines)
foreach($lines as $line)
header(sprintf('%s: %s', $name, $line), false);
header(sprintf('%s: %s', $name, $line));
$stream = $response->getBody();
if($stream->isReadable())

View file

@ -1,7 +1,7 @@
<?php
// RouterProcessors.php
// Created: 2025-03-15
// Updated: 2025-03-25
// Updated: 2025-03-15
namespace Index\Http\Routing;
@ -23,20 +23,18 @@ class RouterProcessors implements RouteHandler {
if(!$contentType->equals('application/x-www-form-urlencoded'))
return false;
$stream = $request->getBody();
if($stream->isSeekable())
$stream->rewind();
$context->deps->register(UrlEncodedFormContent::parseStream($stream));
$context->deps->register(UrlEncodedFormContent::parseStream($request->getBody()));
return true;
}
/** @return void|int */
#[Preprocessor('input:urlencoded')]
public function preInputUrlencoded(HandlerContext $context, RequestInterface $request, bool $required = true) {
if(!$this->handlePreInputUrlencoded($context, $request) && $required)
return 400;
public function preInputUrlencoded(HandlerContext $context, RequestInterface $request, bool $required = true): void {
if(!$this->handlePreInputUrlencoded($context, $request) && $required) {
$context->response->statusCode = 400;
$context->response->reasonPhrase = '';
$context->halt();
}
}
private function handlePreInputMultipart(HandlerContext $context, RequestInterface $request): bool {
@ -48,18 +46,13 @@ class RouterProcessors implements RouteHandler {
if(empty($boundary))
return false;
$stream = $request->getBody();
if($stream->isSeekable())
$stream->rewind();
$context->deps->register(MultipartFormContent::parseStream($stream, $boundary));
$context->deps->register(MultipartFormContent::parseStream($request->getBody(), $boundary));
return true;
}
/** @return void|int */
#[Preprocessor('input:multipart')]
public function preInputMultipart(HandlerContext $context, RequestInterface $request, bool $required = true) {
public function preInputMultipart(HandlerContext $context, RequestInterface $request, bool $required = true): void {
try {
$result = $this->handlePreInputMultipart($context, $request);
} catch(RuntimeException $ex) {
@ -67,8 +60,11 @@ class RouterProcessors implements RouteHandler {
$required = true;
}
if(!$result && $required)
return 400;
if(!$result && $required) {
$context->response->statusCode = 400;
$context->response->reasonPhrase = '';
$context->halt();
}
}
#[Postprocessor('output:stream')]

View file

@ -1,7 +1,7 @@
<?php
// PatternRoute.php
// Created: 2024-03-28
// Updated: 2025-03-23
// Updated: 2025-03-07
namespace Index\Http\Routing\Routes;
@ -19,15 +19,13 @@ class PatternRoute extends RouteAttribute {
* @param string $method HTTP Method.
* @param string $pattern URI path pattern.
* @param bool $raw If true, $pattern is taken as is, if false, $pattern is prefixed with #^ and suffixed with $#uD
* @param bool $slash Only has effect if $raw is false. If true /? is appended to $pattern to allow for trailing slashes.
*/
public function __construct(
private string $method,
string $pattern,
bool $raw = false,
bool $slash = true,
bool $raw = false
) {
$this->pattern = $raw ? $pattern : sprintf('#^%s%s$#uD', $pattern, $slash ? '/?' : '');
$this->pattern = $raw ? $pattern : sprintf('#^%s$#uD', $pattern);
}
public function createInstance(Closure $handler): RouteInfo {

View file

@ -1,7 +1,7 @@
<?php
// ExactPathUriMatcher.php
// Created: 2025-03-07
// Updated: 2025-03-23
// Updated: 2025-03-07
namespace Index\Http\Routing\UriMatchers;
@ -19,7 +19,6 @@ class ExactPathUriMatcher implements UriMatcher {
) {}
public function match(UriInterface $uri): array|bool {
$path = $uri->getPath();
return $path === $this->path || $path === ($this->path . '/');
return $uri->getPath() === $this->path;
}
}

View file

@ -1,7 +1,7 @@
<?php
// Stopwatch.php
// Created: 2021-04-26
// Updated: 2025-04-03
// Updated: 2025-02-27
namespace Index\Performance;
@ -9,15 +9,11 @@ namespace Index\Performance;
* Provides a means to measure elapsed time at a high resolution.
*/
class Stopwatch {
private int $start = -1;
private int $elapsed = 0;
private int $frequency;
/**
* @param int $start Starting value, must be the output of hrtime(true) if specified.
*/
public function __construct(
private int $start = -1
) {
public function __construct() {
$this->frequency = PerformanceCounter::frequency();
}

View file

@ -1,16 +1,14 @@
<?php
// TimingPoint.php
// Created: 2022-02-16
// Updated: 2025-04-03
// Updated: 2025-02-27
namespace Index\Performance;
use Stringable;
/**
* Represents a timing point.
*/
class TimingPoint implements Stringable {
class TimingPoint {
/**
* @param int $timePoint Starting tick count.
* @param int $duration Duration tick count.
@ -32,14 +30,4 @@ class TimingPoint implements Stringable {
public int|float $durationTime {
get => $this->duration / PerformanceCounter::frequency();
}
public function __toString(): string {
$string = $this->name;
if(!empty($this->comment))
$string .= sprintf(';desc="%s"', rawurlencode($this->comment));
$string .= sprintf(';dur=%.5f', $this->durationTime);
return $string;
}
}

View file

@ -1,17 +1,16 @@
<?php
// Timings.php
// Created: 2022-02-16
// Updated: 2025-04-03
// Updated: 2025-02-27
namespace Index\Performance;
use InvalidArgumentException;
use Stringable;
/**
* Represents a Stopwatch with timing points.
*/
class Timings implements Stringable {
class Timings {
public private(set) Stopwatch $stopwatch;
private int $lastLapTicks;
@ -75,8 +74,4 @@ class Timings implements Stringable {
$comment
);
}
public function __toString(): string {
return implode(', ', $this->laps);
}
}

View file

@ -1,60 +0,0 @@
<?php
// BinarySnowflake.php
// Created: 2025-03-26
// Updated: 2025-03-26
namespace Index\Snowflake;
use DateTimeInterface;
/**
* Implements a snowflake generator wrapper that takes user provided data.
*/
class BinarySnowflake {
/**
* Reference to the underlying generator.
*/
public private(set) SnowflakeGenerator $generator;
/**
* @param bool $littleEndian Whether to convert the given data to a little endian integer or big endian.
* @param ?SnowflakeGenerator $generator Snowflake generator instance.
*/
public function __construct(
public private(set) bool $littleEndian = false,
?SnowflakeGenerator $generator = null,
) {
$this->generator = $generator ?? new SnowflakeGenerator;
}
/**
* Generates the next snowflake.
*
* @param string $data Data to shove in.
* @param DateTimeInterface|string|int<0, max>|null $at Timestamp to base at.
* @return int
*/
public function next(string $data, DateTimeInterface|string|int|null $at = null): int {
$length = strlen($data);
if($length <= 1) {
$format = 'C';
$data = str_pad($data, 1, "\0", STR_PAD_LEFT);
} elseif($length === 2) {
$format = $this->littleEndian ? 'v' : 'n';
} elseif($length <= 4) {
$format = $this->littleEndian ? 'V' : 'N';
$data = str_pad($data, 4, "\0", STR_PAD_LEFT);
} else {
$format = $this->littleEndian ? 'P' : 'J';
if($length < 8)
$data = str_pad($data, 8, "\0", STR_PAD_LEFT);
elseif($length > 8)
$data = substr($data, 0, 8);
}
$data = unpack(sprintf('%sdata', $format), $data);
$data = is_array($data) && isset($data['data']) && is_int($data['data']) && $data['data'] >= 0 ? $data['data'] : 0;
return $this->generator->next($data, $at);
}
}

View file

@ -1,53 +0,0 @@
<?php
// IncrementalSnowflake.php
// Created: 2025-03-26
// Updated: 2025-03-26
namespace Index\Snowflake;
use DateTimeInterface;
use InvalidArgumentException;
/**
* Implements a snowflake generator wrapper that just counts up.
*/
class IncrementalSnowflake {
/**
* Reference to the underlying generator.
*/
public private(set) SnowflakeGenerator $generator;
/**
* @param int<0, max> $counter Incremental counter value.
* @param ?SnowflakeGenerator $generator Snowflake generator instance.
* @throws InvalidArgumentException if $counter is less than 0.
*/
public function __construct(
public private(set) int $counter = 0,
?SnowflakeGenerator $generator = null,
) {
if($counter < 0)
throw new InvalidArgumentException('$counter must be a positive integer');
$this->generator = $generator ?? new SnowflakeGenerator;
}
/**
* Increments the counter, with overflow protection.
*
* @return int<0, max>
*/
public function increment(): int {
return $this->counter = ($this->counter >= PHP_INT_MAX ? 0 : ($this->counter + 1));
}
/**
* Generates the next snowflake.
*
* @param DateTimeInterface|string|int<0, max>|null $at Timestamp to base at.
* @return int
*/
public function next(DateTimeInterface|string|int|null $at = null): int {
return $this->generator->next($this->increment(), $at);
}
}

View file

@ -1,46 +0,0 @@
<?php
// RandomSnowflake.php
// Created: 2025-03-26
// Updated: 2025-03-26
namespace Index\Snowflake;
use DateTimeInterface;
use Random\{Engine,Randomizer};
/**
* Implements a snowflake generator wrapper that uses a randomizer engine.
*/
class RandomSnowflake {
/**
* Underlying randomness source.
*/
public private(set) Randomizer $randomizer;
/**
* Reference to the underlying generator.
*/
public private(set) SnowflakeGenerator $generator;
/**
* @param ?Engine $engine Randomness engine to use.
* @param ?SnowflakeGenerator $generator Snowflake generator instance.
*/
public function __construct(
?Engine $engine = null,
?SnowflakeGenerator $generator = null,
) {
$this->randomizer = new Randomizer($engine);
$this->generator = $generator ?? new SnowflakeGenerator;
}
/**
* Generates the next snowflake.
*
* @param DateTimeInterface|string|int<0, max>|null $at Timestamp to base at.
* @return int
*/
public function next(DateTimeInterface|string|int|null $at = null): int {
return $this->generator->next(abs($this->randomizer->nextInt()), $at);
}
}

View file

@ -1,83 +0,0 @@
<?php
// SnowflakeGenerator.php
// Created: 2025-03-26
// Updated: 2025-03-26
namespace Index\Snowflake;
use DateTimeInterface;
use InvalidArgumentException;
use Index\XDateTime;
/**
* Base snowflake generator.
*/
final class SnowflakeGenerator {
/**
* Masks the output for a signed 64-bit integer.
*/
public const int MASK = 0x7FFFFFFFFFFFFFFF;
/**
* Default epoch value, 1st of January 2013 at 0:00.
*/
public const int EPOCH = 1356998400000;
/**
* Amount by which to shift the timestamp left and clear space for sequence data.
*/
public const int SHIFT = 16;
/**
* Cached mask for timestamps.
*/
public private(set) int $tsMask;
/**
* Cached mask for sequence data.
*/
public private(set) int $sqMask;
/**
* @param int<0, max> $epoch Timestamp epoch.
* @param int<0, 63> $shift Amount to shift the timestamp left by.
* @throws InvalidArgumentException if $epoch or $shift is not within the given boundaries.
*/
public function __construct(
public private(set) int $epoch = self::EPOCH,
public private(set) int $shift = self::SHIFT,
) {
if($epoch < 0 || $epoch > self::MASK)
throw new InvalidArgumentException('$epoch is not a valid positive integer');
if($shift < 1 || $shift > 63)
throw new InvalidArgumentException('$shift must be between 0 and 63 inclusive');
$this->tsMask = ~(~0 << (63 - $shift));
$this->sqMask = ~(~0 << $shift);
}
/**
* Current timestamp with the epoch subtracted.
*
* @param DateTimeInterface|string|int<0, max>|null $at Timestamp to base at.
* @return int<0, max>
*/
public function nowMs(DateTimeInterface|string|int|null $at = null): int {
return max(0, XDateTime::toUnixMilliseconds($at ?? XDateTime::now(), true) - $this->epoch);
}
/**
* Generates a snowflake.
*
* @param int<0, max> $sequence Sequence data, will be masked to fit within $shift in the constructor.
* @param DateTimeInterface|string|int<0, max>|null $at Timestamp to base at.
* @throws InvalidArgumentException if $sequence is not a positive integer.
* @return int
*/
public function next(int $sequence = 0, DateTimeInterface|string|int|null $at = null): int {
if($sequence < 0)
throw new InvalidArgumentException('$sequence must be a positive integer');
return (($this->nowMs($at) & $this->tsMask) << $this->shift) | ($sequence & $this->sqMask);
}
}

View file

@ -1,65 +0,0 @@
<?php
// TplExtensionCommon.php
// Created: 2025-04-01
// Updated: 2025-04-01
namespace Index\Templating\Extension;
use ReflectionClass;
use Twig\Node\Expression\Binary\AbstractBinary;
use Twig\Node\Expression\Unary\AbstractUnary;
use Twig\NodeVisitor\NodeVisitorInterface;
use Twig\TokenParser\TokenParserInterface;
use Twig\{ExpressionParser,OperatorPrecedenceChange,TwigFilter,TwigFunction,TwigTest};
/**
* Provides common implementations of the Twig extension methods so you can yoink them without having to extend AbstractExtension.
*/
trait TplExtensionCommon {
/** @return TokenParserInterface[] */
public function getTokenParsers() {
return [];
}
/** @return NodeVisitorInterface[] */
public function getNodeVisitors() {
return [];
}
/** @return TwigFilter[] */
public function getFilters() {
return [];
}
/** @return TwigTest[] */
public function getTests() {
return [];
}
/** @return TwigFunction[] */
public function getFunctions() {
return [];
}
/**
* @return array{
* array<string, array{precedence: int, precedence_change?: OperatorPrecedenceChange, class: class-string<AbstractUnary>}>,
* array<string, array{precedence: int, precedence_change?: OperatorPrecedenceChange, class?: class-string<AbstractBinary>, associativity: ExpressionParser::OPERATOR_*}>
* }
*/
public function getOperators() {
return [[], []];
}
public function getLastModified(): int {
$path = (new ReflectionClass($this))->getFileName();
if($path === false || !is_file($path))
return 0;
$time = filemtime($path);
if($time === false)
return 0;
return $time;
}
}

View file

@ -1,28 +1,24 @@
<?php
// TplIndexExtension.php
// Created: 2024-08-04
// Updated: 2025-04-01
// Updated: 2024-10-04
namespace Index\Templating\Extension;
use Index\{ByteFormat,Index};
use Twig\{Environment,TwigFilter,TwigFunction};
use Twig\Extension\LastModifiedExtensionInterface;
use Twig\Extension\AbstractExtension;
/**
* Provides version functions and additional functionality implemented in Index.
*/
class TplIndexExtension implements LastModifiedExtensionInterface {
use TplExtensionCommon;
#[\Override]
class TplIndexExtension extends AbstractExtension {
public function getFilters() {
return [
new TwigFilter('format_filesize', ByteFormat::format(...)),
];
}
#[\Override]
public function getFunctions() {
return [
new TwigFunction('ndx_version', Index::version(...)),

View file

@ -1,7 +1,7 @@
<?php
// XArray.php
// Created: 2022-02-02
// Updated: 2025-04-02
// Updated: 2025-01-21
namespace Index;
@ -10,7 +10,6 @@ use InvalidArgumentException;
use Countable;
use Iterator;
use IteratorAggregate;
use Traversable;
/**
* Provides various helper methods for collections.
@ -481,7 +480,7 @@ final class XArray {
if($iterable instanceof Iterator)
return $iterable;
if($iterable instanceof IteratorAggregate)
return self::extractIterator($iterable->getIterator()); // @phpstan-ignore return.type
return $iterable->getIterator(); // @phpstan-ignore-line
if(is_array($iterable))
return new ArrayIterator($iterable);

View file

@ -1,7 +1,7 @@
<?php
// XDateTime.php
// Created: 2024-07-31
// Updated: 2025-03-26
// Updated: 2025-01-18
namespace Index;
@ -24,30 +24,6 @@ final class XDateTime {
return new DateTimeImmutable('now');
}
/**
* Returns the current date and time in seconds since the 0:00 on January 1st, 1970.
*
* @param DateTimeInterface|string|int $dt
* @return int
*/
public static function toUnixSeconds(DateTimeInterface|string|int|null $dt): int {
return (int)self::format($dt, 'U');
}
/**
* Returns the current date and time in milliseconds since the 0:00 on January 1st, 1970.
*
* @param DateTimeInterface|string|int $dt
* @param bool $intDtAsMilliseconds Whether to treat $dt as milliseconds instead of seconds if it is an integer.
* @return int
*/
public static function toUnixMilliseconds(DateTimeInterface|string|int|null $dt, bool $intDtAsMilliseconds = false): int {
if($intDtAsMilliseconds && is_int($dt))
$dt = new DateTimeImmutable(sprintf('@%.3F', (float)$dt / 1000));
return (int)self::format($dt, 'Uv');
}
private static function toCompareFormat(DateTimeInterface|string|int $dt): string {
if($dt instanceof DateTimeInterface)
return $dt->format(self::COMPARE_FORMAT);

View file

@ -1,7 +1,7 @@
<?php
// BencodeSerialisableTest.php
// Created: 2024-09-29
// Updated: 2025-04-02
// Updated: 2025-01-18
declare(strict_types=1);
@ -53,9 +53,9 @@ final class BencodeSerialisableTest extends TestCase {
#[BencodeProperty(omitIfNull: false)]
public mixed $nullValPresent = null;
/** @var array<?scalar> */
#[BencodeProperty]
public array $scalarVals = [null, 0, 1234, 12.34, 'str', true, false];
/** @var scalar[] */
public array $scalarVals = [null, 0, 1234, 12.34, 'str', true, false]; // @phpstan-ignore-line: idgi??
#[BencodeProperty('stringVal_method')]
public function getStringVal(): string {
@ -112,8 +112,8 @@ final class BencodeSerialisableTest extends TestCase {
return null;
}
/** @return array<?scalar> */
#[BencodeProperty]
#[BencodeProperty] // @phpstan-ignore-line: idgi??
/** @return scalar[] */
public function getScalarVals(): array {
return [null, 0, 1234, 12.34, 'str', true, false];
}
@ -156,11 +156,11 @@ final class BencodeSerialisableTest extends TestCase {
use BencodeSerializableCommon;
#[BencodeProperty('test1')]
#[BencodeProperty('test2')] // @phpstan-ignore attribute.nonRepeatable
#[BencodeProperty('test2')] // @phpstan-ignore-line: this is meant to test the dupe exception
public string $stringVal = 'string value';
#[BencodeProperty('test3')]
#[BencodeProperty('test4')] // @phpstan-ignore attribute.nonRepeatable
#[BencodeProperty('test4')] // @phpstan-ignore-line: this is meant to test the dupe exception
public function getIntVal(): int {
return 1234;
}

View file

@ -1,7 +1,7 @@
<?php
// BencodeTest.php
// Created: 2023-07-21
// Updated: 2025-04-02
// Updated: 2024-12-02
declare(strict_types=1);
@ -35,15 +35,15 @@ final class BencodeTest extends TestCase {
$this->assertIsObject($decoded);
$this->assertObjectHasProperty('announce', $decoded);
$this->assertEquals('https://tracker.flashii.net/announce.php/meow', $decoded->announce); // @phpstan-ignore property.notFound
$this->assertEquals('https://tracker.flashii.net/announce.php/meow', $decoded->announce); // @phpstan-ignore-line: checked above
$this->assertObjectHasProperty('creation date', $decoded);
$this->assertEquals(1689973664, $decoded->{'creation date'}); // @phpstan-ignore property.notFound
$this->assertEquals(1689973664, $decoded->{'creation date'}); // @phpstan-ignore-line: checked above
$this->assertObjectHasProperty('comment', $decoded);
$this->assertEquals('this is the comments field', $decoded->comment); // @phpstan-ignore property.notFound
$this->assertEquals('this is the comments field', $decoded->comment); // @phpstan-ignore-line: checked above
$this->assertObjectHasProperty('info', $decoded);
$this->assertIsObject($decoded->info); // @phpstan-ignore property.notFound
$this->assertIsObject($decoded->info); // @phpstan-ignore-line: checked above
$this->assertObjectHasProperty('private', $decoded->info);
$this->assertEquals(1, $decoded->info->private); // @phpstan-ignore property.notFound
$this->assertEquals(1, $decoded->info->private); // @phpstan-ignore-line: checked above
}
public function testEncode(): void {

View file

@ -1,7 +1,7 @@
<?php
// DbConfigTest.php
// Created: 2023-10-20
// Updated: 2025-04-02
// Updated: 2025-01-18
declare(strict_types=1);
@ -17,8 +17,8 @@ use Index\Db\{DbBackends,DbConnection};
#[CoversClass(GetValueInfoCommon::class)]
#[CoversClass(GetValuesCommon::class)]
final class DbConfigTest extends TestCase {
private DbConnection $dbConn; // @phpstan-ignore property.uninitialized
private DbConfig $config; // @phpstan-ignore property.uninitialized
private DbConnection $dbConn; // @phpstan-ignore-line: defined by PHPunit in setUp()
private DbConfig $config; // @phpstan-ignore-line: defined by PHPunit in setUp()
private const VALUES = [
'private.allow_password_reset' => 'b:1;',

View file

@ -1,7 +1,7 @@
<?php
// DependenciesTest.php
// Created: 2025-01-21
// Updated: 2025-03-21
// Updated: 2025-03-07
declare(strict_types=1);
@ -60,7 +60,8 @@ namespace {
}
public function testRequiredDependency(): void {
$this->expectException(TypeError::class);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage(sprintf('was not able to resolve %s', DependenciesTest\DependencyTestInterface::class));
$deps = new Dependencies;
$deps->register(DependenciesTest\RequiredParamTestClass::class);

View file

@ -1,7 +1,7 @@
<?php
// JsonSerializableTest.php
// Created: 2024-09-29
// Updated: 2025-04-02
// Updated: 2025-01-18
declare(strict_types=1);
@ -51,9 +51,9 @@ final class JsonSerializableTest extends TestCase {
#[JsonProperty(omitIfNull: false)]
public mixed $nullValPresent = null;
/** @var array<?scalar> */
#[JsonProperty]
public array $scalarVals = [null, 0, 1234, 12.34, 'str', true, false];
/** @var scalar[] */
public array $scalarVals = [null, 0, 1234, 12.34, 'str', true, false]; // @phpstan-ignore-line: idgi??
#[JsonProperty('stringVal_method')]
public function getStringVal(): string {
@ -110,8 +110,8 @@ final class JsonSerializableTest extends TestCase {
return null;
}
/** @return array<?scalar> */
#[JsonProperty]
#[JsonProperty] // @phpstan-ignore-line: idgi??
/** @return scalar[] */
public function getScalarVals(): array {
return [null, 0, 1234, 12.34, 'str', true, false];
}
@ -154,11 +154,11 @@ final class JsonSerializableTest extends TestCase {
use JsonSerializableCommon;
#[JsonProperty('test1')]
#[JsonProperty('test2')] // @phpstan-ignore attribute.nonRepeatable
#[JsonProperty('test2')] // @phpstan-ignore-line: this is meant to test the dupe exception
public string $stringVal = 'string value';
#[JsonProperty('test3')]
#[JsonProperty('test4')] // @phpstan-ignore attribute.nonRepeatable
#[JsonProperty('test4')] // @phpstan-ignore-line: this is meant to test the dupe exception
public function getIntVal(): int {
return 1234;
}

View file

@ -1,7 +1,7 @@
<?php
// RouterTest.php
// Created: 2022-01-20
// Updated: 2025-03-23
// Updated: 2025-03-19
declare(strict_types=1);
@ -112,39 +112,6 @@ final class RouterTest extends TestCase {
return sprintf('still the profile of %s', $beans);
}
#[ExactRoute('GET', '/announce')]
#[ExactRoute('GET', '/announce.php')]
#[PatternRoute('GET', '/announce/([A-Za-z0-9]+)')]
#[PatternRoute('GET', '/announce.php/([A-Za-z0-9]+)')]
public function getMultipleTestWithDefault(string $key = 'empty'): string {
return $key;
}
#[PatternRoute('GET', '/uploads/([A-Za-z0-9]+|[A-Za-z0-9\-_]{32})(?:-([a-z0-9]+))?(?:\.([A-Za-z0-9\-_]+))?')]
public function getEepromEdgeCase(
string $uploadId,
string $variant = '',
string $extension = ''
): string {
return sprintf('%s//%s//%s', $uploadId, $variant, $extension);
}
#[ExactRoute('GET', '/messages/stats')]
public function getStats(): string {
return 'hit exact';
}
#[PatternRoute('GET', '/messages/([A-Za-z0-9]+)')]
public function getView(): string {
return 'hit pattern';
}
#[ExactRoute('GET', '/assets/avatar')]
#[PatternRoute('GET', '/assets/avatar/([0-9]+)(?:\.[a-z]+)?')]
public function getTrailingSlash(): string {
return 'hit';
}
public function hasNoAttr(): string {
return 'not a route';
}
@ -159,18 +126,6 @@ final class RouterTest extends TestCase {
$this->assertSame('meow', (string)$router->handle(HttpRequest::createRequestWithoutBody('POST', '/meow'))->getBody());
$this->assertSame('profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile/Cool134'))->getBody());
$this->assertSame('still the profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile-but-raw/Cool134'))->getBody());
$this->assertSame('empty', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/announce'))->getBody());
$this->assertSame('empty', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/announce.php'))->getBody());
$this->assertSame('Z643QANLgGNkF4D4h4qvFpyeXjx4TcDE', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/announce/Z643QANLgGNkF4D4h4qvFpyeXjx4TcDE'))->getBody());
$this->assertSame('1aKq8VaGyHohNUUR7RzU1W57Z3hQ6m0YMazAkr2IoiSPsvQJ6QoQutywwiOBlNka', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/announce.php/1aKq8VaGyHohNUUR7RzU1W57Z3hQ6m0YMazAkr2IoiSPsvQJ6QoQutywwiOBlNka'))->getBody());
$this->assertSame('1RJNSRYmxrvXUr////', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/uploads/1RJNSRYmxrvXUr'))->getBody());
$this->assertSame('1RJNSRYmxrvXUr//thumb//', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/uploads/1RJNSRYmxrvXUr-thumb'))->getBody());
$this->assertSame('1RJNSRYmxrvXUr//thumb//jpg', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/uploads/1RJNSRYmxrvXUr-thumb.jpg'))->getBody());
$this->assertSame('1RJNSRYmxrvXUr////jpg', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/uploads/1RJNSRYmxrvXUr.jpg'))->getBody());
$this->assertSame('hit exact', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/messages/stats'))->getBody());
$this->assertSame('hit pattern', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/messages/soaps'))->getBody());
$this->assertSame('hit', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/assets/avatar/'))->getBody());
$this->assertSame('hit', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/assets/avatar/123/'))->getBody());
}
public function testEEPROMSituation(): void {
@ -299,19 +254,6 @@ final class RouterTest extends TestCase {
public function testOutputBencode(): array {
return ['benben' => 12345];
}
#[After('output:bencode')]
#[ExactRoute('GET', '/output/bencode/object')]
public function testOutputBencodeObject(): object {
return new class implements \Index\Bencode\BencodeSerializable {
use \Index\Bencode\BencodeSerializableCommon;
public function __construct(
#[\Index\Bencode\BencodeProperty('failure reason')]
public private(set) string $reason = 'Invalid info hash.'
) {}
};
}
});
$response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/stream'));
@ -346,10 +288,6 @@ final class RouterTest extends TestCase {
$this->assertSame('application/x-bittorrent', $response->getHeaderLine('Content-Type'));
$this->assertSame('d6:benbeni12345ee', (string)$response->getBody());
$response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/bencode/object'));
$this->assertSame('application/x-bittorrent', $response->getHeaderLine('Content-Type'));
$this->assertSame('d14:failure reason18:Invalid info hash.e', (string)$response->getBody());
$response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/optional-form'));
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('none', (string)$response->getBody());

View file

@ -1,73 +0,0 @@
<?php
// SnowflakeTest.php
// Created: 2025-03-26
// Updated: 2025-03-26
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\{CoversClass,UsesClass};
use Index\XDateTime;
use Index\Snowflake\{BinarySnowflake,IncrementalSnowflake,RandomSnowflake,SnowflakeGenerator};
use Random\Engine\Mt19937;
#[CoversClass(IncrementalSnowflake::class)]
#[CoversClass(SnowflakeGenerator::class)]
#[UsesClass(XDateTime::class)]
final class SnowflakeTest extends TestCase {
public function testSnowflakeGenerator(): void {
// default epoch and shift
$generator = new SnowflakeGenerator;
$this->assertSame(SnowflakeGenerator::EPOCH, $generator->epoch);
$this->assertSame(SnowflakeGenerator::SHIFT, $generator->shift);
$this->assertSame(0x7FFFFFFFFFFF, $generator->tsMask);
$this->assertSame(0xFFFF, $generator->sqMask);
$snowflake = $generator->next(0x123456, 1742946997548);
$this->assertSame(0x59DC543D2C3456, $snowflake);
// copy date exactly
$generator = new SnowflakeGenerator(epoch: 0);
$this->assertSame(0, $generator->epoch);
$this->assertSame(SnowflakeGenerator::SHIFT, $generator->shift);
$this->assertSame(0x7FFFFFFFFFFF, $generator->tsMask);
$this->assertSame(0xFFFF, $generator->sqMask);
$snowflake = $generator->next(0x654321, 1742946997548);
$this->assertSame(0x195CFBC952C4321, $snowflake);
// quirky shift
$generator = new SnowflakeGenerator(shift: 14);
$this->assertSame(SnowflakeGenerator::EPOCH, $generator->epoch);
$this->assertSame(14, $generator->shift);
$this->assertSame(0x1FFFFFFFFFFFF, $generator->tsMask);
$this->assertSame(0x3FFF, $generator->sqMask);
$snowflake = $generator->next(0xFDE9, 1742947403098);
$this->assertSame(0x1677169B56BDE9, $snowflake);
// incremental
$incremental = new IncrementalSnowflake(0x10001, $generator);
$this->assertSame(0x10002, $incremental->increment());
$this->assertSame(0x16771F5C1C4003, $incremental->next(1742949697649));
// big endian binary
$big = new BinarySnowflake(generator: new SnowflakeGenerator(shift: 20));
$this->assertSame(0x59DC920D23000DE, $big->next("\xDE", 1742951048483));
$this->assertSame(0x59DC920D230DEAD, $big->next("\xDE\xAD", 1742951048483));
$this->assertSame(0x59DC920D23EADBE, $big->next("\xDE\xAD\xBE", 1742951048483));
$this->assertSame(0x59DC920D23FBEAD, $big->next("\xDE\xAF\xBE\xAD", 1742951048483));
// little endian binary
$little = new BinarySnowflake(littleEndian: true, generator: new SnowflakeGenerator(shift: 20));
$this->assertSame(0x59DC920D23000DE, $little->next("\xDE", 1742951048483));
$this->assertSame(0x59DC920D230ADDE, $little->next("\xDE\xAD", 1742951048483));
$this->assertSame(0x59DC920D23DDE00, $little->next("\xDE\xAD\xBE", 1742951048483));
$this->assertSame(0x59DC920D23EAFDE, $little->next("\xDE\xAF\xBE\xAD", 1742951048483));
// random
$random = new RandomSnowflake(new Mt19937(2525));
$this->assertSame(0x59DCAD88D22D3E, $random->next(1742952849618));
$this->assertSame(0x59DCAD88D2CFDC, $random->next(1742952849618));
$this->assertSame(0x59DCAD88D27EA2, $random->next(1742952849618));
$this->assertSame(0x59DCAD88D26E0A, $random->next(1742952849618));
$this->assertSame(0x59DCAD88D24C90, $random->next(1742952849618));
}
}