packages/requirements/tests/Unit/Config/Markdown/NodesTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config\Markdown;
6
7use League\CommonMark\Exception\CommonMarkException;
8use League\CommonMark\Extension\CommonMark\Node\Inline\Code;
9use League\CommonMark\Node\Block\Paragraph;
10use League\CommonMark\Node\Inline\Newline;
11use League\CommonMark\Node\Inline\Text;
12use PHPUnit\Framework\Attributes\CoversClass;
13use PHPUnit\Framework\Attributes\DataProvider;
14use PHPUnit\Framework\Attributes\Small;
15use PHPUnit\Framework\TestCase;
16use Requirements\Config\Markdown\Nodes;
17use Requirements\Input\InvalidInputException;
18use Tests\Fake\MarkdownNodes;
19
20#[CoversClass(Nodes::class)]
21#[Small]
22final class NodesTest extends TestCase
23{
24 public function testTextReturnsTheLiteralOfATextNode(): void
25 {
26 self::assertSame('plain', Nodes::text(new Text('plain')));
27 }
28
29 public function testTextReturnsTheCodeWithoutBackticks(): void
30 {
31 self::assertSame('shall', Nodes::text(new Code('shall')));
32 }
33
34 public function testTextKeepsTheBackticksOfCodeAsALiteral(): void
35 {
36 self::assertSame('`shall`', Nodes::text(new Code('shall'), true));
37 }
38
39 public function testTextReturnsALineBreakForANewline(): void
40 {
41 self::assertSame("\n", Nodes::text(new Newline(Newline::SOFTBREAK)));
42 }
43
44 /**
45 * @throws CommonMarkException
46 */
47 #[DataProvider('providerInlineText')]
48 public function testTextJoinsTheInlineChildren(string $markdown, bool $literals, string $expected): void
49 {
50 self::assertSame($expected, Nodes::text(MarkdownNodes::first($markdown), $literals));
51 }
52
53 /**
54 * @return array<string, array{string, bool, string}>
55 */
56 public static function providerInlineText(): array
57 {
58 return [
59 'emphasis and code' => ["a **b** _c_ `d`\ne", false, "a b c d\ne"],
60 'code literal' => ["a **b** _c_ `d`\ne", true, "a b c `d`\ne"],
61 'nested code literal' => ['**`shall`**', true, '`shall`'],
62 'heading' => ['# SPEC-001', false, 'SPEC-001'],
63 'escaped characters' => ['\\*not emphasis\\*', false, '*not emphasis*'],
64 ];
65 }
66
67 /**
68 * @throws CommonMarkException
69 */
70 #[DataProvider('providerUnsupportedInline')]
71 public function testTextRejectsUnsupportedInlineMarkdown(string $markdown): void
72 {
73 $this->expectException(InvalidInputException::class);
74 $this->expectExceptionMessage('Unsupported inline Markdown; use text, emphasis or code spans. Put links in reference or design fields.');
75 Nodes::text(MarkdownNodes::first($markdown));
76 }
77
78 /**
79 * @return array<string, array{string}>
80 */
81 public static function providerUnsupportedInline(): array
82 {
83 return [
84 'link' => ['The reader shall emit [a tree](design.md).'],
85 'image' => [''],
86 'inline HTML' => ['a <b>tree</b>'],
87 'link inside emphasis' => ['*[a](b)*'],
88 ];
89 }
90
91 /**
92 * @throws CommonMarkException
93 */
94 public function testItemsReturnsTheItemsOfABulletList(): void
95 {
96 $items = Nodes::items(MarkdownNodes::first("- a\n- b\n- c\n"));
97 self::assertCount(3, $items);
98 self::assertSame('b', Nodes::text(Nodes::paragraph($items[1])));
99 }
100
101 /**
102 * @throws CommonMarkException
103 */
104 #[DataProvider('providerNotBulletLists')]
105 public function testItemsRejectsAnythingButABulletList(string $markdown): void
106 {
107 $this->expectException(InvalidInputException::class);
108 $this->expectExceptionMessage('Expected a bullet list.');
109 Nodes::items(MarkdownNodes::first($markdown));
110 }
111
112 /**
113 * @return array<string, array{string}>
114 */
115 public static function providerNotBulletLists(): array
116 {
117 return [
118 'ordered list' => ['1. grammar'],
119 'paragraph' => ['grammar'],
120 'quotation' => ['> - grammar'],
121 ];
122 }
123
124 /**
125 * @throws CommonMarkException
126 */
127 public function testParagraphReturnsTheSingleParagraphOfAListItem(): void
128 {
129 $items = Nodes::items(MarkdownNodes::first("- a *b*\n"));
130 self::assertSame('a b', Nodes::text(Nodes::paragraph($items[0])));
131 }
132
133 /**
134 * @throws CommonMarkException
135 */
136 #[DataProvider('providerNotSingleParagraphs')]
137 public function testParagraphRejectsAnItemWithoutASingleParagraph(string $markdown): void
138 {
139 $items = Nodes::items(MarkdownNodes::first($markdown));
140 $this->expectException(InvalidInputException::class);
141 $this->expectExceptionMessage('Expected a single paragraph in this list item.');
142 Nodes::paragraph($items[0]);
143 }
144
145 /**
146 * @return array<string, array{string}>
147 */
148 public static function providerNotSingleParagraphs(): array
149 {
150 return [
151 'two paragraphs' => ["- a\n\n b\n"],
152 'paragraph and list' => ["- a\n - b\n"],
153 'quotation' => ["- > a\n"],
154 'empty item' => ["-\n- b\n"],
155 ];
156 }
157
158 /**
159 * @throws CommonMarkException
160 */
161 public function testLinkReturnsTheSingleLinkOfAParagraph(): void
162 {
163 $link = Nodes::link(MarkdownNodes::first('[REQ-001](reference.yaml#req-001)'));
164 self::assertSame('reference.yaml#req-001', $link->getUrl());
165 self::assertSame('REQ-001', Nodes::text($link));
166 }
167
168 /**
169 * @throws CommonMarkException
170 */
171 #[DataProvider('providerNotSingleLinks')]
172 public function testLinkRejectsAnythingButOneLinkWithADestination(string $markdown): void
173 {
174 $this->expectException(InvalidInputException::class);
175 $this->expectExceptionMessage('Expected one Markdown link with a nonempty destination.');
176 Nodes::link(MarkdownNodes::first($markdown));
177 }
178
179 /**
180 * @return array<string, array{string}>
181 */
182 public static function providerNotSingleLinks(): array
183 {
184 return [
185 'text' => ['REQ-001'],
186 'link and text' => ['[REQ-001](a.md) and more'],
187 'text and link' => ['see [REQ-001](a.md)'],
188 'empty destination' => ['[REQ-001]()'],
189 'image' => [''],
190 ];
191 }
192
193 /**
194 * @throws CommonMarkException
195 */
196 #[DataProvider('providerFieldHeadings')]
197 public function testFieldReturnsTheLowercaseNameOfABoldHeading(string $markdown, ?string $expected): void
198 {
199 self::assertSame($expected, Nodes::field(MarkdownNodes::first($markdown)));
200 }
201
202 /**
203 * @return array<string, array{string, ?string}>
204 */
205 public static function providerFieldHeadings(): array
206 {
207 return [
208 'field' => ['**tests**', 'tests'],
209 'uppercase' => ['**Unsupported Reason**', 'unsupported reason'],
210 'bold followed by text' => ['**runner:** target', null],
211 'text followed by bold' => ['a **tests**', null],
212 'emphasis' => ['*tests*', null],
213 'plain paragraph' => ['tests', null],
214 'bold heading' => ['# **tests**', null],
215 'bullet list' => ['- **tests**', null],
216 ];
217 }
218
219 /**
220 * @throws CommonMarkException
221 */
222 public function testFieldRejectsUnsupportedInlineMarkdownInTheName(): void
223 {
224 $this->expectException(InvalidInputException::class);
225 $this->expectExceptionMessage('Unsupported inline Markdown');
226 Nodes::field(MarkdownNodes::first('**[tests](a.md)**'));
227 }
228
229 /**
230 * @throws CommonMarkException
231 */
232 #[DataProvider('providerPairs')]
233 public function testPairSplitsTheBoldNameAndItsValue(string $markdown, string $name, string $value): void
234 {
235 $paragraph = MarkdownNodes::first($markdown);
236 self::assertInstanceOf(Paragraph::class, $paragraph);
237 self::assertSame([$name, $value], Nodes::pair($paragraph));
238 }
239
240 /**
241 * @return array<string, array{string, string, string}>
242 */
243 public static function providerPairs(): array
244 {
245 return [
246 'colon inside the bold name' => ['**runner:** target', 'runner', 'target'],
247 'colon after the bold name' => ['**runner**: target', 'runner', 'target'],
248 'spaced colon after the bold name' => ['**runner** : target ', 'runner', 'target'],
249 'empty value' => ['**values:**', 'values', ''],
250 'value with emphasis and code' => ['**owner:** *Parser* `team`', 'owner', 'Parser team'],
251 'empty name' => ['**:** 1', '', '1'],
252 ];
253 }
254
255 /**
256 * @throws CommonMarkException
257 */
258 #[DataProvider('providerNotPairs')]
259 public function testPairRejectsAParagraphWithoutABoldNameAndColon(string $markdown, string $message): void
260 {
261 $paragraph = MarkdownNodes::first($markdown);
262 self::assertInstanceOf(Paragraph::class, $paragraph);
263 $this->expectException(InvalidInputException::class);
264 $this->expectExceptionMessage($message);
265 Nodes::pair($paragraph);
266 }
267
268 /**
269 * @return array<string, array{string, string}>
270 */
271 public static function providerNotPairs(): array
272 {
273 return [
274 'no bold name' => ['runner: target', 'Expected a bold field name followed by a colon.'],
275 'no colon' => ['**runner** target', 'Expected a colon after the bold field name.'],
276 'colon later' => ['**runner** target: x', 'Expected a colon after the bold field name.'],
277 ];
278 }
279
280 #[DataProvider('providerEscapes')]
281 public function testEscapeProtectsMarkdownSyntax(string $text, string $expected): void
282 {
283 self::assertSame($expected, Nodes::escape($text));
284 }
285
286 /**
287 * @return array<string, array{string, string}>
288 */
289 public static function providerEscapes(): array
290 {
291 return [
292 'plain' => ['The reader shall emit a tree.', 'The reader shall emit a tree.'],
293 'inline syntax' => ['\\ ` * _ [ ] < > & !', '\\\\ \\` \\* \\_ \\[ \\] \\< \\> \\& \\!'],
294 'heading' => ['# title', '\\# title'],
295 'setext underline' => ["a\n===", "a\n\\==="],
296 'plus bullet' => ['+ entry', '\\+ entry'],
297 'dash bullet' => ['- Entry', '\\- Entry'],
298 'indented bullet' => ["a\n - b", "a\n \\- b"],
299 'ordered entry' => ['1. Entry', '1\\. Entry'],
300 'parenthesized ordered entry' => ['12) Entry', '12\\) Entry'],
301 'number inside text' => ['Version 1. Entry', 'Version 1. Entry'],
302 'dash inside text' => ['a - b', 'a - b'],
303 ];
304 }
305
306 #[DataProvider('providerDestinations')]
307 public function testDestinationBracketsDestinationsThatNeedIt(string $url, string $expected): void
308 {
309 self::assertSame($expected, Nodes::destination($url));
310 }
311
312 /**
313 * @return array<string, array{string, string}>
314 */
315 public static function providerDestinations(): array
316 {
317 return [
318 'plain' => ['https://example.org/a.svg', 'https://example.org/a.svg'],
319 'space' => ['my source.html', '<my%20source.html>'],
320 'parentheses' => ['a(b).html', '<a(b).html>'],
321 'angle brackets' => ['<a>', '<%3Ca%3E>'],
322 'line feed' => ["a\nb", '<a%0Ab>'],
323 'carriage return' => ["a\rb", '<a%0Db>'],
324 'tab' => ["a\tb", "<a\tb>"],
325 ];
326 }
327
328 #[DataProvider('providerAnchors')]
329 public function testAnchorLowercasesAndDropsDots(string $id, string $expected): void
330 {
331 self::assertSame($expected, Nodes::anchor($id));
332 }
333
334 /**
335 * @return array<string, array{string, string}>
336 */
337 public static function providerAnchors(): array
338 {
339 return [
340 'item ID' => ['REQ-001', 'req-001'],
341 'dotted ID' => ['A.B.C-1', 'abc-1'],
342 ];
343 }
344}
345