packages/requirements/tests/Unit/Config/DefinitionReaderTest.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\Small;
10use PHPUnit\Framework\Attributes\UsesClass;
11use PHPUnit\Framework\TestCase;
12use Requirements\Config\DefinitionReader;
13use Requirements\Config\Definitions;
14use Requirements\Config\DocumentReader;
15use Requirements\Config\JsonSchemaFile;
16use Requirements\Config\Markdown\Badges;
17use Requirements\Config\Markdown\CardReader;
18use Requirements\Config\Markdown\FieldReader;
19use Requirements\Config\Markdown\FieldSections;
20use Requirements\Config\Markdown\Frontmatter;
21use Requirements\Config\Markdown\Nodes;
22use Requirements\Config\Markdown\Profile\AllowedBlocks;
23use Requirements\Config\Markdown\Profile\BlockKind;
24use Requirements\Config\Markdown\Profile\DocumentSchema;
25use Requirements\Config\Markdown\Profile\Occurrences;
26use Requirements\Config\Markdown\Profile\SectionBlocks;
27use Requirements\Config\Markdown\Profile\TextConstraint;
28use Requirements\Config\Markdown\QuotationBlocks;
29use Requirements\Config\Markdown\Reference;
30use Requirements\Config\MarkdownDocument;
31use Requirements\Config\SchemaValidator;
32use Requirements\Ears\ConditionOrder;
33use Requirements\Ears\LiteralMask;
34use Requirements\Ears\SystemResponse;
35use Requirements\Ears\Validator;
36use Requirements\Ears\Wording;
37use Requirements\Input\Fields;
38use Requirements\Input\InvalidInputException;
39use Requirements\Model\Excerpt;
40use Requirements\Model\Item;
41use Requirements\Model\ItemValidator;
42use Requirements\Model\Source;
43use Symfony\Component\Yaml\Exception\ParseException;
44use Tests\Fake\ProjectDirectory;
45
46#[CoversClass(DefinitionReader::class)]
47#[UsesClass(Definitions::class)]
48#[UsesClass(DocumentReader::class)]
49#[UsesClass(JsonSchemaFile::class)]
50#[UsesClass(MarkdownDocument::class)]
51#[UsesClass(Badges::class)]
52#[UsesClass(CardReader::class)]
53#[UsesClass(FieldReader::class)]
54#[UsesClass(FieldSections::class)]
55#[UsesClass(Frontmatter::class)]
56#[UsesClass(Nodes::class)]
57#[UsesClass(AllowedBlocks::class)]
58#[UsesClass(BlockKind::class)]
59#[UsesClass(DocumentSchema::class)]
60#[UsesClass(Occurrences::class)]
61#[UsesClass(SectionBlocks::class)]
62#[UsesClass(TextConstraint::class)]
63#[UsesClass(QuotationBlocks::class)]
64#[UsesClass(Reference::class)]
65#[UsesClass(SchemaValidator::class)]
66#[UsesClass(ConditionOrder::class)]
67#[UsesClass(LiteralMask::class)]
68#[UsesClass(SystemResponse::class)]
69#[UsesClass(Validator::class)]
70#[UsesClass(Wording::class)]
71#[UsesClass(Fields::class)]
72#[UsesClass(Excerpt::class)]
73#[UsesClass(Item::class)]
74#[UsesClass(ItemValidator::class)]
75#[UsesClass(Source::class)]
76#[Small]
77final class DefinitionReaderTest extends TestCase
78{
79    /**
80     * @throws JsonException
81     */
82    public function testReadReturnsItemsSourcesAndFiles(): void
83    {
84        $project = new ProjectDirectory();
85        $definitions = (new DefinitionReader())->read($project->directory, ['definition.yaml'], []);
86        self::assertSame(['SPEC-001'], array_keys($definitions->items));
87        self::assertSame('When a name is read, the parser shall require a leading letter.', $definitions->items['SPEC-001']->statement);
88        self::assertSame($project->path('definition.yaml'), $definitions->items['SPEC-001']->file);
89        self::assertEquals(new Source('manual', 'source.html', 'html', 'main p'), $definitions->sources['manual']);
90        self::assertSame($definitions->sources['manual'], $definitions->items['SPEC-001']->source);
91        self::assertSame([$project->path('definition.yaml')], $definitions->files);
92    }
93
94    /**
95     * @throws JsonException
96     */
97    public function testReadOrdersFilesByPath(): void
98    {
99        $project = new ProjectDirectory();
100        $project->write('b.yaml', ['version' => 1, 'source' => null, 'items' => [['id' => 'SPEC-B', 'statement' => 'The parser shall read b.', 'origin' => 'original', 'reason' => 'Local rule.']]]);
101        $project->write('a.yaml', ['version' => 1, 'source' => null, 'items' => [['id' => 'SPEC-A', 'statement' => 'The parser shall read a.', 'origin' => 'original', 'reason' => 'Local rule.']]]);
102        $definitions = (new DefinitionReader())->read($project->directory, ['b.yaml', 'definition.yaml', 'a.yaml'], []);
103        self::assertSame([$project->path('a.yaml'), $project->path('b.yaml'), $project->path('definition.yaml')], $definitions->files);
104        self::assertSame(['SPEC-A', 'SPEC-B', 'SPEC-001'], array_keys($definitions->items));
105    }
106
107    /**
108     * @throws JsonException
109     */
110    public function testReadListsEachFileOnce(): void
111    {
112        $project = new ProjectDirectory();
113        $project->write('defs/b.yaml', ['version' => 1, 'source' => null, 'items' => []]);
114        $project->write('defs/a.yaml', ['version' => 1, 'source' => null, 'items' => []]);
115        $definitions = (new DefinitionReader())->read($project->directory, ['defs/*.yaml', 'defs/a.yaml', 'defs/*.yaml'], []);
116        self::assertSame([$project->path('defs/a.yaml'), $project->path('defs/b.yaml')], $definitions->files);
117        self::assertSame([], $definitions->items);
118        self::assertSame([], $definitions->sources);
119    }
120
121    /**
122     * @throws JsonException
123     */
124    public function testReadResolvesPatternsAgainstDirectory(): void
125    {
126        $project = new ProjectDirectory();
127        $project->write('nested/definition.yaml', ['version' => 1, 'source' => null, 'items' => []]);
128        $definitions = (new DefinitionReader())->read($project->path('nested'), ['definition.yaml'], []);
129        self::assertSame([$project->path('nested/definition.yaml')], $definitions->files);
130    }
131
132    /**
133     * @throws JsonException
134     */
135    public function testReadKeepsItemsWithoutSource(): void
136    {
137        $project = new ProjectDirectory();
138        $project->write('definition.yaml', ['version' => 1, 'source' => null, 'items' => [['id' => 'SPEC-001', 'statement' => 'The parser shall read.', 'origin' => 'original', 'reason' => 'Local rule.']]]);
139        $definitions = (new DefinitionReader())->read($project->directory, ['definition.yaml'], []);
140        self::assertSame([], $definitions->sources);
141        self::assertNull($definitions->items['SPEC-001']->source);
142        self::assertSame('original', $definitions->items['SPEC-001']->origin);
143    }
144
145    /**
146     * @throws JsonException
147     */
148    public function testReadRejectsPatternWithoutMatches(): void
149    {
150        $project = new ProjectDirectory();
151        $this->expectException(InvalidInputException::class);
152        $this->expectExceptionMessage('Definition pattern has no matches: missing/*.yaml');
153        (new DefinitionReader())->read($project->directory, ['definition.yaml', 'missing/*.yaml'], []);
154    }
155
156    /**
157     * @throws JsonException
158     */
159    public function testReadRequiresOneDefinition(): void
160    {
161        $project = new ProjectDirectory();
162        $this->expectException(InvalidInputException::class);
163        $this->expectExceptionMessage('At least one definition is required.');
164        (new DefinitionReader())->read($project->directory, [], []);
165    }
166
167    /**
168     * @throws JsonException
169     */
170    public function testReadRejectsDuplicateSourceId(): void
171    {
172        $project = new ProjectDirectory();
173        $project->write('other.yaml', ['version' => 1, 'source' => ['id' => 'manual', 'uri' => 'source.html', 'format' => 'html', 'selector' => '#b'], 'items' => []]);
174        $this->expectException(InvalidInputException::class);
175        $this->expectExceptionMessage('Duplicate source ID: manual');
176        (new DefinitionReader())->read($project->directory, ['definition.yaml', 'other.yaml'], []);
177    }
178
179    /**
180     * @throws JsonException
181     */
182    public function testReadRejectsDuplicateItemIdAcrossFiles(): void
183    {
184        $project = new ProjectDirectory();
185        $project->write('other.yaml', ['version' => 1, 'source' => null, 'items' => [['id' => 'SPEC-001', 'statement' => 'The parser shall read.', 'origin' => 'original', 'reason' => 'Local rule.']]]);
186        $this->expectException(InvalidInputException::class);
187        $this->expectExceptionMessage('Duplicate item ID: SPEC-001');
188        (new DefinitionReader())->read($project->directory, ['definition.yaml', 'other.yaml'], []);
189    }
190
191    /**
192     * @throws JsonException
193     */
194    public function testReadRejectsDuplicateItemIdInOneFile(): void
195    {
196        $project = new ProjectDirectory();
197        $project->write('definition.yaml', ['version' => 1, 'source' => null, 'items' => [['id' => 'SPEC-001', 'statement' => 'The parser shall read.', 'origin' => 'original', 'reason' => 'Local rule.'], ['id' => 'SPEC-001', 'statement' => 'The parser shall write.', 'origin' => 'original', 'reason' => 'Local rule.']]]);
198        $this->expectException(InvalidInputException::class);
199        $this->expectExceptionMessage('Duplicate item ID: SPEC-001');
200        (new DefinitionReader())->read($project->directory, ['definition.yaml'], []);
201    }
202
203    /**
204     * @throws JsonException
205     */
206    public function testReadRejectsDefinitionWithoutSourceDeclaration(): void
207    {
208        $project = new ProjectDirectory();
209        $project->put('definition.yaml', "version: 1\nitems: []\n");
210        $this->expectException(InvalidInputException::class);
211        $this->expectExceptionMessage($project->path('definition.yaml') . ': schema validation failed: {"/":["The required properties (source) are missing"]}');
212        (new DefinitionReader())->read($project->directory, ['definition.yaml'], []);
213    }
214
215    /**
216     * @throws JsonException
217     */
218    public function testReadRejectsInvalidItem(): void
219    {
220        $project = new ProjectDirectory();
221        $project->write('definition.yaml', ['version' => 1, 'source' => null, 'items' => [['id' => 'SPEC-001', 'statement' => 'Maybe names are letters', 'origin' => 'original', 'reason' => 'Local rule.']]]);
222        $this->expectException(InvalidInputException::class);
223        $this->expectExceptionMessage('EARS');
224        (new DefinitionReader())->read($project->directory, ['definition.yaml'], []);
225    }
226
227    /**
228     * @throws JsonException
229     */
230    public function testReadRejectsMalformedYaml(): void
231    {
232        $project = new ProjectDirectory();
233        $project->put('definition.yaml', "version: [\n");
234        $this->expectException(ParseException::class);
235        (new DefinitionReader())->read($project->directory, ['definition.yaml'], []);
236    }
237
238    /**
239     * @throws JsonException
240     */
241    public function testReadPassesMarkdownOptions(): void
242    {
243        $project = new ProjectDirectory();
244        $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 reader.\n");
245        $definitions = (new DefinitionReader())->read($project->directory, ['definition.md'], ['experimental' => true]);
246        self::assertSame(['ORIGINAL-001'], array_keys($definitions->items));
247        self::assertSame('Demonstrate the reader.', $definitions->items['ORIGINAL-001']->reason);
248    }
249
250    /**
251     * @throws JsonException
252     */
253    public function testReadRejectsMarkdownWithoutOptions(): void
254    {
255        $project = new ProjectDirectory();
256        $project->put('definition.md', "---\nversion: 1\nsource: null\n---\n\n# ORIGINAL-001\n\nThe converter shall uppercase letters.\n");
257        $this->expectException(InvalidInputException::class);
258        $this->expectExceptionMessage($project->path('definition.md') . ': Markdown definitions require markdown.experimental: true.');
259        (new DefinitionReader())->read($project->directory, ['definition.md'], []);
260    }
261
262    /**
263     * @throws JsonException
264     */
265    public function testReadResolvesMarkdownLinksAcrossFiles(): void
266    {
267        $project = new ProjectDirectory();
268        $project->write('reference.yaml', ['version' => 1, 'source' => ['id' => 'reference', 'uri' => 'source.html', 'format' => 'html', 'selector' => '#b'], 'items' => [['id' => 'REQ-001', 'kind' => 'requirement', 'statement' => 'Names may contain digits.', 'evidence' => [['selector' => '#b', 'quote' => 'Names may contain digits.']]]]]);
269        $project->put('a-spec.md', "---\nversion: 1\nsource: null\n---\n\n# SPEC-002\n\nThe parser shall accept digits.\n\n**requirements**\n\n- [REQ-001](reference.yaml#req-001)\n");
270        $definitions = (new DefinitionReader())->read($project->directory, ['a-spec.md', 'reference.yaml'], ['experimental' => true]);
271        self::assertSame(['REQ-001'], $definitions->items['SPEC-002']->requirements);
272        self::assertSame([$project->path('a-spec.md'), $project->path('reference.yaml')], $definitions->files);
273    }
274
275    /**
276     * @throws JsonException
277     */
278    public function testReadRejectsMarkdownLinkToWrongFile(): void
279    {
280        $project = new ProjectDirectory();
281        $project->write('reference.yaml', ['version' => 1, 'source' => ['id' => 'reference', 'uri' => 'source.html', 'format' => 'html', 'selector' => '#b'], 'items' => [['id' => 'REQ-001', 'kind' => 'requirement', 'statement' => 'Names may contain digits.', 'evidence' => [['selector' => '#b', 'quote' => 'Names may contain digits.']]]]]);
282        $project->put('spec.md', "---\nversion: 1\nsource: null\n---\n\n# SPEC-002\n\nThe parser shall accept digits.\n\n**requirements**\n\n- [REQ-001](definition.yaml#req-001)\n");
283        $this->expectException(InvalidInputException::class);
284        $this->expectExceptionMessage($project->path('spec.md') . ": link to 'REQ-001' does not point to its loaded definition file.");
285        (new DefinitionReader())->read($project->directory, ['spec.md', 'reference.yaml', 'definition.yaml'], ['experimental' => true]);
286    }
287
288    /**
289     * @throws JsonException
290     */
291    public function testReadKeepsMarkdownLinksOfEveryFile(): void
292    {
293        $project = new ProjectDirectory();
294        $project->write('reference.yaml', ['version' => 1, 'source' => ['id' => 'reference', 'uri' => 'source.html', 'format' => 'html', 'selector' => '#b'], 'items' => [['id' => 'REQ-001', 'kind' => 'requirement', 'statement' => 'Names may contain digits.', 'evidence' => [['selector' => '#b', 'quote' => 'Names may contain digits.']]]]]);
295        $project->put('a.md', "---\nversion: 1\nsource: null\n---\n\n# SPEC-002\n\nThe parser shall accept digits.\n\n**requirements**\n\n- [REQ-001](missing.yaml#req-001)\n");
296        $project->put('b.md', "---\nversion: 1\nsource: null\n---\n\n# SPEC-003\n\nThe converter shall uppercase letters.\n\n**origin**\n\noriginal\n\n**reason**\n\nDemonstrate the reader.\n");
297        $this->expectException(InvalidInputException::class);
298        $this->expectExceptionMessage($project->path('a.md') . ": link to 'REQ-001' does not point to its loaded definition file.");
299        (new DefinitionReader())->read($project->directory, ['a.md', 'b.md', 'reference.yaml'], ['experimental' => true]);
300    }
301}
302