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