diff --git a/src/Bootstrappers/BootFiles.php b/src/Bootstrappers/BootFiles.php index 765d0843..7629e7ed 100644 --- a/src/Bootstrappers/BootFiles.php +++ b/src/Bootstrappers/BootFiles.php @@ -4,11 +4,13 @@ declare(strict_types=1); namespace Pest\Bootstrappers; +use Pest\Support\DatasetInfo; use Pest\Support\Str; use function Pest\testDirectory; use Pest\TestSuite; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use SebastianBergmann\FileIterator\Facade as PhpUnitFileIterator; /** * @internal @@ -21,8 +23,6 @@ final class BootFiles * @var array */ private const STRUCTURE = [ - 'Datasets', - 'Datasets.php', 'Expectations', 'Expectations.php', 'Helpers', @@ -56,6 +56,8 @@ final class BootFiles $this->load($filename); } } + + $this->bootDatasets($testsPath); } /** @@ -73,4 +75,15 @@ final class BootFiles include_once $filename; } + + private function bootDatasets(string $testsPath): void + { + $files = (new PhpUnitFileIterator)->getFilesAsArray($testsPath, '.php'); + + foreach ($files as $file) { + if (DatasetInfo::isADatasetsFile($file) || DatasetInfo::isInsideADatasetsDirectory($file)) { + $this->load($file); + } + } + } } diff --git a/src/Exceptions/DatasetAlreadyExist.php b/src/Exceptions/DatasetAlreadyExists.php similarity index 63% rename from src/Exceptions/DatasetAlreadyExist.php rename to src/Exceptions/DatasetAlreadyExists.php index b5276329..4e42397a 100644 --- a/src/Exceptions/DatasetAlreadyExist.php +++ b/src/Exceptions/DatasetAlreadyExists.php @@ -12,13 +12,13 @@ use Symfony\Component\Console\Exception\ExceptionInterface; /** * @internal */ -final class DatasetAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace +final class DatasetAlreadyExists extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace { /** * Creates a new Exception instance. */ - public function __construct(string $name) + public function __construct(string $name, string $scope) { - parent::__construct(sprintf('A dataset with the name `%s` already exist.', $name)); + parent::__construct(sprintf('A dataset with the name `%s` already exist in scope [%s].', $name, $scope)); } } diff --git a/src/Functions.php b/src/Functions.php index 50571727..ea53c22c 100644 --- a/src/Functions.php +++ b/src/Functions.php @@ -9,6 +9,7 @@ use Pest\PendingCalls\TestCall; use Pest\PendingCalls\UsesCall; use Pest\Repositories\DatasetsRepository; use Pest\Support\Backtrace; +use Pest\Support\DatasetInfo; use Pest\Support\HigherOrderTapProxy; use Pest\TestSuite; use PHPUnit\Framework\TestCase; @@ -60,7 +61,8 @@ if (! function_exists('dataset')) { */ function dataset(string $name, Closure|iterable $dataset): void { - DatasetsRepository::set($name, $dataset); + $scope = DatasetInfo::scope(Backtrace::datasetsFile()); + DatasetsRepository::set($name, $dataset, $scope); } } diff --git a/src/Repositories/DatasetsRepository.php b/src/Repositories/DatasetsRepository.php index f4c7c11d..3c3f5fc1 100644 --- a/src/Repositories/DatasetsRepository.php +++ b/src/Repositories/DatasetsRepository.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Pest\Repositories; use Closure; -use Pest\Exceptions\DatasetAlreadyExist; +use Pest\Exceptions\DatasetAlreadyExists; use Pest\Exceptions\DatasetDoesNotExist; use Pest\Exceptions\ShouldNotHappen; use SebastianBergmann\Exporter\Exporter; @@ -17,6 +17,8 @@ use Traversable; */ final class DatasetsRepository { + private const SEPARATOR = '>>'; + /** * Holds the datasets. * @@ -36,13 +38,15 @@ final class DatasetsRepository * * @param Closure|iterable $data */ - public static function set(string $name, Closure|iterable $data): void + public static function set(string $name, Closure|iterable $data, string $scope): void { - if (array_key_exists($name, self::$datasets)) { - throw new DatasetAlreadyExist($name); + $datasetKey = "$scope".self::SEPARATOR."$name"; + + if (array_key_exists("$datasetKey", self::$datasets)) { + throw new DatasetAlreadyExists($name, $scope); } - self::$datasets[$name] = $data; + self::$datasets[$datasetKey] = $data; } /** @@ -52,12 +56,12 @@ final class DatasetsRepository */ public static function with(string $filename, string $description, array $with): void { - self::$withs[$filename.'>>>'.$description] = $with; + self::$withs["$filename".self::SEPARATOR."$description"] = $with; } public static function has(string $filename, string $description): bool { - return array_key_exists($filename.'>>>'.$description, self::$withs); + return array_key_exists($filename.self::SEPARATOR.$description, self::$withs); } /** @@ -67,9 +71,9 @@ final class DatasetsRepository */ public static function get(string $filename, string $description) { - $dataset = self::$withs[$filename.'>>>'.$description]; + $dataset = self::$withs[$filename.self::SEPARATOR.$description]; - $dataset = self::resolve($description, $dataset); + $dataset = self::resolve($dataset, $filename); if ($dataset === null) { throw ShouldNotHappen::fromMessage('Dataset [%s] not resolvable.'); @@ -84,14 +88,14 @@ final class DatasetsRepository * @param array|string> $dataset * @return array|null */ - public static function resolve(string $description, array $dataset): array|null + public static function resolve(array $dataset, string $currentTestFile): array|null { /* @phpstan-ignore-next-line */ if (empty($dataset)) { return null; } - $dataset = self::processDatasets($dataset); + $dataset = self::processDatasets($dataset, $currentTestFile); $datasetCombinations = self::getDatasetsCombinations($dataset); @@ -136,7 +140,7 @@ final class DatasetsRepository * @param array|string> $datasets * @return array> */ - private static function processDatasets(array $datasets): array + private static function processDatasets(array $datasets, string $currentTestFile): array { $processedDatasets = []; @@ -144,11 +148,7 @@ final class DatasetsRepository $processedDataset = []; if (is_string($data)) { - if (! array_key_exists($data, self::$datasets)) { - throw new DatasetDoesNotExist($data); - } - - $datasets[$index] = self::$datasets[$data]; + $datasets[$index] = self::getScopedDataset($data, $currentTestFile); } if (is_callable($datasets[$index])) { @@ -174,6 +174,37 @@ final class DatasetsRepository return $processedDatasets; } + /** + * @return Closure|iterable + */ + private static function getScopedDataset(string $name, string $currentTestFile) + { + $matchingDatasets = array_filter(self::$datasets, function (string $key) use ($name, $currentTestFile) { + [$datasetScope, $datasetName] = explode(self::SEPARATOR, $key); + + if ($name !== $datasetName) { + return false; + } + + if (! str_starts_with($currentTestFile, $datasetScope)) { + return false; + } + + return true; + }, ARRAY_FILTER_USE_KEY); + + $closestScopeDatasetKey = array_reduce( + array_keys($matchingDatasets), + fn ($keyA, $keyB) => $keyA !== null && strlen($keyA) > strlen($keyB) ? $keyA : $keyB + ); + + if ($closestScopeDatasetKey === null) { + throw new DatasetDoesNotExist($name); + } + + return $matchingDatasets[$closestScopeDatasetKey]; + } + /** * @param array> $combinations * @return array>> diff --git a/src/Support/Backtrace.php b/src/Support/Backtrace.php index 33a44a2e..5077fb5a 100644 --- a/src/Support/Backtrace.php +++ b/src/Support/Backtrace.php @@ -44,6 +44,30 @@ final class Backtrace return $current[self::FILE]; } + /** + * Returns the current datasets file. + */ + public static function datasetsFile(): string + { + $current = null; + + foreach (debug_backtrace(self::BACKTRACE_OPTIONS) as $trace) { + assert(array_key_exists(self::FILE, $trace)); + + if (Str::endsWith($trace['file'], 'Bootstrappers/BootFiles.php') || Str::endsWith($trace[self::FILE], 'overrides/Runner/TestSuiteLoader.php')) { + break; + } + + $current = $trace; + } + + if ($current === null) { + throw ShouldNotHappen::fromMessage('Dataset file not found.'); + } + + return $current[self::FILE]; + } + /** * Returns the filename that called the current function/method. */ diff --git a/src/Support/DatasetInfo.php b/src/Support/DatasetInfo.php new file mode 100644 index 00000000..cfcf65b6 --- /dev/null +++ b/src/Support/DatasetInfo.php @@ -0,0 +1,38 @@ +expectException(DatasetDoesNotExist::class); $this->expectExceptionMessage("A dataset with the name `first` does not exist. You can create it using `dataset('first', ['a', 'b']);`."); - DatasetsRepository::resolve('foo', ['first']); + DatasetsRepository::resolve(['first'], __FILE__); }); it('throws exception if dataset already exist', function () { - DatasetsRepository::set('second', [[]]); - $this->expectException(DatasetAlreadyExist::class); - $this->expectExceptionMessage('A dataset with the name `second` already exist.'); - DatasetsRepository::set('second', [[]]); + DatasetsRepository::set('second', [[]], __DIR__); + $this->expectException(DatasetAlreadyExists::class); + $this->expectExceptionMessage('A dataset with the name `second` already exist in scope ['.__DIR__.'].'); + DatasetsRepository::set('second', [[]], __DIR__); }); it('sets closures', function () { DatasetsRepository::set('foo', function () { yield [1]; - }); + }, __DIR__); - expect(DatasetsRepository::resolve('foo', ['foo']))->toBe(['(1)' => [1]]); + expect(DatasetsRepository::resolve(['foo'], __FILE__))->toBe(['(1)' => [1]]); }); it('sets arrays', function () { - DatasetsRepository::set('bar', [[2]]); + DatasetsRepository::set('bar', [[2]], __DIR__); - expect(DatasetsRepository::resolve('bar', ['bar']))->toBe(['(2)' => [2]]); + expect(DatasetsRepository::resolve(['bar'], __FILE__))->toBe(['(2)' => [2]]); }); it('gets bound to test case object', function ($value) { @@ -323,3 +323,5 @@ it('can correctly resolve a bound dataset that returns an array but wants to be return ['foo', 'bar', 'baz']; }, ]); + +todo('forbids to define tests in Datasets dirs and Datasets.php files'); diff --git a/tests/Features/ScopedDatasets/Directory/Datasets/Scoped.php b/tests/Features/ScopedDatasets/Directory/Datasets/Scoped.php new file mode 100644 index 00000000..d49e32c3 --- /dev/null +++ b/tests/Features/ScopedDatasets/Directory/Datasets/Scoped.php @@ -0,0 +1,5 @@ +text = ''; +test('uses dataset', function ($value) use ($state) { + $state->text .= $value; + expect(true)->toBe(true); +})->with('numbers.array'); + +test('the right dataset is taken', function () use ($state) { + expect($state->text)->toBe('12345ScopedDatasets/NestedDirectory1/Datasets.php'); +}); diff --git a/tests/Features/ScopedDatasets/Directory/NestedDirectory2/TestFileInNestedDirectory.php b/tests/Features/ScopedDatasets/Directory/NestedDirectory2/TestFileInNestedDirectory.php new file mode 100644 index 00000000..a86b9d23 --- /dev/null +++ b/tests/Features/ScopedDatasets/Directory/NestedDirectory2/TestFileInNestedDirectory.php @@ -0,0 +1,12 @@ +text = ''; +test('uses dataset', function ($value) use ($state) { + $state->text .= $value; + expect(true)->toBe(true); +})->with('numbers.array'); + +test('the right dataset is taken', function () use ($state) { + expect($state->text)->toBe('12345ScopedDatasets/Datasets/Scoped.php'); +}); diff --git a/tests/Features/ScopedDatasets/Directory/TestFileWithLocallyDefinedDataset.php b/tests/Features/ScopedDatasets/Directory/TestFileWithLocallyDefinedDataset.php new file mode 100644 index 00000000..0bf23b8c --- /dev/null +++ b/tests/Features/ScopedDatasets/Directory/TestFileWithLocallyDefinedDataset.php @@ -0,0 +1,16 @@ +text = ''; +test('uses dataset', function ($value) use ($state) { + $state->text .= $value; + expect(true)->toBe(true); +})->with('numbers.array'); + +test('the right dataset is taken', function () use ($state) { + expect($state->text)->toBe('12345ScopedDatasets/ScopedDatasets.php'); +}); diff --git a/tests/Features/ScopedDatasets/Directory/TestFileWithScopedDataset.php b/tests/Features/ScopedDatasets/Directory/TestFileWithScopedDataset.php new file mode 100644 index 00000000..a86b9d23 --- /dev/null +++ b/tests/Features/ScopedDatasets/Directory/TestFileWithScopedDataset.php @@ -0,0 +1,12 @@ +text = ''; +test('uses dataset', function ($value) use ($state) { + $state->text .= $value; + expect(true)->toBe(true); +})->with('numbers.array'); + +test('the right dataset is taken', function () use ($state) { + expect($state->text)->toBe('12345ScopedDatasets/Datasets/Scoped.php'); +}); diff --git a/tests/Features/ScopedDatasets/TestFileOutOfScope.php b/tests/Features/ScopedDatasets/TestFileOutOfScope.php new file mode 100644 index 00000000..e2d23225 --- /dev/null +++ b/tests/Features/ScopedDatasets/TestFileOutOfScope.php @@ -0,0 +1,12 @@ +text = ''; +test('uses dataset', function ($value) use ($state) { + $state->text .= $value; + expect(true)->toBe(true); +})->with('numbers.array'); + +test('the right dataset is taken', function () use ($state) { + expect($state->text)->toBe('12'); +}); diff --git a/tests/Unit/Datasets.php b/tests/Unit/DatasetsTests.php similarity index 81% rename from tests/Unit/Datasets.php rename to tests/Unit/DatasetsTests.php index 83b33b04..a1914f82 100644 --- a/tests/Unit/Datasets.php +++ b/tests/Unit/DatasetsTests.php @@ -3,31 +3,31 @@ use Pest\Repositories\DatasetsRepository; it('show only the names of named datasets in their description', function () { - $descriptions = array_keys(DatasetsRepository::resolve('test description', [ + $descriptions = array_keys(DatasetsRepository::resolve([ [ 'one' => [1], 'two' => [[2]], ], - ])); + ], __FILE__)); expect($descriptions[0])->toBe('data set "one"') ->and($descriptions[1])->toBe('data set "two"'); }); it('show the actual dataset of non-named datasets in their description', function () { - $descriptions = array_keys(DatasetsRepository::resolve('test description', [ + $descriptions = array_keys(DatasetsRepository::resolve([ [ [1], [[2]], ], - ])); + ], __FILE__)); expect($descriptions[0])->toBe('(1)'); expect($descriptions[1])->toBe('(array(2))'); }); it('show only the names of multiple named datasets in their description', function () { - $descriptions = array_keys(DatasetsRepository::resolve('test description', [ + $descriptions = array_keys(DatasetsRepository::resolve([ [ 'one' => [1], 'two' => [[2]], @@ -36,7 +36,7 @@ it('show only the names of multiple named datasets in their description', functi 'three' => [3], 'four' => [[4]], ], - ])); + ], __FILE__)); expect($descriptions[0])->toBe('data set "one" / data set "three"'); expect($descriptions[1])->toBe('data set "one" / data set "four"'); @@ -45,7 +45,7 @@ it('show only the names of multiple named datasets in their description', functi }); it('show the actual dataset of multiple non-named datasets in their description', function () { - $descriptions = array_keys(DatasetsRepository::resolve('test description', [ + $descriptions = array_keys(DatasetsRepository::resolve([ [ [1], [[2]], @@ -54,7 +54,7 @@ it('show the actual dataset of multiple non-named datasets in their description' [3], [[4]], ], - ])); + ], __FILE__)); expect($descriptions[0])->toBe('(1) / (3)'); expect($descriptions[1])->toBe('(1) / (array(4))'); @@ -63,7 +63,7 @@ it('show the actual dataset of multiple non-named datasets in their description' }); it('show the correct description for mixed named and not-named datasets', function () { - $descriptions = array_keys(DatasetsRepository::resolve('test description', [ + $descriptions = array_keys(DatasetsRepository::resolve([ [ 'one' => [1], [[2]], @@ -72,7 +72,7 @@ it('show the correct description for mixed named and not-named datasets', functi [3], 'four' => [[4]], ], - ])); + ], __FILE__)); expect($descriptions[0])->toBe('data set "one" / (3)'); expect($descriptions[1])->toBe('data set "one" / data set "four"'); diff --git a/tests/Unit/Support/DatasetInfo.php b/tests/Unit/Support/DatasetInfo.php new file mode 100644 index 00000000..dfe2b417 --- /dev/null +++ b/tests/Unit/Support/DatasetInfo.php @@ -0,0 +1,36 @@ +toBe($inside); +})->with([ + ['file' => '/var/www/project/tests/Datasets/Numbers.php', 'inside' => true], + ['file' => '/var/www/project/tests/Datasets.php', 'inside' => false], + ['file' => '/var/www/project/tests/Features/Datasets/Numbers.php', 'inside' => true], + ['file' => '/var/www/project/tests/Features/Numbers.php', 'inside' => false], + ['file' => '/var/www/project/tests/Features/Datasets.php', 'inside' => false], +]); + +it('can check if dataset is defined inside a Datasets.php file', function (string $path, bool $inside) { + expect(DatasetInfo::isADatasetsFile($path))->toBe($inside); +})->with([ + ['file' => '/var/www/project/tests/Datasets/Numbers.php', 'inside' => false], + ['file' => '/var/www/project/tests/Datasets.php', 'inside' => true], + ['file' => '/var/www/project/tests/Features/Datasets/Numbers.php', 'inside' => false], + ['file' => '/var/www/project/tests/Features/Numbers.php', 'inside' => false], + ['file' => '/var/www/project/tests/Features/Datasets.php', 'inside' => true], +]); + +it('computes the dataset scope', function (string $file, string $scope) { + expect(DatasetInfo::scope($file))->toBe($scope); +})->with([ + ['file' => '/var/www/project/tests/Datasets/Numbers.php', 'scope' => '/var/www/project/tests'], + ['file' => '/var/www/project/tests/Datasets.php', 'scope' => '/var/www/project/tests'], + ['file' => '/var/www/project/tests/Features/Datasets/Numbers.php', 'scope' => '/var/www/project/tests/Features'], + ['file' => '/var/www/project/tests/Features/Numbers.php', 'scope' => '/var/www/project/tests/Features/Numbers.php'], + ['file' => '/var/www/project/tests/Features/Datasets.php', 'scope' => '/var/www/project/tests/Features'], + ['file' => '/var/www/project/tests/Features/Controllers/Datasets/Numbers.php', 'scope' => '/var/www/project/tests/Features/Controllers'], + ['file' => '/var/www/project/tests/Features/Controllers/Numbers.php', 'scope' => '/var/www/project/tests/Features/Controllers/Numbers.php'], + ['file' => '/var/www/project/tests/Features/Controllers/Datasets.php', 'scope' => '/var/www/project/tests/Features/Controllers'], +]);