328 lines
10 KiB
PHP
328 lines
10 KiB
PHP
<?php
|
|
/**
|
|
* Copyright 2026 Kacper Donat
|
|
*
|
|
* @author Kacper "Kadet" Donat <kacper@kadet.net>
|
|
*
|
|
* Full license available in separate LICENSE file
|
|
*/
|
|
|
|
use Kadet\Functional as f;
|
|
use Kadet\Functional\Compilable;
|
|
use Kadet\Functional\Decorator;
|
|
use Kadet\Functional\Predicate\ClosurePredicate;
|
|
use Kadet\Functional\Predicates as p;
|
|
use Kadet\Functional\Utils\FunctionPipeline;
|
|
use Kadet\Functional\Utils\PartiallyAppliedFunction;
|
|
use Kadet\Functional\Utils\UnpackedArgs;
|
|
use const Kadet\Functional\_;
|
|
|
|
class CompileTest extends \PHPUnit\Framework\TestCase
|
|
{
|
|
public function testCompileReturnsClosureForPartial()
|
|
{
|
|
$compiled = f\partial(str_replace(...), 'a', _, 'banana')->compile();
|
|
|
|
$this->assertInstanceOf(\Closure::class, $compiled);
|
|
$this->assertNotInstanceOf(Decorator::class, $compiled);
|
|
$this->assertSame('bxnxnx', $compiled('x'));
|
|
}
|
|
|
|
public function testCompiledPartialDoesNotInvokeDecorator()
|
|
{
|
|
$spy = new class(fn ($a, $b) => $a . $b, 'a') extends PartiallyAppliedFunction {
|
|
public int $calls = 0;
|
|
|
|
public function __invoke(...$args)
|
|
{
|
|
$this->calls++;
|
|
return parent::__invoke(...$args);
|
|
}
|
|
|
|
protected function prepareArguments($args)
|
|
{
|
|
$this->calls++;
|
|
return parent::prepareArguments($args);
|
|
}
|
|
};
|
|
|
|
$compiled = $spy->compile();
|
|
|
|
$this->assertSame('ab', $compiled('b'));
|
|
$this->assertSame(0, $spy->calls);
|
|
}
|
|
|
|
public function testCompileMatchesDecoratorForCurried()
|
|
{
|
|
$curried = f\curry(fn ($a, $b, $c) => "$a$b$c");
|
|
$applied = $curried('a');
|
|
$compiled = $applied->compile();
|
|
|
|
$this->assertSame('abc', $compiled('b', 'c'));
|
|
$this->assertSame($applied('b', 'c'), $compiled('b', 'c'));
|
|
}
|
|
|
|
public function testCompiledCurriedDoesNotCurryFurther()
|
|
{
|
|
$applied = f\curry(fn ($a, $b = 'B', $c = 'C') => "$a$b$c", 3, 'a');
|
|
|
|
$this->assertSame($applied->call('b'), $applied->compile()('b'));
|
|
}
|
|
|
|
public function testCompileNestedPartial()
|
|
{
|
|
$f3 = fn ($a, $b, $c) => [$a, $b, $c];
|
|
|
|
$this->assertSame($f3(1, 2, 3), f\compile(f\partial(f\partial($f3, 1), _, 3))(2));
|
|
}
|
|
|
|
public function testCompileAppendsExtraArguments()
|
|
{
|
|
$partial = f\partial(fn (...$args) => $args, 1, _, 3);
|
|
|
|
$this->assertSame($partial(2, 4, 5), f\compile($partial)(2, 4, 5));
|
|
$this->assertSame([1, 2, 3, 4, 5], f\compile($partial)(2, 4, 5));
|
|
}
|
|
|
|
public function testCompileKeepsUnfilledPlaceholders()
|
|
{
|
|
$partial = f\partial(fn (...$args) => $args, _, 2, _);
|
|
|
|
$this->assertSame($partial(1), f\compile($partial)(1));
|
|
$this->assertSame($partial(), f\compile($partial)());
|
|
}
|
|
|
|
public function testCompileDropsExtraArgumentsForInternalTarget()
|
|
{
|
|
$this->assertSame('aa', f\compile(f\partial(str_repeat(...), _, 2))('a', 'extra'));
|
|
}
|
|
|
|
public function testCompileKeepsExtraArgumentsForVariadicInternalTarget()
|
|
{
|
|
$this->assertSame(['a', 'b', 'c'], f\compile(f\partial(array_merge(...), ['a']))(['b'], ['c']));
|
|
}
|
|
|
|
public function testCompileDropsExtraArgumentsForCurriedInternalTarget()
|
|
{
|
|
$this->assertSame('aa', f\compile(f\curry(str_repeat(...), null, 'a'))(2, 'extra'));
|
|
}
|
|
|
|
public function testCompileSinglePlaceholderOfInternalTarget()
|
|
{
|
|
$leading = f\partial(str_pad(...), _, 3, '0', STR_PAD_LEFT);
|
|
$middle = f\partial(substr(...), 'abcdef', _, 2);
|
|
|
|
$this->assertSame('007', f\compile($leading)('7', 'extra'));
|
|
$this->assertSame('cd', f\compile($middle)(2, 'extra'));
|
|
$this->assertSame($this->outcome(fn () => $leading()), $this->outcome(fn () => f\compile($leading)()));
|
|
$this->assertSame($this->outcome(fn () => $middle()), $this->outcome(fn () => f\compile($middle)()));
|
|
}
|
|
|
|
public function testCompileWithFewerArgumentsThanPlaceholdersMatchesDecorator()
|
|
{
|
|
$partial = f\partial(str_repeat(...), _, _);
|
|
|
|
$this->assertSame($this->outcome(fn () => $partial('a')), $this->outcome(fn () => f\compile($partial)('a')));
|
|
}
|
|
|
|
private function outcome(callable $call)
|
|
{
|
|
try {
|
|
return ['result', $call()];
|
|
} catch (\Throwable $error) {
|
|
return ['error', get_class($error), $error->getMessage()];
|
|
}
|
|
}
|
|
|
|
public function testCompileFunctionDelegatesToMethod()
|
|
{
|
|
$partial = f\partial(str_replace(...), 'a', _, 'banana');
|
|
|
|
$this->assertSame($partial->compile()('o'), f\compile($partial)('o'));
|
|
}
|
|
|
|
public function testCompilePassesThroughClosures()
|
|
{
|
|
$closure = fn ($x) => $x;
|
|
|
|
$this->assertSame($closure, f\compile($closure));
|
|
}
|
|
|
|
public function testCompileConvertsCallableToClosure()
|
|
{
|
|
$compiled = f\compile('strtoupper');
|
|
|
|
$this->assertInstanceOf(\Closure::class, $compiled);
|
|
$this->assertSame('ABC', $compiled('abc'));
|
|
}
|
|
|
|
public function testDecoratorsAreCompilable()
|
|
{
|
|
$this->assertInstanceOf(Compilable::class, f\partial('strtoupper'));
|
|
$this->assertInstanceOf(Compilable::class, f\curry('strtoupper'));
|
|
$this->assertInstanceOf(Compilable::class, f\fix(fn ($f) => $f));
|
|
}
|
|
|
|
public function testCompiledFixedFunctionReferencesItself()
|
|
{
|
|
$compiled = f\fix(function ($f) use (&$compiled) {
|
|
$this->assertSame($f, $compiled);
|
|
|
|
return fn ($x) => $x;
|
|
})->compile();
|
|
|
|
$this->assertSame(1, $compiled(1));
|
|
}
|
|
|
|
public function testCompiledFixedFunctionRecurses()
|
|
{
|
|
$compiled = f\compile(f\fix(function ($f) {
|
|
return function ($x) use ($f) { return $x >= 2 ? $f($x - 1) * $x : 1; };
|
|
}));
|
|
|
|
$this->assertInstanceOf(\Closure::class, $compiled);
|
|
$this->assertSame(6, $compiled(3));
|
|
$this->assertSame(24, $compiled(4));
|
|
}
|
|
|
|
public function testCompiledFixedCurriedFunction()
|
|
{
|
|
$compiled = f\compile(f\fix(f\curry(function ($f, $x) { return $x >= 2 ? $f($x - 1) * $x : 1; })));
|
|
|
|
$this->assertSame(6, $compiled(3));
|
|
$this->assertSame(24, $compiled(4));
|
|
}
|
|
|
|
public function testPipelineAndPredicatesAreCompilable()
|
|
{
|
|
$this->assertInstanceOf(Compilable::class, f\pipe('strtoupper'));
|
|
$this->assertInstanceOf(Compilable::class, f\predicate(fn ($x) => $x));
|
|
$this->assertInstanceOf(Compilable::class, f\not(fn ($x) => $x));
|
|
$this->assertInstanceOf(Compilable::class, f\all(fn ($x) => $x));
|
|
$this->assertInstanceOf(Compilable::class, f\any(fn ($x) => $x));
|
|
$this->assertInstanceOf(Compilable::class, f\always());
|
|
}
|
|
|
|
public function testCompiledPipelineDoesNotInvokePipeline()
|
|
{
|
|
$spy = new class(trim(...), strtoupper(...)) extends FunctionPipeline {
|
|
public int $calls = 0;
|
|
|
|
public function __invoke(...$args)
|
|
{
|
|
$this->calls++;
|
|
return parent::__invoke(...$args);
|
|
}
|
|
};
|
|
|
|
$compiled = $spy->compile();
|
|
|
|
$this->assertSame('A', $compiled(' a '));
|
|
$this->assertSame(0, $spy->calls);
|
|
}
|
|
|
|
public function testCompiledPipelineMatchesPipeline()
|
|
{
|
|
$pipelines = [
|
|
f\pipe(),
|
|
f\pipe(fn (...$args) => $args),
|
|
f\pipe(trim(...), strtoupper(...)),
|
|
f\pipe(fn ($a, $b) => $a + $b, fn ($x) => $x * 2, fn ($x) => $x - 1),
|
|
f\pipe(fn ($a, $b) => new UnpackedArgs([$b, $a]), fn ($a, $b) => "$a$b"),
|
|
];
|
|
|
|
foreach ($pipelines as $pipeline) {
|
|
$this->assertEquals($pipeline(1, 2), f\compile($pipeline)(1, 2));
|
|
}
|
|
}
|
|
|
|
public function testCompiledPipelineKeepsCurrying()
|
|
{
|
|
$pipeline = f\pipe(strtoupper(...), f\curry(fn ($a, $b) => "$a$b"));
|
|
|
|
$this->assertSame($pipeline('a')('b'), f\compile($pipeline)('a')('b'));
|
|
}
|
|
|
|
public function testCompileUnwrapsClosureOfCompilable()
|
|
{
|
|
$spy = new class(strtoupper(...)) extends FunctionPipeline {
|
|
public int $calls = 0;
|
|
|
|
public function __invoke(...$args)
|
|
{
|
|
$this->calls++;
|
|
return parent::__invoke(...$args);
|
|
}
|
|
};
|
|
|
|
$this->assertSame('A', f\compile(\Closure::fromCallable($spy))('a'));
|
|
$this->assertSame('A', f\compile($spy(...))('a'));
|
|
$this->assertSame(0, $spy->calls);
|
|
}
|
|
|
|
public function testCompiledPredicatesMatchPredicates()
|
|
{
|
|
$values = [0, 1, 2, 3, '1', 'x'];
|
|
$cases = [
|
|
[f\predicate(fn ($x) => $x), $values],
|
|
[f\predicate(is_numeric(...)), $values],
|
|
[p\gt(1), $values],
|
|
[p\gt(1)->not(), $values],
|
|
[f\not(fn ($x) => $x > 1), $values],
|
|
[f\all(p\gt(0), p\lt(3)), $values],
|
|
[f\any(p\lt(1), p\gt(2)), $values],
|
|
[f\always(), $values],
|
|
[f\never(), $values],
|
|
[p\property('x', p\gt(1)), [(object) ['x' => 1], (object) ['x' => 2]]],
|
|
];
|
|
|
|
foreach ($cases as [$predicate, $inputs]) {
|
|
$compiled = f\compile($predicate);
|
|
|
|
foreach ($inputs as $value) {
|
|
$this->assertSame($predicate($value), $compiled($value));
|
|
}
|
|
}
|
|
}
|
|
|
|
public function testCompiledPredicateDoesNotInvokePredicate()
|
|
{
|
|
$inner = new class(fn ($x) => $x > 1) extends ClosurePredicate {
|
|
public int $calls = 0;
|
|
|
|
public function __invoke(...$args): bool
|
|
{
|
|
$this->calls++;
|
|
return parent::__invoke(...$args);
|
|
}
|
|
};
|
|
|
|
$compiled = f\compile(f\all($inner->not(), p\lt(5)));
|
|
|
|
$this->assertTrue($compiled(1));
|
|
$this->assertFalse($compiled(2));
|
|
$this->assertSame(0, $inner->calls);
|
|
}
|
|
|
|
public function testUnaryCompiledCallablesMatchWithExtraArguments()
|
|
{
|
|
$callables = [
|
|
f\pipe(fn ($x) => $x, strtoupper(...)),
|
|
f\predicate(fn ($x) => $x === 'a'),
|
|
f\not(fn ($x) => $x === 'a'),
|
|
f\all(fn ($x) => $x === 'a', p\same('a')),
|
|
f\any(fn ($x) => $x === 'b', p\same('a')),
|
|
];
|
|
|
|
foreach ($callables as $callable) {
|
|
$this->assertSame($callable('a', 'extra'), f\compile($callable)('a', 'extra'));
|
|
}
|
|
}
|
|
|
|
public function testCompiledEmptyCombinatorsTakeNoArguments()
|
|
{
|
|
$this->assertSame(f\all()(), f\compile(f\all())());
|
|
$this->assertSame(f\any()(), f\compile(f\any())());
|
|
}
|
|
}
|