Inital commit after splitting from project

This commit is contained in:
René Halberstadt
2026-07-21 22:00:16 +02:00
commit f299f2c60e
49 changed files with 3102 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: PHPStan
run: composer phpstan
- name: Code style check
run: composer cs-check
- name: Tests
run: composer test
+178
View File
@@ -0,0 +1,178 @@
# ---> Symfony
# Cache and logs (Symfony2)
/app/cache/*
/app/logs/*
!app/cache/.gitkeep
!app/logs/.gitkeep
# Email spool folder
/app/spool/*
# Cache, session files and logs (Symfony3)
/var/cache/*
/var/logs/*
/var/sessions/*
!var/cache/.gitkeep
!var/logs/.gitkeep
!var/sessions/.gitkeep
# Logs (Symfony4)
/var/log/*
!var/log/.gitkeep
# Parameters
/app/config/parameters.yml
/app/config/parameters.ini
# Managed by Composer
/app/bootstrap.php.cache
/var/bootstrap.php.cache
/bin/*
!bin/console
!bin/symfony_requirements
/vendor/
# Assets and user uploads
/web/bundles/
/web/uploads/
# PHPUnit
/app/phpunit.xml
/phpunit.xml
# Build data
/build/
# Composer PHAR
/composer.phar
# Backup entities generated with doctrine:generate:entities command
**/Entity/*~
# Embedded web-server pid file
/.web-server-pid
# ---> JetBrains
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
# ---> macOS
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# ---> php-cs-fixer
.php-cs-fixer.cache
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
$finder = Finder::create()
->in([
__DIR__ . '/src',
__DIR__ . '/tests',
])
->name('*.php')
->notName('*.blade.php')
->ignoreDotFiles(true)
->ignoreVCS(true);
return (new Config())
->setRiskyAllowed(true)
->setRules([
'@PER-CS2.0' => true,
'@PHP83Migration' => true,
// Imports
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'no_unused_imports' => true,
'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false],
// Strings
'single_quote' => true,
'explicit_string_variable' => true,
// Arrays
'array_syntax' => ['syntax' => 'short'],
'trim_array_spaces' => true,
'no_whitespace_before_comma_in_array' => true,
'whitespace_after_comma_in_array' => ['ensure_single_space' => true],
// Types / strictness
'declare_strict_types' => true,
'strict_param' => true,
'strict_comparison' => true,
// PHPDoc
'phpdoc_align' => ['align' => 'left'],
'phpdoc_order' => true,
'phpdoc_trim' => true,
'phpdoc_scalar' => true,
'no_superfluous_phpdoc_tags' => ['remove_inheritdoc' => true],
// Control structures
'yoda_style' => false,
'no_useless_else' => true,
'no_useless_return' => true,
// Misc
'concat_space' => ['spacing' => 'one'],
'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']],
'blank_line_before_statement' => ['statements' => ['return', 'throw', 'try', 'if', 'foreach', 'for', 'while']],
])
->setFinder($finder);
+7
View File
@@ -0,0 +1,7 @@
FROM php:8.3-cli-alpine
RUN apk add --no-cache git unzip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
+38
View File
@@ -0,0 +1,38 @@
PHP = docker compose run --rm php
.PHONY: install test phpstan cs-fix cs-check audit shell build
## Build the Docker image
build:
docker compose build
## Install Composer dependencies
install:
$(PHP) composer install
## Run unit tests
test:
$(PHP) vendor/bin/phpunit --testsuite Unit --colors=always
## Run PHPStan static analysis
phpstan:
$(PHP) vendor/bin/phpstan analyse --no-progress --ansi --memory-limit=512M
## Auto-fix code style
cs-fix:
$(PHP) vendor/bin/php-cs-fixer fix --ansi
## Check code style (dry-run)
cs-check:
$(PHP) vendor/bin/php-cs-fixer fix --dry-run --diff --ansi
## Check for known security vulnerabilities in dependencies
audit:
$(PHP) composer audit
## Run all checks (same as CI)
ci: audit phpstan cs-check test
## Open a shell in the PHP container
shell:
docker compose run --rm php sh
+44
View File
@@ -0,0 +1,44 @@
{
"name": "tez/utils-arr",
"type": "library",
"description": "Array utility helpers",
"license": "MIT",
"autoload": {
"psr-4": {
"Tez\\Utils\\Arr\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tez\\Utils\\Tests\\Arr\\": "tests/"
}
},
"repositories": [
{
"type": "git",
"url": "https://gitea.root-zone.info/Development/tez-utils-enum.git",
"priority": 100
},
{
"type": "git",
"url": "https://github.com/tezmanian/tez-utils-enum.git",
"priority": 50
}
],
"require": {
"php": ">=8.3",
"tez/utils-enum": "^1.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"jetbrains/phpstorm-attributes": "^1.0",
"phpstan/phpstan": "^2.0",
"friendsofphp/php-cs-fixer": "^3.0"
},
"scripts": {
"cs-fix": "php-cs-fixer fix",
"cs-check": "php-cs-fixer fix --dry-run --diff",
"phpstan": "phpstan analyse",
"test": "phpunit --testsuite Unit --colors=always"
}
}
+15
View File
@@ -0,0 +1,15 @@
services:
php:
build:
context: .
dockerfile: Dockerfile
volumes:
- .:/app
- ../tez-utils-enum:/packages/tez-utils-enum
- composer-cache:/tmp/composer
user: "${UID:-1000}:${GID:-1000}"
environment:
COMPOSER_HOME: /tmp/composer
volumes:
composer-cache:
+8
View File
@@ -0,0 +1,8 @@
parameters:
level: 9
paths:
- src
- tests
treatPhpDocTypesAsCertain: false
parallel:
maximumNumberOfProcesses: 1
+16
View File
@@ -0,0 +1,16 @@
<?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"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class ArrayDiff
{
/**
* Recursively compares two arrays and returns a DiffResult with all differences.
* Uses strict equality (1 !== "1"). Paths are reported in dot-notation.
*
* @param array<mixed> $old
* @param array<mixed> $new
*/
public static function compare(array $old, array $new): DiffResult
{
$changes = [];
self::diff($old, $new, '', $changes);
return new DiffResult($changes);
}
/**
* @param array<mixed> $old
* @param array<mixed> $new
* @param list<Change> $changes
*/
private static function diff(array $old, array $new, string $prefix, array &$changes): void
{
$allKeys = array_unique(array_merge(array_keys($old), array_keys($new)));
foreach ($allKeys as $key) {
$path = $prefix !== '' ? $prefix . '.' . $key : (string) $key;
$inOld = array_key_exists($key, $old);
$inNew = array_key_exists($key, $new);
if ($inOld && !$inNew) {
$changes[] = new Change($path, ChangeType::Removed, $old[$key], null);
continue;
}
if (!$inOld && $inNew) {
$changes[] = new Change($path, ChangeType::Added, null, $new[$key]);
continue;
}
$oldVal = $old[$key];
$newVal = $new[$key];
if (is_array($oldVal) && is_array($newVal)) {
self::diff($oldVal, $newVal, $path, $changes);
continue;
}
if ($oldVal !== $newVal) {
$changes[] = new Change($path, ChangeType::Changed, $oldVal, $newVal);
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
/**
* Computes the Cartesian product of any number of sets lazily via a Generator.
*
* Each yielded value is a tuple (list) containing one element from every input set.
* The number of tuples equals the product of all set sizes.
* Associative keys in input sets are stripped; only values are used.
*/
class CartesianProduct
{
/**
* Yields every combination of one element from each provided set.
* The result is computed lazily — no intermediate array is built.
* Passing no sets yields a single empty tuple.
*
* @param iterable<mixed> ...$sets
*/
public static function create(iterable ...$sets): \Generator
{
if (empty($sets)) {
yield [];
return;
}
$sets = array_map(fn($set) => self::toIndexedArray($set), $sets);
$counts = array_map('count', $sets);
$total = array_product($counts);
for ($i = 0; $i < $total; $i++) {
$tuple = [];
$divisor = 1;
// Mixed-radix index decomposition: walk sets right-to-left so that the
// rightmost set cycles fastest, matching standard Cartesian-product order.
for ($j = count($sets) - 1; $j >= 0; $j--) {
$index = (int) ($i / $divisor) % $counts[$j];
$tuple[] = $sets[$j][$index];
$divisor *= $counts[$j];
}
yield array_reverse($tuple);
}
}
/**
* @param iterable<mixed> $set
* @return list<mixed>
*/
private static function toIndexedArray(iterable $set): array
{
return match (true) {
is_array($set) => array_values($set),
default => iterator_to_array($set, false),
};
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
/**
* Represents a single difference between two arrays at a specific dot-notation path.
*/
final readonly class Change
{
public function __construct(
/** Dot-notation path to the changed key, e.g. "user.address.city". */
public readonly string $path,
/** Whether the key was added, removed, or its value changed. */
public readonly ChangeType $type,
/** The value in the old array, or null when the key was added. */
public readonly mixed $old,
/** The value in the new array, or null when the key was removed. */
public readonly mixed $new,
) {}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
use Tez\Utils\Enum\BackedEnumTrait;
/**
* Classifies the type of a single difference detected by ArrayDiff.
*/
enum ChangeType: string
{
use BackedEnumTrait;
case Added = 'added';
case Removed = 'removed';
case Changed = 'changed';
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class DeepMerge
{
/**
* Recursively merges one or more override arrays into a base array.
* Overrides are applied left to right.
*
* Merge rules:
* - String key exists only in base → kept as-is
* - String key exists only in override → added
* - Both values are arrays → merged recursively
* - Override value is scalar or null → overwrites base value
* - Integer keys → appended (like array_merge)
*
* @param array<mixed> $base
* @param array<mixed> ...$overrides
* @return array<mixed>
*/
public static function merge(array $base, array ...$overrides): array
{
foreach ($overrides as $override) {
$base = self::mergeTwo($base, $override);
}
return $base;
}
/**
* @param array<mixed> $base
* @param array<mixed> $override
* @return array<mixed>
*/
private static function mergeTwo(array $base, array $override): array
{
foreach ($override as $key => $value) {
if (is_int($key)) {
// Integer keys are appended, matching array_merge behaviour.
$base[] = $value;
} elseif (is_array($value) && isset($base[$key]) && is_array($base[$key])) {
// Both sides are arrays — recurse.
$base[$key] = self::mergeTwo($base[$key], $value);
} else {
// Scalar, null, or type mismatch — right side wins.
$base[$key] = $value;
}
}
return $base;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
/**
* Immutable result of an ArrayDiff comparison.
*/
final readonly class DiffResult
{
/**
* @param list<Change> $changes
*/
public function __construct(
private array $changes,
) {}
public function hasChanges(): bool
{
return $this->changes !== [];
}
/** @return list<Change> */
public function changes(): array
{
return $this->changes;
}
/** @return list<string> */
public function paths(): array
{
return array_values(array_map(
static fn(Change $c): string => $c->path,
$this->changes,
));
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class Find
{
/**
* Returns the first item for which $callback returns true, or null if none matches.
*
* @template TKey of array-key
* @template TValue
* @param array<TKey, TValue> $items
* @param callable(TValue): bool $callback
* @return TValue|null
*/
public static function first(array $items, callable $callback): mixed
{
foreach ($items as $item) {
if ($callback($item)) {
return $item;
}
}
return null;
}
/**
* Returns the last item for which $callback returns true, or null if none matches.
*
* @template TKey of array-key
* @template TValue
* @param array<TKey, TValue> $items
* @param callable(TValue): bool $callback
* @return TValue|null
*/
public static function last(array $items, callable $callback): mixed
{
$match = null;
foreach ($items as $item) {
if ($callback($item)) {
$match = $item;
}
}
return $match;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class Flatten
{
/**
* Recursively flattens a nested array into a single flat list.
* Associative keys in nested arrays are discarded.
*
* @param array<mixed> $array
* @return list<mixed>
*/
public static function flatten(array $array, int $depth = PHP_INT_MAX): array
{
$result = [];
self::flattenInto($array, $depth, $result);
return $result;
}
/**
* @param array<mixed> $array
* @param list<mixed> $result
*/
private static function flattenInto(array $array, int $depth, array &$result): void
{
foreach ($array as $item) {
if (is_array($item) && $depth > 0) {
self::flattenInto($item, $depth - 1, $result);
} else {
$result[] = $item;
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class GroupBy
{
/**
* Groups the items of an array by the key returned from a callback.
*
* @template TKey of int|string
* @template TValue
* @param array<TValue> $items
* @param callable(TValue): TKey $keyFn
* @return array<TKey, list<TValue>>
*/
public static function group(array $items, callable $keyFn): array
{
$result = [];
foreach ($items as $item) {
$key = $keyFn($item);
$result[$key][] = $item;
}
return $result;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class KeyBy
{
/**
* Builds an associative lookup map by indexing each item under the key
* returned from the callback. If two items produce the same key, the last wins.
*
* @template TKey of int|string
* @template TValue
* @param array<TValue> $items
* @param callable(TValue): TKey $keyFn
* @return array<TKey, TValue>
*/
public static function index(array $items, callable $keyFn): array
{
$result = [];
foreach ($items as $item) {
$result[$keyFn($item)] = $item;
}
return $result;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class MapWithKeys
{
/**
* Maps over an array where the callback returns a new [key => value] pair.
* If two items produce the same key, the last one wins.
*
* @param array<mixed> $items
* @param callable(mixed, int|string): array<int|string, mixed> $fn
* @return array<int|string, mixed>
*/
public static function map(array $items, callable $fn): array
{
$result = [];
foreach ($items as $key => $value) {
foreach ($fn($value, $key) as $newKey => $newValue) {
$result[$newKey] = $newValue;
}
}
return $result;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
/**
* Immutable result of a paginated array slice.
*
* @template T
*/
final readonly class Page
{
/**
* @param list<T> $items
*/
public function __construct(
private array $items,
private int $total,
private int $currentPage,
private int $perPage,
) {}
/** @return list<T> The items on the current page. */
public function items(): array
{
return $this->items;
}
/** Returns the total number of items across all pages. */
public function total(): int
{
return $this->total;
}
/** Returns the current page number (1-based). */
public function currentPage(): int
{
return $this->currentPage;
}
/** Returns the number of items per page. */
public function perPage(): int
{
return $this->perPage;
}
/** Returns the last page number. Always at least 1, even for empty sets. */
public function lastPage(): int
{
return max(1, (int) ceil($this->total / $this->perPage));
}
/** Returns true if a next page exists. */
public function hasNext(): bool
{
return $this->currentPage < $this->lastPage();
}
/** Returns true if a previous page exists (i.e. current page > 1). */
public function hasPrev(): bool
{
return $this->currentPage > 1;
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class Paginator
{
/**
* Paginates an array and returns an immutable Page with slice and metadata.
*
* @template T
* @param list<T> $items
*
* @throws \InvalidArgumentException if $page < 1 or $perPage < 1
* @return Page<T>
*/
public static function paginate(array $items, int $page, int $perPage): Page
{
if ($page < 1) {
throw new \InvalidArgumentException('Page must be >= 1, got ' . $page . '.');
}
if ($perPage < 1) {
throw new \InvalidArgumentException('Per-page must be >= 1, got ' . $perPage . '.');
}
$total = count($items);
$offset = ($page - 1) * $perPage;
$slice = array_values(array_slice($items, $offset, $perPage));
return new Page($slice, $total, $page, $perPage);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class Partition
{
/**
* Splits an array into two lists in a single pass.
* Returns [passing, failing] — items satisfying the predicate come first.
*
* @template TValue
* @param array<TValue> $items
* @param callable(TValue): bool $predicate
* @return array{list<TValue>, list<TValue>}
*/
public static function by(array $items, callable $predicate): array
{
$pass = [];
$fail = [];
foreach ($items as $item) {
if ($predicate($item)) {
$pass[] = $item;
} else {
$fail[] = $item;
}
}
return [$pass, $fail];
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class Pluck
{
/**
* Extracts the values of a single dot-notation key from an array of records.
* Items where the key is absent are skipped.
*
* @param array<array<mixed>> $items
* @return list<mixed>
*/
public static function values(array $items, string $key): array
{
$result = [];
foreach ($items as $item) {
[$found, $value] = self::resolve($item, $key);
if ($found) {
$result[] = $value;
}
}
return $result;
}
/**
* Returns an associative array indexed by $indexKey, with $valueKey as the value.
* Items where either key is absent are skipped.
*
* @param array<array<mixed>> $items
* @return array<int|string, mixed>
*/
public static function keyedBy(array $items, string $valueKey, string $indexKey): array
{
$result = [];
foreach ($items as $item) {
[$foundIdx, $idx] = self::resolve($item, $indexKey);
[$foundVal, $value] = self::resolve($item, $valueKey);
if ($foundIdx && $foundVal && (is_int($idx) || is_string($idx))) {
$result[$idx] = $value;
}
}
return $result;
}
/**
* Resolves a dot-notation key against an array.
* Returns [true, $value] on success, [false, null] when any segment is missing.
*
* @param array<mixed> $data
* @return array{bool, mixed}
*/
private static function resolve(array $data, string $key): array
{
$parts = explode('.', $key);
$current = $data;
foreach ($parts as $part) {
if (!is_array($current) || !array_key_exists($part, $current)) {
return [false, null];
}
$current = $current[$part];
}
return [true, $current];
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
/**
* Safely read deeply nested values from an array using dot-notation paths.
*
* Each segment of the path is resolved in turn. Integer-looking segments
* (e.g. "0") address list indices. When any segment is missing or has the
* wrong type, the method returns $default.
*
* Example path: 'chart.result.0.meta.currency'
*/
final class SafeGet
{
/**
* Read a nested value as a string.
*
* @param array<mixed> $data
*/
public static function string(array $data, string $path, ?string $default = null): ?string
{
$value = self::resolve($data, $path);
if (!is_string($value)) {
return $default;
}
return $value;
}
/**
* Read a nested value as an integer.
*
* @param array<mixed> $data
*/
public static function int(array $data, string $path, ?int $default = null): ?int
{
$value = self::resolve($data, $path);
if (!is_int($value)) {
return $default;
}
return $value;
}
/**
* Read a nested value as a float.
* Integer values are widened to float automatically.
*
* @param array<mixed> $data
*/
public static function float(array $data, string $path, ?float $default = null): ?float
{
$value = self::resolve($data, $path);
if (is_float($value)) {
return $value;
}
if (is_int($value)) {
return (float) $value;
}
return $default;
}
/**
* Read a nested value as a bool.
*
* @param array<mixed> $data
*/
public static function bool(array $data, string $path, ?bool $default = null): ?bool
{
$value = self::resolve($data, $path);
if (!is_bool($value)) {
return $default;
}
return $value;
}
/**
* Read a nested value as an array.
*
* @param array<mixed> $data
* @param array<mixed>|null $default
* @return array<mixed>|null
*/
public static function array(array $data, string $path, ?array $default = null): ?array
{
$value = self::resolve($data, $path);
if (!is_array($value)) {
return $default;
}
return $value;
}
/**
* Walk the dot-notation path and return the raw value, or null when missing.
*
* @param array<mixed> $data
*/
private static function resolve(array $data, string $path): mixed
{
$segments = explode('.', $path);
$current = $data;
foreach ($segments as $segment) {
if (!is_array($current)) {
return null;
}
$key = is_numeric($segment) && ctype_digit($segment)
? (int) $segment
: $segment;
if (!array_key_exists($key, $current)) {
return null;
}
$current = $current[$key];
}
return $current;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class Sliding
{
/**
* Produces consecutive overlapping sub-arrays (windows) of a fixed size.
* The $step parameter controls how many positions to advance between windows.
*
* @param array<mixed> $items
* @throws \InvalidArgumentException when $size or $step is less than 1
* @return list<list<mixed>>
*/
public static function window(array $items, int $size, int $step = 1): array
{
if ($size < 1) {
throw new \InvalidArgumentException('Window size must be >= 1.');
}
if ($step < 1) {
throw new \InvalidArgumentException('Step must be >= 1.');
}
$items = array_values($items);
$count = count($items);
$result = [];
for ($i = 0; $i + $size <= $count; $i += $step) {
$result[] = array_slice($items, $i, $size);
}
return $result;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class SortBy
{
/**
* Sorts a copy of the array by the value extracted via callback.
* Does not mutate the original array. Result is always re-indexed.
*
* @template TValue
* @param array<TValue> $items
* @param callable(TValue): (int|float|string) $keyFn
* @return list<TValue>
*/
public static function sort(array $items, callable $keyFn, bool $descending = false): array
{
$copy = array_values($items);
usort($copy, static function ($a, $b) use ($keyFn, $descending): int {
$ka = $keyFn($a);
$kb = $keyFn($b);
$cmp = is_string($ka) ? strcmp((string) $ka, (string) $kb) : ($ka <=> $kb);
return $descending ? -$cmp : $cmp;
});
return $copy;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class Transpose
{
/**
* Swaps rows and columns of a 2D array.
* All rows must have the same number of columns.
* Associative keys are stripped; result is always a list of lists.
*
* @param array<array<mixed>> $rows
* @throws \InvalidArgumentException when rows have unequal column counts
* @return list<list<mixed>>
*/
public static function matrix(array $rows): array
{
if ($rows === []) {
return [];
}
$rows = array_values(array_map('array_values', $rows));
$colCount = count($rows[0]);
foreach ($rows as $row) {
if (count($row) !== $colCount) {
throw new \InvalidArgumentException(
'All rows must have the same number of columns.',
);
}
}
$result = [];
for ($col = 0; $col < $colCount; $col++) {
$column = [];
foreach ($rows as $row) {
$column[] = $row[$col];
}
$result[] = $column;
}
return $result;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class UniqueBy
{
/**
* Returns an array with duplicates removed, where uniqueness is determined by the callback.
*
* By default the first occurrence of each key wins and keys are re-indexed.
* Set $lastWins = true to keep the last occurrence instead.
* Set $preserveKeys = true to retain the original array keys.
*
* @template TKey of array-key
* @template TValue
* @param array<TKey, TValue> $items
* @param callable(TValue): (string|int) $callback
* @return ($preserveKeys is true ? array<TKey, TValue> : list<TValue>)
*/
public static function filter(
array $items,
callable $callback,
bool $lastWins = false,
bool $preserveKeys = false,
): array {
if ($lastWins) {
$items = array_reverse($items, preserve_keys: true);
}
$seen = [];
$result = [];
foreach ($items as $key => $item) {
$uniqueKey = $callback($item);
if (!isset($seen[$uniqueKey])) {
$seen[$uniqueKey] = true;
$result[$key] = $item;
}
}
if ($lastWins) {
$result = array_reverse($result, preserve_keys: true);
}
return $preserveKeys ? $result : array_values($result);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class Wrap
{
/**
* Ensures the given value is wrapped in an array.
* null becomes [], an existing array is returned as-is,
* any other value is wrapped in a single-element array.
*
* @return array<mixed>
*/
public static function ensure(mixed $value): array
{
if ($value === null) {
return [];
}
if (is_array($value)) {
return $value;
}
return [$value];
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Arr;
final class Zip
{
/**
* Combines multiple arrays element-by-element into a list of tuples.
* Shorter arrays are padded with null to match the longest input.
*
* @param array<mixed> ...$arrays
* @return list<list<mixed>>
*/
public static function zip(array ...$arrays): array
{
if ($arrays === []) {
return [];
}
$arrays = array_map('array_values', $arrays);
$maxLen = (int) max(array_map('count', $arrays));
$result = [];
for ($i = 0; $i < $maxLen; $i++) {
$tuple = [];
foreach ($arrays as $arr) {
$tuple[] = $arr[$i] ?? null;
}
$result[] = $tuple;
}
return $result;
}
/**
* Combines multiple arrays element-by-element, stopping at the shortest input.
*
* @param array<mixed> ...$arrays
* @return list<list<mixed>>
*/
public static function shortest(array ...$arrays): array
{
if ($arrays === []) {
return [];
}
$arrays = array_map('array_values', $arrays);
$minLen = (int) min(array_map('count', $arrays));
$result = [];
for ($i = 0; $i < $minLen; $i++) {
$tuple = [];
foreach ($arrays as $arr) {
$tuple[] = $arr[$i];
}
$result[] = $tuple;
}
return $result;
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\ArrayDiff;
use Tez\Utils\Arr\ChangeType;
use Tez\Utils\Arr\DiffResult;
final class ArrayDiffTest extends TestCase
{
public function testIdenticalArraysHaveNoChanges(): void
{
$result = ArrayDiff::compare(['a' => 1], ['a' => 1]);
self::assertInstanceOf(DiffResult::class, $result);
self::assertFalse($result->hasChanges());
self::assertSame([], $result->changes());
self::assertSame([], $result->paths());
}
public function testDetectsChangedScalarValue(): void
{
$result = ArrayDiff::compare(
['user' => ['name' => 'Anna', 'city' => 'Berlin']],
['user' => ['name' => 'Anna', 'city' => 'Hamburg']],
);
self::assertTrue($result->hasChanges());
self::assertCount(1, $result->changes());
$change = $result->changes()[0];
self::assertSame('user.city', $change->path);
self::assertSame(ChangeType::Changed, $change->type);
self::assertSame('Berlin', $change->old);
self::assertSame('Hamburg', $change->new);
self::assertSame(['user.city'], $result->paths());
}
public function testDetectsAddedKey(): void
{
$result = ArrayDiff::compare(
['a' => 1],
['a' => 1, 'b' => 2],
);
self::assertTrue($result->hasChanges());
$change = $result->changes()[0];
self::assertSame('b', $change->path);
self::assertSame(ChangeType::Added, $change->type);
self::assertNull($change->old);
self::assertSame(2, $change->new);
}
public function testDetectsRemovedKey(): void
{
$result = ArrayDiff::compare(
['a' => 1, 'b' => 2],
['a' => 1],
);
self::assertTrue($result->hasChanges());
$change = $result->changes()[0];
self::assertSame('b', $change->path);
self::assertSame(ChangeType::Removed, $change->type);
self::assertSame(2, $change->old);
self::assertNull($change->new);
}
public function testDeepNestedPath(): void
{
$result = ArrayDiff::compare(
['a' => ['b' => ['c' => 'old']]],
['a' => ['b' => ['c' => 'new']]],
);
self::assertSame(['a.b.c'], $result->paths());
self::assertSame(ChangeType::Changed, $result->changes()[0]->type);
}
public function testStrictTypeCheckIntVsString(): void
{
$result = ArrayDiff::compare(['x' => 1], ['x' => '1']);
self::assertTrue($result->hasChanges());
self::assertSame(ChangeType::Changed, $result->changes()[0]->type);
}
public function testStrictTypeCheckIntVsTrue(): void
{
$result = ArrayDiff::compare(['x' => 1], ['x' => true]);
self::assertTrue($result->hasChanges());
}
public function testMultipleDifferencesReported(): void
{
$result = ArrayDiff::compare(
['a' => 1, 'b' => 2, 'c' => 3],
['a' => 1, 'b' => 99, 'c' => 3, 'd' => 4],
);
self::assertCount(2, $result->changes());
self::assertContains('b', $result->paths());
self::assertContains('d', $result->paths());
}
public function testNestedAddedAndRemovedKeys(): void
{
$result = ArrayDiff::compare(
['user' => ['name' => 'Anna', 'age' => 30]],
['user' => ['name' => 'Anna', 'email' => 'anna@example.com']],
);
$paths = $result->paths();
self::assertContains('user.age', $paths);
self::assertContains('user.email', $paths);
$types = array_map(
static fn($c) => $c->type,
$result->changes(),
);
self::assertContains(ChangeType::Removed, $types);
self::assertContains(ChangeType::Added, $types);
}
public function testEmptyArraysProduceNoDiff(): void
{
$result = ArrayDiff::compare([], []);
self::assertFalse($result->hasChanges());
}
public function testScalarToArrayIsReportedAsChange(): void
{
$result = ArrayDiff::compare(
['x' => 'scalar'],
['x' => ['nested' => 'value']],
);
self::assertTrue($result->hasChanges());
self::assertSame(ChangeType::Changed, $result->changes()[0]->type);
}
public function testArrayToScalarIsReportedAsChange(): void
{
$result = ArrayDiff::compare(
['x' => ['nested' => 'value']],
['x' => 'scalar'],
);
self::assertTrue($result->hasChanges());
self::assertSame(ChangeType::Changed, $result->changes()[0]->type);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\CartesianProduct;
final class CartesianProductTest extends TestCase
{
public function testEmptySetsYieldsOneEmptyTuple(): void
{
$result = iterator_to_array(CartesianProduct::create(), false);
self::assertSame([[]], $result);
}
public function testSingleSet(): void
{
$result = iterator_to_array(CartesianProduct::create([1, 2, 3]), false);
self::assertSame([[1], [2], [3]], $result);
}
public function testTwoSets(): void
{
$result = iterator_to_array(CartesianProduct::create([1, 2], ['a', 'b']), false);
self::assertSame(
[[1, 'a'], [1, 'b'], [2, 'a'], [2, 'b']],
$result,
);
}
public function testThreeSets(): void
{
$result = iterator_to_array(
CartesianProduct::create(['x', 'y'], [1, 2], ['a', 'b']),
false,
);
self::assertCount(8, $result);
self::assertSame(['x', 1, 'a'], $result[0]);
self::assertSame(['x', 1, 'b'], $result[1]);
self::assertSame(['x', 2, 'a'], $result[2]);
self::assertSame(['x', 2, 'b'], $result[3]);
self::assertSame(['y', 1, 'a'], $result[4]);
self::assertSame(['y', 2, 'b'], $result[7]);
}
public function testAssociativeArrayKeysAreStripped(): void
{
$result = iterator_to_array(
CartesianProduct::create(['foo' => 'A', 'bar' => 'B'], ['x' => 1, 'y' => 2]),
false,
);
self::assertSame(
[['A', 1], ['A', 2], ['B', 1], ['B', 2]],
$result,
);
}
public function testTotalCountEqualsProductOfSetSizes(): void
{
$result = iterator_to_array(CartesianProduct::create([1, 2, 3], ['a', 'b'], [true, false]), false);
self::assertCount(3 * 2 * 2, $result);
}
public function testSingleElementSets(): void
{
$result = iterator_to_array(CartesianProduct::create(['only'], [42]), false);
self::assertSame([['only', 42]], $result);
}
public function testGeneratorAsInput(): void
{
$gen = (static function (): \Generator {
yield 10;
yield 20;
})();
$result = iterator_to_array(CartesianProduct::create([1, 2], $gen), false);
self::assertSame([[1, 10], [1, 20], [2, 10], [2, 20]], $result);
}
public function testReturnsGenerator(): void
{
$generator = CartesianProduct::create([1], [2]);
self::assertInstanceOf(\Generator::class, $generator);
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\DeepMerge;
final class DeepMergeTest extends TestCase
{
public function testMergesTopLevelScalars(): void
{
$result = DeepMerge::merge(
['a' => 1, 'b' => 2],
['b' => 99, 'c' => 3],
);
self::assertSame(['a' => 1, 'b' => 99, 'c' => 3], $result);
}
public function testMergesNestedArraysRecursively(): void
{
$result = DeepMerge::merge(
['db' => ['host' => 'localhost', 'port' => 3306]],
['db' => ['host' => 'db.prod']],
);
self::assertSame(['db' => ['host' => 'db.prod', 'port' => 3306]], $result);
}
public function testBaseOnlyKeysAreKept(): void
{
$result = DeepMerge::merge(
['a' => 1, 'b' => 2],
['b' => 99],
);
self::assertSame(1, $result['a']);
}
public function testOverrideOnlyKeysAreAdded(): void
{
$result = DeepMerge::merge(['a' => 1], ['b' => 2]);
self::assertSame(['a' => 1, 'b' => 2], $result);
}
public function testNullOverwrites(): void
{
$result = DeepMerge::merge(['a' => 'value'], ['a' => null]);
self::assertNull($result['a']);
}
public function testIntegerKeysAreAppended(): void
{
$result = DeepMerge::merge([1, 2, 3], [4, 5]);
self::assertSame([1, 2, 3, 4, 5], $result);
}
public function testMultipleOverridesAppliedLeftToRight(): void
{
$result = DeepMerge::merge(
['a' => 1],
['a' => 2],
['a' => 3],
);
self::assertSame(3, $result['a']);
}
public function testDeepNestedMerge(): void
{
$defaults = [
'cache' => ['ttl' => 3600, 'driver' => 'file'],
'debug' => false,
];
$env = [
'cache' => ['driver' => 'redis'],
'debug' => true,
];
$result = DeepMerge::merge($defaults, $env);
/** @var array<string, mixed> $cache */
$cache = $result['cache'];
self::assertSame(3600, $cache['ttl']);
self::assertSame('redis', $cache['driver']);
self::assertTrue($result['debug']);
}
public function testOverridingArrayWithScalarWins(): void
{
$result = DeepMerge::merge(
['key' => ['nested' => 1]],
['key' => 'scalar'],
);
self::assertSame('scalar', $result['key']);
}
public function testEmptyOverrideReturnsBase(): void
{
$base = ['a' => 1, 'b' => 2];
self::assertSame($base, DeepMerge::merge($base));
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\Find;
final class FindTest extends TestCase
{
public function testFirstReturnsFirstMatch(): void
{
$items = [1, 2, 3, 4, 5];
$result = Find::first($items, fn($v) => $v > 2);
self::assertSame(3, $result);
}
public function testFirstReturnsNullWhenNoMatch(): void
{
/** @var list<int> $items */
$items = [1, 2, 3];
$result = Find::first($items, fn($v) => $v > 10);
self::assertNull($result);
}
public function testFirstReturnsNullOnEmptyArray(): void
{
$result = Find::first([], fn($v) => true);
self::assertNull($result);
}
public function testFirstStopsAtFirstMatch(): void
{
$calls = 0;
$items = [1, 2, 3, 4, 5];
Find::first($items, function ($v) use (&$calls): bool {
$calls++;
return $v === 2;
});
self::assertSame(2, $calls);
}
public function testFirstWithAssociativeArray(): void
{
$items = ['a' => 1, 'b' => 2, 'c' => 3];
$result = Find::first($items, fn($v) => $v === 2);
self::assertSame(2, $result);
}
public function testFirstWithObjects(): void
{
$a = new \stdClass();
$a->id = 1;
$b = new \stdClass();
$b->id = 2;
$result = Find::first([$a, $b], fn($o) => $o->id === 2);
self::assertSame($b, $result);
}
public function testLastReturnsLastMatch(): void
{
$items = [1, 2, 3, 4, 5];
$result = Find::last($items, fn($v) => $v < 4);
self::assertSame(3, $result);
}
public function testLastReturnsNullWhenNoMatch(): void
{
/** @var list<int> $items */
$items = [1, 2, 3];
$result = Find::last($items, fn($v) => $v > 10);
self::assertNull($result);
}
public function testLastReturnsNullOnEmptyArray(): void
{
$result = Find::last([], fn($v) => true);
self::assertNull($result);
}
public function testLastWithObjects(): void
{
$a = new \stdClass();
$a->active = true;
$b = new \stdClass();
$b->active = false;
$c = new \stdClass();
$c->active = true;
$result = Find::last([$a, $b, $c], fn($o) => $o->active);
self::assertSame($c, $result);
}
public function testFirstAndLastDifferWhenMultipleMatches(): void
{
$items = [10, 20, 30, 40, 50];
$first = Find::first($items, fn($v) => $v % 20 === 0);
$last = Find::last($items, fn($v) => $v % 20 === 0);
self::assertSame(20, $first);
self::assertSame(40, $last);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\Flatten;
final class FlattenTest extends TestCase
{
public function testFlattensFullyByDefault(): void
{
$result = Flatten::flatten([1, [2, 3], [4, [5, 6]]]);
self::assertSame([1, 2, 3, 4, 5, 6], $result);
}
public function testDepthOne(): void
{
$result = Flatten::flatten([1, [2, 3], [4, [5, 6]]], depth: 1);
self::assertSame([1, 2, 3, 4, [5, 6]], $result);
}
public function testDepthTwo(): void
{
$result = Flatten::flatten([1, [2, [3, [4]]]], depth: 2);
self::assertSame([1, 2, 3, [4]], $result);
}
public function testAlreadyFlatArrayIsUnchanged(): void
{
$result = Flatten::flatten([1, 2, 3]);
self::assertSame([1, 2, 3], $result);
}
public function testEmptyArrayReturnsEmpty(): void
{
self::assertSame([], Flatten::flatten([]));
}
public function testAssociativeKeysAreDiscarded(): void
{
$result = Flatten::flatten([['a' => 1, 'b' => 2], ['c' => 3]]);
self::assertSame([1, 2, 3], $result);
}
public function testDepthZeroReturnsOriginalValues(): void
{
$result = Flatten::flatten([[1, 2], [3, 4]], depth: 0);
self::assertSame([[1, 2], [3, 4]], $result);
}
public function testMixedNestingDepths(): void
{
$result = Flatten::flatten([1, [2, [3]], 4, [[5]]]);
self::assertSame([1, 2, 3, 4, 5], $result);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\GroupBy;
final class GroupByTest extends TestCase
{
public function testGroupsByStringKey(): void
{
$users = [
['name' => 'Alice', 'role' => 'admin'],
['name' => 'Bob', 'role' => 'user'],
['name' => 'Carol', 'role' => 'admin'],
];
$result = GroupBy::group($users, fn($u) => $u['role']);
self::assertSame([
'admin' => [
['name' => 'Alice', 'role' => 'admin'],
['name' => 'Carol', 'role' => 'admin'],
],
'user' => [
['name' => 'Bob', 'role' => 'user'],
],
], $result);
}
public function testGroupsByIntKey(): void
{
$result = GroupBy::group(range(1, 6), fn($n) => $n % 2);
self::assertSame([
1 => [1, 3, 5],
0 => [2, 4, 6],
], $result);
}
public function testGroupsPreserveDefinitionOrder(): void
{
$items = ['b', 'a', 'b', 'a', 'c'];
$result = GroupBy::group($items, fn($v) => $v);
self::assertSame(['b', 'b'], $result['b']);
self::assertSame(['a', 'a'], $result['a']);
self::assertSame(['c'], $result['c']);
}
public function testEmptyArrayReturnsEmpty(): void
{
self::assertSame([], GroupBy::group([], fn($v) => $v));
}
public function testSingleGroupWhenAllItemsShareKey(): void
{
$result = GroupBy::group([1, 2, 3], fn() => 'same');
self::assertSame(['same' => [1, 2, 3]], $result);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\KeyBy;
final class KeyByTest extends TestCase
{
public function testIndexByStringKey(): void
{
$users = [
['id' => 1, 'name' => 'Alice'],
['id' => 2, 'name' => 'Bob'],
];
$result = KeyBy::index($users, fn($u) => $u['name']);
self::assertSame([
'Alice' => ['id' => 1, 'name' => 'Alice'],
'Bob' => ['id' => 2, 'name' => 'Bob'],
], $result);
}
public function testIndexByIntKey(): void
{
$users = [
['id' => 1, 'name' => 'Alice'],
['id' => 2, 'name' => 'Bob'],
];
$result = KeyBy::index($users, fn($u) => $u['id']);
self::assertSame(1, array_key_first($result));
self::assertSame('Alice', $result[1]['name']);
}
public function testLastItemWinsOnDuplicateKey(): void
{
$items = [
['key' => 'x', 'val' => 1],
['key' => 'x', 'val' => 2],
];
$result = KeyBy::index($items, fn($i) => $i['key']);
self::assertSame(2, $result['x']['val']);
}
public function testEmptyInputReturnsEmpty(): void
{
self::assertSame([], KeyBy::index([], fn($v) => $v));
}
public function testFullItemIsPreserved(): void
{
$items = [['id' => 1, 'name' => 'Alice', 'score' => 95]];
$result = KeyBy::index($items, fn($i) => $i['id']);
self::assertSame(['id' => 1, 'name' => 'Alice', 'score' => 95], $result[1]);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\MapWithKeys;
final class MapWithKeysTest extends TestCase
{
public function testTransformsKeysAndValues(): void
{
/** @var list<array{id: int, name: string}> $users */
$users = [
['id' => 1, 'name' => 'Alice'],
['id' => 2, 'name' => 'Bob'],
];
$result = MapWithKeys::map($users, function (mixed $u) {
/** @var array{id: int, name: string} $u */
return [$u['id'] => $u['name']];
});
self::assertSame([1 => 'Alice', 2 => 'Bob'], $result);
}
public function testSwapsKeyAndValue(): void
{
$result = MapWithKeys::map(['a' => 1, 'b' => 2], function (mixed $v, int|string $k) {
/** @var int $v */
return [$v => $k];
});
self::assertSame([1 => 'a', 2 => 'b'], $result);
}
public function testLastWinsOnDuplicateKey(): void
{
$result = MapWithKeys::map([1, 2, 3], fn($v) => ['key' => $v]);
self::assertSame(['key' => 3], $result);
}
public function testEmptyInputReturnsEmpty(): void
{
self::assertSame([], MapWithKeys::map([], fn($v) => ['x' => $v]));
}
public function testReceivesOriginalKey(): void
{
$received = [];
MapWithKeys::map(['x' => 10, 'y' => 20], function ($v, $k) use (&$received) {
$received[] = $k;
return [$k => $v];
});
self::assertSame(['x', 'y'], $received);
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\Page;
use Tez\Utils\Arr\Paginator;
final class PaginatorTest extends TestCase
{
/** @return list<int> */
private function range100(): array
{
return range(1, 100);
}
public function testFirstPageReturnsCorrectSlice(): void
{
$page = Paginator::paginate($this->range100(), page: 1, perPage: 10);
self::assertInstanceOf(Page::class, $page);
self::assertSame(range(1, 10), $page->items());
self::assertSame(100, $page->total());
self::assertSame(1, $page->currentPage());
self::assertSame(10, $page->lastPage());
}
public function testSecondPageReturnsCorrectSlice(): void
{
$page = Paginator::paginate($this->range100(), page: 2, perPage: 10);
self::assertSame(range(11, 20), $page->items());
self::assertSame(2, $page->currentPage());
}
public function testLastPageHasFewerItems(): void
{
$items = range(1, 25);
$page = Paginator::paginate($items, page: 3, perPage: 10);
self::assertSame([21, 22, 23, 24, 25], $page->items());
self::assertSame(3, $page->lastPage());
self::assertFalse($page->hasNext());
self::assertTrue($page->hasPrev());
}
public function testHasNextAndHasPrev(): void
{
$items = range(1, 30);
$first = Paginator::paginate($items, page: 1, perPage: 10);
self::assertTrue($first->hasNext());
self::assertFalse($first->hasPrev());
$middle = Paginator::paginate($items, page: 2, perPage: 10);
self::assertTrue($middle->hasNext());
self::assertTrue($middle->hasPrev());
$last = Paginator::paginate($items, page: 3, perPage: 10);
self::assertFalse($last->hasNext());
self::assertTrue($last->hasPrev());
}
public function testEmptyArrayReturnsEmptyPage(): void
{
$page = Paginator::paginate([], page: 1, perPage: 10);
self::assertSame([], $page->items());
self::assertSame(0, $page->total());
self::assertSame(1, $page->lastPage());
self::assertFalse($page->hasNext());
self::assertFalse($page->hasPrev());
}
public function testPageBeyondLastPageReturnsEmptyItems(): void
{
$page = Paginator::paginate(range(1, 5), page: 10, perPage: 10);
self::assertSame([], $page->items());
}
public function testPageZeroThrowsException(): void
{
$this->expectException(\InvalidArgumentException::class);
Paginator::paginate([], page: 0, perPage: 10);
}
public function testNegativePageThrowsException(): void
{
$this->expectException(\InvalidArgumentException::class);
Paginator::paginate([], page: -1, perPage: 10);
}
public function testPerPageZeroThrowsException(): void
{
$this->expectException(\InvalidArgumentException::class);
Paginator::paginate([], page: 1, perPage: 0);
}
public function testPerPageNegativeThrowsException(): void
{
$this->expectException(\InvalidArgumentException::class);
Paginator::paginate([], page: 1, perPage: -5);
}
public function testItemsAreReIndexed(): void
{
$items = ['a' => 'x', 'b' => 'y', 'c' => 'z'];
$page = Paginator::paginate(array_values($items), page: 1, perPage: 2);
self::assertSame(['x', 'y'], $page->items());
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\Partition;
final class PartitionTest extends TestCase
{
public function testSplitsIntoPassAndFail(): void
{
/** @var list<int> $numbers */
$numbers = [1, 2, 3, 4, 5, 6];
[$even, $odd] = Partition::by($numbers, fn($n) => $n % 2 === 0);
self::assertSame([2, 4, 6], $even);
self::assertSame([1, 3, 5], $odd);
}
public function testAllPassReturnsEmptyFail(): void
{
[$pass, $fail] = Partition::by([1, 2, 3], fn() => true);
self::assertSame([1, 2, 3], $pass);
self::assertSame([], $fail);
}
public function testAllFailReturnsEmptyPass(): void
{
[$pass, $fail] = Partition::by([1, 2, 3], fn() => false);
self::assertSame([], $pass);
self::assertSame([1, 2, 3], $fail);
}
public function testEmptyArrayReturnsTwoEmptyLists(): void
{
[$pass, $fail] = Partition::by([], fn() => true);
self::assertSame([], $pass);
self::assertSame([], $fail);
}
public function testWorksWithAssocArrayItems(): void
{
$users = [
['name' => 'Alice', 'active' => true],
['name' => 'Bob', 'active' => false],
['name' => 'Carol', 'active' => true],
];
[$active, $inactive] = Partition::by($users, fn($u) => $u['active']);
self::assertCount(2, $active);
self::assertCount(1, $inactive);
self::assertSame('Alice', $active[0]['name']);
self::assertSame('Carol', $active[1]['name']);
}
public function testResultIsReindexed(): void
{
[$pass, $fail] = Partition::by([10, 20, 30, 40], fn($n) => $n > 15);
self::assertSame(0, array_key_first($pass));
self::assertSame(0, array_key_first($fail));
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\Pluck;
final class PluckTest extends TestCase
{
public function testPlucksTopLevelKey(): void
{
$items = [
['name' => 'Alice', 'age' => 28],
['name' => 'Bob', 'age' => 34],
];
self::assertSame(['Alice', 'Bob'], Pluck::values($items, 'name'));
}
public function testPlucksDotNotationKey(): void
{
$items = [
['id' => 1, 'profile' => ['name' => 'Alice']],
['id' => 2, 'profile' => ['name' => 'Bob']],
];
self::assertSame(['Alice', 'Bob'], Pluck::values($items, 'profile.name'));
}
public function testSkipsMissingKeys(): void
{
$items = [
['name' => 'Alice'],
['age' => 30],
['name' => 'Carol'],
];
self::assertSame(['Alice', 'Carol'], Pluck::values($items, 'name'));
}
public function testKeyedByReturnsAssocArray(): void
{
$items = [
['id' => 1, 'name' => 'Alice'],
['id' => 2, 'name' => 'Bob'],
];
self::assertSame([1 => 'Alice', 2 => 'Bob'], Pluck::keyedBy($items, 'name', 'id'));
}
public function testKeyedByWithDotNotation(): void
{
$items = [
['id' => 1, 'profile' => ['name' => 'Alice']],
['id' => 2, 'profile' => ['name' => 'Bob']],
];
self::assertSame([1 => 'Alice', 2 => 'Bob'], Pluck::keyedBy($items, 'profile.name', 'id'));
}
public function testEmptyInputReturnsEmpty(): void
{
self::assertSame([], Pluck::values([], 'name'));
self::assertSame([], Pluck::keyedBy([], 'name', 'id'));
}
public function testPlucksNullValues(): void
{
$items = [
['name' => null],
['name' => 'Bob'],
];
self::assertSame([null, 'Bob'], Pluck::values($items, 'name'));
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\SafeGet;
final class SafeGetTest extends TestCase
{
/** @var array<mixed> */
private array $data;
protected function setUp(): void
{
$this->data = [
'user' => [
'name' => 'Alice',
'age' => 30,
'score' => 9.5,
'active' => true,
'tags' => ['admin', 'user'],
],
'chart' => [
'result' => [
[
'meta' => ['currency' => 'EUR', 'price' => 123.45],
'data' => [10, 20, 30],
],
],
],
'empty_string' => '',
'zero' => 0,
];
}
// ── string ────────────────────────────────────────────────────────────────
public function testStringShallowKey(): void
{
self::assertSame('Alice', SafeGet::string($this->data, 'user.name'));
}
public function testStringDeeplyNested(): void
{
self::assertSame('EUR', SafeGet::string($this->data, 'chart.result.0.meta.currency'));
}
public function testStringMissingPathReturnsDefault(): void
{
self::assertSame('fallback', SafeGet::string($this->data, 'user.missing', 'fallback'));
}
public function testStringMissingPathReturnsNullByDefault(): void
{
self::assertNull(SafeGet::string($this->data, 'does.not.exist'));
}
public function testStringWrongTypeReturnsDefault(): void
{
// age is int, not string
self::assertNull(SafeGet::string($this->data, 'user.age'));
}
// ── int ───────────────────────────────────────────────────────────────────
public function testIntShallowKey(): void
{
self::assertSame(30, SafeGet::int($this->data, 'user.age'));
}
public function testIntFromListIndex(): void
{
self::assertSame(20, SafeGet::int($this->data, 'chart.result.0.data.1'));
}
public function testIntMissingReturnsDefault(): void
{
self::assertSame(42, SafeGet::int($this->data, 'user.missing', 42));
}
public function testIntWrongTypeReturnsDefault(): void
{
// name is string, not int
self::assertNull(SafeGet::int($this->data, 'user.name'));
}
// ── float ─────────────────────────────────────────────────────────────────
public function testFloatDirectValue(): void
{
self::assertSame(9.5, SafeGet::float($this->data, 'user.score'));
}
public function testFloatWidensFromInt(): void
{
// age is int 30 → widened to 30.0
self::assertSame(30.0, SafeGet::float($this->data, 'user.age'));
}
public function testFloatNestedValue(): void
{
self::assertSame(123.45, SafeGet::float($this->data, 'chart.result.0.meta.price'));
}
public function testFloatMissingReturnsDefault(): void
{
self::assertSame(1.5, SafeGet::float($this->data, 'missing', 1.5));
}
// ── bool ──────────────────────────────────────────────────────────────────
public function testBoolTrue(): void
{
self::assertTrue(SafeGet::bool($this->data, 'user.active'));
}
public function testBoolMissingReturnsDefault(): void
{
self::assertFalse(SafeGet::bool($this->data, 'user.missing', false));
}
public function testBoolWrongTypeReturnsDefault(): void
{
// integer 1 is not a bool
self::assertNull(SafeGet::bool($this->data, 'user.age'));
}
// ── array ─────────────────────────────────────────────────────────────────
public function testArrayShallowKey(): void
{
self::assertSame(['admin', 'user'], SafeGet::array($this->data, 'user.tags'));
}
public function testArrayNestedObject(): void
{
$result = SafeGet::array($this->data, 'chart.result.0');
self::assertIsArray($result);
self::assertArrayHasKey('meta', $result);
}
public function testArrayMissingReturnsDefault(): void
{
self::assertSame(['x'], SafeGet::array($this->data, 'missing', ['x']));
}
public function testArrayMissingReturnsNullByDefault(): void
{
self::assertNull(SafeGet::array($this->data, 'user.name'));
}
// ── edge cases ────────────────────────────────────────────────────────────
public function testMidPathNotArrayReturnsDefault(): void
{
// 'user.name' is a string; trying to go deeper must return null
self::assertNull(SafeGet::string($this->data, 'user.name.nested'));
}
public function testNumericStringKeyAddressesListIndex(): void
{
self::assertSame(10, SafeGet::int($this->data, 'chart.result.0.data.0'));
self::assertSame(30, SafeGet::int($this->data, 'chart.result.0.data.2'));
}
public function testEmptyPathSegmentsAreHandled(): void
{
$flat = ['key' => 'value'];
self::assertSame('value', SafeGet::string($flat, 'key'));
}
public function testRootLevelArrayAccess(): void
{
$data = ['name' => 'Bob'];
self::assertSame('Bob', SafeGet::string($data, 'name'));
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\Sliding;
final class SlidingTest extends TestCase
{
public function testDefaultStepOfOne(): void
{
$result = Sliding::window([1, 2, 3, 4, 5], size: 3);
self::assertSame([[1, 2, 3], [2, 3, 4], [3, 4, 5]], $result);
}
public function testCustomStep(): void
{
$result = Sliding::window([1, 2, 3, 4, 5], size: 3, step: 2);
self::assertSame([[1, 2, 3], [3, 4, 5]], $result);
}
public function testStepEqualsSizeProducesNonOverlappingChunks(): void
{
$result = Sliding::window([1, 2, 3, 4, 5, 6], size: 2, step: 2);
self::assertSame([[1, 2], [3, 4], [5, 6]], $result);
}
public function testArraySmallerThanWindowReturnsEmpty(): void
{
self::assertSame([], Sliding::window([1, 2], size: 5));
}
public function testExactFitProducesOneWindow(): void
{
$result = Sliding::window([1, 2, 3], size: 3);
self::assertSame([[1, 2, 3]], $result);
}
public function testSizeOneReturnsEachElementWrapped(): void
{
$result = Sliding::window([1, 2, 3], size: 1);
self::assertSame([[1], [2], [3]], $result);
}
public function testEmptyArrayReturnsEmpty(): void
{
self::assertSame([], Sliding::window([], size: 3));
}
public function testInvalidSizeThrows(): void
{
$this->expectException(\InvalidArgumentException::class);
Sliding::window([1, 2, 3], size: 0);
}
public function testInvalidStepThrows(): void
{
$this->expectException(\InvalidArgumentException::class);
Sliding::window([1, 2, 3], size: 2, step: 0);
}
public function testAssociativeKeysAreStripped(): void
{
$result = Sliding::window(['a' => 1, 'b' => 2, 'c' => 3], size: 2);
self::assertSame([[1, 2], [2, 3]], $result);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\SortBy;
final class SortByTest extends TestCase
{
public function testSortsByIntKeyAscending(): void
{
$users = [
['name' => 'Carol', 'age' => 32],
['name' => 'Alice', 'age' => 28],
['name' => 'Bob', 'age' => 45],
];
$result = SortBy::sort($users, fn($u) => $u['age']);
self::assertSame('Alice', $result[0]['name']);
self::assertSame('Carol', $result[1]['name']);
self::assertSame('Bob', $result[2]['name']);
}
public function testSortsByIntKeyDescending(): void
{
$users = [
['name' => 'Carol', 'age' => 32],
['name' => 'Alice', 'age' => 28],
['name' => 'Bob', 'age' => 45],
];
$result = SortBy::sort($users, fn($u) => $u['age'], descending: true);
self::assertSame('Bob', $result[0]['name']);
self::assertSame('Carol', $result[1]['name']);
self::assertSame('Alice', $result[2]['name']);
}
public function testSortsByStringKey(): void
{
$items = ['banana', 'apple', 'cherry'];
$result = SortBy::sort($items, fn($v) => $v);
self::assertSame(['apple', 'banana', 'cherry'], $result);
}
public function testDoesNotMutateOriginal(): void
{
$original = [3, 1, 2];
SortBy::sort($original, fn($v) => $v);
self::assertSame([3, 1, 2], $original);
}
public function testResultIsReindexed(): void
{
$result = SortBy::sort([3, 1, 2], fn($v) => $v);
self::assertSame(0, array_key_first($result));
self::assertSame(1, $result[0]);
}
public function testEmptyArrayReturnsEmpty(): void
{
self::assertSame([], SortBy::sort([], fn($v) => $v));
}
public function testStableSortPreservesEqualOrder(): void
{
$items = [
['name' => 'A', 'age' => 30],
['name' => 'B', 'age' => 30],
];
$result = SortBy::sort($items, fn($u) => $u['age']);
// Equal keys: original order must be preserved (PHP's usort is stable since 8.0)
self::assertSame('A', $result[0]['name']);
self::assertSame('B', $result[1]['name']);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\Transpose;
final class TransposeTest extends TestCase
{
public function testTransposesMatrix(): void
{
$result = Transpose::matrix([
[1, 2, 3],
[4, 5, 6],
]);
self::assertSame([[1, 4], [2, 5], [3, 6]], $result);
}
public function testTransposesSquareMatrix(): void
{
$result = Transpose::matrix([
[1, 2],
[3, 4],
]);
self::assertSame([[1, 3], [2, 4]], $result);
}
public function testTransposesSingleRow(): void
{
$result = Transpose::matrix([[1, 2, 3]]);
self::assertSame([[1], [2], [3]], $result);
}
public function testTransposesSingleColumn(): void
{
$result = Transpose::matrix([[1], [2], [3]]);
self::assertSame([[1, 2, 3]], $result);
}
public function testEmptyInputReturnsEmpty(): void
{
self::assertSame([], Transpose::matrix([]));
}
public function testAssociativeKeysAreStripped(): void
{
$result = Transpose::matrix([
['a' => 1, 'b' => 2],
['a' => 3, 'b' => 4],
]);
self::assertSame([[1, 3], [2, 4]], $result);
}
public function testJaggedArrayThrows(): void
{
$this->expectException(\InvalidArgumentException::class);
Transpose::matrix([[1, 2, 3], [4, 5]]);
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\UniqueBy;
final class UniqueByTest extends TestCase
{
public function testFirstWinsByDefault(): void
{
$items = [
['id' => 1, 'name' => 'Alice'],
['id' => 2, 'name' => 'Bob'],
['id' => 1, 'name' => 'Alice2'],
];
$result = UniqueBy::filter($items, fn($item) => $item['id']);
self::assertCount(2, $result);
self::assertSame('Alice', $result[0]['name']);
}
public function testLastWins(): void
{
$items = [
['id' => 1, 'name' => 'Alice'],
['id' => 2, 'name' => 'Bob'],
['id' => 1, 'name' => 'Alice2'],
];
$result = UniqueBy::filter($items, fn($item) => $item['id'], lastWins: true);
// Bob's only occurrence is at position 1, Alice2's last occurrence is at position 2
// → order reflects last-occurrence position in the original array
self::assertCount(2, $result);
self::assertSame('Bob', $result[0]['name']);
self::assertSame('Alice2', $result[1]['name']);
}
public function testLastWinsPreservesOriginalOrder(): void
{
$items = [
['id' => 1, 'name' => 'A'],
['id' => 2, 'name' => 'B'],
['id' => 1, 'name' => 'C'],
['id' => 2, 'name' => 'D'],
];
$result = UniqueBy::filter($items, fn($item) => $item['id'], lastWins: true);
self::assertCount(2, $result);
self::assertSame('C', $result[0]['name']);
self::assertSame('D', $result[1]['name']);
}
public function testReIndexedByDefault(): void
{
$items = [10 => 'a', 20 => 'b', 30 => 'a'];
$result = UniqueBy::filter($items, fn($v) => $v);
self::assertSame([0 => 'a', 1 => 'b'], $result);
}
public function testPreserveKeys(): void
{
$items = [10 => 'a', 20 => 'b', 30 => 'a'];
$result = UniqueBy::filter($items, fn($v) => $v, preserveKeys: true);
self::assertArrayHasKey(10, $result);
self::assertArrayHasKey(20, $result);
self::assertArrayNotHasKey(30, $result);
}
public function testPreserveKeysWithLastWins(): void
{
$items = [10 => 'a', 20 => 'b', 30 => 'a'];
$result = UniqueBy::filter($items, fn($v) => $v, lastWins: true, preserveKeys: true);
self::assertArrayNotHasKey(10, $result);
self::assertArrayHasKey(20, $result);
self::assertArrayHasKey(30, $result);
}
public function testEmptyArrayReturnsEmpty(): void
{
$result = UniqueBy::filter([], fn($v) => $v);
self::assertSame([], $result);
}
public function testAllUniqueReturnsAllItems(): void
{
$items = [1, 2, 3, 4];
$result = UniqueBy::filter($items, fn($v) => $v);
self::assertSame([1, 2, 3, 4], $result);
}
public function testAllDuplicatesReturnsSingleItem(): void
{
$items = ['a', 'a', 'a'];
$result = UniqueBy::filter($items, fn($v) => $v);
self::assertSame(['a'], $result);
}
public function testCallbackTransformation(): void
{
$items = ['Alice', 'alice', 'BOB', 'Bob'];
$result = UniqueBy::filter($items, fn($v) => strtolower($v));
self::assertSame(['Alice', 'BOB'], $result);
}
public function testCallbackOnObjects(): void
{
$a = new \stdClass();
$a->id = 1;
$b = new \stdClass();
$b->id = 2;
$c = new \stdClass();
$c->id = 1;
$result = UniqueBy::filter([$a, $b, $c], fn($o) => $o->id);
self::assertCount(2, $result);
self::assertSame($a, $result[0]);
self::assertSame($b, $result[1]);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\Wrap;
final class WrapTest extends TestCase
{
public function testWrapsScalarInArray(): void
{
self::assertSame(['hello'], Wrap::ensure('hello'));
self::assertSame([42], Wrap::ensure(42));
self::assertSame([3.14], Wrap::ensure(3.14));
self::assertSame([true], Wrap::ensure(true));
}
public function testReturnsArrayAsIs(): void
{
self::assertSame([1, 2, 3], Wrap::ensure([1, 2, 3]));
self::assertSame(['a' => 1], Wrap::ensure(['a' => 1]));
self::assertSame([], Wrap::ensure([]));
}
public function testNullReturnsEmptyArray(): void
{
self::assertSame([], Wrap::ensure(null));
}
public function testObjectIsWrapped(): void
{
$obj = new \stdClass();
$result = Wrap::ensure($obj);
self::assertCount(1, $result);
self::assertSame($obj, $result[0]);
}
public function testAssociativeArrayIsReturnedAsIs(): void
{
$input = ['key' => 'value', 'other' => 42];
$result = Wrap::ensure($input);
self::assertSame($input, $result);
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Arr;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Arr\Zip;
final class ZipTest extends TestCase
{
public function testZipEqualLengthArrays(): void
{
$result = Zip::zip([1, 2, 3], ['a', 'b', 'c']);
self::assertSame([[1, 'a'], [2, 'b'], [3, 'c']], $result);
}
public function testZipPadsShorterArrayWithNull(): void
{
$result = Zip::zip([1, 2, 3], ['a', 'b']);
self::assertSame([[1, 'a'], [2, 'b'], [3, null]], $result);
}
public function testZipThreeArrays(): void
{
$result = Zip::zip([1, 2], ['a', 'b'], [true, false]);
self::assertSame([[1, 'a', true], [2, 'b', false]], $result);
}
public function testZipNoArraysReturnsEmpty(): void
{
self::assertSame([], Zip::zip());
}
public function testZipEmptyArraysReturnEmpty(): void
{
self::assertSame([], Zip::zip([], []));
}
public function testShortestStopsAtSmallest(): void
{
$result = Zip::shortest([1, 2, 3], ['a', 'b']);
self::assertSame([[1, 'a'], [2, 'b']], $result);
}
public function testShortestWithEqualLengths(): void
{
$result = Zip::shortest([1, 2], ['a', 'b']);
self::assertSame([[1, 'a'], [2, 'b']], $result);
}
public function testShortestWithEmptyArrayReturnsEmpty(): void
{
self::assertSame([], Zip::shortest([1, 2, 3], []));
}
public function testShortestNoArraysReturnsEmpty(): void
{
self::assertSame([], Zip::shortest());
}
public function testZipStripsAssociativeKeys(): void
{
$result = Zip::zip(['a' => 1, 'b' => 2], ['x' => 10, 'y' => 20]);
self::assertSame([[1, 10], [2, 20]], $result);
}
}