* * 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 testFilterWithByReferenceCallbackDoesNotWarn() { $warnings = []; set_error_handler(function (int $level, string $message) use (&$warnings) { $warnings[] = $message; return true; }); try { $array = c\filter(fn (&$v) => $v > 1)([1, 2, 3]); $seq = c\filter(fn (&$v) => $v > 1)(new Seq([1, 2, 3])); $both = c\filter(fn (&$v, $k) => $k > 0)([1, 2, 3]); } finally { restore_error_handler(); } $this->assertSame([], $warnings); $this->assertSame([1 => 2, 2 => 3], $array); $this->assertSeq([2, 3], $seq); $this->assertSame([1 => 2, 2 => 3], $both); } public function testByReferenceCallbacksWriteBackOutsideNativeFilter() { // Where the callback is called with a variable, its changes to the value are kept, as in 1.0.0. $trim = function (&$v) { $v = trim($v); return $v !== ''; }; $gen = fn () => self::gen([' a ', ' ', 'b ']); $this->assertSame(['a', 'b'], iterator_to_array(\Kadet\Functional\Collections\Lazy\filter($trim)($gen()), false)); $this->assertSame([1 => ''], c\reject($trim)([' a ', ' ', 'b '])); $this->assertSame('x', c\find($trim)(new Seq([' x ']))); $this->assertSame('a', c\first($trim)($gen())); } 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 testSortByIsStableForTies() { $items = [['k' => 1, 'n' => 'a'], ['k' => 0, 'n' => 'b'], ['k' => 1, 'n' => 'c'], ['k' => 0, 'n' => 'd'], ['k' => 1, 'n' => 'e']]; $this->assertSame(['b', 'd', 'a', 'c', 'e'], array_column(c\sortBy(fn ($o) => $o['k'])($items), 'n')); $this->assertSame(['b', 'd', 'a', 'c', 'e'], array_column(c\sortBy(fn ($o) => $o['k'])(new Seq($items))->toArray(), 'n')); } public function testSortByDescendingIsStableForTies() { $items = [['k' => 0, 'n' => 'a'], ['k' => 1, 'n' => 'b'], ['k' => 0, 'n' => 'c'], ['k' => 1, 'n' => 'd'], ['k' => 0, 'n' => 'e']]; $this->assertSame(['b', 'd', 'a', 'c', 'e'], array_column(c\sortBy(fn ($o) => $o['k'], true)($items), 'n')); $this->assertSame(['b', 'd', 'a', 'c', 'e'], array_column(c\sortBy(fn ($o) => $o['k'], true)(new Seq($items))->toArray(), 'n')); } public function testSortByWithDateTimeKeys() { $at = fn (string $time) => new \DateTimeImmutable("2026-01-01 $time"); $items = ['c' => $at('10:00'), 'a' => $at('08:00'), 'd' => $at('10:00:01'), 'b' => $at('09:30')]; $this->assertSame(['a', 'b', 'c', 'd'], array_keys(c\sortBy(fn ($v) => $v)($items))); $this->assertSame(['d', 'c', 'b', 'a'], array_keys(c\sortBy(fn ($v) => $v, true)($items))); } public function testSortByMixedNumericKeys() { $values = [3, 1.5, -2, 2.0, 0, 1, -0.5]; $this->assertSame([-2, -0.5, 0, 1, 1.5, 2.0, 3], c\sortBy(fn ($v) => $v)(new Seq($values))->toArray()); $this->assertSame([3, 2.0, 1.5, 1, 0, -0.5, -2], array_values(c\sortBy(fn ($v) => $v, true)($values))); } public function testSortAndSortByCompareLikeSpaceship() { $keys = [10, '9', 'abc', null, 2.5, '10', true, 'b', '', 0, [1, 2], [1], '1e1', ' 9', false]; $ascending = $keys; usort($ascending, fn ($a, $b) => $a <=> $b); $descending = $keys; usort($descending, fn ($a, $b) => $b <=> $a); $this->assertSame($ascending, array_values(c\sortBy(fn ($v) => $v)($keys))); $this->assertSame($descending, array_values(c\sortBy(fn ($v) => $v, true)($keys))); $this->assertSame($ascending, array_values(c\sort()($keys))); } public function testSortByKeepsObjectKeysOfMap() { $a = new \stdClass(); $b = new \stdClass(); $map = new Map(); $map->put($a, 2); $map->put($b, 1); $sorted = c\sortBy(fn ($v) => $v)($map); $this->assertInstanceOf(Map::class, $sorted); $this->assertSame([$b, $a], $sorted->keys()->toArray()); $this->assertSame([1, 2], $sorted->values()->toArray()); } public function testSortWithComparatorKeepsObjectKeysOfMap() { $a = new \stdClass(); $b = new \stdClass(); $map = new Map(); $map->put($a, 1); $map->put($b, 2); $sorted = c\sort(fn ($x, $y) => $y <=> $x)($map); $this->assertInstanceOf(Map::class, $sorted); $this->assertSame([$b, $a], $sorted->keys()->toArray()); $this->assertSame([2, 1], $sorted->values()->toArray()); } public function testSortByDoesNotMutateInput() { $array = ['x' => 3, 'y' => 1, 'z' => 2]; $seq = new Seq([3, 1, 2]); c\sortBy(fn ($v) => $v)($array); c\sortBy(fn ($v) => $v, true)($seq); c\sort()($array); $this->assertSame(['x' => 3, 'y' => 1, 'z' => 2], $array); $this->assertSame([3, 1, 2], $seq->toArray()); } 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; } }