This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Fn;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Fn\Compose;
|
||||
|
||||
final class ComposeTest extends TestCase
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// apply()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testApplyExecutesRightToLeft(): void
|
||||
{
|
||||
$result = Compose::apply(
|
||||
-3,
|
||||
fn(int $n) => $n * 2, // applied third: 4 * 2 = 8
|
||||
fn(int $n) => $n + 1, // applied second: 3 + 1 = 4
|
||||
fn(int $n) => abs($n), // applied first: abs(-3) = 3
|
||||
);
|
||||
|
||||
self::assertSame(8, $result);
|
||||
}
|
||||
|
||||
public function testApplyWithNoFnsReturnsValueUnchanged(): void
|
||||
{
|
||||
self::assertSame(42, Compose::apply(42));
|
||||
}
|
||||
|
||||
public function testApplyWithSingleFn(): void
|
||||
{
|
||||
self::assertSame('HELLO', Compose::apply('hello', 'strtoupper'));
|
||||
}
|
||||
|
||||
public function testApplyRightToLeftOrderWithStrings(): void
|
||||
{
|
||||
// trim first, then strtolower, then replace spaces
|
||||
$result = Compose::apply(
|
||||
' Hello World ',
|
||||
fn(string $s) => str_replace(' ', '-', $s),
|
||||
'strtolower',
|
||||
'trim',
|
||||
);
|
||||
|
||||
self::assertSame('hello-world', $result);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// build()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testBuildReturnsReusableClosure(): void
|
||||
{
|
||||
$process = Compose::build(
|
||||
fn(string $s) => str_replace(' ', '-', $s),
|
||||
'strtolower',
|
||||
'trim',
|
||||
);
|
||||
|
||||
self::assertSame('hello-world', $process(' Hello World '));
|
||||
self::assertSame('foo-bar', $process(' FOO BAR '));
|
||||
}
|
||||
|
||||
public function testBuildWithNoFnsReturnsValueUnchanged(): void
|
||||
{
|
||||
$fn = Compose::build();
|
||||
|
||||
self::assertSame('hello', $fn('hello'));
|
||||
}
|
||||
|
||||
public function testBuildReturnsClosure(): void
|
||||
{
|
||||
self::assertInstanceOf(\Closure::class, Compose::build('trim'));
|
||||
}
|
||||
|
||||
public function testBuildIsEquivalentToPipelineWithReversedStages(): void
|
||||
{
|
||||
$double = fn(int $n) => $n * 2;
|
||||
$increment = fn(int $n) => $n + 1;
|
||||
$absolute = fn(int $n) => abs($n);
|
||||
|
||||
$composed = Compose::build($double, $increment, $absolute);
|
||||
|
||||
self::assertSame(8, $composed(-3));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Fn;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Fn\Memoize;
|
||||
|
||||
final class MemoizeTest extends TestCase
|
||||
{
|
||||
public function testCallableIsExecutedOnFirstCall(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$memoized = Memoize::wrap(function (int $n) use (&$calls): int {
|
||||
$calls++;
|
||||
|
||||
return $n * 2;
|
||||
});
|
||||
|
||||
self::assertSame(84, $memoized(42));
|
||||
self::assertSame(1, $calls);
|
||||
}
|
||||
|
||||
public function testSameArgumentsReturnCachedValue(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$memoized = Memoize::wrap(function (int $n) use (&$calls): int {
|
||||
$calls++;
|
||||
|
||||
return $n * 2;
|
||||
});
|
||||
|
||||
$memoized(42);
|
||||
$memoized(42);
|
||||
$memoized(42);
|
||||
|
||||
self::assertSame(1, $calls);
|
||||
}
|
||||
|
||||
public function testDifferentArgumentsAreComputedSeparately(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$memoized = Memoize::wrap(function (int $n) use (&$calls): int {
|
||||
$calls++;
|
||||
|
||||
return $n * 2;
|
||||
});
|
||||
|
||||
self::assertSame(84, $memoized(42));
|
||||
self::assertSame(198, $memoized(99));
|
||||
self::assertSame(2, $calls);
|
||||
}
|
||||
|
||||
public function testNullReturnIsCachedCorrectly(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$memoized = Memoize::wrap(function () use (&$calls): ?string {
|
||||
$calls++;
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
self::assertNull($memoized());
|
||||
self::assertNull($memoized());
|
||||
self::assertSame(1, $calls);
|
||||
}
|
||||
|
||||
public function testFlushClearsCache(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$memoized = Memoize::wrap(function (int $n) use (&$calls): int {
|
||||
$calls++;
|
||||
|
||||
return $n;
|
||||
});
|
||||
|
||||
$memoized(1);
|
||||
$memoized->flush();
|
||||
$memoized(1);
|
||||
|
||||
self::assertSame(2, $calls);
|
||||
}
|
||||
|
||||
public function testFlushDoesNotAffectSubsequentCaching(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$memoized = Memoize::wrap(function (int $n) use (&$calls): int {
|
||||
$calls++;
|
||||
|
||||
return $n;
|
||||
});
|
||||
|
||||
$memoized(5);
|
||||
$memoized->flush();
|
||||
$memoized(5);
|
||||
$memoized(5);
|
||||
|
||||
self::assertSame(2, $calls);
|
||||
}
|
||||
|
||||
public function testMultipleArgumentsFormUniqueKey(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$memoized = Memoize::wrap(function (int $a, int $b) use (&$calls): int {
|
||||
$calls++;
|
||||
|
||||
return $a + $b;
|
||||
});
|
||||
|
||||
self::assertSame(3, $memoized(1, 2));
|
||||
self::assertSame(3, $memoized(1, 2));
|
||||
self::assertSame(5, $memoized(2, 3));
|
||||
self::assertSame(2, $calls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Fn;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Fn\Once;
|
||||
|
||||
final class OnceTest extends TestCase
|
||||
{
|
||||
public function testCallableIsExecutedOnFirstCall(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$once = Once::wrap(function () use (&$calls): int {
|
||||
$calls++;
|
||||
|
||||
return 42;
|
||||
});
|
||||
|
||||
$result = $once();
|
||||
|
||||
self::assertSame(42, $result);
|
||||
self::assertSame(1, $calls);
|
||||
}
|
||||
|
||||
public function testCallableIsNotExecutedAgainOnSubsequentCalls(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$once = Once::wrap(function () use (&$calls): int {
|
||||
$calls++;
|
||||
|
||||
return 42;
|
||||
});
|
||||
|
||||
$once();
|
||||
$once();
|
||||
$once();
|
||||
|
||||
self::assertSame(1, $calls);
|
||||
}
|
||||
|
||||
public function testCachedValueIsReturnedOnSubsequentCalls(): void
|
||||
{
|
||||
$once = Once::wrap(fn(): string => 'hello');
|
||||
|
||||
self::assertSame('hello', $once());
|
||||
self::assertSame('hello', $once());
|
||||
}
|
||||
|
||||
public function testNullReturnIsCachedCorrectly(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$once = Once::wrap(function () use (&$calls): ?string {
|
||||
$calls++;
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
self::assertNull($once());
|
||||
self::assertNull($once());
|
||||
self::assertSame(1, $calls);
|
||||
}
|
||||
|
||||
public function testEachInstanceIsIndependent(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$fn = function () use (&$calls): int {
|
||||
return ++$calls;
|
||||
};
|
||||
|
||||
$a = Once::wrap($fn);
|
||||
$b = Once::wrap($fn);
|
||||
|
||||
self::assertSame(1, $a());
|
||||
self::assertSame(2, $b());
|
||||
self::assertSame(1, $a());
|
||||
self::assertSame(2, $b());
|
||||
self::assertSame(2, $calls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Fn;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Fn\Partial;
|
||||
|
||||
final class PartialTest extends TestCase
|
||||
{
|
||||
public function testPrefillsSingleArgument(): void
|
||||
{
|
||||
$multiply = fn(int $a, int $b): int => $a * $b;
|
||||
|
||||
$double = Partial::apply($multiply, 2);
|
||||
$triple = Partial::apply($multiply, 3);
|
||||
|
||||
self::assertSame(10, $double(5));
|
||||
self::assertSame(15, $triple(5));
|
||||
}
|
||||
|
||||
public function testPrefillsMultipleArguments(): void
|
||||
{
|
||||
$fn = fn(int $a, int $b, int $c): int => $a + $b + $c;
|
||||
|
||||
$addFive = Partial::apply($fn, 2, 3);
|
||||
|
||||
self::assertSame(15, $addFive(10));
|
||||
}
|
||||
|
||||
public function testReturnsClosure(): void
|
||||
{
|
||||
$fn = Partial::apply('strtoupper');
|
||||
|
||||
self::assertInstanceOf(\Closure::class, $fn);
|
||||
}
|
||||
|
||||
public function testWorksWithBuiltinFunctions(): void
|
||||
{
|
||||
$implodeWithComma = Partial::apply('implode', ', ');
|
||||
|
||||
self::assertSame('a, b, c', $implodeWithComma(['a', 'b', 'c']));
|
||||
}
|
||||
|
||||
public function testWorksWithNoRemainingArguments(): void
|
||||
{
|
||||
$greet = fn(string $greeting, string $name): string => "{$greeting}, {$name}!";
|
||||
|
||||
$sayHello = Partial::apply($greet, 'Hello', 'World');
|
||||
|
||||
self::assertSame('Hello, World!', $sayHello());
|
||||
}
|
||||
|
||||
public function testPrefillsNoArguments(): void
|
||||
{
|
||||
$fn = fn(int $n): int => $n * 2;
|
||||
$wrapped = Partial::apply($fn);
|
||||
|
||||
self::assertSame(10, $wrapped(5));
|
||||
}
|
||||
|
||||
public function testComposesWithPipeline(): void
|
||||
{
|
||||
$addPrefix = Partial::apply(fn(string $prefix, string $s) => $prefix . $s, '>>> ');
|
||||
|
||||
$pipeline = \Tez\Utils\Fn\Pipeline::build(
|
||||
'strtoupper',
|
||||
$addPrefix,
|
||||
);
|
||||
|
||||
self::assertSame('>>> HELLO', $pipeline('hello'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Fn;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Fn\Pipeline;
|
||||
|
||||
final class PipelineTest extends TestCase
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// process()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testProcessPassesValueThroughStages(): void
|
||||
{
|
||||
$result = Pipeline::process(
|
||||
' Hello World ',
|
||||
'trim',
|
||||
'strtolower',
|
||||
fn(string $s) => str_replace(' ', '-', $s),
|
||||
);
|
||||
|
||||
self::assertSame('hello-world', $result);
|
||||
}
|
||||
|
||||
public function testProcessWithNoStagesReturnsPayloadUnchanged(): void
|
||||
{
|
||||
self::assertSame(42, Pipeline::process(42));
|
||||
}
|
||||
|
||||
public function testProcessWithSingleStage(): void
|
||||
{
|
||||
self::assertSame('HELLO', Pipeline::process('hello', 'strtoupper'));
|
||||
}
|
||||
|
||||
public function testProcessPassesReturnValueOfEachStageToNext(): void
|
||||
{
|
||||
$log = [];
|
||||
|
||||
Pipeline::process(
|
||||
1,
|
||||
function (int $v) use (&$log): int {
|
||||
$log[] = $v;
|
||||
|
||||
return $v + 1;
|
||||
},
|
||||
function (int $v) use (&$log): int {
|
||||
$log[] = $v;
|
||||
|
||||
return $v + 1;
|
||||
},
|
||||
function (int $v) use (&$log): int {
|
||||
$log[] = $v;
|
||||
|
||||
return $v + 1;
|
||||
},
|
||||
);
|
||||
|
||||
self::assertSame([1, 2, 3], $log);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// build()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testBuildReturnsReusableClosure(): void
|
||||
{
|
||||
$normalize = Pipeline::build(
|
||||
'trim',
|
||||
'strtolower',
|
||||
fn(string $s) => preg_replace('/\s+/', ' ', $s) ?? '',
|
||||
);
|
||||
|
||||
self::assertSame('hello world', $normalize(' Hello World '));
|
||||
self::assertSame('foo bar', $normalize(' FOO BAR '));
|
||||
}
|
||||
|
||||
public function testBuildWithNoStagesReturnsPayloadUnchanged(): void
|
||||
{
|
||||
$pipe = Pipeline::build();
|
||||
|
||||
self::assertSame('hello', $pipe('hello'));
|
||||
}
|
||||
|
||||
public function testBuildReturnsClosure(): void
|
||||
{
|
||||
self::assertInstanceOf(\Closure::class, Pipeline::build('trim'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Fn;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Fn\Retry;
|
||||
|
||||
final class RetryTest extends TestCase
|
||||
{
|
||||
public function testSucceedsOnFirstAttempt(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$result = Retry::run(function () use (&$calls): string {
|
||||
$calls++;
|
||||
|
||||
return 'ok';
|
||||
});
|
||||
|
||||
self::assertSame('ok', $result);
|
||||
self::assertSame(1, $calls);
|
||||
}
|
||||
|
||||
public function testRetriesAndSucceedsOnSecondAttempt(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$result = Retry::run(function () use (&$calls): string {
|
||||
$calls++;
|
||||
|
||||
if ($calls < 2) {
|
||||
throw new \RuntimeException('fail');
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}, maxAttempts: 3);
|
||||
|
||||
self::assertSame('ok', $result);
|
||||
self::assertSame(2, $calls);
|
||||
}
|
||||
|
||||
public function testThrowsLastExceptionAfterAllAttempts(): void
|
||||
{
|
||||
$calls = 0;
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('attempt 3');
|
||||
|
||||
Retry::run(function () use (&$calls): never {
|
||||
$calls++;
|
||||
|
||||
throw new \RuntimeException('attempt ' . $calls);
|
||||
}, maxAttempts: 3);
|
||||
}
|
||||
|
||||
public function testMaxAttemptsIsRespected(): void
|
||||
{
|
||||
$calls = 0;
|
||||
|
||||
try {
|
||||
Retry::run(function () use (&$calls): never {
|
||||
$calls++;
|
||||
|
||||
throw new \RuntimeException();
|
||||
}, maxAttempts: 4);
|
||||
} catch (\RuntimeException) {
|
||||
}
|
||||
|
||||
self::assertSame(4, $calls);
|
||||
}
|
||||
|
||||
public function testOnlyRetiesOnMatchingExceptionType(): void
|
||||
{
|
||||
$calls = 0;
|
||||
|
||||
$result = Retry::run(function () use (&$calls): string {
|
||||
$calls++;
|
||||
|
||||
if ($calls === 1) {
|
||||
throw new \InvalidArgumentException('retry me');
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}, maxAttempts: 3, only: [\InvalidArgumentException::class]);
|
||||
|
||||
self::assertSame('ok', $result);
|
||||
self::assertSame(2, $calls);
|
||||
}
|
||||
|
||||
public function testOnlyThrowsImmediatelyForNonMatchingType(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
Retry::run(function (): never {
|
||||
throw new \RuntimeException('not in only list');
|
||||
}, maxAttempts: 3, only: [\InvalidArgumentException::class]);
|
||||
}
|
||||
|
||||
public function testExceptThrowsImmediatelyForMatchingType(): void
|
||||
{
|
||||
$calls = 0;
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
Retry::run(function () use (&$calls): never {
|
||||
$calls++;
|
||||
|
||||
throw new \InvalidArgumentException('stop immediately');
|
||||
}, maxAttempts: 3, except: [\InvalidArgumentException::class]);
|
||||
}
|
||||
|
||||
public function testExceptAllowsRetryForNonMatchingType(): void
|
||||
{
|
||||
$calls = 0;
|
||||
|
||||
$result = Retry::run(function () use (&$calls): string {
|
||||
$calls++;
|
||||
|
||||
if ($calls === 1) {
|
||||
throw new \RuntimeException('retry ok');
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}, maxAttempts: 3, except: [\InvalidArgumentException::class]);
|
||||
|
||||
self::assertSame('ok', $result);
|
||||
self::assertSame(2, $calls);
|
||||
}
|
||||
|
||||
public function testBackoffWithZeroDoesNotSleep(): void
|
||||
{
|
||||
// Just verify it runs correctly without any sleep delay
|
||||
$calls = 0;
|
||||
$result = Retry::run(function () use (&$calls): string {
|
||||
$calls++;
|
||||
|
||||
if ($calls < 3) {
|
||||
throw new \RuntimeException();
|
||||
}
|
||||
|
||||
return 'done';
|
||||
}, maxAttempts: 3, backoffMs: 0);
|
||||
|
||||
self::assertSame('done', $result);
|
||||
}
|
||||
|
||||
public function testSubclassMatchesOnlyFilter(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$result = Retry::run(function () use (&$calls): string {
|
||||
$calls++;
|
||||
|
||||
if ($calls === 1) {
|
||||
// LogicException extends \Exception which extends \Throwable
|
||||
throw new \LogicException('subclass');
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}, maxAttempts: 3, only: [\Exception::class]);
|
||||
|
||||
self::assertSame('ok', $result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Fn;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Fn\Tap;
|
||||
|
||||
final class TapTest extends TestCase
|
||||
{
|
||||
public function testReturnsOriginalValue(): void
|
||||
{
|
||||
$result = Tap::value('hello', fn($v) => strtoupper($v));
|
||||
|
||||
self::assertSame('hello', $result);
|
||||
}
|
||||
|
||||
public function testCallableSideEffectIsExecuted(): void
|
||||
{
|
||||
$called = false;
|
||||
|
||||
Tap::value('anything', function () use (&$called): void {
|
||||
$called = true;
|
||||
});
|
||||
|
||||
self::assertTrue($called);
|
||||
}
|
||||
|
||||
public function testCallableReceivesTheValue(): void
|
||||
{
|
||||
$received = null;
|
||||
|
||||
Tap::value(42, function (int $v) use (&$received): void {
|
||||
$received = $v;
|
||||
});
|
||||
|
||||
self::assertSame(42, $received);
|
||||
}
|
||||
|
||||
public function testReturnValueOfCallableIsDiscarded(): void
|
||||
{
|
||||
$result = Tap::value('original', fn() => 'ignored');
|
||||
|
||||
self::assertSame('original', $result);
|
||||
}
|
||||
|
||||
public function testWorksWithObjects(): void
|
||||
{
|
||||
$obj = new \stdClass();
|
||||
$result = Tap::value($obj, fn($v) => null);
|
||||
|
||||
self::assertSame($obj, $result);
|
||||
}
|
||||
|
||||
public function testWorksWithNull(): void
|
||||
{
|
||||
$result = Tap::value(null, fn($v) => 'ignored');
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user