Skip to content

Commit 4ed803f

Browse files
committed
feat(Bench): Add attribute and interceptors
1 parent 99202ed commit 4ed803f

File tree

7 files changed

+296
-0
lines changed

7 files changed

+296
-0
lines changed

src/Bench/BenchWith.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Bench;
6+
7+
use Testo\Bench\Internal\BenchWithInterceptor;
8+
use Testo\Pipeline\Attribute\FallbackInterceptor;
9+
use Testo\Pipeline\Attribute\Interceptable;
10+
11+
/**
12+
* Attribute to specify additional functions to benchmark with.
13+
*
14+
* @api
15+
*/
16+
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION | \Attribute::IS_REPEATABLE)]
17+
#[FallbackInterceptor(BenchWithInterceptor::class)]
18+
final class BenchWith implements Interceptable
19+
{
20+
/**
21+
* @param array<callable|array{class-string, non-empty-string}> $callables Functions to benchmark with.
22+
* It might be a callable or an array with class name and non-public method name.
23+
*/
24+
public function __construct(
25+
public readonly array $callables,
26+
public readonly array $arguments = [],
27+
) {}
28+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Bench\Exception;
6+
7+
use Testo\Bench\BenchWith;
8+
use Testo\Bench\Internal\BenchWithInterceptor;
9+
use Testo\Core\Context\TestInfo;
10+
11+
/**
12+
* Thrown when {@see BenchWith} attribute is missing in {@see BenchInvoker}.
13+
*
14+
* This indicates a broken pipeline where the {@see BenchWithInterceptor} middleware
15+
* did not execute as expected. The {@see BenchWith} attribute should have been set
16+
* by the interceptor before reaching the invoker.
17+
*
18+
* @internal
19+
*/
20+
final class BenchWithAttributeMissingException extends \LogicException
21+
{
22+
public static function fromTestInfo(TestInfo $info): self
23+
{
24+
return new self(
25+
\sprintf(
26+
'Target BenchWith attribute is missing for `%s%s()`. This indicates a broken test pipeline.',
27+
$info->caseInfo->definition->reflection === null
28+
? ''
29+
: $info->caseInfo->definition->reflection->getName() . '::',
30+
$info->testDefinition->reflection->getName(),
31+
),
32+
);
33+
}
34+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Bench\Internal;
6+
7+
use Testo\Bench\BenchWith;
8+
use Testo\Bench\Exception\BenchWithAttributeMissingException;
9+
use Testo\Core\Context\TestInfo;
10+
11+
final class BenchInvoker
12+
{
13+
public function __invoke(TestInfo $info): mixed
14+
{
15+
$attr = $info->getAttribute(BenchWith::class);
16+
$attr instanceof BenchWith or throw BenchWithAttributeMissingException::fromTestInfo($info);
17+
18+
# TODO: bench
19+
# Execute the method
20+
$result = $info->caseInfo->instance === null || $info->testDefinition->reflection->isStatic()
21+
? $info->testDefinition->reflection->invoke(null, ...$info->arguments)
22+
: $info->testDefinition->reflection->invoke($info->caseInfo->instance->getInstance(), ...$info->arguments);
23+
24+
return $result;
25+
}
26+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Bench\Internal;
6+
7+
use Psr\EventDispatcher\EventDispatcherInterface;
8+
use Testo\Bench\BenchWith;
9+
use Testo\Core\Context\TestInfo;
10+
use Testo\Core\Context\TestResult;
11+
use Testo\Core\Value\Status;
12+
use Testo\Data\MultipleResult;
13+
use Testo\Event\Test\TestBatchFinished;
14+
use Testo\Event\Test\TestBatchStarting;
15+
use Testo\Event\Test\TestDataSetFinished;
16+
use Testo\Event\Test\TestDataSetStarting;
17+
use Testo\Pipeline\Attribute\InterceptorOptions;
18+
use Testo\Pipeline\Middleware\TestRunInterceptor;
19+
use Testo\Pipeline\Policy\ConflictPolicy;
20+
21+
/**
22+
* Handles {@see BenchWith} attribute by running
23+
*
24+
* @internal
25+
*/
26+
#[InterceptorOptions(order: InterceptorOptions::ORDER_DATA_PROVIDER, onConflict: ConflictPolicy::First)]
27+
final class BenchWithInterceptor implements TestRunInterceptor
28+
{
29+
public function __construct(
30+
private readonly EventDispatcherInterface $eventDispatcher,
31+
) {}
32+
33+
#[\Override]
34+
public function runTest(TestInfo $info, callable $next): TestResult
35+
{
36+
/** @var BenchWith[] $attributes */
37+
$attributes = $info->getAttribute(BenchWith::class);
38+
if ($attributes === []) {
39+
return $next($info);
40+
}
41+
42+
if (\count($attributes) === 1) {
43+
$attr = \reset($attributes);
44+
return $next($info->with(arguments: $attr->arguments)->withAttribute(BenchWith::class, $attr));
45+
}
46+
47+
# Dispatch batch starting event
48+
$this->eventDispatcher->dispatch(new TestBatchStarting($info));
49+
50+
# Run the test for each data set
51+
$results = [];
52+
$status = Status::Passed;
53+
foreach ($attributes as $index => $attr) {
54+
$newInfo = $info->with(arguments: $attr->arguments)->withAttribute(BenchWith::class, $attr);
55+
$label = "$index";
56+
57+
# Dispatch dataset starting event
58+
$this->eventDispatcher->dispatch(
59+
new TestDataSetStarting($newInfo, $label, null, $index),
60+
);
61+
62+
try {
63+
$result = $next($newInfo);
64+
} catch (\Throwable $throwable) {
65+
$result = new TestResult(info: $newInfo, status: Status::Error, failure: $throwable);
66+
}
67+
68+
# Dispatch dataset finished event
69+
$this->eventDispatcher->dispatch(
70+
new TestDataSetFinished($newInfo, $result, $label, null, $index),
71+
);
72+
73+
unset($attr, $newInfo);
74+
$result->status->isFailure() and ($status = Status::Failed);
75+
$results[] = $result;
76+
}
77+
78+
$results = new MultipleResult($results);
79+
80+
$finalResult = new TestResult(info: $info, status: $status, result: $results, attributes: [
81+
MultipleResult::class => $results,
82+
]);
83+
84+
# Dispatch batch finished event
85+
$this->eventDispatcher->dispatch(new TestBatchFinished($info, $finalResult));
86+
87+
return $finalResult;
88+
}
89+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Bench\Middleware;
6+
7+
use Testo\Bench\BenchWith;
8+
use Testo\Bench\Internal\BenchInvoker;
9+
use Testo\Common\Reflection;
10+
use Testo\Core\Context\TestInfo;
11+
use Testo\Core\Definition\CaseDefinitions;
12+
use Testo\Pipeline\Attribute\InterceptorOptions;
13+
use Testo\Pipeline\Middleware\CaseLocatorInterceptor;
14+
use Testo\Pipeline\Middleware\FileLocatorInterceptor;
15+
use Testo\Tokenizer\Reflection\FileDefinitions;
16+
use Testo\Tokenizer\Reflection\TokenizedFile;
17+
18+
/**
19+
* Finds benchmarks defined with the {@see BenchWith} attribute.
20+
*/
21+
#[InterceptorOptions(order: -20_000)]
22+
final class BenchFinder implements FileLocatorInterceptor, CaseLocatorInterceptor
23+
{
24+
/** @var \Closure(TestInfo): mixed Invoker for the test method. */
25+
private readonly \Closure $invoker;
26+
27+
public function __construct(BenchInvoker $invoker)
28+
{
29+
$this->invoker = $invoker(...);
30+
}
31+
32+
#[\Override]
33+
public function locateFile(TokenizedFile $file, callable $next): ?bool
34+
{
35+
return $file->getClasses() !== [] || $file->getFunctions() !== [] ? true : $next($file);
36+
}
37+
38+
#[\Override]
39+
public function locateTestCases(FileDefinitions $file, callable $next): CaseDefinitions
40+
{
41+
echo 123;
42+
// Define cases for classes
43+
foreach ($file->classes as $class) {
44+
if ($class->isAbstract()) {
45+
continue;
46+
}
47+
48+
$case = null;
49+
foreach ($class->getMethods() as $method) {
50+
if (Reflection::fetchFunctionAttributes($method, attributeClass: BenchWith::class)) {
51+
if ($case === null) {
52+
$case = $file->cases->define($class, $file);
53+
$case->invoker = $this->invoker;
54+
}
55+
56+
$case->tests->define($method);
57+
}
58+
}
59+
}
60+
61+
if ($file->functions === []) {
62+
return $next($file);
63+
}
64+
65+
// Define a case for functions
66+
// Implement a lazy case definition
67+
$case = null;
68+
foreach ($file->functions as $function) {
69+
if (Reflection::fetchFunctionAttributes($function, attributeClass: BenchWith::class)) {
70+
if ($case === null) {
71+
$case = $file->cases->define(null, $file);
72+
$case->invoker = $this->invoker;
73+
}
74+
75+
$case->tests->define($function);
76+
}
77+
}
78+
79+
return $next($file);
80+
}
81+
}

tests/Bench/Self/BenchWithAttr.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Bench\Self;
6+
7+
use Testo\Bench\BenchWith;
8+
9+
final class BenchWithAttr
10+
{
11+
#[BenchWith([
12+
[self::class, 'sumSlow'],
13+
], arguments: [1, 2])]
14+
public static function sumFast(int $a, int $b): int
15+
{
16+
return $a + $b;
17+
}
18+
19+
public static function sumSlow(int $a, int $b): int
20+
{
21+
return (int) \array_sum([$a, $b]);
22+
}
23+
}

tests/Bench/suites.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Testo\Application\Config\FinderConfig;
6+
use Testo\Application\Config\SuiteConfig;
7+
8+
return [
9+
new SuiteConfig(
10+
name: 'Bench/Self',
11+
location: new FinderConfig(
12+
include: [__DIR__ . '/Self'],
13+
),
14+
),
15+
];

0 commit comments

Comments
 (0)