This commit is contained in:
icefox 2025-12-22 17:54:16 -03:00
commit a5ce423afe
No known key found for this signature in database
30 changed files with 1807 additions and 0 deletions

View file

@ -0,0 +1,58 @@
<?php
use IceFox\Aspect\AspectException;
use PHPUnit\Framework\TestCase;
use Tests\Aspects\ThrowingAspect;
use Tests\Classes\ThrowingClass;
final class AspectExceptionTest extends TestCase
{
protected function setUp(): void
{
ThrowingAspect::reset();
}
public function testExceptionInBeforeIsWrappedInAspectException(): void
{
ThrowingAspect::$throwInBefore = true;
$instance = new ThrowingClass();
try {
$instance->methodWithAspect();
$this->fail('Expected AspectException to be thrown');
} catch (AspectException $e) {
$this->assertEquals('Exception in before() method of aspect', $e->getMessage());
$this->assertEquals(ThrowingAspect::class, $e->aspectClass);
$this->assertEquals('before', $e->method);
$this->assertInstanceOf(\RuntimeException::class, $e->getPrevious());
$this->assertEquals('Exception thrown in before()', $e->getPrevious()->getMessage());
}
}
public function testExceptionInAfterIsWrappedInAspectException(): void
{
ThrowingAspect::$throwInAfter = true;
$instance = new ThrowingClass();
try {
$instance->methodWithAspect();
$this->fail('Expected AspectException to be thrown');
} catch (AspectException $e) {
$this->assertEquals('Exception in after() method of aspect', $e->getMessage());
$this->assertEquals(ThrowingAspect::class, $e->aspectClass);
$this->assertEquals('after', $e->method);
$this->assertInstanceOf(\RuntimeException::class, $e->getPrevious());
$this->assertEquals('Exception thrown in after()', $e->getPrevious()->getMessage());
}
}
public function testMethodExecutesSuccessfullyWhenNoExceptions(): void
{
$instance = new ThrowingClass();
$result = $instance->methodWithAspect();
$this->assertEquals('success', $result);
}
}

View file

