functional-php/tests/Collections/TransformsTest.php
Kacper Donat f3f05fd00c add native fast paths for eager transforms
Arrays and php-ds collections are transformed directly instead of through the lazy generators. merge() into an array now rejects keys that can't be array keys with a clear error, like toArray().
2026-10-27 23:08:34 +01:00

445 lines
15 KiB
PHP

<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
use Ds\Map;
use Ds\Seq;
use Ds\Set;
use Kadet\Functional\Collections as c;
use function Kadet\Functional\partial;
use const Kadet\Functional\_;
class TransformsTest extends \PHPUnit\Framework\TestCase
{
private static function gen(iterable $entries): \Generator
{
foreach ($entries as $key => $value) {
yield $key => $value;
}
}
private function assertSeq(array $expected, $actual)
{
$this->assertInstanceOf(Seq::class, $actual);
$this->assertSame($expected, $actual->toArray());
}
private function assertMap(array $expected, $actual)
{
$this->assertInstanceOf(Map::class, $actual);
$this->assertSame($expected, $actual->toArray());
}
// map
public function testMapOnArrayPreservesKeys()
{
$this->assertSame(['a' => 2, 'b' => 4], c\map(fn ($v) => $v * 2)(['a' => 1, 'b' => 2]));
}
public function testMapOnSeq()
{
$this->assertSeq([2, 4], c\map(fn ($v) => $v * 2)(new Seq([1, 2])));
}
public function testMapOnMapPassesValueThenKey()
{
$this->assertEquals(new Map(['a' => 'a:1']), c\map(fn ($v, $k) => "$k:$v")(new Map(['a' => 1])));
}
public function testMapOnGeneratorGivesSeq()
{
$this->assertSeq([2, 4], c\map(fn ($v) => $v * 2)(self::gen([1, 2])));
}
public function testMapOnGeneratorPassesGeneratorKeys()
{
$this->assertSeq(['x:1', 'y:2'], c\map(fn ($v, $k) => "$k:$v")(self::gen(['x' => 1, 'y' => 2])));
}
public function testMapWithStaticMethodArrayCallable()
{
$this->assertSame(['a' => 2, 'b' => 4], c\map([TransformsTestHelper::class, 'double'])(['a' => 1, 'b' => 2]));
}
public function testMapOnSetFollowsSetMap()
{
$result = c\map(fn ($v) => $v % 2)(new Set([1, 2, 3]));
$this->assertInstanceOf(Set::class, $result);
$this->assertSame([1, 0], $result->toArray());
}
public function testMapWithInternalFunctionGetsValueOnly()
{
$this->assertSame(['a' => 'x'], c\map(trim(...))(['a' => ' x ']));
}
public function testTransformsWorkWithDecoratedCallback()
{
$this->assertSame(['aa'], c\map(partial(str_repeat(...), _, 2))(['a']));
}
// filter / reject
public function testFilterOnSeqReindexes()
{
$this->assertSame([1, 2], c\filter()(new Seq([0, 1, '', 2]))->toArray());
}
public function testFilterOnArrayPreservesKeys()
{
$this->assertSame([1 => 1, 3 => 2], c\filter()([0, 1, '', 2]));
}
public function testFilterOnMapPassesValueThenKey()
{
$this->assertMap(['b' => 2], c\filter(fn ($v, $k) => $k === 'b')(new Map(['a' => 1, 'b' => 2])));
}
public function testFilterOnGeneratorGivesSeq()
{
$this->assertSeq([2], c\filter(fn ($v) => $v % 2 === 0)(self::gen([1, 2, 3])));
}
public function testFilterWithInternalPredicate()
{
$this->assertSame([1 => 'a'], c\filter(is_string(...))([1, 'a']));
}
public function testReject()
{
$odd = fn ($v) => $v % 2 === 1;
$this->assertSame([1 => 2], c\reject($odd)([1, 2, 3]));
$this->assertSeq([2], c\reject($odd)(new Seq([1, 2, 3])));
$this->assertMap(['b' => 2], c\reject($odd)(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([2], c\reject($odd)(self::gen([1, 2, 3])));
}
// flatMap
public function testFlatMap()
{
$pair = fn ($v) => [$v, $v * 10];
$this->assertSame([1, 10, 2, 20], c\flatMap($pair)(['a' => 1, 'b' => 2]));
$this->assertSeq([1, 10, 2, 20], c\flatMap($pair)(new Seq([1, 2])));
$this->assertSeq([1, 10, 2, 20], c\flatMap($pair)(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([1, 10, 2, 20], c\flatMap($pair)(self::gen([1, 2])));
}
public function testFlatMapFlattensOneLevelOnly()
{
$this->assertSame([[1], 2], c\flatMap(fn ($v) => $v)([[[1]], [2]]));
}
public function testFlatMapAcceptsIterableResults()
{
$this->assertSame(['a', 'b', 'c'], c\flatMap(fn ($v) => new Seq(str_split($v)))(['ab', 'c']));
}
// values / keys
public function testValues()
{
$this->assertSame([1, 2], c\values()(['a' => 1, 'b' => 2]));
$this->assertSeq([1, 2], c\values()(new Seq([1, 2])));
$this->assertSeq([1, 2], c\values()(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([1, 2], c\values()(self::gen(['a' => 1, 'b' => 2])));
}
public function testKeys()
{
$this->assertSame(['a', 'b'], c\keys()(['a' => 1, 'b' => 2]));
$this->assertSeq([0, 1], c\keys()(new Seq([5, 6])));
$this->assertSeq(['a', 'b'], c\keys()(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq(['a', 'b'], c\keys()(self::gen(['a' => 1, 'b' => 2])));
}
// unique / uniqueBy
public function testUnique()
{
$this->assertSame([0 => 1, 1 => '1', 3 => 2], c\unique()([1, '1', 1, 2]));
$this->assertSeq([1, '1', 2], c\unique()(new Seq([1, '1', 1, 2])));
$this->assertMap(['a' => 1, 'c' => 2], c\unique()(new Map(['a' => 1, 'b' => 1, 'c' => 2])));
$this->assertSeq([1, 2], c\unique()(self::gen([1, 2, 2, 1])));
}
public function testUniqueComparesObjectsByIdentity()
{
$a = new \stdClass();
$b = new \stdClass();
$this->assertSame([$a, $b], array_values(c\unique()([$a, $b, $a])));
}
public function testUniqueComparesFloatsAndArraysStrictly()
{
$this->assertSame([0 => 1.0, 1 => 1, 2 => [1], 4 => ['1']], c\unique()([1.0, 1, [1], 1.0, ['1'], [1]]));
}
public function testUniqueByKeepsFirst()
{
$items = [['id' => 1, 'n' => 'a'], ['id' => 1, 'n' => 'b'], ['id' => 2, 'n' => 'c']];
$this->assertSame(['a', 'c'], array_column(c\uniqueBy(fn ($o) => $o['id'])($items), 'n'));
$this->assertSame([0, 2], array_keys(c\uniqueBy(fn ($o) => $o['id'])($items)));
$this->assertSame(
['a', 'c'],
c\uniqueBy(fn ($o) => $o['id'])(new Seq($items))->map(fn ($o) => $o['n'])->toArray(),
);
$this->assertSame(['x', 'z'], c\uniqueBy(fn ($o) => $o['id'])(new Map(['x' => $items[0], 'y' => $items[1], 'z' => $items[2]]))->keys()->toArray());
$this->assertCount(2, c\uniqueBy(fn ($o) => $o['id'])(self::gen($items)));
}
public function testUniqueByKeepsTemporaryObjectsDistinct()
{
$this->assertSame([1, 2, 3], c\uniqueBy(fn ($v) => new \stdClass())([1, 2, 3]));
}
// sort / sortBy
public function testSort()
{
$this->assertSame([1 => 1, 2 => 2, 0 => 3], c\sort()([3, 1, 2]));
$this->assertSeq([1, 2, 3], c\sort()(new Seq([3, 1, 2])));
$this->assertMap(['b' => 1, 'a' => 3], c\sort()(new Map(['a' => 3, 'b' => 1])));
$this->assertSeq([1, 2, 3], c\sort()(self::gen([3, 1, 2])));
}
public function testSortWithComparator()
{
$this->assertSame([3, 2, 1], array_values(c\sort(fn ($a, $b) => $b <=> $a)([1, 3, 2])));
}
public function testSortWithInternalComparatorGetsBothArguments()
{
$this->assertSame(['a', 'b', 'c'], array_values(c\sort(strcmp(...))(['c', 'a', 'b'])));
}
public function testSortIsStable()
{
$items = [['k' => 1, 'n' => 'a'], ['k' => 0, 'n' => 'b'], ['k' => 1, 'n' => 'c'], ['k' => 0, 'n' => 'd']];
$this->assertSame(['b', 'd', 'a', 'c'], array_column(c\sort(fn ($a, $b) => $a['k'] <=> $b['k'])($items), 'n'));
$this->assertSame(['a', 'c', 'b', 'd'], array_column(c\sortBy(fn ($o) => $o['k'], descending: true)($items), 'n'));
}
public function testSortBy()
{
$len = fn ($v) => strlen($v);
$this->assertSame([1 => 'a', 2 => 'bb', 0 => 'ccc'], c\sortBy($len)(['ccc', 'a', 'bb']));
$this->assertSame([0 => 'ccc', 2 => 'bb', 1 => 'a'], c\sortBy($len, true)(['ccc', 'a', 'bb']));
$this->assertSeq(['a', 'bb', 'ccc'], c\sortBy($len)(new Seq(['ccc', 'a', 'bb'])));
$this->assertMap(['y' => 'a', 'x' => 'ccc'], c\sortBy($len)(new Map(['x' => 'ccc', 'y' => 'a'])));
$this->assertSeq(['a', 'bb', 'ccc'], c\sortBy($len)(self::gen(['ccc', 'a', 'bb'])));
}
public function testSortByPassesKey()
{
$this->assertSame(['b' => 1, 'a' => 2], c\sortBy(fn ($v, $k) => $k, true)(['a' => 2, 'b' => 1]));
}
public function testSortDoesNotMutateInput()
{
$seq = new Seq([3, 1, 2]);
$map = new Map(['a' => 3, 'b' => 1]);
c\sortBy(fn ($v) => $v)($seq);
c\sort()($seq);
c\sort()($map);
$this->assertSame([3, 1, 2], $seq->toArray());
$this->assertSame(['a' => 3, 'b' => 1], $map->toArray());
}
// slice / take / skip
public function testSlice()
{
$this->assertSame(['b' => 2, 'c' => 3], c\slice(1, 2)(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4]));
$this->assertSeq([2, 3], c\slice(1, 2)(new Seq([1, 2, 3, 4])));
$this->assertMap(['b' => 2, 'c' => 3], c\slice(1, 2)(new Map(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4])));
$this->assertSeq([2, 3], c\slice(1, 2)(self::gen([1, 2, 3, 4])));
}
public function testSliceWithNegativeArguments()
{
$this->assertSame([2 => 3, 3 => 4], c\slice(-2)([1, 2, 3, 4]));
$this->assertSame([1 => 2], c\slice(1, -2)([1, 2, 3, 4]));
$this->assertSeq([3], c\slice(-2, -1)(self::gen([1, 2, 3, 4])));
}
public function testTake()
{
$this->assertSame(['a' => 1], c\take(1)(['a' => 1, 'b' => 2]));
$this->assertSeq([1, 2], c\take(2)(new Seq([1, 2, 3])));
$this->assertMap(['a' => 1], c\take(1)(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([], c\take(0)(new Seq([1, 2, 3])));
}
public function testTakeStopsConsumingGenerator()
{
$infinite = (function () {
for ($i = 1; ; $i++) {
yield $i;
}
})();
$this->assertSeq([1, 2, 3], c\take(3)($infinite));
}
public function testSkip()
{
$this->assertSame(['b' => 2], c\skip(1)(['a' => 1, 'b' => 2]));
$this->assertSeq([3], c\skip(2)(new Seq([1, 2, 3])));
$this->assertMap(['b' => 2], c\skip(1)(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([2, 3], c\skip(1)(self::gen([1, 2, 3])));
}
public function testTakeRejectsNegativeCount()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('take(): count must be >= 0, got -1');
c\take(-1);
}
public function testSkipRejectsNegativeCount()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('skip(): count must be >= 0, got -1');
c\skip(-1);
}
// prepend / append / concat / merge
public function testPrepend()
{
$this->assertSame([0, 1, 1, 2], c\prepend(0, 1)(['a' => 1, 'b' => 2]));
$this->assertSeq([0, 1, 2], c\prepend(0)(new Seq([1, 2])));
$this->assertSeq([0, 1, 2], c\prepend(0)(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([0, 1, 2], c\prepend(0)(self::gen([1, 2])));
}
public function testAppend()
{
$this->assertSame([1, 2, 3, 4], c\append(3, 4)(['a' => 1, 'b' => 2]));
$this->assertSeq([1, 2, 3], c\append(3)(new Seq([1, 2])));
$this->assertSeq([1, 2, 3], c\append(3)(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([1, 2, 3], c\append(3)(self::gen([1, 2])));
}
public function testConcat()
{
$this->assertSame([1, 2, 3, 4], c\concat(['x' => 3, 'a' => 4])(['a' => 1, 'b' => 2]));
$this->assertSeq([1, 2, 3], c\concat(self::gen([3]))(new Seq([1, 2])));
$this->assertSeq([1, 2, 3], c\concat([3])(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([1, 2, 3], c\concat(new Seq([3]))(self::gen([1, 2])));
}
public function testListOperationsOnSetGiveSet()
{
$result = c\append(2, 3)(new Set([1, 2]));
$this->assertInstanceOf(Set::class, $result);
$this->assertSame([1, 2, 3], $result->toArray());
}
public function testMerge()
{
$this->assertSame(['a' => 1, 'b' => 3, 'c' => 4], c\merge(['b' => 3, 'c' => 4])(['a' => 1, 'b' => 2]));
$this->assertSame([0 => 'x', 1 => 'b', 5 => 'c'], c\merge([0 => 'x', 5 => 'c'])(['a', 'b']));
$this->assertSeq([1, 2, 3], c\merge([3])(new Seq([1, 2])));
$this->assertMap(['a' => 1, 'b' => 3, 'c' => 4], c\merge(new Map(['b' => 3, 'c' => 4]))(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([1, 2, 3], c\merge([3])(self::gen([1, 2])));
}
public function testMergeObjectKeyIntoArrayThrows()
{
$map = new Map();
$map->put(new \stdClass(), 1);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('merge(): key of type stdClass cannot be an array key');
c\merge($map)(['a' => 1]);
}
// each / tap
public function testEachDoesNotReplaceValues()
{
$calls = 0;
$seq = new Seq([1, 2, 3]);
$result = c\each(function ($v) use (&$calls) { $calls++; return 'x'; })($seq);
$this->assertSame([1, 2, 3], $result->toArray());
$this->assertSame([1, 2, 3], $seq->toArray());
$this->assertSame(3, $calls);
}
public function testEachPassesValueThenKey()
{
$seen = [];
$fn = function ($v, $k) use (&$seen) { $seen[] = "$k:$v"; };
$this->assertSame(['a' => 1], c\each($fn)(['a' => 1]));
$map = new Map(['b' => 2]);
$this->assertSame($map, c\each($fn)($map));
$this->assertSeq([3], c\each($fn)(self::gen(['c' => 3])));
$this->assertSame(['a:1', 'b:2', 'c:3'], $seen);
}
public function testTapIsEach()
{
$seen = [];
$this->assertSame([1, 2], c\tap(function ($v) use (&$seen) { $seen[] = $v; })([1, 2]));
$this->assertSame([1, 2], $seen);
}
// object keys (Review Focus 3)
public function testMapWithObjectKeysKeepsKeys()
{
$k1 = new \stdClass();
$k2 = new \stdClass();
$map = new Map();
$map->put($k1, 2);
$map->put($k2, 1);
foreach ([
c\map(fn ($v) => $v * 10),
c\filter(),
c\sortBy(fn ($v) => $v),
c\uniqueBy(fn ($v, $k) => $k),
] as $transform) {
$result = $transform($map);
$this->assertInstanceOf(Map::class, $result);
$this->assertTrue($result->hasKey($k1));
$this->assertTrue($result->hasKey($k2));
}
}
}
class TransformsTestHelper
{
public static function double(int $value): int
{
return $value * 2;
}
}