packages/requirements/tests/Unit/Config/LoaderTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config;
6
7use JsonException;
8use PHPUnit\Framework\Attributes\CoversClass;
9use PHPUnit\Framework\Attributes\DataProvider;
10use PHPUnit\Framework\Attributes\Small;
11use PHPUnit\Framework\Attributes\UsesClass;
12use PHPUnit\Framework\TestCase;
13use Requirements\Config\Bootstrap;
14use Requirements\Config\CoverageThresholds;
15use Requirements\Config\DefinitionReader;
16use Requirements\Config\Definitions;
17use Requirements\Config\DocumentReader;
18use Requirements\Config\ExtensionClasses;
19use Requirements\Config\JsonSchemaFile;
20use Requirements\Config\LinkValidator;
21use Requirements\Config\Loader;
22use Requirements\Config\Markdown\Badges;
23use Requirements\Config\Markdown\CardReader;
24use Requirements\Config\Markdown\FieldReader;
25use Requirements\Config\Markdown\FieldSections;
26use Requirements\Config\Markdown\Frontmatter;
27use Requirements\Config\Markdown\Nodes;
28use Requirements\Config\Markdown\Profile\AllowedBlocks;
29use Requirements\Config\Markdown\Profile\BlockKind;
30use Requirements\Config\Markdown\Profile\DocumentSchema;
31use Requirements\Config\Markdown\Profile\Occurrences;
32use Requirements\Config\Markdown\Profile\SectionBlocks;
33use Requirements\Config\Markdown\Profile\TextConstraint;
34use Requirements\Config\Markdown\QuotationBlocks;
35use Requirements\Config\MarkdownDocument;
36use Requirements\Config\SchemaValidator;
37use Requirements\Ears\ConditionOrder;
38use Requirements\Ears\LiteralMask;
39use Requirements\Ears\SystemResponse;
40use Requirements\Ears\Validator;
41use Requirements\Ears\Wording;
42use Requirements\Input\Fields;
43use Requirements\Input\InvalidInputException;
44use Requirements\Model\Excerpt;
45use Requirements\Model\Item;
46use Requirements\Model\ItemValidator;
47use Requirements\Model\Project;
48use Requirements\Model\Source;
49use Requirements\Model\TestReference;
50use Requirements\Source\DomSource;
51use Requirements\Source\JsonSource;
52use Requirements\Source\Registry as SourceRegistry;
53use Requirements\Source\ResourceLoader;
54use Requirements\Source\TextSource;
55use Requirements\Test\Registry as RunnerRegistry;
56use Requirements\Test\RunnerConfig;
57use Symfony\Component\Yaml\Exception\ParseException;
58use Tests\Fake\CountingRunner;
59use Tests\Fake\MemorySource;
60use Tests\Fake\ProjectDirectory;
61
62#[CoversClass(Loader::class)]
63#[UsesClass(CoverageThresholds::class)]
64#[UsesClass(DefinitionReader::class)]
65#[UsesClass(Definitions::class)]
66#[UsesClass(DocumentReader::class)]
67#[UsesClass(ExtensionClasses::class)]
68#[UsesClass(JsonSchemaFile::class)]
69#[UsesClass(LinkValidator::class)]
70#[UsesClass(MarkdownDocument::class)]
71#[UsesClass(Badges::class)]
72#[UsesClass(CardReader::class)]
73#[UsesClass(FieldReader::class)]
74#[UsesClass(FieldSections::class)]
75#[UsesClass(Frontmatter::class)]
76#[UsesClass(Nodes::class)]
77#[UsesClass(AllowedBlocks::class)]
78#[UsesClass(BlockKind::class)]
79#[UsesClass(DocumentSchema::class)]
80#[UsesClass(Occurrences::class)]
81#[UsesClass(SectionBlocks::class)]
82#[UsesClass(TextConstraint::class)]
83#[UsesClass(QuotationBlocks::class)]
84#[UsesClass(SchemaValidator::class)]
85#[UsesClass(ConditionOrder::class)]
86#[UsesClass(LiteralMask::class)]
87#[UsesClass(SystemResponse::class)]
88#[UsesClass(Validator::class)]
89#[UsesClass(Wording::class)]
90#[UsesClass(Fields::class)]
91#[UsesClass(Excerpt::class)]
92#[UsesClass(Item::class)]
93#[UsesClass(ItemValidator::class)]
94#[UsesClass(Project::class)]
95#[UsesClass(Source::class)]
96#[UsesClass(TestReference::class)]
97#[UsesClass(DomSource::class)]
98#[UsesClass(JsonSource::class)]
99#[UsesClass(SourceRegistry::class)]
100#[UsesClass(RunnerRegistry::class)]
101#[UsesClass(Bootstrap::class)]
102#[UsesClass(ResourceLoader::class)]
103#[UsesClass(TextSource::class)]
104#[UsesClass(RunnerConfig::class)]
105#[Small]
106final class LoaderTest extends TestCase
107{
108    /**
109     * @param array<string, mixed> $changes
110     * @throws JsonException
111     */
112    #[DataProvider('providerInvalidItems')]
113    public function testLoadRejectsInvalidDefinitions(array $changes, string $message): void
114    {
115        $project = new ProjectDirectory();
116        $definition = (new Loader())->document($project->directory . '/definition.yaml');
117        $definition['items'] = [array_replace(ProjectDirectory::item(), $changes)];
118        $project->write('definition.yaml', $definition);
119        $this->expectException(InvalidInputException::class);
120        $this->expectExceptionMessage($message);
121        (new Loader())->load($project->directory . '/requirements.yaml');
122    }
123
124    /**
125     * @return list<array{array<string, mixed>, string}>
126     */
127    public static function providerInvalidItems(): array
128    {
129        return [
130            [['status' => 'unsupported'], 'reason'],
131            [['statement' => 'Maybe names are letters'], 'EARS'],
132            [['requirements' => ['MISSING']], 'reference'],
133            [['related' => ['SPEC-001']], 'reference'],
134            [['lables' => ['typo']], 'Additional object properties'],
135            [['tests' => [['runner' => 'missing', 'target' => 'Test::test']]], 'unknown runner'],
136            [['labels' => ['same', 'same']], 'unique items'],
137        ];
138    }
139
140    /**
141     * @throws JsonException
142     */
143    public function testLoadReturnsProject(): void
144    {
145        $project = new ProjectDirectory();
146        $directory = realpath($project->directory);
147        self::assertIsString($directory);
148        $loaded = (new Loader())->load($project->path('requirements.yaml'));
149        self::assertSame($directory, $loaded->directory);
150        self::assertSame(['SPEC-001'], array_keys($loaded->items));
151        self::assertEquals(['manual' => new Source('manual', 'source.html', 'html', 'main p')], $loaded->sources);
152        self::assertSame([], $loaded->runners);
153        self::assertSame([], $loaded->sourceExtensions);
154        self::assertSame([], $loaded->runnerExtensions);
155        self::assertSame(0.0, $loaded->minimum);
156        self::assertSame(0.0, $loaded->diffMinimum);
157        self::assertSame([], $loaded->sourceThresholds);
158        self::assertSame([$directory . '/requirements.yaml', $directory . '/definition.yaml'], $loaded->files);
159        self::assertSame([], $loaded->markdown);
160    }
161
162    /**
163     * @throws JsonException
164     */
165    public function testLoadResolvesConfigurationPath(): void
166    {
167        $project = new ProjectDirectory();
168        $project->put('nested/.keep', '');
169        $directory = realpath($project->directory);
170        self::assertIsString($directory);
171        $loaded = (new Loader())->load($project->path('nested/../requirements.yaml'));
172        self::assertSame($directory, $loaded->directory);
173        self::assertSame($directory . '/requirements.yaml', $loaded->files[0]);
174    }
175
176    /**
177     * @throws JsonException
178     */
179    public function testLoadRejectsMissingConfiguration(): void
180    {
181        $project = new ProjectDirectory();
182        $this->expectException(InvalidInputException::class);
183        $this->expectExceptionMessage('Configuration does not exist: ' . $project->path('missing.yaml'));
184        (new Loader())->load($project->path('missing.yaml'));
185    }
186
187    /**
188     * @throws JsonException
189     */
190    public function testLoadValidatesConfigurationSchema(): void
191    {
192        $project = new ProjectDirectory();
193        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml'], 'reports' => []]);
194        $this->expectException(InvalidInputException::class);
195        $this->expectExceptionMessage('requirements.yaml: schema validation failed:');
196        (new Loader())->load($project->path('requirements.yaml'));
197    }
198
199    /**
200     * @throws JsonException
201     */
202    public function testLoadRejectsMalformedConfiguration(): void
203    {
204        $project = new ProjectDirectory();
205        $project->put('requirements.yaml', "version: [\n");
206        $this->expectException(ParseException::class);
207        (new Loader())->load($project->path('requirements.yaml'));
208    }
209
210    /**
211     * @throws JsonException
212     */
213    public function testLoadIncludesBootstrap(): void
214    {
215        $project = new ProjectDirectory();
216        $project->put('tools/bootstrap.php', "<?php\nfile_put_contents(__DIR__ . '/loaded.txt', 'loaded;', FILE_APPEND);\n");
217        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml'], 'bootstrap' => 'tools/bootstrap.php']);
218        (new Loader())->load($project->path('requirements.yaml'));
219        self::assertSame('loaded;', $project->read('tools/loaded.txt'));
220    }
221
222    /**
223     * @throws JsonException
224     */
225    public function testLoadRejectsMissingBootstrap(): void
226    {
227        $project = new ProjectDirectory();
228        $directory = realpath($project->directory);
229        self::assertIsString($directory);
230        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml'], 'bootstrap' => 'missing.php']);
231        $this->expectException(InvalidInputException::class);
232        $this->expectExceptionMessage("Bootstrap does not exist: $directory/missing.php");
233        (new Loader())->load($project->path('requirements.yaml'));
234    }
235
236    /**
237     * @throws JsonException
238     */
239    public function testLoadRejectsDefinitionPatternWithoutMatches(): void
240    {
241        $project = new ProjectDirectory();
242        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml', 'specs/*.yaml']]);
243        $this->expectException(InvalidInputException::class);
244        $this->expectExceptionMessage('Definition pattern has no matches: specs/*.yaml');
245        (new Loader())->load($project->path('requirements.yaml'));
246    }
247
248    /**
249     * @throws JsonException
250     */
251    public function testLoadRejectsDefinitionWithoutSource(): void
252    {
253        $project = new ProjectDirectory();
254        $project->put('definition.yaml', "version: 1\nitems: []\n");
255        $this->expectException(InvalidInputException::class);
256        $this->expectExceptionMessage('definition.yaml: schema validation failed: {"/":["The required properties (source) are missing"]}');
257        (new Loader())->load($project->path('requirements.yaml'));
258    }
259
260    /**
261     * @throws JsonException
262     */
263    public function testLoadRejectsDuplicateSourceId(): void
264    {
265        $project = new ProjectDirectory();
266        $project->write('other.yaml', ['version' => 1, 'source' => ['id' => 'manual', 'uri' => 'source.html', 'format' => 'html', 'selector' => '#b'], 'items' => []]);
267        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml', 'other.yaml']]);
268        $this->expectException(InvalidInputException::class);
269        $this->expectExceptionMessage('Duplicate source ID: manual');
270        (new Loader())->load($project->path('requirements.yaml'));
271    }
272
273    /**
274     * @throws JsonException
275     */
276    public function testLoadRejectsDuplicateItemId(): void
277    {
278        $project = new ProjectDirectory();
279        $project->write('other.yaml', ['version' => 1, 'source' => null, 'items' => [['id' => 'SPEC-001', 'statement' => 'The parser shall read.', 'origin' => 'original', 'reason' => 'Local rule.']]]);
280        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml', 'other.yaml']]);
281        $this->expectException(InvalidInputException::class);
282        $this->expectExceptionMessage('Duplicate item ID: SPEC-001');
283        (new Loader())->load($project->path('requirements.yaml'));
284    }
285
286    /**
287     * @throws JsonException
288     */
289    public function testLoadReadsRunners(): void
290    {
291        $project = new ProjectDirectory();
292        $directory = realpath($project->directory);
293        self::assertIsString($directory);
294        $item = ProjectDirectory::item();
295        $item['tests'] = [['runner' => 'unit', 'target' => 'Sample\\PassingTest::testPass']];
296        $project->write('definition.yaml', ['version' => 1, 'source' => ['id' => 'manual', 'uri' => 'source.html', 'format' => 'html', 'selector' => 'main p'], 'items' => [$item]]);
297        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml'], 'runners' => ['unit' => ['extension' => 'phpunit', 'command' => ['phpunit'], 'cwd' => 'tests', 'timeout' => 5], 'features' => ['extension' => 'behat', 'command' => ['behat']]]]);
298        $loaded = (new Loader())->load($project->path('requirements.yaml'));
299        self::assertEquals(['unit' => new RunnerConfig('phpunit', ['phpunit'], "$directory/tests", 5.0), 'features' => new RunnerConfig('behat', ['behat'], "$directory/.", 60.0)], $loaded->runners);
300        self::assertSame('unit', $loaded->items['SPEC-001']->tests[0]->runner);
301    }
302
303    /**
304     * @throws JsonException
305     */
306    public function testLoadRejectsUnknownRunnerExtension(): void
307    {
308        $project = new ProjectDirectory();
309        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml'], 'runners' => ['unit' => ['extension' => 'missing', 'command' => ['phpunit']]]]);
310        $this->expectException(InvalidInputException::class);
311        $this->expectExceptionMessage('Unknown runner extension: missing');
312        (new Loader())->load($project->path('requirements.yaml'));
313    }
314
315    /**
316     * @throws JsonException
317     */
318    public function testLoadRejectsTestLinkedToUnknownRunner(): void
319    {
320        $project = new ProjectDirectory();
321        $item = ProjectDirectory::item();
322        $item['tests'] = [['runner' => 'phpunit', 'target' => 'Sample\\PassingTest::testPass']];
323        $project->write('definition.yaml', ['version' => 1, 'source' => ['id' => 'manual', 'uri' => 'source.html', 'format' => 'html', 'selector' => 'main p'], 'items' => [$item]]);
324        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml'], 'runners' => ['unit' => ['extension' => 'phpunit', 'command' => ['phpunit']]]]);
325        $this->expectException(InvalidInputException::class);
326        $this->expectExceptionMessage("SPEC-001: unknown runner 'phpunit'.");
327        (new Loader())->load($project->path('requirements.yaml'));
328    }
329
330    /**
331     * @throws JsonException
332     */
333    public function testLoadReadsCoverage(): void
334    {
335        $project = new ProjectDirectory();
336        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml'], 'coverage' => ['minimum' => 80, 'diff_minimum' => 12.5, 'sources' => ['manual' => 50]]]);
337        $loaded = (new Loader())->load($project->path('requirements.yaml'));
338        self::assertSame(80.0, $loaded->minimum);
339        self::assertSame(12.5, $loaded->diffMinimum);
340        self::assertSame(['manual' => 50.0], $loaded->sourceThresholds);
341    }
342
343    /**
344     * @throws JsonException
345     */
346    public function testLoadDefaultsCoverageToZero(): void
347    {
348        $project = new ProjectDirectory();
349        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml']]);
350        $loaded = (new Loader())->load($project->path('requirements.yaml'));
351        self::assertSame(0.0, $loaded->minimum);
352        self::assertSame(0.0, $loaded->diffMinimum);
353        self::assertSame([], $loaded->sourceThresholds);
354    }
355
356    /**
357     * @throws JsonException
358     */
359    public function testLoadRejectsCoverageOutOfRange(): void
360    {
361        $project = new ProjectDirectory();
362        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml'], 'coverage' => ['minimum' => 101]]);
363        $this->expectException(InvalidInputException::class);
364        $this->expectExceptionMessage('requirements.yaml: schema validation failed: {"/coverage/minimum":');
365        (new Loader())->load($project->path('requirements.yaml'));
366    }
367
368    /**
369     * @throws JsonException
370     */
371    public function testLoadRejectsUnknownSourceThreshold(): void
372    {
373        $project = new ProjectDirectory();
374        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml'], 'coverage' => ['sources' => ['manual' => 50, 'other' => 50]]]);
375        $this->expectException(InvalidInputException::class);
376        $this->expectExceptionMessage('Unknown source threshold: other');
377        (new Loader())->load($project->path('requirements.yaml'));
378    }
379
380    /**
381     * @throws JsonException
382     */
383    public function testLoadRejectsUnknownSourceFormat(): void
384    {
385        $project = new ProjectDirectory();
386        $project->write('definition.yaml', ['version' => 1, 'source' => ['id' => 'manual', 'uri' => 'message', 'format' => 'memory', 'selector' => 'all'], 'items' => []]);
387        $this->expectException(InvalidInputException::class);
388        $this->expectExceptionMessage('Unknown source extension: memory');
389        (new Loader())->load($project->path('requirements.yaml'));
390    }
391
392    /**
393     * @throws JsonException
394     */
395    public function testLoadRegistersExtensions(): void
396    {
397        $project = new ProjectDirectory();
398        $project->write('definition.yaml', ['version' => 1, 'source' => ['id' => 'manual', 'uri' => 'message', 'format' => 'memory', 'selector' => 'all'], 'items' => []]);
399        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml'], 'extensions' => ['sources' => ['memory' => MemorySource::class], 'runners' => ['counting' => CountingRunner::class]], 'runners' => ['count' => ['extension' => 'counting', 'command' => ['count']]]]);
400        $loaded = (new Loader())->load($project->path('requirements.yaml'));
401        self::assertSame(['memory' => MemorySource::class], $loaded->sourceExtensions);
402        self::assertSame(['counting' => CountingRunner::class], $loaded->runnerExtensions);
403        self::assertSame('counting', $loaded->runners['count']->extension);
404    }
405
406    /**
407     * @throws JsonException
408     */
409    public function testLoadRejectsSourceExtensionOfWrongType(): void
410    {
411        $project = new ProjectDirectory();
412        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml'], 'extensions' => ['sources' => ['memory' => CountingRunner::class]]]);
413        $this->expectException(InvalidInputException::class);
414        $this->expectExceptionMessage(CountingRunner::class . ' must implement SourceExtension.');
415        (new Loader())->load($project->path('requirements.yaml'));
416    }
417
418    /**
419     * @throws JsonException
420     */
421    public function testLoadRejectsRunnerExtensionOfWrongType(): void
422    {
423        $project = new ProjectDirectory();
424        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml'], 'extensions' => ['runners' => ['counting' => MemorySource::class]]]);
425        $this->expectException(InvalidInputException::class);
426        $this->expectExceptionMessage(MemorySource::class . ' must implement RunnerExtension.');
427        (new Loader())->load($project->path('requirements.yaml'));
428    }
429
430    /**
431     * @throws JsonException
432     */
433    public function testLoadRejectsUnknownExtensionKind(): void
434    {
435        $project = new ProjectDirectory();
436        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.yaml'], 'extensions' => ['reporters' => ['memory' => MemorySource::class]]]);
437        $this->expectException(InvalidInputException::class);
438        $this->expectExceptionMessage('requirements.yaml: schema validation failed:');
439        (new Loader())->load($project->path('requirements.yaml'));
440    }
441
442    /**
443     * @throws JsonException
444     */
445    public function testLoadPassesMarkdownOptions(): void
446    {
447        $project = new ProjectDirectory();
448        $project->put('definition.md', "---\nversion: 1\nsource: null\n---\n\n# ORIGINAL-001\n\nThe converter shall uppercase letters.\n\n**origin**\n\noriginal\n\n**reason**\n\nDemonstrate the loader.\n");
449        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.md'], 'markdown' => ['experimental' => true]]);
450        $loaded = (new Loader())->load($project->path('requirements.yaml'));
451        self::assertSame(['experimental' => true], $loaded->markdown);
452        self::assertSame(['ORIGINAL-001'], array_keys($loaded->items));
453    }
454
455    /**
456     * @throws JsonException
457     */
458    public function testLoadRejectsMarkdownWithoutOptions(): void
459    {
460        $project = new ProjectDirectory();
461        $directory = realpath($project->directory);
462        self::assertIsString($directory);
463        $project->put('definition.md', "---\nversion: 1\nsource: null\n---\n\n# ORIGINAL-001\n\nThe converter shall uppercase letters.\n");
464        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.md']]);
465        $this->expectException(InvalidInputException::class);
466        $this->expectExceptionMessage("$directory/definition.md: Markdown definitions require markdown.experimental: true.");
467        (new Loader())->load($project->path('requirements.yaml'));
468    }
469
470    /**
471     * @throws JsonException
472     */
473    public function testDocumentReadsDefinitionByDefault(): void
474    {
475        $project = new ProjectDirectory();
476        self::assertSame(['version' => 1, 'source' => ['id' => 'manual', 'uri' => 'source.html', 'format' => 'html', 'selector' => 'main p'], 'items' => [ProjectDirectory::item()]], (new Loader())->document($project->path('definition.yaml')));
477    }
478
479    /**
480     * @throws JsonException
481     */
482    public function testDocumentReadsConfiguration(): void
483    {
484        $project = new ProjectDirectory();
485        self::assertSame(['version' => 1, 'definitions' => ['definition.yaml'], 'coverage' => ['minimum' => 0]], (new Loader())->document($project->path('requirements.yaml'), 'config'));
486    }
487
488    /**
489     * @throws JsonException
490     */
491    public function testDocumentValidatesDefaultKindAsDefinition(): void
492    {
493        $project = new ProjectDirectory();
494        $this->expectException(InvalidInputException::class);
495        $this->expectExceptionMessage($project->path('requirements.yaml') . ': schema validation failed:');
496        (new Loader())->document($project->path('requirements.yaml'));
497    }
498
499    /**
500     * @throws JsonException
501     */
502    public function testDocumentReadsMarkdownWithOptions(): void
503    {
504        $project = new ProjectDirectory();
505        $project->put('definition.md', "---\nversion: 1\nsource: null\n---\n\n# ORIGINAL-001\n\nThe converter shall uppercase letters.\n\n**origin**\n\noriginal\n\n**reason**\n\nDemonstrate the loader.\n");
506        self::assertSame(['version' => 1, 'source' => null, 'items' => [['id' => 'ORIGINAL-001', 'statement' => 'The converter shall uppercase letters.', 'origin' => 'original', 'reason' => 'Demonstrate the loader.']]], (new Loader())->document($project->path('definition.md'), 'definition', ['experimental' => true]));
507    }
508
509    /**
510     * @throws JsonException
511     */
512    public function testDocumentRejectsMarkdownWithoutOptions(): void
513    {
514        $project = new ProjectDirectory();
515        $project->put('definition.md', "---\nversion: 1\nsource: null\n---\n\n# ORIGINAL-001\n\nThe converter shall uppercase letters.\n");
516        $this->expectException(InvalidInputException::class);
517        $this->expectExceptionMessage($project->path('definition.md') . ': Markdown definitions require markdown.experimental: true.');
518        (new Loader())->document($project->path('definition.md'));
519    }
520}
521