diff --git a/src/Bootstrappers/BootFiles.php b/src/Bootstrappers/BootFiles.php index 765d0843..13ad0504 100644 --- a/src/Bootstrappers/BootFiles.php +++ b/src/Bootstrappers/BootFiles.php @@ -9,6 +9,7 @@ use function Pest\testDirectory; use Pest\TestSuite; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use SebastianBergmann\FileIterator\Facade as PhpUnitFileIterator; /** * @internal @@ -21,8 +22,6 @@ final class BootFiles * @var array */ private const STRUCTURE = [ - 'Datasets', - 'Datasets.php', 'Expectations', 'Expectations.php', 'Helpers', @@ -56,6 +55,8 @@ final class BootFiles $this->load($filename); } } + + $this->bootDatasets($testsPath); } /** @@ -73,4 +74,26 @@ final class BootFiles include_once $filename; } + + private function bootDatasets(string $testsPath): void + { + $files = (new PhpUnitFileIterator)->getFilesAsArray($testsPath, '.php'); + + foreach ($files as $fullPath) { + $filename = Str::afterLast($fullPath, DIRECTORY_SEPARATOR); + + if ($filename === 'Datasets.php') { + $this->load($fullPath); + + continue; + } + + $directoryFullPath = Str::beforeLast($fullPath, DIRECTORY_SEPARATOR); + $directory = Str::afterLast($directoryFullPath, DIRECTORY_SEPARATOR); + + if ($directory === 'Datasets') { + $this->load($fullPath); + } + } + } } diff --git a/src/Exceptions/DatasetAlreadyExist.php b/src/Exceptions/DatasetAlreadyExist.php index b5276329..91146997 100644 --- a/src/Exceptions/DatasetAlreadyExist.php +++ b/src/Exceptions/DatasetAlreadyExist.php @@ -17,8 +17,8 @@ final class DatasetAlreadyExist extends InvalidArgumentException implements Exce /** * 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..a1dad4fe 100644 --- a/src/Functions.php +++ b/src/Functions.php @@ -10,6 +10,7 @@ use Pest\PendingCalls\UsesCall; use Pest\Repositories\DatasetsRepository; use Pest\Support\Backtrace; use Pest\Support\HigherOrderTapProxy; +use Pest\Support\Str; use Pest\TestSuite; use PHPUnit\Framework\TestCase; @@ -60,7 +61,17 @@ if (! function_exists('dataset')) { */ function dataset(string $name, Closure|iterable $dataset): void { - DatasetsRepository::set($name, $dataset); + $file = Backtrace::datasetsFile(); + $filename = Str::afterLast($file, DIRECTORY_SEPARATOR); + $scope = Str::beforeLast($file, DIRECTORY_SEPARATOR); + + if (Str::afterLast($scope, DIRECTORY_SEPARATOR) === 'Datasets') { + $scope = Str::beforeLast($scope, DIRECTORY_SEPARATOR); + } elseif ($filename !== 'Datasets.php') { + $scope = $file; + } + + DatasetsRepository::set($name, $dataset, $scope); } } diff --git a/src/Repositories/DatasetsRepository.php b/src/Repositories/DatasetsRepository.php index f6874fc3..d9f06874 100644 --- a/src/Repositories/DatasetsRepository.php +++ b/src/Repositories/DatasetsRepository.php @@ -36,13 +36,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>>>$name"; + + if (array_key_exists("$datasetKey", self::$datasets)) { + throw new DatasetAlreadyExist($name, $scope); } - self::$datasets[$name] = $data; + self::$datasets[$datasetKey] = $data; } /** @@ -52,7 +54,7 @@ final class DatasetsRepository */ public static function with(string $filename, string $description, array $with): void { - self::$withs[$filename.'>>>'.$description] = $with; + self::$withs["$filename>>>$description"] = $with; } public static function has(string $filename, string $description): bool @@ -67,9 +69,10 @@ final class DatasetsRepository */ public static function get(string $filename, string $description) { + // dump("requesting file: " . $filename); $dataset = self::$withs[$filename.'>>>'.$description]; - $dataset = self::resolve($description, $dataset); + $dataset = self::resolve($dataset, $filename); if ($dataset === null) { throw ShouldNotHappen::fromMessage('Dataset [%s] not resolvable.'); @@ -84,14 +87,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); @@ -132,11 +135,45 @@ final class DatasetsRepository return $namedData; } + /** + * @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('>>>', $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), function ($keyA, $keyB) { + if ($keyA === null) { + return $keyB; + } + + return strlen($keyA) > strlen($keyB) ? $keyA : $keyB; + }); + + if ($closestScopeDatasetKey === null) { + throw new DatasetDoesNotExist($name); + } + + return $matchingDatasets[$closestScopeDatasetKey]; + } + /** * @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 +181,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])) { diff --git a/src/Support/Backtrace.php b/src/Support/Backtrace.php index 56ac4fc9..beccedac 100644 --- a/src/Support/Backtrace.php +++ b/src/Support/Backtrace.php @@ -42,6 +42,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/Str.php b/src/Support/Str.php index 69522b6a..28d4ef93 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -59,6 +59,24 @@ final class Str return (string) preg_replace('/[^A-Z_a-z0-9\\\\]/', '', $code); } + /** + * Return the remainder of a string after the last occurrence of a given value. + */ + public static function afterLast(string $subject, string $search): string + { + if ($search === '') { + return $subject; + } + + $position = strrpos($subject, $search); + + if ($position === false) { + return $subject; + } + + return substr($subject, $position + strlen($search)); + } + /** * Get the portion of a string before the last occurrence of a given value. */ diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index c23ef369..50598b85 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -29,7 +29,7 @@ ✓ it does not append CoversNothing to other methods ✓ it throws exception if no class nor method has been found - PASS Tests\Features\Datasets + PASS Tests\Features\DatasetsTests ✓ it throws exception if dataset does not exist ✓ it throws exception if dataset already exist ✓ it sets closures @@ -116,6 +116,7 @@ ✓ it will not resolve a closure if it is type hinted as a callable with (Closure Object (...)) #2 ✓ it can correctly resolve a bound dataset that returns an array with (Closure Object (...)) ✓ it can correctly resolve a bound dataset that returns an array but wants to be spread with (Closure Object (...)) + ↓ forbids to define tests in Datasets dirs and Datasets.php files PASS Tests\Features\Depends ✓ first @@ -663,6 +664,47 @@ ✓ get 'foo' → get 'bar' → expect true → toBeTrue ✓ get 'foo' → expect true → toBeTrue + PASS Tests\Features\ScopedDatasets\Directory\NestedDirectory1\TestFileInNestedDirectoryWithDatasetsFile + ✓ uses dataset with (1) + ✓ uses dataset with (2) + ✓ uses dataset with (3) + ✓ uses dataset with (4) + ✓ uses dataset with (5) + ✓ uses dataset with ('ScopedDatasets/NestedDirector...ts.php') + ✓ the right dataset is taken + + PASS Tests\Features\ScopedDatasets\Directory\NestedDirectory2\TestFileInNestedDirectory + ✓ uses dataset with (1) + ✓ uses dataset with (2) + ✓ uses dataset with (3) + ✓ uses dataset with (4) + ✓ uses dataset with (5) + ✓ uses dataset with ('ScopedDatasets/Datasets/Scoped.php') + ✓ the right dataset is taken + + PASS Tests\Features\ScopedDatasets\Directory\TestFileWithLocallyDefinedDataset + ✓ uses dataset with (1) + ✓ uses dataset with (2) + ✓ uses dataset with (3) + ✓ uses dataset with (4) + ✓ uses dataset with (5) + ✓ uses dataset with ('ScopedDatasets/ScopedDatasets.php') + ✓ the right dataset is taken + + PASS Tests\Features\ScopedDatasets\Directory\TestFileWithScopedDataset + ✓ uses dataset with (1) + ✓ uses dataset with (2) + ✓ uses dataset with (3) + ✓ uses dataset with (4) + ✓ uses dataset with (5) + ✓ uses dataset with ('ScopedDatasets/Datasets/Scoped.php') + ✓ the right dataset is taken + + PASS Tests\Features\ScopedDatasets\TestFileOutOfScope + ✓ uses dataset with (1) + ✓ uses dataset with (2) + ✓ the right dataset is taken + WARN Tests\Features\Skip ✓ it do not skips - it skips with truthy → 1 @@ -761,7 +803,7 @@ PASS Tests\Unit\Console\Help ✓ it outputs the help information when --help is used - PASS Tests\Unit\Datasets + PASS Tests\Unit\DatasetsTests ✓ it show only the names of named datasets in their description ✓ it show the actual dataset of non-named datasets in their description ✓ it show only the names of multiple named datasets in their description @@ -818,4 +860,4 @@ PASS Tests\Visual\Version ✓ visual snapshot of help command output - Tests: 4 incomplete, 1 todo, 18 skipped, 562 passed (1460 assertions) + Tests: 4 incomplete, 2 todos, 18 skipped, 593 passed (1503 assertions) \ No newline at end of file diff --git a/tests/Features/Datasets.php b/tests/Features/DatasetsTests.php similarity index 94% rename from tests/Features/Datasets.php rename to tests/Features/DatasetsTests.php index 183b2d3b..9dc72b2b 100644 --- a/tests/Features/Datasets.php +++ b/tests/Features/DatasetsTests.php @@ -13,28 +13,28 @@ it('throws exception if dataset does not exist', function () { $this->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', [[]]); + DatasetsRepository::set('second', [[]], __DIR__); $this->expectException(DatasetAlreadyExist::class); - $this->expectExceptionMessage('A dataset with the name `second` already exist.'); - DatasetsRepository::set('second', [[]]); + $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) { @@ -304,3 +304,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"');