@ -0,0 +1,67 @@
<?php
use PHPUnit\Framework\TestCase;
use Tests\Classes\StackedAspectsClass;
use Tests\Aspects\LoggingAspect;
final class AspectStackingTest extends TestCase
{
private StackedAspectsClass $instance;
protected function setUp(): void
{
$this->instance = new StackedAspectsClass();
LoggingAspect::clearLogs();
}
public function testMultipleAspectsExecuteInOrder(): void
{
$tracker = (object) ['before' => false, 'after' => false];
$result = $this->instance->multipleAspects(10, $tracker);
$this->assertEquals(20, $result);
$this->assertTrue($tracker->before);
$this->assertTrue($tracker->after);
$this->assertCount(2, LoggingAspect::$logs);
$this->assertEquals('logging_before', LoggingAspect::$logs[0]['type']);
$this->assertEquals('logging_after', LoggingAspect::$logs[1]['type']);
$this->assertEquals(20, LoggingAspect::$logs[1]['result']);
}
public function testSingleBasicAspect(): void
{
$tracker = (object) ['before' => false, 'after' => false];
$result = $this->instance->onlyBasic(5, $tracker);
$this->assertEquals(6, $result);
$this->assertTrue($tracker->before);
$this->assertTrue($tracker->after);
$this->assertEmpty(LoggingAspect::$logs);
}
public function testSingleLoggingAspect(): void
{
$result = $this->instance->onlyLogging('hello');
$this->assertEquals('HELLO', $result);
$this->assertCount(2, LoggingAspect::$logs);
$this->assertEquals('logging_before', LoggingAspect::$logs[0]['type']);
$this->assertEquals('logging_after', LoggingAspect::$logs[1]['type']);
}
public function testNoAspects(): void
{
$result = $this->instance->noAspects();
$this->assertEquals('plain', $result);
$this->assertEmpty(LoggingAspect::$logs);
}
public function testAspectsReceiveCorrectArguments(): void
{
$tracker = (object) ['before' => false, 'after' => false];
$this->instance->multipleAspects(15, $tracker);
$this->assertEquals([15, $tracker], LoggingAspect::$logs[0]['args']);
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace Tests\Aspects;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class BasicAspect
{
public ?object $object = null;
public function before(object|string $target, mixed ...$args): void
{
$this->object = end($args);
$this->object->before = true;
}
public function after(object|string $target, mixed $return): mixed
{
$this->object->after = true;
return $return;
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace Tests\Aspects;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class ConfigurableAspect
{
public static array $executionLog = [];
public function __construct(
public readonly string $prefix = 'default',
public readonly int $multiplier = 1,
public readonly bool $enabled = true,
) {
}
public function before(object|string $target, mixed ...$args): void
{
if ($this->enabled) {
self::$executionLog[] = [
'event' => 'before',
'prefix' => $this->prefix,
'multiplier' => $this->multiplier,
'args' => $args,
];
}
}
public function after(object|string $target, mixed $result): mixed
{
if (!$this->enabled) {
return $result;
}
self::$executionLog[] = [
'event' => 'after',
'prefix' => $this->prefix,
'multiplier' => $this->multiplier,
'result' => $result,
];
// Modify result based on constructor parameters
if (is_int($result)) {
return $result * $this->multiplier;
}
if (is_string($result)) {
return $this->prefix . $result;
}
return $result;
}
public static function reset(): void
{
self::$executionLog = [];
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace Tests\Aspects;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class LoggingAspect
{
public static array $logs = [];
public function before(object|string $target, mixed ...$args): void
{
self::$logs[] = ['type' => 'logging_before', 'args' => $args];
}
public function after(object|string $target, mixed $result): mixed
{
self::$logs[] = ['type' => 'logging_after', 'result' => $result];
return $result;
}
public static function clearLogs(): void
{
self::$logs = [];
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace Tests\Aspects;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class ModifyingAspect
{
public static mixed $modifier = null;
public function after(object|string $target, mixed $result): mixed
{
if (self::$modifier !== null) {
return (self::$modifier)($result);
}
return $result; // Return original value when not modifying
}
public static function reset(): void
{
self::$modifier = null;
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace Tests\Aspects;
use Attribute;
use RuntimeException;
#[Attribute(Attribute::TARGET_METHOD)]
class ThrowingAspect
{
public static bool $throwInBefore = false;
public static bool $throwInAfter = false;
public function before(object|string $target, mixed ...$args): void
{
if (self::$throwInBefore) {
throw new RuntimeException('Exception thrown in before()');
}
}
public function after(object|string $target, mixed $result): mixed
{
if (self::$throwInAfter) {
throw new RuntimeException('Exception thrown in after()');
}
return $result;
}
public static function reset(): void
{
self::$throwInBefore = false;
self::$throwInAfter = false;
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace Tests\Aspects;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class TrackingAspect
{
public static array $calls = [];
public function before(object|string $target, mixed ...$args): void
{
self::$calls[] = ['event' => 'before', 'args' => $args];
}
public function after(object|string $target, mixed $result): mixed
{
self::$calls[] = ['event' => 'after', 'result' => $result];
return $result;
}
public static function clearCalls(): void
{
self::$calls = [];
}
public static function getLastBefore(): ?array
{
foreach (array_reverse(self::$calls) as $call) {
if ($call['event'] === 'before') {
return $call;
}
}
return null;
}
public static function getLastAfter(): ?array
{
foreach (array_reverse(self::$calls) as $call) {
if ($call['event'] === 'after') {
return $call;
}
}
return null;
}
}

View file

@ -0,0 +1,163 @@
<?php
use PHPUnit\Framework\TestCase;
use Tests\Aspects\ConfigurableAspect;
use Tests\Classes\ConfigurableClass;
final class AttributeArgumentsTest extends TestCase
{
private ConfigurableClass $instance;
protected function setUp(): void
{
$this->instance = new ConfigurableClass();
ConfigurableAspect::reset();
}
public function testAttributeArgumentsArePassedToConstructor(): void
{
$result = $this->instance->customConfigMethod();
// Verify the result was modified using the constructor arguments
$this->assertEquals('PREFIX:value', $result);
// Verify the execution log contains the constructor parameters
$this->assertCount(2, ConfigurableAspect::$executionLog);
$beforeLog = ConfigurableAspect::$executionLog[0];
$this->assertEquals('before', $beforeLog['event']);
$this->assertEquals('PREFIX:', $beforeLog['prefix']);
$this->assertEquals(10, $beforeLog['multiplier']);
$afterLog = ConfigurableAspect::$executionLog[1];
$this->assertEquals('after', $afterLog['event']);
$this->assertEquals('PREFIX:', $afterLog['prefix']);
$this->assertEquals(10, $afterLog['multiplier']);
}
public function testDifferentMethodsCanHaveDifferentConfigurations(): void
{
$result1 = $this->instance->customConfigMethod();
ConfigurableAspect::reset();
$result2 = $this->instance->anotherMethod();
// First method uses prefix: 'PREFIX:', multiplier: 10
$this->assertEquals('PREFIX:value', $result1);
// Second method uses prefix: 'BEFORE_', multiplier: 5
$this->assertEquals(35, $result2); // 7 * 5
// Verify execution log for second method
$afterLog = ConfigurableAspect::$executionLog[1];
$this->assertEquals('BEFORE_', $afterLog['prefix']);
$this->assertEquals(5, $afterLog['multiplier']);
}
public function testDisabledAspectDoesNotModifyResult(): void
{
$result = $this->instance->disabledMethod();
// enabled: false should prevent modification
$this->assertEquals('should not be modified', $result);
// No logs should be created when disabled
$this->assertEmpty(ConfigurableAspect::$executionLog);
}
public function testDefaultConstructorArgumentsAreUsed(): void
{
$result = $this->instance->defaultConfigMethod();
// Default multiplier is 1, so 42 * 1 = 42
$this->assertEquals(42, $result);
// Verify default values in execution log
$beforeLog = ConfigurableAspect::$executionLog[0];
$this->assertEquals('default', $beforeLog['prefix']);
$this->assertEquals(1, $beforeLog['multiplier']);
}
public function testConstructorArgumentsAffectBehavior(): void
{
// Test with multiplier
$result = $this->instance->anotherMethod();
$this->assertEquals(35, $result); // 7 * 5 = 35
ConfigurableAspect::reset();
// Test with prefix
$result = $this->instance->customConfigMethod();
$this->assertEquals('PREFIX:value', $result);
}
public function testEachConstructorArgumentIsActuallyUsed(): void
{
// Test 1: Verify 'prefix' argument is used
ConfigurableAspect::reset();
$result = $this->instance->customConfigMethod();
$this->assertEquals('PREFIX:value', $result, 'prefix argument should prepend to string result');
$afterLog = ConfigurableAspect::$executionLog[1];
$this->assertEquals('PREFIX:', $afterLog['prefix'], 'prefix constructor argument should be stored and used');
// Test 2: Verify 'multiplier' argument is used
ConfigurableAspect::reset();
$result = $this->instance->anotherMethod();
$this->assertEquals(35, $result, 'multiplier argument should multiply integer result (7 * 5 = 35)');
$afterLog = ConfigurableAspect::$executionLog[1];
$this->assertEquals(5, $afterLog['multiplier'], 'multiplier constructor argument should be stored and used');
// Test 3: Verify 'enabled' argument is used
ConfigurableAspect::reset();
$result = $this->instance->disabledMethod();
$this->assertEquals('should not be modified', $result, 'enabled=false should prevent result modification');
$this->assertEmpty(ConfigurableAspect::$executionLog, 'enabled=false should prevent logging');
}
public function testPartialConstructorArgumentsWithDefaults(): void
{
// disabledMethod uses only 'enabled: false', should use defaults for prefix and multiplier
ConfigurableAspect::reset();
$this->instance->disabledMethod();
// Since enabled=false, no logs - but we can test with default values
ConfigurableAspect::reset();
$result = $this->instance->defaultConfigMethod();
// Should use all defaults: prefix='default', multiplier=1, enabled=true
$this->assertEquals(42, $result, 'default multiplier (1) should not change result');
$beforeLog = ConfigurableAspect::$executionLog[0];
$this->assertEquals('default', $beforeLog['prefix'], 'default prefix should be "default"');
$this->assertEquals(1, $beforeLog['multiplier'], 'default multiplier should be 1');
$afterLog = ConfigurableAspect::$executionLog[1];
$this->assertEquals('default', $afterLog['prefix'], 'default prefix should persist in after hook');
$this->assertEquals(1, $afterLog['multiplier'], 'default multiplier should persist in after hook');
}
public function testAllThreeConstructorArgumentsSimultaneously(): void
{
ConfigurableAspect::reset();
// customConfigMethod uses: prefix='PREFIX:', multiplier=10, enabled=true (default)
$result = $this->instance->customConfigMethod();
// Verify all three arguments are working together
$this->assertEquals('PREFIX:value', $result);
$beforeLog = ConfigurableAspect::$executionLog[0];
$this->assertEquals('before', $beforeLog['event']);
$this->assertEquals('PREFIX:', $beforeLog['prefix'], 'First argument (prefix) should be used');
$this->assertEquals(10, $beforeLog['multiplier'], 'Second argument (multiplier) should be used');
// Third argument (enabled=true) is verified by the fact that logs exist
$afterLog = ConfigurableAspect::$executionLog[1];
$this->assertEquals('after', $afterLog['event']);
$this->assertEquals('PREFIX:', $afterLog['prefix'], 'First argument should persist in after hook');
$this->assertEquals(10, $afterLog['multiplier'], 'Second argument should persist in after hook');
// Verify the log exists (proving enabled=true was used)
$this->assertCount(2, ConfigurableAspect::$executionLog, 'Third argument (enabled=true) should allow logging');
}
}

17
tests/BasicAspectTest.php Normal file
View file

@ -0,0 +1,17 @@
<?php
use PHPUnit\Framework\TestCase;
use Tests\Classes\WrappedClass;
final class BasicAspectTest extends TestCase
{
public function testAspectMethodIsCalled(): void
{
$sideEffect = (object) ['before' => false, 'after' => false];
$c = new WrappedClass();
$r = $c->wrappedMethod(1, 3, $sideEffect);
$this->assertEquals(4, $r);
$this->assertTrue($sideEffect->before);
$this->assertTrue($sideEffect->after);
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace Tests\Classes;
use Tests\Aspects\ConfigurableAspect;
class ConfigurableClass
{
#[ConfigurableAspect(prefix: 'PREFIX:', multiplier: 10)]
public function customConfigMethod(): string
{
return 'value';
}
#[ConfigurableAspect(prefix: 'BEFORE_', multiplier: 5)]
public function anotherMethod(): int
{
return 7;
}
#[ConfigurableAspect(enabled: false)]
public function disabledMethod(): string
{
return 'should not be modified';
}
#[ConfigurableAspect] // Uses default values
public function defaultConfigMethod(): int
{
return 42;
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace Tests\Classes;
use Tests\Aspects\ModifyingAspect;
class ModifyingClass
{
#[ModifyingAspect]
public function getValue(): int
{
return 42;
}
#[ModifyingAspect]
public function getString(): string
{
return 'original';
}
#[ModifyingAspect]
public function getArray(): array
{
return ['key' => 'value'];
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace Tests\Classes;
use Tests\Aspects\TrackingAspect;
class ParameterTypesClass
{
#[TrackingAspect]
public function withNullable(?string $value): ?string
{
return $value;
}
#[TrackingAspect]
public function withVariadic(string ...$items): array
{
return $items;
}
#[TrackingAspect]
public function withReference(int &$counter): void
{
$counter++;
}
#[TrackingAspect]
public function withUnionType(int|string $value): int|string
{
return $value;
}
#[TrackingAspect]
public function withMixed(mixed $data): mixed
{
return $data;
}
#[TrackingAspect]
public function withArray(array $data): array
{
return array_merge($data, ['processed' => true]);
}
#[TrackingAspect]
public function withDefaultValues(string $name = 'default', int $age = 0): string
{
return "$name:$age";
}
#[TrackingAspect]
public function voidReturn(): void
{
}
#[TrackingAspect]
protected function protectedMethod(string $value): string
{
return strtoupper($value);
}
public function callProtected(string $value): string
{
return $this->protectedMethod($value);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace Tests\Classes;
use Tests\Aspects\BasicAspect;
use Tests\Aspects\LoggingAspect;
class StackedAspectsClass
{
#[BasicAspect]
#[LoggingAspect]
public function multipleAspects(int $value, object $tracker): int
{
return $value * 2;
}
#[BasicAspect]
public function onlyBasic(int $value, object $tracker): int
{
return $value + 1;
}
#[LoggingAspect]
public function onlyLogging(string $message): string
{
return strtoupper($message);
}
public function noAspects(): string
{
return 'plain';
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace Tests\Classes;
use Tests\Aspects\ThrowingAspect;
class ThrowingClass
{
#[ThrowingAspect]
public function methodWithAspect(): string
{
return 'success';
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace Tests\Classes;
use Tests\Aspects\BasicAspect;
class WrappedClass
{
#[BasicAspect]
public function wrappedMethod(int $a, int $b, object $sideEffect): int
{
return $a + $b;
}
}

View file

@ -0,0 +1,98 @@
<?php
use PHPUnit\Framework\TestCase;
use Tests\Classes\ParameterTypesClass;
use Tests\Aspects\TrackingAspect;
final class ParameterTypesTest extends TestCase
{
private ParameterTypesClass $instance;
protected function setUp(): void
{
$this->instance = new ParameterTypesClass();
TrackingAspect::clearCalls();
}
public function testNullableParameter(): void
{
$result = $this->instance->withNullable('test');
$this->assertEquals('test', $result);
$this->assertCount(2, TrackingAspect::$calls);
TrackingAspect::clearCalls();
$result = $this->instance->withNullable(null);
$this->assertNull($result);
$this->assertCount(2, TrackingAspect::$calls);
}
public function testVariadicParameters(): void
{
$result = $this->instance->withVariadic('a', 'b', 'c');
$this->assertEquals(['a', 'b', 'c'], $result);
$result = $this->instance->withVariadic();
$this->assertEquals([], $result);
}
public function testReferenceParameter(): void
{
$counter = 5;
$this->instance->withReference($counter);
$this->assertEquals(6, $counter);
}
public function testUnionType(): void
{
$result = $this->instance->withUnionType(42);
$this->assertEquals(42, $result);
$result = $this->instance->withUnionType('hello');
$this->assertEquals('hello', $result);
}
public function testMixedType(): void
{
$result = $this->instance->withMixed(['key' => 'value']);
$this->assertEquals(['key' => 'value'], $result);
$result = $this->instance->withMixed('string');
$this->assertEquals('string', $result);
$result = $this->instance->withMixed(null);
$this->assertNull($result);
}
public function testArrayParameter(): void
{
$result = $this->instance->withArray(['original' => true]);
$this->assertEquals(['original' => true, 'processed' => true], $result);
}
public function testDefaultValues(): void
{
$result = $this->instance->withDefaultValues();
$this->assertEquals('default:0', $result);
$result = $this->instance->withDefaultValues('John', 25);
$this->assertEquals('John:25', $result);
$result = $this->instance->withDefaultValues('Jane');
$this->assertEquals('Jane:0', $result);
}
public function testVoidReturnType(): void
{
$this->instance->voidReturn();
$this->assertCount(2, TrackingAspect::$calls);
$this->assertEquals('before', TrackingAspect::$calls[0]['event']);
$this->assertEquals('after', TrackingAspect::$calls[1]['event']);
$this->assertNull(TrackingAspect::$calls[1]['result']);
}
public function testProtectedMethod(): void
{
$result = $this->instance->callProtected('hello');
$this->assertEquals('HELLO', $result);
}
}

View file

@ -0,0 +1,91 @@
<?php
use PHPUnit\Framework\TestCase;
use Tests\Aspects\ModifyingAspect;
use Tests\Classes\ModifyingClass;
final class ReturnModificationTest extends TestCase
{
private ModifyingClass $instance;
protected function setUp(): void
{
$this->instance = new ModifyingClass();
ModifyingAspect::reset();
}
public function testAfterReturningOriginalValueDoesNotModifyResult(): void
{
ModifyingAspect::$modifier = fn($result) => $result;
$result = $this->instance->getValue();
$this->assertEquals(42, $result);
}
public function testAfterCanModifyIntegerResult(): void
{
ModifyingAspect::$modifier = fn($result) => $result * 2;
$result = $this->instance->getValue();
$this->assertEquals(84, $result);
}
public function testAfterCanModifyStringResult(): void
{
ModifyingAspect::$modifier = fn($result) => strtoupper($result);
$result = $this->instance->getString();
$this->assertEquals('ORIGINAL', $result);
}
public function testAfterCanIncrementIntegerResult(): void
{
ModifyingAspect::$modifier = fn($result) => $result + 100;
$result = $this->instance->getValue();
$this->assertEquals(142, $result);
}
public function testAfterCanModifyArrayResult(): void
{
ModifyingAspect::$modifier = fn($result) => array_merge($result, ['added' => 'new']);
$result = $this->instance->getArray();
$this->assertEquals(['key' => 'value', 'added' => 'new'], $result);
}
public function testAfterCanFilterArrayResult(): void
{
ModifyingAspect::$modifier = fn($result) => array_filter($result, fn($v) => $v !== 'value');
$result = $this->instance->getArray();
$this->assertEquals([], $result);
}
public function testMultipleCallsWithDifferentModifiers(): void
{
ModifyingAspect::$modifier = fn($result) => $result * 2;
$result1 = $this->instance->getValue();
ModifyingAspect::$modifier = fn($result) => $result + 10;
$result2 = $this->instance->getValue();
$this->assertEquals(84, $result1);
$this->assertEquals(52, $result2);
}
public function testNoModifierReturnsOriginalValue(): void
{
// ModifyingAspect::$modifier is null, so after() returns original value
$result = $this->instance->getValue();
$this->assertEquals(42, $result);
}
}

39
tests/bootstrap.php Normal file
View file

@ -0,0 +1,39 @@
<?php
use IceFox\Aspect\AspectBuilder;
use IceFox\Aspect\AspectWeaver;
use Psr\Log\NullLogger;
use Tests\Aspects\BasicAspect;
use Tests\Aspects\LoggingAspect;
use Tests\Aspects\TrackingAspect;
use Tests\Aspects\ThrowingAspect;
use Tests\Aspects\ModifyingAspect;
use Tests\Aspects\ConfigurableAspect;
use Tests\Classes\WrappedClass;
use Tests\Classes\ParameterTypesClass;
use Tests\Classes\StackedAspectsClass;
use Tests\Classes\ThrowingClass;
use Tests\Classes\ModifyingClass;
use Tests\Classes\ConfigurableClass;
$cacheDir = sys_get_temp_dir() . '/cache/php-aop-cache';
$useCache = false;
$weaver = new AspectWeaver(
[BasicAspect::class, LoggingAspect::class, TrackingAspect::class, ThrowingAspect::class, ModifyingAspect::class, ConfigurableAspect::class],
$cacheDir,
$useCache,
new NullLogger(),
);
$loader = AspectBuilder::begin()
->withClasses([
WrappedClass::class,
ParameterTypesClass::class,
StackedAspectsClass::class,
ThrowingClass::class,
ModifyingClass::class,
ConfigurableClass::class,
])
->build($weaver)
->register();