Initial commit after splitting from main project
PHP Composer / build (push) Successful in 38s

This commit is contained in:
2026-07-26 17:27:01 +02:00
commit afc5538e56
24 changed files with 1431 additions and 0 deletions
+89
View File
@@ -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));
}
}
+116
View File
@@ -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);
}
}
+81
View File
@@ -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);
}
}
+74
View File
@@ -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'));
}
}
+91
View File
@@ -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'));
}
}
+163
View File
@@ -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);
}
}
+62
View File
@@ -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);
}
}