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](assets/grammar.svg)', ['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 ![label](image-url) for each label badge.'],
142            'lowercase none' => ['labels', 'none.', 'Use ![label](image-url) 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' => ['- [![REQ-001](a.svg)](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("![grammar](assets/grammar.svg) ![strictness](https://example.invalid/badge.svg)\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("![grammar](grammar.svg)\n![strictness](strictness.svg)\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 ![label](image-url) for each label badge.'],
322            'text after a badge' => ['![grammar](grammar.svg) and more', 'Use ![label](image-url) for each label badge.'],
323            'empty destination' => ['![grammar]()', 'Use ![label](image-url) for each label badge.'],
324            'blank paragraph' => ['&#32;', '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