packages/requirements/tests/Unit/Config/Markdown/FieldReaderTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config\Markdown;
6
7use League\CommonMark\Exception\CommonMarkException;
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\Markdown\FieldReader;
14use Requirements\Config\Markdown\MetadataReader;
15use Requirements\Config\Markdown\Nodes;
16use Requirements\Input\InvalidInputException;
17use Tests\Fake\MarkdownNodes;
18
19#[CoversClass(FieldReader::class)]
20#[UsesClass(MetadataReader::class)]
21#[UsesClass(Nodes::class)]
22#[Small]
23final class FieldReaderTest extends TestCase
24{
25 /**
26 * @throws CommonMarkException
27 */
28 #[DataProvider('providerProseFields')]
29 public function testReadReadsProseFields(string $name): void
30 {
31 self::assertSame("Avoid silent\ndata loss.\n\nKeep shall.", (new FieldReader())->read($name, MarkdownNodes::blocks("Avoid silent\ndata loss.\n\nKeep `shall`.\n")));
32 }
33
34 /**
35 * @return array<string, array{string}>
36 */
37 public static function providerProseFields(): array
38 {
39 return [
40 'kind' => ['kind'],
41 'status' => ['status'],
42 'origin' => ['origin'],
43 'category' => ['category'],
44 'reason' => ['reason'],
45 ];
46 }
47
48 /**
49 * @throws CommonMarkException
50 */
51 #[DataProvider('providerListFields')]
52 public function testReadReadsNoneAsAnEmptyList(string $name): void
53 {
54 self::assertSame([], (new FieldReader())->read($name, MarkdownNodes::blocks('None.')));
55 }
56
57 /**
58 * @return array<string, array{string}>
59 */
60 public static function providerListFields(): array
61 {
62 return [
63 'evidence' => ['evidence'],
64 'tests' => ['tests'],
65 'requirements' => ['requirements'],
66 'related' => ['related'],
67 'labels' => ['labels'],
68 'design' => ['design'],
69 ];
70 }
71
72 /**
73 * @throws CommonMarkException
74 */
75 public function testReadReadsNoneAsProseForAProseField(): void
76 {
77 self::assertSame('None.', (new FieldReader())->read('reason', MarkdownNodes::blocks('None.')));
78 }
79
80 /**
81 * @throws CommonMarkException
82 */
83 public function testReadReadsAListField(): void
84 {
85 self::assertEquals([(object) ['runner' => 'unit', 'target' => 'Sample\\PassingTest::testPass']], (new FieldReader())->read('tests', MarkdownNodes::blocks('- **unit:** Sample\\PassingTest::testPass')));
86 }
87
88 /**
89 * @throws CommonMarkException
90 */
91 #[DataProvider('providerListValues')]
92 public function testReadReadsTheListOfAField(string $name, string $markdown, mixed $expected): void
93 {
94 self::assertEquals($expected, (new FieldReader())->read($name, MarkdownNodes::blocks($markdown)));
95 }
96
97 /**
98 * @return array<string, array{string, string, mixed}>
99 */
100 public static function providerListValues(): array
101 {
102 return [
103 'evidence' => ['evidence', "- **selector:** #a\n\n > Names shall start with a letter.\n", [(object) ['selector' => '#a', 'quote' => 'Names shall start with a letter.']]],
104 'requirements' => ['requirements', '- [REQ-001](reference.yaml#req-001)', ['REQ-001']],
105 'related' => ['related', '- [SPEC-001](other.md#spec-001)', ['SPEC-001']],
106 'labels' => ['labels', '', ['grammar']],
107 'design' => ['design', '- Preserve the original source spelling.', [(object) ['text' => 'Preserve the original source spelling.']]],
108 ];
109 }
110
111 /**
112 * @throws CommonMarkException
113 */
114 public function testReadReadsMetadata(): void
115 {
116 self::assertEquals((object) ['owner' => 'Parser team'], (new FieldReader())->read('metadata', MarkdownNodes::blocks('- **owner:** Parser team')));
117 }
118
119 /**
120 * @throws CommonMarkException
121 */
122 #[DataProvider('providerMalformedSections')]
123 public function testReadRejectsMalformedSections(string $name, string $markdown, string $message): void
124 {
125 $this->expectException(InvalidInputException::class);
126 $this->expectExceptionMessage($message);
127 (new FieldReader())->read($name, MarkdownNodes::blocks($markdown));
128 }
129
130 /**
131 * @return array<string, array{string, string, string}>
132 */
133 public static function providerMalformedSections(): array
134 {
135 return [
136 'empty section' => ['reason', '', "Markdown field 'reason' needs a value."],
137 'prose list' => ['origin', '- original', 'Expected prose paragraphs.'],
138 'two lists' => ['tests', "- **unit:** a\n\n***\n\n- **unit:** b\n", "Markdown field 'tests' needs one list or badge paragraph."],
139 'None followed by a list' => ['labels', "None.\n\n- grammar\n", "Markdown field 'labels' needs one list or badge paragraph."],
140 'None for metadata' => ['metadata', 'None.', 'Metadata must be a list of bold keys and values.'],
141 'None with emphasis' => ['labels', 'None. *really*', 'Use  for each label badge.'],
142 'lowercase none' => ['labels', 'none.', 'Use  for each label badge.'],
143 'unknown field' => ['lables', '- grammar', "Unknown Markdown field 'lables'."],
144 ];
145 }
146
147 /**
148 * @throws CommonMarkException
149 */
150 public function testProseJoinsParagraphsWithBlankLines(): void
151 {
152 self::assertSame("a\n\nb", (new FieldReader())->prose(MarkdownNodes::blocks("a\n\nb\n")));
153 }
154
155 /**
156 * @throws CommonMarkException
157 */
158 public function testProseRejectsABlockThatIsNotAParagraph(): void
159 {
160 $this->expectException(InvalidInputException::class);
161 $this->expectExceptionMessage('Expected prose paragraphs.');
162 (new FieldReader())->prose(MarkdownNodes::blocks("a\n\n> b\n"));
163 }
164
165 /**
166 * @throws CommonMarkException
167 */
168 public function testEvidenceReadsSelectorsAndQuotations(): void
169 {
170 $markdown = "- **selector:** #a\n\n > Names shall start\n > with a letter.\n >\n > Names may contain digits.\n\n- **selector:** main p\n\n > The generator shall produce C code.\n";
171 self::assertEquals([
172 (object) ['selector' => '#a', 'quote' => "Names shall start\nwith a letter.\n\nNames may contain digits."],
173 (object) ['selector' => 'main p', 'quote' => 'The generator shall produce C code.'],
174 ], (new FieldReader())->evidence(MarkdownNodes::first($markdown)));
175 }
176
177 /**
178 * @throws CommonMarkException
179 */
180 #[DataProvider('providerMalformedEvidence')]
181 public function testEvidenceRejectsMalformedEntries(string $markdown, string $message): void
182 {
183 $this->expectException(InvalidInputException::class);
184 $this->expectExceptionMessage($message);
185 (new FieldReader())->evidence(MarkdownNodes::first($markdown));
186 }
187
188 /**
189 * @return array<string, array{string, string}>
190 */
191 public static function providerMalformedEvidence(): array
192 {
193 return [
194 'quotation without a list' => ['> Original text.', 'Expected a bullet list.'],
195 'quotation first' => ['- > Original text.', 'Evidence needs a selector and a block quotation.'],
196 'empty entry' => ["-\n- **selector:** #a\n", 'Evidence needs a selector and a block quotation.'],
197 'no quotation' => ['- **selector:** #a', 'Evidence must use a bold selector field followed by one block quotation.'],
198 'other bold name' => ["- **quote:** #a\n\n > Original text.\n", 'Evidence must use a bold selector field followed by one block quotation.'],
199 'paragraph instead of quotation' => ["- **selector:** #a\n\n Original text.\n", 'Evidence must use a bold selector field followed by one block quotation.'],
200 'block after the quotation' => ["- **selector:** #a\n\n > Original text.\n\n More.\n", 'Evidence must use a bold selector field followed by one block quotation.'],
201 'selector without bold name' => ["- selector: #a\n\n > Original text.\n", 'Expected a bold field name followed by a colon.'],
202 'list in the quotation' => ["- **selector:** #a\n\n > - Original text.\n", 'Expected prose paragraphs.'],
203 ];
204 }
205
206 /**
207 * @throws CommonMarkException
208 */
209 public function testTestsReadsRunnersAndTargets(): void
210 {
211 self::assertEquals([(object) ['runner' => 'unit', 'target' => 'Sample\\PassingTest::testPass'], (object) ['runner' => 'behat', 'target' => 'example.feature:2']], (new FieldReader())->tests(MarkdownNodes::first("- **unit:** Sample\\PassingTest::testPass\n- **behat**: example.feature:2\n")));
212 }
213
214 /**
215 * @throws CommonMarkException
216 */
217 #[DataProvider('providerMalformedTests')]
218 public function testTestsRejectsEntriesWithoutABoldRunner(string $markdown, string $message): void
219 {
220 $this->expectException(InvalidInputException::class);
221 $this->expectExceptionMessage($message);
222 (new FieldReader())->tests(MarkdownNodes::first($markdown));
223 }
224
225 /**
226 * @return array<string, array{string, string}>
227 */
228 public static function providerMalformedTests(): array
229 {
230 return [
231 'unknown option' => ["- **unit:** a\n - **skip:** true\n", 'A test accepts only one run option.'],
232 'duplicate option' => ["- **unit:** a\n - **run:** auto\n - **run:** manual\n", 'A test accepts only one run option.'],
233 'missing target paragraph' => ["- > a\n", 'A test needs a runner and target paragraph.'],
234 'extra paragraph after options' => ["- **unit:** a\n - **run:** manual\n\n Extra text.\n", 'A test accepts only one nested options list.'],
235 'plain entry' => ['- unit: Sample\\PassingTest::testPass', 'Expected a bold field name followed by a colon.'],
236 'two paragraphs' => ["- **unit:** a\n\n b\n", 'Expected a bullet list.'],
237 'ordered list' => ['1. **unit:** a', 'Expected a bullet list.'],
238 ];
239 }
240
241 /**
242 * @throws CommonMarkException
243 */
244 public function testReferencesReadsLinkedIdsAndRemembersTheirTargets(): void
245 {
246 $reader = new FieldReader();
247 self::assertSame(['REQ-001', 'SPEC-001'], $reader->references(MarkdownNodes::first("- [REQ-001](reference.yaml#req-001)\n- [SPEC-001](#spec-001)\n")));
248 self::assertSame(['REQ-001' => 'reference.yaml#req-001', 'SPEC-001' => '#spec-001'], $reader->links);
249 }
250
251 /**
252 * @throws CommonMarkException
253 */
254 #[DataProvider('providerMalformedReferences')]
255 public function testReferencesRejectsEntriesThatAreNotASingleLink(string $markdown, string $message): void
256 {
257 $this->expectException(InvalidInputException::class);
258 $this->expectExceptionMessage($message);
259 (new FieldReader())->references(MarkdownNodes::first($markdown));
260 }
261
262 /**
263 * @return array<string, array{string, string}>
264 */
265 public static function providerMalformedReferences(): array
266 {
267 return [
268 'reference without link' => ['- REQ-001', 'Expected one Markdown link with a nonempty destination.'],
269 'link and text' => ['- [REQ-001](a.md) and more', 'Expected one Markdown link with a nonempty destination.'],
270 'link with an image' => ['- [](a.md)', 'Unsupported inline Markdown'],
271 ];
272 }
273
274 /**
275 * @throws CommonMarkException
276 */
277 public function testLabelsReadsABulletList(): void
278 {
279 $reader = new FieldReader();
280 self::assertSame(['grammar', 'strictness'], $reader->labels(MarkdownNodes::first("- grammar\n- *strictness*\n")));
281 self::assertSame([], $reader->badges);
282 }
283
284 /**
285 * @throws CommonMarkException
286 */
287 public function testLabelsReadsABadgeParagraphAndRemembersTheImages(): void
288 {
289 $reader = new FieldReader();
290 self::assertSame(['grammar', 'strictness'], $reader->labels(MarkdownNodes::first(" \n")));
291 self::assertSame(['grammar' => 'assets/grammar.svg', 'strictness' => 'https://example.invalid/badge.svg'], $reader->badges);
292 }
293
294 /**
295 * @throws CommonMarkException
296 */
297 public function testLabelsReadsBadgesOnSeveralLines(): void
298 {
299 self::assertSame(['grammar', 'strictness'], (new FieldReader())->labels(MarkdownNodes::first("\n\n")));
300 }
301
302 /**
303 * @throws CommonMarkException
304 */
305 #[DataProvider('providerMalformedLabels')]
306 public function testLabelsRejectsAnythingButLabelsOrBadges(string $markdown, string $message): void
307 {
308 $this->expectException(InvalidInputException::class);
309 $this->expectExceptionMessage($message);
310 (new FieldReader())->labels(MarkdownNodes::first($markdown));
311 }
312
313 /**
314 * @return array<string, array{string, string}>
315 */
316 public static function providerMalformedLabels(): array
317 {
318 return [
319 'quotation' => ['> grammar', 'Labels must be a bullet list or a paragraph of badge images.'],
320 'ordered list' => ['1. grammar', 'Expected a bullet list.'],
321 'text' => ['grammar', 'Use  for each label badge.'],
322 'text after a badge' => [' and more', 'Use  for each label badge.'],
323 'empty destination' => ['![grammar]()', 'Use  for each label badge.'],
324 'blank paragraph' => [' ', 'The badge paragraph must contain labels.'],
325 'list entry with a link' => ['- [grammar](a.md)', 'Unsupported inline Markdown'],
326 ];
327 }
328
329 /**
330 * @throws CommonMarkException
331 */
332 public function testDesignReadsLinksAndText(): void
333 {
334 $markdown = "- [Parser design](https://example.org/design)\n- <https://example.org/plain>\n- Preserve the original *source* spelling.\n";
335 self::assertEquals([
336 (object) ['url' => 'https://example.org/design', 'text' => 'Parser design'],
337 (object) ['url' => 'https://example.org/plain'],
338 (object) ['text' => 'Preserve the original source spelling.'],
339 ], (new FieldReader())->design(MarkdownNodes::first($markdown)));
340 }
341
342 /**
343 * @throws CommonMarkException
344 */
345 #[DataProvider('providerMalformedDesign')]
346 public function testDesignRejectsMalformedEntries(string $markdown, string $message): void
347 {
348 $this->expectException(InvalidInputException::class);
349 $this->expectExceptionMessage($message);
350 (new FieldReader())->design(MarkdownNodes::first($markdown));
351 }
352
353 /**
354 * @return array<string, array{string, string}>
355 */
356 public static function providerMalformedDesign(): array
357 {
358 return [
359 'text with a link' => ['- See [design](https://example.org/design).', 'Unsupported inline Markdown'],
360 'two paragraphs' => ["- a\n\n b\n", 'Expected a single paragraph in this list item.'],
361 'paragraph' => ['Preserve spelling.', 'Expected a bullet list.'],
362 ];
363 }
364 /**
365 * @throws CommonMarkException
366 */
367 public function testTestsReadsManualAndAutomaticExecutionPolicies(): void
368 {
369 self::assertEquals([
370 (object) ['runner' => 'unit', 'target' => 'fast', 'run' => 'auto'],
371 (object) ['runner' => 'fuzz', 'target' => 'slow', 'run' => 'manual'],
372 ], (new FieldReader())->tests(MarkdownNodes::first("- **unit:** fast\n - **run:** auto\n- **fuzz:** slow\n - **run:** manual\n")));
373 }
374}
375