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