Skip to content

Commit bb39de2

Browse files
authored
feat: Add DataZip and DataCross attributes (#93)
1 parent da25a2a commit bb39de2

File tree

12 files changed

+458
-15
lines changed

12 files changed

+458
-15
lines changed

src/Common/Reflection.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ public static function getAttributesFromCallStack(
201201
$reflection = match (true) {
202202
isset($frame['class'], $frame['function']) => new \ReflectionMethod(
203203
$frame['class'],
204-
$frame['function']
204+
$frame['function'],
205205
),
206206
isset($frame['function']) => new \ReflectionFunction($frame['function']),
207207
default => null,

src/Data/DataCross.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Data;
6+
7+
use Testo\Data\Internal\DataProviderAttribute;
8+
use Testo\Data\Internal\DataProviderInterceptor;
9+
use Testo\Pipeline\Attribute\FallbackInterceptor;
10+
use Testo\Pipeline\Attribute\Interceptable;
11+
12+
/**
13+
* Crosses multiple data providers together.
14+
*
15+
* Each data set will contain one value from each provider, combined into an array.
16+
* All possible combinations of values from the providers will be generated.
17+
*
18+
* @api
19+
*/
20+
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION | \Attribute::IS_REPEATABLE)]
21+
#[FallbackInterceptor(DataProviderInterceptor::class)]
22+
final class DataCross implements Interceptable, DataProviderAttribute
23+
{
24+
/**
25+
* @param array<DataProviderAttribute> $providers Data providers to cross together.
26+
*/
27+
public readonly array $providers;
28+
29+
public function __construct(DataProviderAttribute ...$providers)
30+
{
31+
$this->providers = $providers;
32+
}
33+
}

src/Data/DataZip.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Data;
6+
7+
use Testo\Data\Internal\DataProviderAttribute;
8+
use Testo\Data\Internal\DataProviderInterceptor;
9+
use Testo\Pipeline\Attribute\FallbackInterceptor;
10+
use Testo\Pipeline\Attribute\Interceptable;
11+
12+
/**
13+
* Zips multiple data providers together.
14+
*
15+
* Each data set will contain one value from each provider, combined into an array.
16+
* If the providers have different lengths, `null` will be used for missing values.
17+
*
18+
* @api
19+
*/
20+
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION | \Attribute::IS_REPEATABLE)]
21+
#[FallbackInterceptor(DataProviderInterceptor::class)]
22+
final class DataZip implements Interceptable, DataProviderAttribute
23+
{
24+
/**
25+
* @param array<DataProvider> $providers Data providers to zip together.
26+
*/
27+
public readonly array $providers;
28+
29+
public function __construct(DataProvider ...$providers)
30+
{
31+
$this->providers = $providers;
32+
}
33+
}

