Inital commit after splitting from project

This commit is contained in:
2026-07-21 22:00:16 +02:00
commit f299f2c60e
49 changed files with 3102 additions and 0 deletions
+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);
}
}