src/Data/Internal/DataProviderInterceptor.php

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
use Testo\Core\Context\TestResult;
1010
use Testo\Core\Filter\DataPointer;
1111
use Testo\Core\Value\Status;
12+
use Testo\Data\DataCross;
1213
use Testo\Data\DataProvider;
1314
use Testo\Data\DataSet;
15+
use Testo\Data\DataZip;
1416
use Testo\Data\MultipleResult;
1517
use Testo\Event\Test\TestBatchFinished;
1618
use Testo\Event\Test\TestBatchStarting;
@@ -26,6 +28,12 @@
2628
#[InterceptorOptions(order: InterceptorOptions::ORDER_DATA_PROVIDER, onConflict: ConflictPolicy::First)]
2729
final class DataProviderInterceptor implements TestRunInterceptor
2830
{
31+
/** Key separator for DataZip (parallel combination). */
32+
private const KEY_SEPARATOR_ZIP = '|';
33+
34+
/** Key separator for DataCross (cartesian product). */
35+
private const KEY_SEPARATOR_CROSS = '×';
36+
2937
public function __construct(
3038
private readonly EventDispatcherInterface $eventDispatcher,
3139
) {}
@@ -55,11 +63,7 @@ public function runTest(TestInfo $info, callable $next): TestResult
5563

5664
$attr = $attribute->newInstance();
5765

58-
$datasets = match (true) {
59-
$attr instanceof DataProvider => self::fromDataProvider($info, $attr),
60-
$attr instanceof DataSet => [($attr->name ?? 0) => $attr->arguments],
61-
default => throw new \RuntimeException('Unknown Data Provider Attribute type.'),
62-
};
66+
$datasets = self::extractDataSets($info, $attr);
6367

6468
# Handle each data set
6569
$results = [];
@@ -160,6 +164,17 @@ public function run(
160164
return $result;
161165
}
162166

167+
private static function extractDataSets(TestInfo $info, object $attr): iterable
168+
{
169+
return match (true) {
170+
$attr instanceof DataProvider => self::fromDataProvider($info, $attr),
171+
$attr instanceof DataSet => [($attr->name ?? 0) => $attr->arguments],
172+
$attr instanceof DataCross => self::fromDataCross($info, $attr),
173+
$attr instanceof DataZip => self::fromDataZip($info, $attr),
174+
default => throw new \RuntimeException('Unknown Data Provider Attribute type.'),
175+
};
176+
}
177+
163178
/**
164179
* Extract data sets from a DataProvider attribute.
165180
*/
@@ -177,7 +192,7 @@ private static function fromDataProvider(TestInfo $info, DataProvider $attribute
177192
$m = $class->getMethod($provider);
178193
$provider = match (true) {
179194
$m->isStatic() => $m->getClosure(null),
180-
default => static fn() => $m->getClosure($info->caseInfo->instance->getInstance()),
195+
default => static fn () => $m->getClosure($info->caseInfo->instance->getInstance()),
181196
};
182197
}
183198

@@ -194,4 +209,96 @@ private static function fromDataProvider(TestInfo $info, DataProvider $attribute
194209

195210
return $datasets;
196211
}
212+
213+
/**
214+
* Extract data sets from a DataZip attribute.
215+
*/
216+
private static function fromDataZip(TestInfo $info, DataZip $attr): iterable
217+
{
218+
$generators = \array_map(
219+
static fn ($providerAttr): DeferredGenerator => DeferredGenerator::fromHandler(
220+
static function () use ($info, $providerAttr) {
221+
yield from self::fromDataProvider($info, $providerAttr);
222+
},
223+
),
224+
$attr->providers,
225+
);
226+
227+
dataset:
228+
$allFinished = true;
229+
$dataSet = [];
230+
$key = [];
231+
foreach ($generators as $gen) {
232+
if ($gen->valid()) {
233+
$allFinished = false;
234+
$dataSet[] = $gen->current();
235+
$key[] = $gen->key();
236+
$gen->next();
237+
} else {
238+
$key[] = '';
239+
$dataSet[] = null;
240+
}
241+
}
242+
243+
if ($allFinished) {
244+
return;
245+
}
246+
247+
yield \implode(self::KEY_SEPARATOR_ZIP, $key) => \array_merge(...$dataSet);
248+
goto dataset;
249+
}
250+
251+
/**
252+
* Extract data sets from a DataCross attribute (cartesian product).
253+
*/
254+
private static function fromDataCross(TestInfo $info, DataCross $attr): iterable
255+
{
256+
// Collect all datasets from each provider into arrays
257+
$allDatasets = [];
258+
foreach ($attr->providers as $providerAttr) {
259+
$datasets = [];
260+
foreach (self::extractDataSets($info, $providerAttr) as $key => $dataset) {
261+
$datasets[$key] = $dataset;
262+
}
263+
if ($datasets === []) {
264+
return; // If any provider is empty, no combinations possible
265+
}
266+
$allDatasets[] = $datasets;
267+
}
268+
269+
if ($allDatasets === []) {
270+
return;
271+
}
272+
273+
yield from self::cartesianProduct($allDatasets);
274+
}
275+
276+
/**
277+
* Generate the cartesian product of multiple arrays of datasets.
278+
*
279+
* @param list<array<array-key, array>> $arrays Arrays of datasets from each provider.
280+
* @param int<0, max> $index Current provider index.
281+
* @param list<array> $currentData Accumulated dataset arguments.
282+
* @param list<string> $currentKeys Accumulated dataset keys.
283+
*/
284+
private static function cartesianProduct(
285+
array $arrays,
286+
int $index = 0,
287+
array $currentData = [],
288+
array $currentKeys = [],
289+
): iterable {
290+
if ($index === \count($arrays)) {
291+
yield \implode(self::KEY_SEPARATOR_CROSS, $currentKeys) => \array_merge(...$currentData);
292+
return;
293+
}
294+
295+
foreach ($arrays[$index] as $key => $dataset) {
296+
yield from self::cartesianProduct(
297+
$arrays,
298+
$index + 1,
299+
[...$currentData, $dataset],
300+
[...$currentKeys, (string) $key],
301+
);
302+
}
303+
}
197304
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Data\Internal;
6+
7+
/**
8+
* A wrapper around a generator that doesn't start the wrapped generator ASAP.
9+
*
10+
* @implements \Iterator<mixed, mixed>
11+
*/
12+
final class DeferredGenerator implements \Iterator
13+
{
14+
private bool $started = false;
15+
private bool $finished = false;
16+
private \Generator $generator;
17+
private \Closure $handler;
18+
19+
private function __construct() {}
20+
21+
/**
22+
* @param \Closure(): mixed $handler
23+
*/
24+
public static function fromHandler(\Closure $handler): self
25+
{
26+
$self = new self();
27+
$self->handler = $handler;
28+
return $self;
29+
}
30+
31+
/**
32+
* Throw an exception into the generator.
33+
*
34+
* @note doesn't throw generator's exceptions; use {@see catch()} to handle them.
35+
*/
36+
public function throw(\Throwable $exception): void
37+
{
38+
$this->started or throw new \LogicException('Cannot throw exception into a generator that was not started.');
39+
$this->finished and throw new \LogicException(
40+
'Cannot throw exception into a generator that was already finished.',
41+
);
42+
$this->generator->throw($exception);
43+
}
44+
45+
/**
46+
* Send a value to the generator.
47+
*
48+
* @note doesn't throw generator's exceptions; use {@see catch()} to handle them.
49+
*/
50+
public function send(mixed $value): mixed
51+
{
52+
$this->start();
53+
$this->finished and throw new \LogicException('Cannot send value to a generator that was already finished.');
54+
return $this->generator->send($value);
55+
}
56+
57+
/**
58+
* Get the return value of the generator if it was finished.
59+
*/
60+
public function getReturn(): mixed
61+
{
62+
$this->finished or throw new \LogicException('Cannot get return value of a generator that was not finished.');
63+
return $this->generator->getReturn();
64+
}
65+
66+
/**
67+
* Get the current value of the generator.
68+
*/
69+
public function current(): mixed
70+
{
71+
$this->start();
72+
return $this->generator->current();
73+
}
74+
75+
/**
76+
* Get the current key of the generator.
77+
*/
78+
public function key(): mixed
79+
{
80+
$this->start();
81+
return $this->generator->key();
82+
}
83+
84+
/**
85+
* Start or resume the generator.
86+
*/
87+
public function next(): void
88+
{
89+
if (!$this->started || $this->finished) {
90+
$this->finished or $this->start();
91+
return;
92+
}
93+
94+
$this->generator->next();
95+
}
96+
97+
/**
98+
* Check if the generator is not finished.
99+
*
100+
* @note It starts the Generator.
101+
*/
102+
public function valid(): bool
103+
{
104+
$this->start();
105+
$result = $this->generator->valid() or $this->finished = true;
106+
return $result;
107+
}
108+
109+
public function rewind(): void
110+
{
111+
$this->generator->rewind();
112+
}
113+
114+
private static function getDummyGenerator(): \Generator
115+
{
116+
static $generator;
117+
118+
if ($generator === null) {
119+
$generator = (static function (): \Generator {
120+
yield;
121+
})();
122+
$generator->current();
123+
}
124+
125+
return $generator;
126+
}
127+
128+
private function start(): void
129+
{
130+
if ($this->started) {
131+
return;
132+
}
133+
134+
$this->started = true;
135+
try {
136+
$result = ($this->handler)();
137+
138+
if ($result instanceof \Generator) {
139+
$this->generator = $result;
140+
return;
141+
}
142+
143+
/** @psalm-suppress all */
144+
$this->generator = (static function (mixed $result): \Generator {
145+
return $result;
146+
yield;
147+
})($result);
148+
$this->finished = true;
149+
} catch (\Throwable $e) {
150+
$this->generator = self::getDummyGenerator();
151+
throw $e;
152+
} finally {
153+
unset($this->handler, $this->values);
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)